loading...

Generating thumbnails via S3 events using aws-cdk (TypeScript)

ryands17 profile image Ryan Dsouza Updated on ・5 min read

This is another post on CDK after a while that's a bit different. You can check out the previous two posts that I had written here:

Let's see something today that we have all done manually in the past, creating thumbnails for images for profile pic uploads in S3.

Here we shall walk through how we can react events when we upload a file to S3 and send that to SQS. We shall also attach a Lambda handler that processes the message from the SQS queue and generates our thumbnail in the same bucket.

Note: The code for the entire repository is here if you want to jump right in :)

GitHub logo ryands17 / s3-thumbnail-generator

Generate thumbnails with S3 via SQL and Lambda created using AWS CDK

S3 image resizer

Build Status

This is an aws-cdk project where you can generate a thumbnail based on S3 event notifications using an SQS Queue with Lambda.

Steps

  1. Rename the .example.env file to .env and replace all the values with predefined values for your stack (not mandatory).

  2. Run yarn (recommended) or npm install

  3. Run yarn cdk deploy --profile profileName to deploy the stack to your specified region. You can skip providing the profile name if it is default. You can learn about creating profiles using the aws-cli here.

  4. Now you can add image/s in the photos folder in S3 and check after a couple of seconds that images in a folder named thumbnails is generated by Lambda via SQS.

The cdk.json file tells the CDK Toolkit how to execute your app.

Useful commands

  • yarn watch watch for changes and compile
  • yarn test perform the jest unit tests
  • yarn cdk deploy

In this repo, all the code that we write to create resources will be in the s3_thumbnail-stack.ts file situated in the lib folder.

Let's start by initializing a couple of constants:

const bucketName = 'all-images-bucket'
const prefix = 'photos/'

These constants are the name of the bucket that we will be creating and the path where we will be storing our photos respectively.

Then, in the constructor of our S3ThumbnailStack class, we shall start by creating the Queue that will handle all the object creation events coming from S3.

const queue = new sqs.Queue(this, 'thumbnailQueue', {
  queueName: 'thumbnailPayload',
  receiveMessageWaitTime: cdk.Duration.seconds(20),
  visibilityTimeout: cdk.Duration.seconds(60),
  retentionPeriod: cdk.Duration.days(2),
})

This code creates an SQS Queue named thumbnailPayload and sets some parameters:

  • receiveMessageWaitTime: This is the time at what interval should one poll for messages. We have kept it at 20 seconds as that is the longest we can have. This method is also known as long polling.

  • visibilityTimeout: This is the time for a message to be visible again for other handlers to process it. Here we are assuming that the Lambda will complete the thumbnail generation within 60 seconds. This time should be set accordingly so that the same messages are not consumed twice.

  • retentionPeriod: The amount of days the message will remain in the queue if unprocessed.

Moving on, lets create the lambda handler.

const handler = new lambda.Function(this, 'resizeImage', {
  runtime: lambda.Runtime.NODEJS_12_X,
  code: lambda.Code.fromAsset('resources'),
  handler: 'index.handler',
  timeout: cdk.Duration.seconds(20),
  memorySize: 256,
  logRetention: logs.RetentionDays.ONE_WEEK,
  environment: {
    QUEUE_URL: queue.queueUrl,
    PREFIX: prefix,
  },
})

Most of these options are familiar so lets discuss about the code and environment parameters respectively.

  • code: This is where our Lambda code will be taken from. We have specified a fromAsset('resources'), meaning CDK will take this from a folder named resources in our code. In this folder, there's a simple NodeJS Lambda function that resizes images using the sharp library.

  • environment: These contain two values. One for the queue URL which we will be needing to delete the message after we have processed it and the other is the S3 bucket prefix from where we need to fetch the image uploaded.

The Lambda handler looks something like this that is in the resources folder.

const AWS = require('aws-sdk')
const sharp = require('sharp')

const prefix = process.env.PREFIX
const s3 = new AWS.S3()
const sqs = new AWS.SQS({
  region: process.env.AWS_REGION,
})

exports.handler = async event => {
  try {
    for (let message of event.Records) {
      const body = JSON.parse(message.body)

      if (Array.isArray(body.Records) && body.Records[0].s3) {
        const objectName = decodeURIComponent(
          body.Records[0].s3.object.key.replace(/\+/g, ' ')
        )
        const bucketName = body.Records[0].s3.bucket.name
        console.log({ objectName, bucketName })
        try {
          await resizeAndSaveImage({ objectName, bucketName })
          await deleteMessageFromQueue(message.receiptHandle)
        } catch (e) {
          console.log('an error occured!')
          console.error(e)
        }
      } else {
        await deleteMessageFromQueue(message.receiptHandle)
      }
    }

    return {
      statusCode: 200,
      body: JSON.stringify('success!'),
    }
  } catch (e) {
    console.error(e)
    return {
      statusCode: 500,
      error: JSON.stringify('Server error!'),
    }
  }
}

