Creating Lambda Layers with TypeScript and CDK - The Right Way

I recently was working on setting up some Lambda Layers for some projects that I've been working on in the last couple months, and ran into the unfortunate reality that every guide online(there were only a couple) was both incorrect, and based on a poor design in my opinion. They are also the worst type of incorrect, where they appear to be working as expected, and it's pretty easy to miss the signs if you're not paying attention to little things.

This post recaps the issues present in many other posts out there so you know what to look out for when reviewing other articles, and also covers deploying and using a Lambda Layer with CDK and TypeScript in a way that works like you'd expect.

If you don't want to read about the issues I found and just want to see how to set it up, go to How To Do It Right.

I'm not going to be linking to any specific articles, because my intent is not to call out anyone. Some of the articles I found were written by people that have produced prolific amounts of very useful content that I reference all the time, and they create very valuable resources for free. It is not my intent to appear like I'm going after anyone or attacking anyone, many of whom are way more experienced in this area than I am.

First I'm going to touch on the issues at a high level, and then dive into what those look like in code/config. Every article I found contained a subset of the following mistakes and/or poor design choices:

  • Failing to transpile the TypeScript code before uploading it for the Lambda Layer
  • Including the Layer in the same Stack as the Lambda using it
  • Not putting the Layer code in the right directory for NODE_PATH
  • Importing the module from the layer with the path (/opt/nodejs/my-module)
  • Using paths in tsconfig.json to make up for the above mistake
  • Excluding the incorrect module from the Lambda resource in the Stack

Bad Example Walkthrough

First we're going to walk through setting up the bad example. Remember: When setting it up like this, it appears to work correctly. I'm going to be walking through the full setup, and then going over all the issues afterward.

GitHub Repo for bad Lambda/Layer

For this example, we're going to be creating the both the Lambda and the Layer resources in the same CDK Stack. We'll be attaching that Layer to the Lambda, importing the custom package into the Lambda, and making a couple tweaks for config changes. First let's look at part of our file structure:

file structure

These are the files that are going to be used by the Stack to create the Layer and the Lambda:

import { Duration, Stack, StackProps } from 'aws-cdk-lib'
import { Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam';
import { Architecture, Code, LayerVersion, Runtime } from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { Construct } from 'constructs'

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

        // Creating the Lambda Layer
        const badLayer = new LayerVersion(this, 'BadLayer', {
            compatibleRuntimes: [ Runtime.NODEJS_16_X ],
            compatibleArchitectures: [ Architecture.X86_64 ],
            code: Code.fromAsset('src/layer')
        })

        // Creating the Lambda
        const layerExampleLambda = new NodejsFunction(this, 'BadLayerExampleLambda', {
            entry: './src/lambda/handler.ts',
            handler: 'handler',
            runtime: Runtime.NODEJS_16_X,
            environment: {
                REGION: 'us-east-1'
            },
            timeout: Duration.seconds(15),
            memorySize: 128,
            bundling: {
                sourceMap: true,
                externalModules: [
                    'aws-sdk',
                    'layer-example'
                ]               
            },
            layers: [
                // Adding the above Layer to our Lambda
                badLayer
            ]
        })

        // Adding IAM Permissions used for our example
        layerExampleLambda.addToRolePolicy(
            new PolicyStatement({
                effect: Effect.ALLOW,
                actions: [
                    's3:ListAllMyBuckets'
                ],
                resources: ['*']
            })
        )
    }
}

First we're setting up the Lambda Layer, and using Code.fromAsset to grab the code we made for the layer. Next we're setting up the Lambda Function. When using a Layer, you want to exclude the packages in the Layer so they don't get bundled into your Lambda. We do that using externalModules under bundling. Note that we're also excluding aws-sdk, which is normally excluded by default. We're also referencing the Layer we created under layers.

Lastly we're adding an IAM permission for S3, which is how we're testing the Layer that we deployed.

The NodeJsFunction Construct uses esbuild to transpile the TypeScript to JavaScript, which can be run in the Lambda. For the Lambda Layer, it bundles the files in the folder into a zip folder. When Lambda extracts the files in the zip folder for the Lambda, it mounts those files in the /opt directory.

Here's our handler, which is pretty simple:

import { Context } from 'aws-lambda';
import { S3Example } from '/opt/nodejs/layer-example'

