Generating video thumbnails with S3 and Fargate using the CDK
Tags: aws, typescript, cdk, fargate
Published: 2021-03-01 07:17:54

## Introduction

This post is inspired from the following [write-up](https://www.serverless.com/blog/serverless-application-for-long-running-process-fargate-lambda) which shows how to generate a thumbnail from a video using S3, Lambda and AWS Fargate. We will be creating all these resources using the CDK.

## Setup

To follow along, I would suggest cloning the repo and installing the dependencies.

{% github ryands17/thumbnail-creator no-readme %}

## CDK Constructs

Let's start with the resources that we need to create to run this. I'm excluding the imports here to make the snippets smaller, but you can easily find them in the above repo.

First, we will create the VPC in which we want to run our Fargate task.

```ts
// lib/thumbnail-creator-stack.ts

const vpc = new ec2.Vpc(this, 'serverless-app', {
  cidr: '10.0.0.0/21',
  natGateways: 0,
  maxAzs: 2,
  enableDnsHostnames: true,
  enableDnsSupport: true,
  subnetConfiguration: [
    {
      cidrMask: 23,
      name: 'public',
      subnetType: ec2.SubnetType.PUBLIC,
    },
    {
      cidrMask: 23,
      name: 'private',
      subnetType: ec2.SubnetType.ISOLATED,
    },
  ],
})
```

This will create our VPC with 4 subnets (2 Public and 2 Isolated) in each availability zone with the `cidr` specified. We will be launching our Fargate task in public subnets as it requires internet access.

Now, let's create our ECS Cluster that our Fargate task will run in, and the S3 bucket which will be storing our uploaded videos and the generated thumbnail.

```ts
// lib/thumbnail-creator-stack.ts

const cluster = new ecs.Cluster(this, 'FargateCluster', { vpc })

const imagesBucket = new s3.Bucket(this, bucketName, {
  removalPolicy: cdk.RemovalPolicy.DESTROY,
  autoDeleteObjects: true,
})
```

The first line creates an ECS Cluster and we pass the VPC created above. The next line creates an S3 bucket to store all our videos and thumbnails.

__*Note*__: We have specified `removalPolicy` and `autoDeleteObjects` to make it easier to delete this stack, but this is **not recommended in production** as it will cause data loss.

Now come the exciting parts. Let's continue further by creating the task definition and container that will be run when we execute the Fargate task.

```ts
// lib/thumbnail-creator-stack.ts

const taskDefinition = new ecs.FargateTaskDefinition(
  this,
  'GenerateThumbnail',
  { memoryLimitMiB: 512, cpu: 256 }
)
imagesBucket.grantReadWrite(taskDefinition.taskRole)

taskDefinition.addContainer('ffmpeg', {
  image: ecs.ContainerImage.fromRegistry(
    'ryands1701/thumbnail-creator:1.0.0'
  ),
  logging: new ecs.AwsLogDriver({
    streamPrefix: 'FargateGenerateThumbnail',
    logRetention: RetentionDays.ONE_WEEK,
  }),
  environment: {
    AWS_REGION: this.region,
    INPUT_VIDEO_FILE_URL: '',
    POSITION_TIME_DURATION: '00:01',
    OUTPUT_THUMBS_FILE_NAME: '',
    OUTPUT_S3_PATH: '',
  },
})
```

The first line creates a Fargate task definition where we provide the name `GenerateThumbnail` along with memory and CPU limits which should be enough for generating thumbnails.

Now as we need to read the video from the bucket and write the thumbnail back to the same bucket, we need permissions on that bucket. The line:

```ts
imagesBucket.grantReadWrite(taskDefinition.taskRole)
```

is a construct provided by CDK and is a handy way of saying grant this Task definition's `taskRole` read and write access to the bucket we created above. This is one of the reasons why I love writing CDK constructs :)

Finally we have a snippet that adds a container to the `taskDefinition`. This contains 3 parts:

1. **Image**: The [image](https://hub.docker.com/repository/docker/ryands1701/thumbnail-creator) that we will be pulling from DockerHub to create the thumbnail. This is an extension of the `ffmpeg` image and I will explain what's inside this image in a later section.

2. **Logging**: This will create a Log group in CloudWatch where we can see the output emitted by our container which is great for debugging.

3. **Environment**: There are some environment variables the container needs which are as follows:

  - `AWS_REGION`: The region that our S3 bucket is in to download the video and upload the thumbnail.
  - `INPUT_VIDEO_FILE_URL`: This is the URL of our video that will be uploaded by the user.
  - `POSITION_TIME_DURATION`: The frame of the video at `mm:ss` required to generate the thumbnail.
  - `OUTPUT_THUMBS_FILE_NAME`: The name of the thumbnail.
  - `OUTPUT_S3_PATH`: The path of the S3 bucket where the thumbnail will be stored.

__*Note*__: Apart from `AWS_REGION`, all the variables will be provided at runtime by the Lambda function. In our S3 bucket, we will be storing the videos under the `video` prefix and the thumbnails under the `thumbnails` prefix.

The next part is creating the Lambda function which the S3 bucket will trigger on object creation.

```ts
// lib/thumbnail-creator-stack.ts

const initiateThumbnailGeneration = createLambdaFn(
  this,
  'initiateThumbnailGeneration',
  {
    reservedConcurrentExecutions: 10,
    environment: {
      ECS_CLUSTER_NAME: cluster.clusterName,
      ECS_TASK_DEFINITION: taskDefinition.taskDefinitionArn,
      VPC_SUBNETS: vpc.publicSubnets.map(s => s.subnetId).join(','),
      VPC_SECURITY_GROUP: vpc.vpcDefaultSecurityGroup,
    },
  }
)
```

This will create the Lambda function using the `createLambdaFn` helper function that just uses the `aws-lambda-nodejs` module under the hood. We have specified some environment variables here as well that we need to look at.

- `ECS_CLUSTER_NAME`: We pass the cluster's name that we created above as we need to run the Fargate task in this cluster.
- `ECS_TASK_DEFINITION`: The task definition that we created above which we will be running to generate the thumbnail.
- `VPC_SUBNETS`: I had mentioned before that we will be running our task in a public subnet as it needs internet access. So here we pass our public subnet id's that we fetch from the VPC created above.
- `VPC_SECURITY_GROUP`: We pass the security group that the VPC creates by default as Fargate requires one to run.

Now, this Lambda function needs to run the Fargate task and also needs to pass the role to the task that will be running. For this, we need to add a couple of permissions to the function's role.

```ts
// lib/thumbnail-creator-stack.ts

initiateThumbnailGeneration.addToRolePolicy(
  new iam.PolicyStatement({
    effect: iam.Effect.ALLOW,
    actions: ['ecs:RunTask'],
    resources: [taskDefinition.taskDefinitionArn],
  })
)
initiateThumbnailGeneration.addToRolePolicy(
  new iam.PolicyStatement({
    effect: iam.Effect.ALLOW,
    actions: ['iam:Passrole'],
    resources: [
      taskDefinition.taskRole.roleArn,
      taskDefinition.executionRole?.roleArn || '',
    ],
  })
)
```

The first role allows the function to run the Fargate task that we have created above by passing the `taskDefinitionArn` to the `resources`.

The second role is special. This makes sure that when Lambda creates the ECS task and runs it, it has the permission to pass the `taskRole` and `executionRole` to the running task. `iam:Passrole` is a safeguard by AWS that makes sure no resource can pass elevated privileges that it's own.

__*Note*__: For those who do not know what `iam:Passrole` does, here's a great [article](https://blog.rowanudell.com/iam-passrole-explained/) by [Rowan Udell](https://twitter.com/elrowan) on what the use case for this is and I would highly recommend reading this!

The final snippet is adding a trigger to fire the function we created above whenever a new object is created in S3.

```ts
// lib/thumbnail-creator-stack.ts

imagesBucket.addObjectCreatedNotification(
  new s3Notif.LambdaDestination(initiateThumbnailGeneration),
  { prefix: 'videos/', suffix: '.mp4' }
)
```

We listen for objects being created but with specific constraints. The object should have the `videos/` prefix, or as per the S3 console be in the `videos` folder and it should have an `.mp4` extension.

__*Note*__: It's needed to have a prefix when you are going to add something back to the same bucket or else the Lambda will **trigger infinitely**! So the best way is listening for objects in a specific prefix and creating objects in another prefix if working in the same bucket.

## Lambda function implementation

Now as we're done with the constructs, let's look at what the Lambda function has. This is available in the [functions](https://github.com/ryands17/thumbnail-creator/tree/main/functions) directory.

First, we define our environment variables required.

```ts
// functions/config.ts

export const envs = {
  AWS_REGION: process.env.AWS_REGION,
  ECS_CLUSTER_NAME: process.env.ECS_CLUSTER_NAME,
  ECS_TASK_DEFINITION: process.env.ECS_TASK_DEFINITION,
  VPC_SUBNETS: process.env.VPC_SUBNETS?.split(','),
  VPC_SECURITY_GROUP: process.env.VPC_SECURITY_GROUP,
}
```

These are all the environment variables needed that we defined while constructing our Lambda function and which we will be passing to the Fargate task.

Next, let's move on to where the function is defined.

```ts
// functions/initiateThumbnailGeneration.ts

const ecs = new ECS()

export const handler = async (event: S3Event) => {
  let { bucket, object } = event.Records[0].s3
  let videoURL = `s3://${bucket.name}/${object.key}`
  let thumbnailName = `${object.key.replace('videos/', '')}.png`
  let framePosition = '01:32'

  await generateThumbnail({
    videoURL,
    thumbnailName,
    framePosition,
    bucketName: bucket.name,
  })
}
```

We initialise the `ECS` instance and this is our main `handler` where we get the `event` (S3Event) in which we get all the details of the uploaded object or video in our case.

In this handler, we extract all the values needed like the bucket name, object key and send them all to the `generateThumbnail` method which we will view below.

__*Note*__: I have provided a static frame position here, but to make this dynamic, it can come from the file name or an external API.

```ts
// functions/initiateThumbnailGeneration.ts

const generateThumbnail = async ({
  videoURL,
  thumbnailName,
  framePosition = '00:01',
  bucketName,
}: GenerateThumbnail) => {
  let params: ECS.RunTaskRequest = {
    taskDefinition: envs.ECS_TASK_DEFINITION,
    cluster: envs.ECS_CLUSTER_NAME,
    count: 1,
    networkConfiguration: {
      awsvpcConfiguration: {
        assignPublicIp: 'ENABLED',
        subnets: envs.VPC_SUBNETS,
        securityGroups: [envs.VPC_SECURITY_GROUP],
      },
    },
    launchType: 'FARGATE',
    overrides: {
      containerOverrides: [
        {
          name: 'ffmpeg',
          environment: [
            { name: 'AWS_REGION', value: envs.AWS_REGION },
            { name: 'INPUT_VIDEO_FILE_URL', value: videoURL },
            { name: 'OUTPUT_THUMBS_FILE_NAME', value: thumbnailName },
            { name: 'POSITION_TIME_DURATION', value: framePosition },
            { name: 'OUTPUT_S3_PATH', value: `${bucketName}/thumbnails` },
          ],
        },
      ],
    },
  }
  try {
    let data = await ecs.runTask(params).promise()
    console.log(
      `ECS Task ${params.taskDefinition} started: ${JSON.stringify(
        data.tasks,
        null,
        2
      )}`
    )
  } catch (error) {
    console.error(
      `Error processing ECS Task ${params.taskDefinition}: ${error}`
    )
  }
}
```

The first block in this function defines the parameters to run the ECS task. The gist of this is that we want to create run just a single instance of this Fargate task with the given task definition inside the cluster that runs the VPC and public subnets obtained from environment variables.

We also add something called `containerOverrides` where we pass the container name that needs the environment variables. In these variables, we pass all the values we constructed in the Lambda handler above like the uploaded `videoURL`, the new `thumbnailName` and `framePosition` for which we want the thumbnail.

Finally, in the `try/catch` block, we run our ECS task via `ecs.runTask` and log the success and error states which will be visible in CloudWatch.

## Dockerfile creation

The Dockerfile creation is quite simple. You can either create your own and deploy it on DockerHub or use the one I created available [here](https://hub.docker.com/repository/docker/ryands1701/thumbnail-creator).

We have two files here that will constitute our image. Let's have a look at the `Dockerfile` first.

```dockerfile
# docker/Dockerfile

FROM jrottenberg/ffmpeg:4.3-ubuntu

RUN apt-get update && \
  apt-get install -y curl unzip && \
  curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"

RUN unzip awscliv2.zip && \ 
  ./aws/install && \
  aws --version

WORKDIR /files

COPY ./copy_thumbs.sh /files

ENTRYPOINT ./copy_thumbs.sh
```

What we do here is use the `jrottenberg/ffmpeg` image and install the [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2-linux.html). This is required to download the video and upload the thumbnail that `ffmpeg` will process.

The last two lines are important here. We copy a certain `copy_thumbs.sh` file and add it to our `ENTRYPOINT` that will be executed when our container is run. Let's look at this file now.

```sh
# docker/copy_thumbs.sh

#!/bin/bash

echo "Downloading ${INPUT_VIDEO_FILE_URL}..."
aws s3 cp ${INPUT_VIDEO_FILE_URL} video.mp4
ffmpeg -i video.mp4 -ss ${POSITION_TIME_DURATION} -vframes 1 -vcodec png -an -y ${OUTPUT_THUMBS_FILE_NAME}

echo "Copying ${OUTPUT_THUMBS_FILE_NAME} to S3 at ${OUTPUT_S3_PATH}/${OUTPUT_THUMBS_FILE_NAME}..."
aws s3 cp ./${OUTPUT_THUMBS_FILE_NAME} s3://${OUTPUT_S3_PATH}/${OUTPUT_THUMBS_FILE_NAME} --region ${AWS_REGION}
```

This file firstly downloads the provided file URL as `video.mp4`. Then we run the `ffmpeg` binary to create a thumbnail from the video on the frame position we specified and outputs it to the name we specified in `OUTPUT_THUMBS_FILE_NAME`. Finally, we upload the thumbnail to S3 in our `thumbnails` prefix/folder and this is again done by the CLI command `aws s3 cp`.

## Deploying the application

As we're done with the constructs and functionality, let's deploy this CDK project and test it out. we need to run `yarn cdk deploy` or `yarn cdk deploy --profile profileName` if you are using a different [AWS profile](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-configure.html).

Now we have deployed the application, you should be able to see a bucket created like this. The name would be different in your environment.

![The S3 bucket created by CDK](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/42kis4j6vcbibehkaop1.png)

Let's create the `videos` folder and upload a video to check if our thumbnail generation task is working. I have uploaded quite a [famous video](https://peach.blender.org/) used widely for testing that you can use too.

After this upload is done, you should see a success message from S3 in this way:

![Video upload successful](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/of8eugkwkpemykxiahvb.png)

This should kick off our Lambda function, so let's check the CloudWatch log group for our Lambda function.

On inspecting the logs, we see that the ECS task has run successfully.

![Lambda ran the ECS task successfully](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7556kw6j7662xzlxpy3z.png)

Let's also check our Fargate task logs to confirm if the thumbnail has been created.

![Fargate task logs](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2n7s945gza10xiizwk3z.png)

This means that our task has completed successfully! Finally let's check our S3 bucket and confirm. Voila! The thumbnail has been generated successfully and the output is exactly at the provided frame :)

![Thumbnail generated in S3](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0e4w8p2ux7jtgne9m55l.png)

![The output of the thumbnail generated](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/y6hbqhod593kbyk2zc0d.png)

So this is how we can generate thumbnails for our video in a truly serverless fashion by using a combination of Lambda and Fargate.

__*Note*__: Do not forget to **delete this stack** after completion by running `yarn cdk destroy` or `yarn cdk destroy --profile profileName` if you're using a [custom profile](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-configure.html).

Again the link to the repo if you haven't checked it out yet :)

{% github ryands17/thumbnail-creator no-readme %}

I hope you liked this post, so it would be great if you could like, share, and also provide some feedback if I have missed something. Thanks for reading!