CDK Local Lambdas

Local Development and Testing of AWS CDK Lambdas

Updated: 03 September 2023

Introduction

The AWS CDK enables us to define application infrastructure using a programming language instead of markup, which is then transformed by the CDK to CloudFormation templates for the management of cloud infrustructure services

The CDK supports TypeScript, JavaScript, Python, Java, and C#

Prerequisites

AWS Lamda development requires SAM to be installed, depending on your OS you can use the installation instructions here

In addition to SAM you will also require Docker

I’m using aws-sam-cli@1.12.0 to avoid certain compat issues from the current version

And lastly, you will need to install cdk

1
npm i -g aws-cdk

Init Project

To initialize a new project using SAM and CDK run the following command:

Terminal window
1
mkdir my-project
2
cd my-project
3
cdk init app --language typescript
4
npm install @aws-cdk/aws-lambda

This will generate the following file structure:

1
my-project
2
|- .npmignore
3
|- jest.config.js
4
|- cdk.json
5
|- README.md
6
|- .gitignore
7
|- package.json
8
|- tsconfig.json
9
|- bin
10
|- my-project.ts
11
|- lib
12
|- my-project-stack.ts
13
|- test
14
|- my-project.test.ts

In the generated files we can see the bin/my-project.ts file which creates an instance of the Stack that we expose from lib/my-project-stack.ts

bin/my-project.ts

1
#!/usr/bin/env node
2
import 'source-map-support/register'
3
import * as cdk from '@aws-cdk/core'
4
import { MyProjectStack } from '../lib/my-project-stack'
5
6
const app = new cdk.App()
7
new MyProjectStack(app, 'MyProjectStack', {})

Create a Handler

Next, we can create a handler for our file, we’ll use the Typescript handler but the concept applies to any handler we may want to use

First, we’ll export a handler function from our code, I’ve named this handler but this can be anything and we will configure CDK as to what function to look for. We’ll do this in the lambdas/hello.ts file as seen below. Note the use of the APIGatewayProxyHandler type imported from aws-lambda, this helps inform us if our event and return types are what AWS expects

lambdas/hello.ts

1
import { APIGatewayProxyHandler } from 'aws-lambda'
2
3
export const handler: APIGatewayProxyHandler = async (event) => {
4
console.log('request:', JSON.stringify(event, undefined, 2))
5
6
const res = {
7
hello: 'world',
8
}
9
10
return {
11
statusCode: 200,
12
headers: { 'Content-Type': 'application/json' },
13
body: JSON.stringify(res),
14
}
15
}

Define Stack

Next, in order to define our application stack we will need to use CDK, we can do this in the lib/my-project-stack.ts file utilizing @aws-cdk/aws-lambda-nodejs to define our Nodejs handler:

lib/my-project-stack.ts

1
import * as cdk from '@aws-cdk/core'
2
import { NodejsFunction } from '@aws-cdk/aws-lambda-nodejs'
3
4
export class MyProjectStack extends cdk.Stack {
5
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
6
super(scope, id, props)
7
8
// this defines a Nodejs function handler
9
const hello = new aws_lambda_nodejs_1.NodejsFunction(this, 'HelloHandler', {
10
runtime: lambda.Runtime.NODEJS_12_X,
11
// code located in lambdas directory
12
entry: 'lambdas/hello.ts',
13
// use the 'hello' file's 'handler' export
14
handler: 'handler',
15
})
16
}
17
}

If we want, we can alternatively use the lower-level cdk.Function class to define the handler like so:

1
const hello = new lambda.Function(this, 'HelloHandler', {
2
runtime: lambda.Runtime.NODEJS_12_X,
3
// define directory for code to be used
4
code: lambda.Code.fromAsset('./lambdas'),
5
// define the name of the file and handler function
6
handler: 'hello.handler',
7
})

Note, avoid running the above command using npm run sdk ... as it will lead to the template.yaml file including the npm log which is not what we want

Create API

Next, we need to add our created lambda to an API Gateway instance so that we can route traffic to it, we can do this using the @aws-cdk/aws-apigateway package

To setup the API we use something like this in the Stack:

1
let api = new apiGateway.LambdaRestApi(this, 'Endpoint', {
2
handler: hello,
3
})

So our Stack now looks something like this:

lib/my-project-stack.ts

1
import * as cdk from '@aws-cdk/core'
2
import * as lambda from '@aws-cdk/aws-lambda'
3
import * as apiGateway from '@aws-cdk/aws-apigateway'
4
import { NodejsFunction } from '@aws-cdk/aws-lambda-nodejs'
5
6
export class MyProjectStack extends cdk.Stack {
7
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
8
super(scope, id, props)
9
10
// define the `hello` lambda
11
const hello = new NodejsFunction(this, 'HelloHandler', {
12
runtime: lambda.Runtime.NODEJS_12_X,
13
// code located in lambdas directory
14
entry: 'lambdas/hello.ts',
15
// use the 'hello' file's 'handler' export
16
handler: 'handler',
17
})
18
19
// our main api
20
let api = new apiGateway.LambdaRestApi(this, 'Endpoint', {
21
handler: hello,
22
})
23
}
24
}

