DEV Community

Ankit Sadana
Ankit Sadana

Posted on • Edited on

Deploying CDK Lambda with Docker and Gitlab CI

Disclaimer: This is not meant to be start from basics tutorial. The following assumes that you are familiar with CDK and Gitlab-CI concepts. Consider this Level 200 in AWS guidance terms.

The Why 🤔

Deploying AWS Lambda with CDK is pretty straightforward and uses zip files, not Docker for simple code base. However, as soon as you get past the basic handler use-case, like setting up poetry for your Python dependencies, you have to switch from using aws_lambda to using language specific module like aws-lambda-python, which rely on Docker packaging to deploy the Lambda.

Now this is all great, and works for local dev environments but a lot of CI platforms like Gitlab CI runs the jobs in a docker container. So now what? We build docker image inside a docker container? Yes!
This feature is called Docker-in-Docker, which allows us to use a base image with the right dependencies to build a docker image.

The How 🏗

Talk is cheap, show me the code!

Basic Lambda (non-Docker)

OK, enough background, let's get started with a basic Python Lambda with CDK and gitlab-ci.yml.

CDK stack

import * as cdk from 'aws-cdk-lib';
import { AssetCode, Function, Runtime } from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';

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

    const testingLambda = new Function(this, 'TestLambda', {
      functionName: 'TestLambda',
      runtime: Runtime.PYTHON_3_9,
      code: new AssetCode('resources/lambdas/testing-lambda'),
      handler: 'index.handler',
      environment: {
        "LOG_LEVEL": "INFO"
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Python Lambda

import logging
import os

logger = logging.getLogger("MyTestLambda")
logger.setLevel(os.getenv("LOG_LEVEL", "INFO"))

def handler(event, context):
    logging.info(f"Starting lambda with event: {event}")

    return {
        "result": "Yay, we deployed a lambda!"
    }
Enter fullscreen mode Exit fullscreen mode

.gitlab-ci.yml

---
variables:
  BUILD_IMAGE: node:lts-alpine

stages:
  - aws-cdk-diff
  - aws-cdk-deploy

.setup:
  script:
    - node --version  # Print out nodejs version for debugging
    - apk add --no-cache aws-cli  # install aws-cli
    - npm install -g aws-cdk  # install aws-cdk
    - npm install  # install project dependencies

.assume:
  # Use the web-identity to fetch temporary credentials
  script:
    - >
      export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s"
      $(aws sts assume-role-with-web-identity
      --role-arn ${AWS_ROLE_ARN}
      --role-session-name "GitLabRunner-${CI_PROJECT_ID}-${CI_PIPELINE_ID}"
      --web-identity-token $CI_JOB_JWT_V2
      --duration-seconds 900
      --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]'
      --output text))

aws-cdk-diff:
  image: $BUILD_IMAGE
  stage: aws-cdk-diff
  script:
    - !reference [.setup, script]
    - !reference [.assume, script]
    - cdk diff

aws-cdk-deploy:
  image: $BUILD_IMAGE
  stage: aws-cdk-deploy
  script:
    - !reference [.setup, script]
    - !reference [.assume, script]
    - cdk bootstrap
    - cdk deploy --require-approval never

Enter fullscreen mode Exit fullscreen mode

Most of this is pretty standard but let me highlight the AWS credential bit. It's bad practice to use long-lived credentials anywhere, so we are using OpenID Connect to retrieve temporary AWS credentials - see Gitlab Docs.

.assume:
  # Use the web-identity to fetch temporary credentials
  script:
    - >
      export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s"
      $(aws sts assume-role-with-web-identity
      --role-arn ${AWS_ROLE_ARN}
      --role-session-name "GitLabRunner-${CI_PROJECT_ID}-${CI_PIPELINE_ID}"
      --web-identity-token $CI_JOB_JWT_V2
      --duration-seconds 900
      --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]'
      --output text))
Enter fullscreen mode Exit fullscreen mode

The gitlab runner assumes AWS_ROLE_ARN (stored in Variables) to retrieve the credentials and export them as variables.

For full code for basic Lambda deployment: cdk-python-lambda-example/-/tree/basic-lambda.