async function resizeAndSaveImage({ objectName, bucketName }) {
  const objectWithoutPrefix = objectName.replace(prefix, '')
  const typeMatch = objectWithoutPrefix.match(/\.([^.]*)$/)
  if (!typeMatch) {
    console.log('Could not determine the image type.')
    return
  }

  // Check that the image type is supported
  const imageType = typeMatch[1].toLowerCase()
  if (imageType !== 'jpg' && imageType !== 'png') {
    console.log(`Unsupported image type: ${imageType}`)
    return
  }

  // Download the image
  const image = await s3
    .getObject({
      Bucket: bucketName,
      Key: objectName,
    })
    .promise()

  // Transform the image via sharp
  const width = 300
  const buffer = await sharp(image.Body).resize(width).toBuffer()

  const key = `thumbnails/${objectWithoutPrefix}`
  return s3
    .putObject({
      Bucket: bucketName,
      Key: key,
      Body: buffer,
      ContentType: 'image',
    })
    .promise()
}

function deleteMessageFromQueue(receiptHandle) {
  if (receiptHandle) {
    return sqs
      .deleteMessage({
        QueueUrl: process.env.QUEUE_URL,
        ReceiptHandle: receiptHandle,
      })
      .promise()
  }
}

This handler performs two functions, resize and save the image to S3, and delete the message from the queue once processed successfully.

And the third and final resource that we will be creating is our S3 bucket as follows:

const imagesBucket = new s3.Bucket(this, 'allImagesBucket', {
  bucketName,
  removalPolicy: cdk.RemovalPolicy.DESTROY,
})

This will just create our bucket and destroy it once we delete our Cloudformation stack.

There are three things needed to make this entire workflow running. Lets go through them and perform the final touches.

  • Send object creation events from S3 to SQS: This is so that S3 sends an event to SQS whenever an object, in our case an profile picture is uploaded in the photos folder. This is how we can add that:
imagesBucket.addObjectCreatedNotification(new s3Notif.SqsDestination(queue), {
  prefix,
})

Here, we are adding an ObjectCreatedNotification in which we are setting the destination to be an SQS Queue. Note the prefix paramter. This means only events for objects added in the photos folder or prefix in the case of S3 will be sent to SQS.

  • Allow Lambda access to S3: This is important as we need to fetch the image, generate the thumbnail, and put the thumbnail back in the bucket. Lets attach a policy to the handler:
handler.addToRolePolicy(
  new iam.PolicyStatement({
    effect: iam.Effect.ALLOW,
    resources: [`${imagesBucket.bucketArn}/*`],
    actions: ['s3:PutObject', 's3:GetObject'],
  })
)

This tells Lambda "You have access just to GetObject and PutObject methods on S3 on the created bucket and nothing else".

  • Allow Lambda to process messages from SQS: Finally, we tell Lambda to execute when a mesasge is added in the SQS Queue from the S3 object creation event.
handler.addEventSource(new eventSource.SqsEventSource(queue))

This basically says, "Lambda, consider this SQS Queue as a source and consume any messages that you get from this queue".

And we're done! Let's deploy this stack by firing yarn cdk deploy or npm run cdk deploy if you're using NPM.

Note: I'm assuming you have setup the AWS CLI and set your access and secret keys in the default profile. You can read more about that here.

After successful stack creation, you will get a bucket like this:

S3 Dashboard

As you can see I have already created a folder named photos. Let's upload a profile picture for our user that's a cat, and see if the event fires to SQS and the Lambda function is triggered.

After uploading, we can see two folders here after some time. You can refresh it after a while if you don't see it.

After the Lambda has run

Awesome! Our Lambda has successfully run and we can check the difference between the two pictures as well to see whether it has worked!

Original profile pic:

The original profile pic of the cat

Resized thumbnail:

Shrunk down cat

This is how we successfully deployed a stack with S3, Lambda and SQS to generate thumbnails automatically on upload.

Thank you all for reading! Do like and share this post if you've enjoyed it :)

Posted on by:

ryands17 profile

Ryan Dsouza

@ryands17

A Web Dev and Guitarist who loves JS & TS :) Always exploring new technologies and solution patterns. Have a soft spot for DevOps.

Discussion

pic
Editor guide