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"
}
});
}
}
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!"
}
.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
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))
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
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.9"
aws-lambda-powertools = "^2.14.1"
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',
}
});
}
}
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.9',
'/builds/asadana/cdk-python-lambda-example/node_modules/@aws-cdk/aws-lambda-python-alpha/lib'
]
}
Subprocess exited with error 1
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
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
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)