Docker Lambda

Let's make the required changes, start with adding poetry...

├── index.py
├── poetry.lock
└── pyproject.toml
Enter fullscreen mode Exit fullscreen mode

We are going to add aws-lambda-powertools as poetry dependency we need. While this is available as Lambda Layer, we are going to use it as a installed dependency for this exercise.

[tool.poetry.dependencies]
python = "^3.11"
aws-lambda-powertools = "^2.14.1"
Enter fullscreen mode Exit fullscreen mode

Now let's update the CDK to handle poetry.

import { PythonFunction } from '@aws-cdk/aws-lambda-python-alpha';
import * as cdk from 'aws-cdk-lib';
import { Runtime } from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';

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

    const testingLambda = new PythonFunction(this, 'TestLambda', {
      functionName: 'TestLambda',
      runtime: Runtime.PYTHON_3_9,
      entry: 'resources/lambdas/testing-lambda',
      index: 'index.py',
      handler: 'handler',
      environment: {
        LOG_LEVEL: 'INFO',
        POWERTOOLS_SERVICE_NAME: 'TestLambda',
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

If you push this as is, without changing the .gitlab-ci.yml, the runner will exit with the following error -

Error: spawnSync docker ENOENT
 ... {
  errno: -2,
  code: 'ENOENT',
  syscall: 'spawnSync docker',
  path: 'docker',
  spawnargs: [
    'build',
    '-t',
    'cdk-1234567890',
    '--platform',
    'linux/amd64',
    '--build-arg',
    'IMAGE=public.ecr.aws/sam/build-python3.10',
    '/builds/asadana/cdk-python-lambda-example/node_modules/@aws-cdk/aws-lambda-python-alpha/lib'
  ]
}
Subprocess exited with error 1
Enter fullscreen mode Exit fullscreen mode

Now, let's plug in the right parts to support building docker images.

---
image: docker:dind

services:
  - docker:dind

stages:
  - aws-cdk-diff
  - aws-cdk-deploy

cache:
  # Caching 'node_modules' directory based on package-lock.json
  key:
    files:
      - package-lock.json
  paths:
    - node_modules/

.setup:
  script:
    - apk add --no-cache aws-cli nodejs npm
    - node --version  # Print version for debugging
    - npm install -g aws-cdk
    - npm install  # install project dependencies

.assume:
  # Use the web-identity to fetch temporary credentials
  script:
    - >
      export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s"
      $(aws sts assume-role-with-web-identity
      --role-arn ${AWS_ROLE_ARN}
      --role-session-name "GitLabRunner-${CI_PROJECT_ID}-${CI_PIPELINE_ID}"
      --web-identity-token $CI_JOB_JWT_V2
      --duration-seconds 900
      --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]'
      --output text))

aws-cdk-diff:
  stage: aws-cdk-diff
  script:
    - !reference [.setup, script]
    - !reference [.assume, script]
    - cdk diff

aws-cdk-deploy:
  stage: aws-cdk-deploy
  script:
    - !reference [.setup, script]
    - !reference [.assume, script]
    - cdk bootstrap
    - cdk deploy --require-approval never
Enter fullscreen mode Exit fullscreen mode

Here, the image docker:dind points to the docker image that is created specifically for Docker-in-Docker purpose.
The services - docker:dind part links the dind service to all the jobs in the yaml and enables CDK to build our Lambda image.

✅  CdkPythonLambdaExampleStack
✨  Deployment time: 34.83s
Enter fullscreen mode Exit fullscreen mode

This works!
Full code for this working example can be found here - cdk-python-lambda-example.

Future Improvements ⏩

Since we are using a base docker images, our setup script is still a bit big. One of the improvements here could be to pull the base image into it's own Dockerfile that extends with our dependencies pre-packaged.

A downside of this setup is a known issue of using Docker-in-Docker is there's no caching since each environment is new - reference. This makes the CDK diff and deploy re-create all docker layers on each execution, increasing our build times.
If we can leverage --cache-from build argument in CDK image building, we can reduce those times down.


Thank you for reading this! I hope the example helped you in some way.
If you have any feedback or questions, please let me know
🙂.

Top comments (0)