Dynamically Resizing Images With AWS Lambda

Photo by Deon Black on Unsplash

Dynamically Resizing Images With AWS Lambda

Learn how to harness the power of AWS to automatically resize and cache your images on the fly!

ยท

11 min read

Introduction

Recently I was in the process of setting up my newest side project, where I had my first encounter with handling images to be displayed on the client-side, which turned out to be no trivial task!

Most of us when we visit a website take images for granted, however, getting every image uploaded to be displayed as a particular size is a lot trickier than one would initially think, and that's why I'm going to be discussing my approach to resizing images dynamically using AWS Lambda!

Different approaches

As with most things in the discipline of software engineering, there are many ways you could approach this problem.

Approach 1 (bad)

The first idea that came to mind was to simply resize every image myself before uploading it. This is by far the simplest method, and to be fair it would work as I handle all of the content that gets uploaded to my website. However, what happens if in the future I allow other people to upload content to my site. How can I make sure they upload the right-sized content? Not to mention manually resizing images is really annoying. Let's think of something else.

Approach 2 (better)

The next idea I thought of was to have a cloud function automatically resize my images when I uploaded them to the bucket and store the resized images with a particular prefix in a new bucket. On initial thought, this idea sounds great since I don't have to take care of manually resizing my images. However, I found that this solution had a few drawbacks. The main drawback that came with this was the lack of flexibility it produced. If I defined my cloud function to resize images to 1000x500 initially, and then I found out I really needed 1000x750, the cloud function would not have been able to update the images to reflect this without extra development effort from me. In addition, if I had multiple dimensions images could take, how would the cloud function know which particular dimensions to resize it to? Would it just resize it to every dimension? This isn't particularly storage effective, for example, if I had 10 different image sizes and each image only needed 1 type of dimension but I created copies for all 10 dimensions, this would increase my cost 10x. There has to be a better way...

Approach 3 (GOOD)

Finally, now we will consider dynamic image resizing. I will upload my images to a separate storage container in the cloud, and then I will make a request to my server which will return the resized image if it exists, or it will dynamically resize it at runtime, store it for later use, and then return it to me all within the same request. This makes sure that my images are only ever resized for the dimensions they require saving me storage fees and allowing me to easily request a different set of dimensions in the future if the frontend requirements change. This is the solution we will be implementing in this article!

High level overview

Here's a diagram providing a high-level overview of what we are going to be building.

Screenshot from 2022-06-25 20-18-59.png

We will have an AWS API Gateway service which will act as the entry point for image requests. This gateway will forward the image request to a lambda function along with the URL params containing the requested image and size, which will then check the S3 resized bucket for the image. If it finds the resized image exists in this bucket, it will simply return it. However, if the image doesn't exist, we will get the image from the S3 Image bucket, resize it, store it in the resized bucket, and then return it.

Requirements

To follow along you will need

Implementation

Creating the S3 buckets

First of all, you will need to create two S3 buckets. One of these buckets will be used for storing your original copies of the images that you wish to have uploaded, and the other will store your resized images. You may call your buckets whatever you wish, but make sure you remember which bucket is responsible for what.

Next, make sure that both of your buckets block all public access. This is a best practice recommended by AWS and will ensure that your images can only be accessed from API Gateway which we will set up later on.

You should now see something like this in your S3 console.

Screenshot from 2022-06-25 20-33-24.png

Creating the Lambda function

This is gonna be the trickiest step so far. We're going to be writing our Lambda function using TypeScript and we're going to be using the Sharp library for resizing our images. When I was setting this up for myself the first time I encountered a few issues with Lambda not working well Sharp when the function was uploaded directly, so therefore we will be uploading our Lambda function as a container using Docker.

Creating the function locally

Run npm init -y in a new directory to create a new npm package for our Lambda function. Now inside that directory run the following commands to install the dependencies for the project npm install -D @types/aws-lambda @types/node @types/sharp typescript && npm install aws-sdk sharp .

To setup TypeScript, create a new file called tsconfig.json and add the following code:

{
    "compilerOptions": {
        "target": "es2020",
        "module": "commonjs",
        "outDir": "./dist",
        "esModuleInterop": true,
        "forceConsistentCasingInFileNames": true,
        "strict": true,
        "skipLibCheck": true
    }
}

This just specifies that we want our compiled TypeScript code to be output to the dist directory.

Now create a new file called template.yaml and add the following code:

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31