Generate Template

Now that we have some API up, we can look at the process for making it requestable. The first step in the process for running this locally is generating a template.yaml file which the sam CLI will look for in order to setup the stack

We can build a Cloud Formation template using the cdk synth command:

Terminal window
1
cdk synth --no-staging > template.yaml

You can take a look at the generated file to see the CloudFormation config that CDK has generated, note that creating the template this way is only required for local sam testing and isn’t the way this would be done during an actual deployment kind of level

Run the Application

Once we’ve got the template.yaml file it’s just a matter of using sam to run our API. To start our API Gateway application locally we can do the following:

Terminal window
1
sam local start-api

This will allow you to make requests to the lambda at http://localhost:3000. A GET request to the above URL should result in the following:

1
{
2
"hello": "world"
3
}

Use a DevContainer

I’ve also written a Dev container Docker setup file for use with CDK and SAM, It’s based on the Remote Containers: Add Development Container Configuration Files > Docker from Docker and has the following config:

Dockerfile

1
# Note: You can use any Debian/Ubuntu based image you want.
2
FROM mcr.microsoft.com/vscode/devcontainers/base:ubuntu
3
4
# [Option] Install zsh
5
ARG INSTALL_ZSH="true"
6
# [Option] Upgrade OS packages to their latest versions
7
ARG UPGRADE_PACKAGES="false"
8
# [Option] Enable non-root Docker access in container
9
ARG ENABLE_NONROOT_DOCKER="true"
10
# [Option] Use the OSS Moby CLI instead of the licensed Docker CLI
11
ARG USE_MOBY="true"
12
13
# Install needed packages and setup non-root user. Use a separate RUN statement to add your
14
# own dependencies. A user of "automatic" attempts to reuse an user ID if one already exists.
15
ARG USERNAME=automatic
16
ARG USER_UID=1000
17
ARG USER_GID=$USER_UID
18
COPY library-scripts/*.sh /tmp/library-scripts/
19
RUN apt-get update \
20
&& /bin/bash /tmp/library-scripts/common-debian.sh "${INSTALL_ZSH}" "${USERNAME}" "${USER_UID}" "${USER_GID}" "${UPGRADE_PACKAGES}" "true" "true" \
21
# Use Docker script from script library to set things up
22
&& /bin/bash /tmp/library-scripts/docker-debian.sh "${ENABLE_NONROOT_DOCKER}" "/var/run/docker-host.sock" "/var/run/docker.sock" "${USERNAME}" "${USE_MOBY}" \
23
# Clean up
24
&& apt-get autoremove -y && apt-get clean -y && rm -rf /var/lib/apt/lists/* /tmp/library-scripts/
25
26
# install python and pip
27
RUN apt-get update && apt-get install -y \
28
python3.4 \
29
python3-pip
30
31
# install nodejs
32
RUN apt-get -y install curl gnupg
33
RUN curl -sL https://deb.nodesource.com/setup_14.x | bash -
34
RUN apt-get -y install nodejs
35
36
# install cdk
37
RUN npm install -g aws-cdk
38
39
# install SAM
40
RUN pip3 install aws-sam-cli==1.12.0
41
42
# Setting the ENTRYPOINT to docker-init.sh will configure non-root access to
43
# the Docker socket if "overrideCommand": false is set in devcontainer.json.
44
# The script will also execute CMD if you need to alter startup behaviors.
45
ENTRYPOINT [ "/usr/local/share/docker-init.sh" ]
46
CMD [ "sleep", "infinity" ]

.devcontainer/devcontainer.json

1
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
2
// https://github.com/microsoft/vscode-dev-containers/tree/v0.166.1/containers/docker-from-docker
3
{
4
"name": "Docker from Docker",
5
"dockerFile": "Dockerfile",
6
"runArgs": ["--init"],
7
"mounts": [
8
"source=/var/run/docker.sock,target=/var/run/docker-host.sock,type=bind"
9
],
10
"overrideCommand": false,
11
// Use this environment variable if you need to bind mount your local source code into a new container.
12
"remoteEnv": {
13
"LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}"
14
},
15
// Set *default* container specific settings.json values on container create.
16
"settings": {
17
"terminal.integrated.shell.linux": "/bin/bash"
18
},
19
// Add the IDs of extensions you want installed when the container is created.
20
"extensions": ["ms-azuretools.vscode-docker"],
21
"workspaceMount": "source=${localWorkspaceFolder},target=${localWorkspaceFolder},type=bind",
22
"workspaceFolder": "${localWorkspaceFolder}",
23
// Use 'forwardPorts' to make a list of ports inside the container available locally.
24
// "forwardPorts": [],
25
// Use 'postCreateCommand' to run commands after the container is created.
26
// "postCreateCommand": "npm install",
27
// Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
28
"remoteUser": "vscode"
29
}

Especially note the workspaceMount and `workspaceFolderz sections as these ensure the directory structure maps correctly between your local folder structure and container volume so that the CDK and SAM builds are able to find and create their assets in the correct locations

References