How to run docker containers in AWS Lambda - along with CI/CD pipeline

How to run docker containers  in AWS Lambda - along with CI/CD pipeline

In this article, you're going to learn about how to run docker containers in AWS Lambda. Before discussing on how to do that, let us discuss about the need of running the lambda using docker.

Sometimes, your lambda function may require huge number of dependencies or dependencies with large size. As you may know, the maximum size of  packaged lambda is 250 MB. But if your package exceeds that limit, you can make use of docker containers as the maximum size of docker image is 10GB.

Even though you're running docker containers in lambda, still you need to implement lambda API and restriction of maximum 15 minutes of execution time of lambda still apply.

Architecture

Below is the high level architecture diagram of the simple application that we're going to build in this article

Lambda function code: This is where your application logic resides. We'll be creating DockerFile in this repository so that we can package the code as docker image

Github CI/CD Pipeline:  Whenever code is pushed into the remote branch, we want the Github workflow to run which in turn will build and push the image to AWS ECR repository

Lambda function: This lambda function will refer to the image in the ECR repository

Eventbridge Rule: We'll create event bridge rule to create a schedule so that lambda can be triggered periodically based on the schedule.

Now, let's create actual application.

Lambda with Docker

We're going to use nodejs framework in this example. Create a new folder and initialize the project

mkdir docker-lambda
cd docker-lambda
npm init -y

We're going to use TypeScript for writing our lambda function and we're going to install esbuild (as dev dependency) to convert our typescript code to javascript code.

npm i -D esbuild

We also install the @types/aws-lambda package as dev dependency for getting type definitions of aws-lambda

Lambda function

I'm keeping my lambda functions pretty simple with couple of log statements. In real world, you'll be using huge number of dependencies (or dependencies with large size), if you're using docker in lambda.

import { Context, APIGatewayProxyResult, APIGatewayEvent } from 'aws-lambda';

export const handler = async (
  event: APIGatewayEvent,
  context: Context
): Promise<APIGatewayProxyResult> => {
  console.log(`Event: ${JSON.stringify(event, null, 2)}`);
  console.log(`Context: ${JSON.stringify(context, null, 2)}`);
  return {
    statusCode: 200,
    body: JSON.stringify({
      message: 'Running this handler from docker',
    }),
  };
};

Now, update the build script in package.json to convert typescript to javascript code using esbuild

 "scripts": {
    "build": "esbuild index.ts --bundle --minify --sourcemap --platform=node --target=es2020 --outfile=dist/index.js"
  },

Dockerfile

We've created lambda function and build script. Now, it is time to create Dockerfile so that we can create docker image of this lambda function that we wrote earlier.

One of the requirement of using docker with lambda is that the base image that you use must implement the runtime interface client to manage the interaction between lambda and function code.

AWS provides many base images which already implement the runtime API that you can use. One such image is public.ecr.aws/lambda/nodejs:16

Based on your language/framework - AWS provides many such base images here

Below is the Dockerfile for lambda. This is a multi stage build so that we can reduce the size of the final image.

FROM public.ecr.aws/lambda/nodejs:16 as builder
WORKDIR /usr/app
COPY package.json index.ts  ./
RUN npm install
RUN npm run build