Resources:
    FactureResizeFunction:
        Type: AWS::Serverless::Function
        Properties:
            PackageType: Image
            Architectures:
                - x86_64
        Metadata:
            DockerTag: nodejs16.x-v1
            DockerContext: .
            Dockerfile: Dockerfile

This is going to define how SAM will build our Lambda function for us from the Dockerfile we provide.

Now we're going to create our function. Create a new directory called src, then navigate into the newly-created directory. Here we're going to add the code which will define our Lambda function.

First, create a new file handleNoSize.ts and add the following code:

import { APIGatewayProxyResult } from "aws-lambda";
import S3 from "aws-sdk/clients/s3";

import { getExtension } from "./misc";

export default async (fileName: string, coldBucket: string, s3: S3): Promise<APIGatewayProxyResult> => {
    const fileExtension = getExtension(fileName);

    const uploaded = await s3.getObject({ Bucket: coldBucket, Key: fileName }).promise();

    return {
        statusCode: 200,
        headers: { "Content-Type": "application/" + fileExtension, "Content-Disposition": `attachment; filename=${fileName}` },
        body: uploaded.Body?.toString("base64") || "",
        isBase64Encoded: true,
    };
};

This function handles requests that do not specify a size and just wish to receive the raw image. It simply pulls the raw image from the original bucket, then returns it.

Next, create a file called handleResize.ts and add the following code:

import { APIGatewayProxyResult } from "aws-lambda";
import S3 from "aws-sdk/clients/s3";
import sharp from "sharp";

import { getExtension } from "./misc";

export default async (fileName: string, key: string, dimensions: { width: number; height: number }, coldBucket: string, resizedBucket: string, s3: S3): Promise<APIGatewayProxyResult> => {
    const fileExtension = getExtension(fileName);

    const uploaded = await s3.getObject({ Bucket: coldBucket, Key: fileName }).promise();

    const image = await sharp(uploaded.Body as Buffer)
        .resize(dimensions.width, dimensions.height)
        .toBuffer();

    await s3.upload({ Body: image, Bucket: resizedBucket, Key: key }).promise();

    return {
        statusCode: 200,
        headers: { "Content-Type": "application/" + fileExtension, "Content-Disposition": `attachment; filename=${key}` },
        body: image.toString("base64"),
        isBase64Encoded: true,
    };
};

This function pulls an image from the original S3 bucket from its name and resizes it to the specified dimensions, stores it in the resized bucket for further use, then finally returns it.

Similarly, create a file called handleResized.ts and add the following code:

import { APIGatewayProxyResult } from "aws-lambda";
import S3 from "aws-sdk/clients/s3";

import { getExtension } from "./misc";

export default async (key: string, resizedBucket: string, s3: S3): Promise<APIGatewayProxyResult> => {
    const fileExtension = getExtension(key);

    const uploaded = await s3.getObject({ Bucket: resizedBucket, Key: key }).promise();

    return {
        statusCode: 200,
        headers: { "Content-Type": "application/" + fileExtension, "Content-Disposition": `attachment; filename=${key}` },
        body: uploaded.Body?.toString("base64") || "",
        isBase64Encoded: true,
    };
};

This function will find a resized image from the resized S3 bucket and then return it.

Now, create a file called index.ts and add the following code:

import { APIGatewayEvent, APIGatewayProxyResult } from "aws-lambda";
import S3 from "aws-sdk/clients/s3";

import handleNoSize from "./handleNoSize";
import handleResize from "./handleResize";
import handleResized from "./handleResized";

const s3 = new S3({ apiVersion: "2006-03-01" });

const COLD_BUCKET = process.env.COLD_BUCKET as string;
const RESIZED_BUCKET = process.env.RESIZED_BUCKET as string;
const ALLOWED_DIMENSIONS = new Set();

// Allowed dimensions format: "wxh,16x16,28x28"
if (process.env.ALLOWED_DIMENSIONS) {
    const dimensions = process.env.ALLOWED_DIMENSIONS.split(",");
    dimensions.forEach((dimension) => ALLOWED_DIMENSIONS.add(dimension));
}