const region = process.env.REGION ?? 'us-east-1'

export async function handler(event: any, context: Context) {
    console.log(event)
    console.log(context)

    const s3 = new S3Example(region)

    console.log(s3)

    console.log(await s3.getAllBucketNames())
}

We're setting up a new S3 object pulled from our Layer example, and running a method we added to get all the S3 Bucket names. Note that we're importing the module from /opt/nodejs/, which is the location it will be when it's running in the Lambda. That's going to cause VS Code to throw an error, since that isn't the location on our system. To resolve that, we can add a paths parameter to our tsconfig.json file, like so:

{
    "compilerOptions": {
        ....
        "paths": {
            "/opt/nodejs/layer-example": ["./src/layer/nodejs/layer-example"]
        }
    ....
    }
}

Now we can run cdk deploy, everything builds, and the Lambda can be tested. It can take any event for the test, it doesn't really matter since the event isn't used. The Lambda runs without issue, our custom code is executed:

We get a list of all the S3 Bucket names, everything appears good.

So What's Wrong?

We'll start with the simple stuff first. Even though this is just an example, putting the Layer and Lambda in the same Stack doesn't really make sense. If we're building a Layer, ideally it's going to be used by multiple Lambdas. When we stick them in the same Stack, we're coupling resources that should be split. It's always nice to have examples that more closely resemble what you're trying to do, and this is off, at least for me. If you're not intending on reusing a Lambda Layer across multiple Lambdas, there is no reason to go through the extra work to create the Layer, and should just rely on the NodeJsFunction Construct.

Next issue is directories and imports. Lets reference the AWS Documentation for Creating And Sharing Lambda Layers. Here we see references to /opt, and we see a table about Layer paths. For Node.js, we see that /opt/nodejs/node_modules is part of the NODE_PATH environment variable. You can test this by setting up a Lambda and using console.log(process.env.NODE_PATH). If we adjust the directory structure of our Layer to match NODE_PATH, then the Lambda should be able to import the module without us referencing the full path. In this example it doesn't completely remove the requirement to setup paths in tsconfig.json since we'd need to reference the Layer by relative path on our system, and it'll be in NODE_PATH in the Lambda. That would change our paths parameter in tsconfig.json to "layer-example": ["./src/layer/nodejs/layer-example"]. If we were installing a module like normal (where it ended up under node_modules), that step wouldn't be required.

So far, those just seem like preferences, and you could prefer to do it differently. Well, the directory issues are one of the things contributing to this appearing to work correctly. If you make the above change, things should still work, right? Obviously not, or I wouldn't be pointing it out. If you make the above change, what happens? One really interesting thing is you may notice that the size of the bundle created by NodeJsFunction decreases dramatically. I actually chose to build the Layer off the AWS SDK instead of a basic hello world type function because it's not tiny, and I wanted to be able to demonstrate this point.

Here's the bundle size from running cdk synth before:

And here's the bundle size after:

1.6 MB compared to 1.4 KB. Why is that the case? Looking at the Stack for the Lambda again:

            bundling: {
                sourceMap: true,
                externalModules: [
                    'aws-sdk',
                    'layer-example'
                ]               
            }

We're excluding layer-example. Before, we were using /opt/nodejs/layer-example to import our module. Because these are different, our Layer code was not excluded by esbuild when bundling the Lambda. This can be validated by checking the index.js file for the Lambda:

It comes in at a massive 35,185 lines, which is just a little bit more than we wrote in our handler.

When we made the changes that allowed us to import the module with import { S3Example } from 'layer-example', esbuild started excluding our Layer code because it now matches.

You can achieve the same effect if you change externalModules to reference the full path, /opt/nodejs/layer-example. Both will cause the Layer code to be excluded as we originally expected, and our bundled Lambda will be nice and tiny. This means that all the Layer code was getting pulled into the Lambda, it didn't need to execute from the Layer at all.

If we make either of those changes and attempt to run our Lambda, we're met with the following error:

{
  "errorType": "Runtime.ImportModuleError",
  "errorMessage": "Error: Cannot find module '/opt/nodejs/layer-example'\nRequire stack:\n- /var/task/index.js\n- /var/runtime/index.mjs",
  "trace": [
    "Runtime.ImportModuleError: Error: Cannot find module '/opt/nodejs/layer-example'",
    "Require stack:",
    "- /var/task/index.js",
    "- /var/runtime/index.mjs",
    "    at _loadUserApp (file:///var/runtime/index.mjs:726:17)",
    "    at async Object.module.exports.load (file:///var/runtime/index.mjs:741:21)",
    "    at async file:///var/runtime/index.mjs:781:15",
    "    at async file:///var/runtime/index.mjs:4:1"
  ]
}

Our Lambda not only didn't need to run from the Layer, it wasn't even attempting to import the module from the Layer or we would have had this error before. This is because esbuild was bundling all the code into the Lambda file, there was nothing to import.

You may try to fix this by throwing the file in another sub folder with the name of the package, and adding a package.json to tell Node where to look. When I did this, I got the following error:

{
  "errorType": "Runtime.UserCodeSyntaxError",
  "errorMessage": "SyntaxError: Cannot use import statement outside a module",
  "trace": [
    "Runtime.UserCodeSyntaxError: SyntaxError: Cannot use import statement outside a module",
    "    at _loadUserApp (file:///var/runtime/index.mjs:724:17)",
    "    at async Object.module.exports.load (file:///var/runtime/index.mjs:741:21)",
    "    at async file:///var/runtime/index.mjs:781:15",
    "    at async file:///var/runtime/index.mjs:4:1"
  ]
}

To understand what's causing this, you can download your Layer from AWS and take a look at it. Here I have that updated structure(with the subfolder and package.json):

And we see layer-example.ts. CDK didn't transpile our code for the Layer, it took what we had exactly and shoved it in a zip file. Oddly enough, the example available in aws-samples on GitHub here does this, and it works. I'm suspecting it's because their TypeScript file isn't actual TypeScript code, it's just JavaScript, but I'm not certain about that and don't feel like burning the time on trying to figure it out.

Our Node Lambda can't directly import TypeScript code like this, it won't work. The code would need to be transpiled first, and the Lambda isn't going to be installing all the dependencies at runtime.

Quick Recap

All those things together created a situation where our code appeared to do what we expected, but it wasn't actually working the right way. These types of issues are often really hard to diagnose because you don't know there's anything that should even be looked at. I stumbled onto this because I was using a module I created in another repo and was trying to get it to import automatically with NODE_PATH but it couldn't find the files, and because I noticed my test Lambda was massive when it should have been really small.

How To Do It Right

I've been working on creating some internal packages for use by other automation and integration work that I've been doing. Think something like an integration with Jira, I only need to build the code once and all my Lambdas that need to create Jira tickets can use the same code. This type of situation is perfect for Lambda Layers. I prefer to keep packages like this in their own repo so they can be developed independently, unlike the example above where the Layer and Function were in the same Stack.

For this example, we have two GitHub repos, one for the package and Layer and one for the Lambda Function that consumes that Layer.

Package and Layer Setup

For this example we'll be installing the package directly from GitHub. This way we're not worrying about publishing anything to npm or setting up private package repos. The Layer and the package that is used during development are going to look slightly different from each other, so we'll be creating two different bundles. When this is all complete, our dist directory will look like this:

Keep the directory structure in mind when we're doing the config changes in the first step.

Config Changes

We'll start with the changes that need to be made to various config files. We'll be editing:

  • tsconfig.json
  • package.json
  • .gitignore
  • .npmignore

The outDir parameter in this file will determine where our transpiled files get put when using tsc. We need to add "outDir": "./dist/package", to this file. We're going to be putting the transpiled code for the Layer and for the package in their own directories, so we can filter them out for each method.

Here we need to add name, main, and types. These will be used during development through the package we install on our machine.

{
    "name": "layer-example",

    "main": "./dist/package/layer-example.js",
    "types": "./dist/package/layer-example.d.ts",
    ....
}

We need to slightly modify our .gitignore file so it doesn't exclude the files generated for our Layer. Since we are going to be using the path from NODE_PATH for the Lambda (AWS Reference), our files are going to be within a directory named node_modules. We still want to exclude the normal node_modules folder, but if we want all the files for the Layer to be checked into GitHub then it needs to be explicitly excluded from being ignored.

This is optional, since the files are generated from the TS code, the real source will be checked in either way, but if you're looking at automated deployment with CodePipeline or something else pulling from GitHub, this is required.