FROM public.ecr.aws/lambda/nodejs:16
WORKDIR ${LAMBDA_TASK_ROOT}
COPY --from=builder /usr/app/dist/* ./
CMD ["index.handler"]

The first 5 lines belong to the build stage and the last 4 lines are creating final image.

Build stage:

In the build stage, we're using the base image (which implements the Runtime API) provided by lambda. We set the WORKDIR and copy package.json and index.ts to that directory. Then, we install and run the build.

Creating final image:

We're using the base image of AWS lambda and set the WORKDIR as LAMBDA_TASK_ROOT . LAMBDA_TASK_ROOT is a reserved environment variable used by AWS lambda which contains the path of the lambda function code. As we want to copy the built files(javascript files that came out of build command) to this path, we're using this predefined reserved environment variable. And, finally we use the start command.

Infrastructure

As mentioned earlier, even though you use Docker with lambda, still Lambda works in the same way as earlier. Lambda will be executed as a result of some event - This event could be uploading file to S3 or someone firing HTTP event on API Gateway or message to a SQS or from SNS and so on.

To make things simple, we're going to use EventBridge to create a schedule so that this lambda can be triggered periodically.

We're going to create a simple CDK project. This project will have 2 stacks - one for creating ECR Repository (let's call this RepoStack ) and another (let's call this LambdaStack ) for creating a event schedule.

The reason why we're creating 2 stacks is because of the dependency

  • First stack is for creating ECR Repository
  • Second stack is for creating lambda function with the ECR image (to be provided by CI/CD pipeline) and for creating event schedule

Even though we create 2 stacks in the CDK project, we'll deploy the first stack and then build CI/CD pipeline so that image can be pushed to ECR repository and then finally we'll deploy the second stack.

mkdir docker-lambda-stack
cd docker-lambda-stack
cdk init app --language=typescript

Stack for creating ECR repository:

Below is the CDK code for creating ECR repository.

export class AwsCdkDockerLambdaRepoStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const repo = new ecr.Repository(this, 'dockerLambda', {
      repositoryName: 'docker-lambda',
    });
  }
}

This is a pretty simple code where we just have to mention the name of the repository through the property repositoryName  for the construct

Stack for creating Lambda and event schedule:

Below is the code for creating lambda and event schedule to trigger the lambda

export class AwsCdkDockerLambdaStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const repo = ecr.Repository.fromRepositoryName(
      this,
      'dockerLambda',
      'docker-lambda'
    );

    const dockerLambda = new lambda.DockerImageFunction(
      this,
      'DockerLambdaFunction',
      {
        code: lambda.DockerImageCode.fromEcr(repo),
      }
    );

    const every5MinRateER = new events.Rule(this, 'every5MinRateER', {
      schedule: events.Schedule.expression('rate(5 minutes)'),
    });

    every5MinRateER.addTarget(new targets.LambdaFunction(dockerLambda));
  }
}

First, we're referring to the repo that we've created earlier in other CDK stack. Please note that we're NOT creating new repository here - we're just referring to existing repository that we've created. Then, we create a lambda with code from the ECR repository image

And finally, we create a event schedule to run every 5 minutes and add lambda as target for the event schedule. You can use any type of event (S3, SQS, SNS etc..) to trigger lambda. Just for the sake of simplicity, I've created event schedule.

Wiring the stacks in the CDK App:

You can modify the CDK App (located in the bin folder) as below

const app = new cdk.App();
new AwsCdkDockerLambdaRepoStack(app, 'RepoStack', {});
new AwsCdkDockerLambdaStack(app, 'LambdaStack', {});

Deploying the first stack:

You can deploy the first stack by executing the below command

cdk deploy RepoStack

This will create a ECR repository by name docker-lambda . Please note that we can't deploy second stack as we don't have any image in the ECR repository yet. The image for the ECR repository would be pushed by the CI/CD pipeline which we're going to create next

CI/CD pipeline

The steps involved in creating CI/CD pipeline for the lambda with docker is pretty simple. You just need to checkout the code. Then build, tag and push the image to ECR

We're going to create CI/CD pipeline using Github Actions. If you're new to Github Actions - I've written a beginner's guide to Github Actions. Please read that first before proceeding.

Create a folder .github/workflows in the root of your folder(where your lambda code is located) and create file by name deploy.yml

Below is the code for deploy.yml file. Let me explain what line in this workflow file does.

name: build & deploy lambda docker image to ECR

on: [push]

jobs:
  deploy-lambda-docker-image:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - name: checkout code
        uses: actions/checkout@v3
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          role-to-assume: arn:aws:iam::853185881679:role/github-actions
          aws-region: us-east-1
      - name: Login to AWS ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v1
      - name: Build, tag, and push image to Amazon
        id: build-image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          ECR_REPOSITORY: docker-lambda
          IMAGE_TAG: latest
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"

name: The name property represents the name of the workflow. You can name anything you want

on: We want this workflow to be executed on push so that if any code is pushed to the remote branch - we want this workflow to run

jobs: We've a single job deploy-lambda-docker-image and it runs on ubuntu-latest

Instead of storing access key and secret key in your repository secrets, you can use OIDC from Gitub to connect to AWS . This approach is more secure and is recommended one. And, we've added required permissions to read the token.

Steps:

checkout code: As the name implies, we're checking out the code in this step

Login to AWS ECR: In this step, we're logging into ECR service

Build, tag and push image to Amazon : In this step, we're building the image, tag it and push it to ECR

Deploying the Event Schedule with Lambda

Now, it is time to deploy the other CDK stack that we've created earlier.

cdk deploy LambdaStack

This will create event schedule and trigger the lambda based on the schedule.

Now, the lambda will be triggered every 5 minutes and you can see the log statements in CloudWatch service