export const handler = async (event: APIGatewayEvent): Promise<APIGatewayProxyResult> => {
    const fileName = event.pathParameters?.file;
    const size = event.queryStringParameters?.size;

    if (!fileName) throw Error("No file name provided");
    if (!size) return await handleNoSize(fileName, COLD_BUCKET, s3);

    if (ALLOWED_DIMENSIONS.size > 0 && !ALLOWED_DIMENSIONS.has(size)) return { statusCode: 403, headers: {}, body: "" };

    const resizedKey = size + "." + fileName;

    try {
        return await handleResized(resizedKey, RESIZED_BUCKET, s3);
    } catch {
        const split = size.split("x");

        return await handleResize(fileName, resizedKey, { width: parseInt(split[0]), height: parseInt(split[1]) }, COLD_BUCKET, RESIZED_BUCKET, s3);
    }
};

This will be the entry point of our Lambda function and will route the request to the functions we specified above.

Finally, create a file misc.ts and add the following code:

export const getExtension = (fileName: string) => {
    const split = fileName.split(".");
    return split[split.length - 1];
};

These are just some helper functions for the other files.

You'll be happy to know that that's all of the code required for the implementation of our Lambda function. Now we just need to deploy it.

To do this we must first Dockerize our application. To do this, navigate back to the root of your package directory and create a new file called Dockerfile then add the following code:

FROM public.ecr.aws/lambda/nodejs:16 AS builder

WORKDIR /usr/local

COPY . .

RUN npm ci

RUN npm run build

FROM public.ecr.aws/lambda/nodejs:16 AS production

COPY --from=builder /usr/local/dist /usr/local/package*.json ./

RUN npm ci --production

CMD ["dist/index.handler"]

This Dockerfile is going to install our project dependencies, build our code, and then expose the handler so that it can be invoked by the Lambda runtime once it is deployed.

Deploying and setting up the function on AWS

Now all that is left to do is deploy our Lambda function! To do this we will be using the SAM CLI.

First run sam build, which will go through and build our Docker container from our Dockerfile.

After making sure that you're logged in to the AWS CLI on your machine, run sam deploy --guided and follow the onscreen walkthrough. Now if you navigate to the AWS Lambda console, you should see your deployed function!

Screenshot from 2022-06-25 21-07-15.png

Now navigate to the dashboard of your newly created Lambda function as we have to add a few more things for the function to work.

First, navigate to the Configuration tab and then navigate to the Permissions tab. Click on the role name which will allow you to configure the permissions for your Lambda function.

image.png

Now click Add permissions, Attach policies, and give your role the AmazonS3FullAccess policy, which will allow your Lambda function to interact with your S3 buckets easily.

Now navigate back to your Lambda function dashboard and go to Environment variables and create the following variables:

image.png

Replace the value for COLD_BUCKET and RESIZED_BUCKET with the respective names you gave to your S3 buckets created earlier. ALLOWED_DIMENSIONS contains a comma separate list of dimensions that the function can resize images to. If you want your function to resize to any dimension, set this value as an empty string.

Setting up API Gateway

Now the last thing for us to do is to setup API Gateway so that our Lambda function can be accessed easily through the web.

Navigate to the API Gateway console and select Create API. Next, choose build on the REST API option (not the private one). Give your API a name, set the Endpoint Type to Regional, and select Create API.

Now under Actions, select Create Resource. Give the resource a name and set the Resource Path as {file}, which will specify the URL param that contains the image name we are requesting that will be passed to our Lambda function. Finally press Create Resource.

image.png

Now under Actions again, select Create method. Choose GET from the dropdown box and press the tick to confirm it. Tick the Use Lambda Proxy Integration box, and then select your Lambda function from the dropdown. Finally press Save.

image.png

Now select navigate to Method Request, and add a URL query string param named size, which will be used for requesting the dimensions of the resized image we wish to receive, then press the tick to confirm it.

image.png

image.png

Now, navigate to Settings under your API name, and update the binary media types parameter to be */*, then press Save Changes.

image.png

image.png

Now all that is left to do is deploy your API. Navigate back to Resources, and under Actions select Deploy API. Create a new deployment stage, then press Deploy.

Congratulations, your automatic image resizing API should now be deployed! You can find the URL on the dashboard, and test it out by entering it into a search bar with the form YOUR_API_URL/YOUR_IMAGE_NAME?size=WIDTHxHEIGHT. If all goes to plan, your image should download with the requested dimensions!

Conclusion

So now you know how to automatically resize your images on the fly! If you want you can extend this even further by putting a CDN in front of your API to have your images served around the globe at blazing-fast speeds! Good luck with whatever you go ahead and build using this!

ย