node_modules
!dist/layer/nodejs/node_modules

# CDK asset staging directory
.cdk.staging
cdk.out

This file determines what is ignored when downloading the package. We don't need the original TS files (but we do need the generated .d.ts definition files) or the files for the Layer.

*.ts
!*.d.ts
dist/layer

CDK Layer Resource

We'll be using the LayerVersion construct to deploy our Lambda Layer. The Stack file looks like this:

import { Stack, StackProps } from 'aws-cdk-lib';
import { Architecture, Code, LayerVersion, Runtime } from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';

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

        new LayerVersion(this, 'LayerExample', {
            layerVersionName: 'TsCdkLayerExample',
            compatibleRuntimes: [
                Runtime.NODEJS_16_X
            ],
            code: Code.fromAsset('./dist/layer'),
            compatibleArchitectures: [
                Architecture.X86_64
            ]
        })
    }
}

Note that we're deploying our code from ./dist/layer, NOT from the original source like in the bad example above.

Building Package and Layer

In order to build our package for installation directly from GitHub, we just need to run tsc. This will generate the layer-example.js and layer-example.d.ts files used during development. These will be relatively small compared to the files generated for the Layer, since any dependencies will be handled by npm.

For the Layer itself, we need it to be entirely self contained. The Lambda can't be installing dependencies at runtime, and we don't want to include our entire node_modules folder to include everything it needs. To accomplish this, we'll be using esbuild, the same tool used by the NodeJsFunction Construct. To create the files for the Layer, run the following command:

esbuild --bundle --platform=node --sourcemap ./src/layer-example.ts --outdir=dist/layer/nodejs/node_modules

This will bundle all the code required for our Layer to function and stick it in the dist/layer directory, where it's pulled in by CDK for deploying the Layer. After this is done, deploy the layer using cdk deploy.

Creating The Lambda

First we're going to install our custom package. We're doing this directly from GitHub in this case, but you can also setup a private NPM repo if you wanted. The installation command is:

npm i git+ssh://git@github.com:Torsitano/cdk-layer-example

Our Lambda handler looks very similar to the bad example above:

import { Context } from 'aws-lambda';
import { S3Example } from 'layer-example'

const region = process.env.REGION ?? 'us-east-1'

export async function handler(event: any, context: Context) {
    console.log(event)
    console.log(context)

    const s3 = new S3Example(region)

    console.log(s3)

    console.log(await s3.getAllBucketNames())
}

The only difference is we're importing from 'layer-example' instead of the full path under /opt.

For the CDK Stack file, we have just the Lambda resource and the IAM Permission, since the Layer was deployed through the other Stack in the other repo. We're also having to refer to the Layer explicitly by ARN, including the version.

import { Duration, Stack, StackProps } from 'aws-cdk-lib'
import { Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam';
import { LayerVersion, Runtime } from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { Construct } from 'constructs'

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

        const layerExampleLambda = new NodejsFunction(this, 'LayerExampleLambda', {
            entry: './src/lambda/handler.ts',
            handler: 'handler',
            runtime: Runtime.NODEJS_16_X,
            environment: {
                REGION: 'us-east-1'
            },
            timeout: Duration.seconds(15),
            memorySize: 128,
            bundling: {
                sourceMap: true,
                externalModules: [
                    'aws-sdk',
                    'layer-example'
                ]               
            },
            layers: [
                LayerVersion.fromLayerVersionArn(this, 'CdkLayerExample', 'arn:aws:lambda:us-east-1:698852667105:layer:TsCdkLayerExample:2')
            ]
        })

        layerExampleLambda.addToRolePolicy(
            new PolicyStatement({
                effect: Effect.ALLOW,
                actions: [
                    's3:ListAllMyBuckets'
                ],
                resources: ['*']
            })
        )
    }
}

When we run cdk synth, we see that our transpiled JS asset is small, like we expected:

Looking at the index.js file in the console to validate:

Only 40 lines, way better than the 35k+ in the example above. When we run a test to check the Lambda, it dumps all our S3 Bucket names as expected:

Conclusion

This post covered issues that I found with other articles when I was trying to figure this out for myself, and the approach that I've taken to deploy TypeScript Layers with CDK. If you have any questions or want to connect for anything, please feel free to reach out to me.