Lambda layers are a way to share code or data between Lambda functions. When working with Node.js, using Layers can lead to some pitfalls and in most cases youβre probably better off bundling the code directly with the function anyway.
However, there are some cases where using a layer can be beneficial. For example, if you want to centrally enforce dependencies across multiple functions, have a module that depends on native dependencies like sharp
, or you want to make available a model or dataset to your function.
If your use case fits, then continue reading to learn how to create a Node.js Lambda layer with the AWS Cloud Development Kit (CDK). First, weβll create a simple layer with some dependencies, then weβll create a layer with a native dependency.
Before you begin
To follow along, youβre going to need Node.js installed on your machine and a way of obtaining AWS credentials in your environment. For the former, I recommend using whatever version is the latest LTS (Long Term Support) version at the time of reading. For the latter, you should use temporary credentials using IAM Identity Center if you can help it at all.
About Lambda Layers
At its core, a Lambda layer is a .zip
archive that contains files that are extracted to a specific directory in the Lambda execution environment and thus made available to your function.
Especially when working with Node.js managed runtimes in AWS Lambda, the layer should be structured in a certain way so that your code can resolve and import the dependencies correctly. The structure of the layer should look like this:
nodejs
βββ node_modules
βββ dependency1
βββ dependency2
βββ ...
With this structure, your layer will be compatible with any Node.js runtime in AWS Lambda. If instead you want to have variations of the layer for different Node.js versions, you can use the following structure:
nodejs
βββ node18
β βββ node_modules
β βββ dependency1
β βββ dependency2
β βββ ...
βββ node20
β βββ node_modules
β βββ dependency1
β βββ dependency2
β βββ ...
βββ ...
For the purpose of this post, Iβm going to use the first structure. If you want to use the other one, you can apply the concepts below under each nodeXX
directory.
Creating a basic Node.js layer
If you already have a CDK project that you want to add a layer to, feel free to use that. If not, now is a good time to create one.
Next, you should create a new directory in your project to hold the custom layers for your project. Iβm going to create mine at lib/layers
. Inside this directory, Iβm then going to create a new subdirectory for each layer I want to create.
For the first layer, Iβm going to create a directory called lib/layers/powertools/nodejs
, where powertools
is the name of the layer and nodejs
is the runtime. You can name your layer whatever you want, but itβs a good idea to name it something that helps you remember what itβs for.
Move into the directory you just created and install the dependencies you want to include in the layer. For this example, Iβm going to use the Powertools for AWS Lambda (TypeScript) library, and specifically the Logger module.
mkdir -p lib/layers/powertools/nodejs
cd lib/layers/powertools/nodejs
npm --prefix . i @aws-lambda-powertools/logger
Tip
The --prefix .
flag is used to install the dependencies in the current directory. This is needed because the node_modules
directory should be at the root of the layer.
After installing the dependencies, you should have a directory structure that looks like this:
lib
βββ layers
βββ powertools
βββ nodejs
βββ node_modules
βββ @aws-lambda-powertools
β βββ commons
β βββ logger
βββ ...
Now that you have the dependencies installed, you can add the layer to your CDK stack. In my case, Iβm going to add it right at the top of my stack file. Hereβs what it looks like:
import {
Stack,
type StackProps,
CfnOutput,
RemovalPolicy
} from 'aws-cdk-lib';
import { Construct } from 'constructs';
import {
Code,
LayerVersion,
Runtime,
Architecture
} from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction, OutputFormat } from 'aws-cdk-lib/aws-lambda-nodejs';
export class Nodejs_lambda_layers_cdkStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
const powertoolsLayer = new LayerVersion(this, 'PowertoolsLayer', {
removalPolicy: RemovalPolicy.DESTROY, // TODO: Change to RETAIN in production
description: 'This layer contains the AWS Lambda Powertools library',
code: Code.fromAsset('./lib/layers/powertools'),
});
const fn = new NodejsFunction(this, 'MyFunction', {
runtime: Runtime.NODEJS_20_X,
architecture: Architecture.ARM_64,
entry: './src/index.ts',
handler: 'handler',
layers: [powertoolsLayer],
bundling: {
minify: true,
mainFields: ['module', 'main'],
sourceMap: true,
format: OutputFormat.ESM,
externalModules: [
'@aws-lambda-powertools/*',
]
}
});
new CfnOutput(this, 'FunctionArn', {
value: fn.functionArn,
});
}
}
In the code above, I am using the LayerVersion
construct to create a new layer from the lib/layers/powertools
directory. I then create a new NodejsFunction
and pass the layer to the layers
property, while also adding the @aws-lambda-powertools/*
modules to the externalModules
property in the bundling
configuration. This will ensure that the dependencies are not bundled with the function but are instead resolved from the layer.
When creating the layer, I also set a description
and a removalPolicy
of DESTROY
. The description
is optional but can be useful for documenting what the layer is for. The removalPolicy
is set to DESTROY
for now, but you should change this to RETAIN
in production to prevent the layer from being deleted when the stack is deleted.
The construct also has other properties that you can set, such as compatibleRuntimes
and compatibleArchitectures
which can be used to restrict the layer to specific runtimes or architectures. Weβll leave these out for now, but weβll come back to them later when we create a layer with a native dependency.
To test the layer, Iβm going to also create a simple Lambda function that imports the Logger module from the layer. Iβm going to create a new src
directory and add a new file called index.ts
with the following code:
import { Logger } from "@aws-lambda-powertools/logger";
const logger = new Logger();
export const handler = async () => {
logger.info("Hello, World!");
return {
statusCode: 200,
body: JSON.stringify("Hello, World!"),
};
};
When authoring the function, you will notice that the IDE will not be able to resolve the Logger
module. This is because the module is not installed in the node_modules
directory of the function, but in the layer. To fix this, I am going to install the @aws-lambda-powertools/logger
module as a dev dependency in my CDK project:
npm i -D @aws-lambda-powertools/logger
Warning
When doing this in a real world application, you should make sure that the version of the module installed in the function matches the version installed in the layer. This is one of the downsides of using layers, as it can lead to inconsistencies in your development environment.
Now you can deploy the stack (npm run cdk deploy
) and test the function. If everything is set up correctly, you should see the log message βHello, World!β in the CloudWatch logs.
Creating a layer with a native dependency
Creating a layer with a native dependency is a bit more involved than creating a layer with just Node.js dependencies. This is because the native dependency needs to be compiled for the correct architecture and runtime.
For this example, Iβm going to use the sharp
library, which is a high-performance image processing library for Node.js. To start, you should create a new directory for the layer, just like before. Iβm going to create a directory called lib/layers/sharp/nodejs
.
Next, you should install the sharp
library in the new directory. However, depending on your operating system and architecture, itβs very likely that the library will not work out of the box because Lambda runs on a different architecture.
To work around this, generally speaking, you have two options: if the library provides prebuilt binaries for the Lambda runtime, you can use those. If not, you can compile the library yourself in a Lambda-like environment using Docker or other containerization tools.
Using prebuilt binaries
The sharp
library provides prebuilt binaries, so you could install it directly in the lib/layers/sharp/nodejs
directory:
mkdir -p lib/layers/sharp/nodejs
cd lib/layers/sharp/nodejs
npm --prefix . --cpu=arm64 --os=linux i sharp
Tip
The --cpu=arm64 --os=linux
flags are used to install the correct binaries for the Lambda runtime. I am using ARM so I am using the appropriate flag. If youβre using a different one, you should adjust the flag accordingly.
Next, you should add the layer to your CDK stack. The code is very similar to the previous example, but with the addition of the compatibleArchitectures
property:
const sharpLayer = new LayerVersion(this, 'SharpLayer', {
removalPolicy: RemovalPolicy.DESTROY, // TODO: Change to RETAIN in production
description: 'This layer contains the sharp library',
compatibleArchitectures: [Architecture.ARM_64],
code: Code.fromAsset('./lib/layers/sharp'),
});
This property is used to specify the architectures that the layer is compatible with. In this case, Iβve set it to [Architecture.ARM_64]
to match the architecture of my function and the prebuilt binaries.
When adding the layer to the function, you should also remember to add the sharp
module to the externalModules
property in the bundling
configuration just like before.
Using a custom build
To keep things interesting, Iβm going to show you also how to create a layer with a native dependency that doesnβt provide precompiled binaries. For this example, Iβll stick with the sharp
library, but Iβm going to let CDK do the heavy lifting for me.
To do so, Iβm going to use the bundling
property of the Code.fromAsset
method to define a custom bundling configuration. This configuration will use a Docker container running the official AWS Lambda Node.js image to install the sharp
library and then package it up as a .zip
file.
To get started, you should create a new directory for the layer, just like we did before for the Powertools layer. Iβm going to create a directory called lib/layers/sharp/nodejs
. If you have followed along with the previous section and installed the prebuilt binaries, you should clean up the directory before continuing.
Next, you should create a lock file for the sharp
library. This is not strictly necessary but is a good practice to ensure that you are using the correct version of the library. To do this, you can install the library as usual in the layer directory, and then clean up the node_modules
directory:
mkdir -p lib/layers/sharp/nodejs
cd lib/layers/sharp/nodejs
npm --prefix . i sharp
rm -rf node_modules
Tip
Once again, the --prefix .
flag is used to install the dependencies in the current directory. This time, we are only installing the sharp
library to create the lock file so we can remove the node_modules
directory afterwards.
Next, Iβm going to add the LayerVersion
construct to my CDK stack, just like before. This time, Iβm going to use the bundling
property to define a custom bundling configuration:
const sharpLayer = new LayerVersion(this, 'SharpLayer', {
removalPolicy: RemovalPolicy.DESTROY, // TODO: remove this in production
description: 'This layer contains the Sharp image processing library',
compatibleArchitectures: [Architecture.ARM_64],
code: Code.fromAsset('./lib/layers/sharp/nodejs', {
bundling: {
image: Runtime.NODEJS_20_X.bundlingImage,
environment: {
npm_config_cache: "/tmp/npm_cache",
npm_config_update_notifier: "false",
},
command: [
'bash',
'-xc',
[
'cd $(mktemp -d)',
'cp /asset-input/package* .',
'npm ci',
'cp -r node_modules /asset-output/'
].join(' && '),
]
}
}),
});
In the code above, Iβm using the bundling
property of the Code.fromAsset
method to define a custom bundling configuration. The image
property is used to specify the Docker image to use for the bundling process. In this case, Iβm using the official AWS Lambda Node.js image for the NODEJS_20_X
runtime.
The environment
property is used to set environment variables for the Docker container. In this case, Iβm setting the npm_config_cache
and npm_config_update_notifier
variables to /tmp/npm_cache
and false
, respectively. This is needed to avoid permission issues with the npm cache and avoid running the container as root.
Finally, the command
property is used to define the commands to run in the Docker container. In this case, Iβm creating a temporary directory, copying the package.json
and package-lock.json
files from the input directory to the temporary directory, running npm ci
to install the dependencies, and then copying the node_modules
directory to the output directory.
When adding the layer to the function, you should also remember to add the sharp
module to the externalModules
property in the bundling
configuration just like before.
Below is the full code for the CDK stack with the custom bundling configuration:
import {
Stack,
type StackProps,
CfnOutput,
RemovalPolicy
} from 'aws-cdk-lib';
import { Construct } from 'constructs';
import {
Code,
LayerVersion,
Runtime,
Architecture
} from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction, OutputFormat } from 'aws-cdk-lib/aws-lambda-nodejs';
export class Nodejs_lambda_layers_cdkStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
const powertoolsLayer = new LayerVersion(this, 'PowertoolsLayer', {
removalPolicy: RemovalPolicy.DESTROY, // TODO: remove this in production
description: 'This layer contains the AWS Lambda Powertools library',
code: Code.fromAsset('./lib/layers/powertools'),
});
const sharpLayer = new LayerVersion(this, 'SharpLayer', {
removalPolicy: RemovalPolicy.DESTROY, // TODO: remove this in production
description: 'This layer contains the Sharp image processing library',
compatibleArchitectures: [Architecture.ARM_64],
code: Code.fromAsset('./lib/layers/sharp/nodejs', {
bundling: {
image: Runtime.NODEJS_20_X.bundlingImage,
environment: {
npm_config_cache: "/tmp/npm_cache",
npm_config_update_notifier: "false",
},
command: [
'bash',
'-xc',
[
'cd $(mktemp -d)',
'cp /asset-input/package* .',
'npm ci',
'cp -r node_modules /asset-output/'
].join(' && '),
]
}
}),
});
const fn = new NodejsFunction(this, 'MyFunction', {
runtime: Runtime.NODEJS_20_X,
architecture: Architecture.ARM_64,
entry: './src/index.ts',
handler: 'handler',
layers: [powertoolsLayer, sharpLayer],
bundling: {
minify: true,
mainFields: ['module', 'main'],
sourceMap: true,
format: OutputFormat.ESM,
externalModules: [
'@aws-lambda-powertools/*',
'sharp',
]
}
});
new CfnOutput(this, 'FunctionArn', {
value: fn.functionArn,
});
}
}
Before deploying the stack, Iβm going to add the sharp
library as a dev dependency in my CDK project npm i -D sharp
, and modify the src/index.ts
file to use it:
import { Logger } from "@aws-lambda-powertools/logger";
import Sharp from "sharp";
const logger = new Logger();
export const handler = async () => {
logger.info("Hello, World!");
const imageData = await Sharp({
create: {
width: 300,
height: 200,
channels: 4,
background: { r: 255, g: 255, b: 255, alpha: 1 },
},
})
.raw()
.toBuffer();
logger.info("Image created", { details: { size: imageData.length } });
return {
statusCode: 200,
body: JSON.stringify("Hello, World!"),
};
};
Finally, Iβm going to deploy the stack (npm run cdk deploy
) and test the function. If everything is set up correctly, you should see the log message βHello, World!β and βImage createdβ, along with the size of the image data in the CloudWatch logs.
{"level":"INFO","message":"Hello, World!","sampling_rate":0,"service":"service_undefined","timestamp":"2024-05-01T13:27:05.488Z","xray_trace_id":"1-66324328-6787b571753e59cd318747b7"}
{"level":"INFO","message":"Image created","sampling_rate":0,"service":"service_undefined","timestamp":"2024-05-01T13:27:05.674Z","xray_trace_id":"1-66324328-6787b571753e59cd318747b7","details":{"size":240000}}
Conclusion
In this post, you learned how to create a Node.js Lambda layer with the AWS Cloud Development Kit (CDK). You created a simple layer with some dependencies and then created a layer with a native dependency. You also learned how to use prebuilt binaries and how to create a custom build using a Docker container.
I hope you found this post helpful. If you have any questions or feedback, feel free to leave a comment below, or reach out to me on X/Twitter.
Thanks for reading!