Skip to content

How to Build a Node.js Lambda Layer with AWS CDK

Published on

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!


Next Post
Self-host Umami analytics with Traefik