Adventures with Lambda layers and CDK

Adventures with Lambda layers and CDK

ยท

6 min read

In this post, I will recount my experience of trying out Lambda layers with a small project. It proved to be a good way to get an understanding of what Lambda layers are, how they are used, what their limitations might be, and when they could be useful.

Clone the companion repo to run the code for yourself.

TL;DR

  • esbuild will bundle layer code if you are not careful
  • Use paths in tsconfig to compile locally
  • Lambda layers are immutable and functions reference a specific version
  • SSM parameters can be used to deploy updates with no rebuild
  • Conclusion, npm better for most use cases of reuse

The starting point

In an earlier post, I created a Lambda function that used a hexagonal architecture approach. The following diagram shows how we abstracted the implementation of the data stores from the domain logic.

Lambda function with hexagonal architecture

This separation made me wonder whether it might be an interesting thing to try to package these data stores as a Lambda layer. The intended result is shown below.

Lambda function with Lambda layer

Packaging and deploying the layer

The folder structure I chose is shown below. The layer contents are in the layer\nodejs folder. The *Store.ts files contain the data store classes which are in turn exported by the data-access.ts file. There is a convention at play here, as for Node.js layers to work, the code must be in a nodejs folder.

The DataAccessLayer.ts file contains the CDK construct that will be used to package and deploy the layer.

Layer source folder structure

The DataAccessLayer construct is shown in full below

export default class DataAccessLayer extends Construct {
  //
  static readonly LAYER_ARN_SSM_PARAMETER = '/layer-arn/data-access';

  readonly layer: ILayerVersion;

  constructor(scope: Construct, id: string) {
    super(scope, id);

    this.layer = new LayerVersion(this, 'DataAccessLayer', {
      compatibleRuntimes: [Runtime.NODEJS_12_X, Runtime.NODEJS_14_X],
      code: Code.fromAsset(
        path.join(__dirname, `/../../dist/src/data-access/layer`)
      ),
      description: 'Provides data access clients',
    });

    new StringParameter(this, 'DataAccessLayerArnSsmParameter', {
      parameterName: DataAccessLayer.LAYER_ARN_SSM_PARAMETER,
      stringValue: this.layer.layerVersionArn,
      description: 'The ARN of the latest Data Access layer',
      type: ParameterType.STRING,
      tier: ParameterTier.STANDARD,
    });
  }
}

The LayerVersion construct uses the Code.fromAsset method to point to the output from the TypeScript compiler (the dist subfolders). Note that it points to the parent of the nodejs folder.

Lambda layers have a version number, which is incremented each time it is deployed. As part of this construct we create an SSM Parameter to store the latest version. We will use this later on when deploying dependent components.

Now that we have our construct, we can create a stack to deploy it.

export default class DataAccessStack extends Stack {
  //
  constructor(scope: Construct, id: string, props?: DataAccessStackProps) {
    super(scope, id, props);

    const dataAccessLayer = new DataAccessLayer(this, 'DataAccessLayer');

    new CfnOutput(this, 'DataAccessLayerArn', {
      value: dataAccessLayer.layer.layerVersionArn,
    });
  }
}

Once deployed, we can see the layer in the console.

Lambda layer shown in the console

Using the Lambda layer

Now that we have our Lambda layer, we need to work out how to use it. The first trick is how we need to reference the layer so that it will work at runtime. This involves referencing an absolute path starting with /opt/nodejs, as shown below.

import { AccountDetailStore, CustomerStore } from '/opt/nodejs/data-access';

This gives us the first major problem, which is how do we compile our TypeScript code against it. Although we have the source code for the layer, it isn't at the location that our import is pointing to.

The solution is to use the paths compiler option in tsconfig.json to point the compiler at the actual location of the layer source code.

{
  "compilerOptions": {
    /* Snip */
    "paths": {
      "/opt/nodejs/data-access": ["src/data-access/layer/nodejs/data-access"]
    }
  }
  /* Snip */
}

With this in place, we can happily build our Lambda function. However, it raises the question of how would you do this if the layer source code was not available locally. You can download the layers via the console, and possibly by the SDK, but this is not ideal. This is one of challenges that seem to make working with layers more complicated than working with npm packages.

Packaging the Lambda function

Our next challenge is how we can package up our Lambda function. The trick here is not to package up the layer code with the Lambda function code. The first example I looked at was using the NodejsFunction construct. This construct uses esbuild behind the scenes to bundle the code into a single file. This is usually very convenient, but had the result of including the layer code directly. When I updated the example layer code, the behaviour of the Lambda function using the layer didn't change. This was because it wasn't actually using the layer code.

You can specify BundlingOptions for the NodejsFunction construct, which has a externalModules property that is documented as follows:

A list of modules that should be considered as externals (already available in the runtime).

However, for our project, we can avoid this bundling issue by using the Function construct and the AssetCode class. Using these, we can point at the TypeScript output folder dist as shown below.

export interface CustomerUpdatedProps {
  dataAccessLayerArn: string;
  customerUpdatedTopic: ITopic;
  customerTableName: string;
  accountDetailTableName: string;
}

export default class CustomerUpdatedHandler extends Construct {
  constructor(scope: Construct, id: string, props: CustomerUpdatedProps) {
    super(scope, id);

    // Snip - getting tables

    const dataAccessLayer = LayerVersion.fromLayerVersionArn(
      this,
      'DataAccessLayer',
      props.dataAccessLayerArn
    );

    const customerUpdatedHandlerFunction = new Function(
      scope,
      'CustomerUpdatedHandlerFunction',
      {
        runtime: Runtime.NODEJS_14_X,
        handler: 'CustomerUpdatedHandlerFunction.handler',
        code: new AssetCode(
          path.join(
            __dirname,
            `/../../dist/src/application/customer-updated-handler-function`
          )
        ),
        environment: {
          [ENV_VAR_CUSTOMER_TABLE_NAME]: props.customerTableName,
          [ENV_VAR_ACCOUNT_DETAIL_TABLE_NAME]: props.accountDetailTableName,
        },
        layers: [dataAccessLayer],
      }
    );

    // Snip - adding subscriptions and permissions
  }
}

We use the layers property to make the layer available to the function. We obtain the reference to the layer by using the LayerVersion.fromLayerVersionArn method and the ARN of the layer, which we pass in via the construct props.

Now we have our function construct we can use it in a stack. Here we obtain the layer ARN from the SSM Parameter that we created when we deployed the layer. This approach means that every time we deploy the function it picks up the latest version of the layer.

export default class ApplicationStack extends Stack {
  constructor(scope: Construct, id: string, props?: ApplicationStackProps) {
    super(scope, id, props);

    // Snip - creating topic and getting table name parameters

    const dataAccessLayerArnSsmParameter =
      StringParameter.fromStringParameterName(
        this,
        'DataAccessLayerArnSsmParameter',
        DataAccessLayer.LAYER_ARN_SSM_PARAMETER
      );

    new CustomerUpdatedHandler(this, 'CustomerUpdatedHandler', {
      dataAccessLayerArn: dataAccessLayerArnSsmParameter.stringValue,
      customerUpdatedTopic,
      customerTableName: customerTableNameParameter.stringValue,
      accountDetailTableName: accountDetailTableNameParameter.stringValue,
    });
  }
}

I am not recommending this approach, but it was an interesting concept to play with. I had wondered if functions could reference the latest layer and then if you could update the latest layer underneath it. However, functions have to reference a specific layer version. So the approach above is as close as I could manage, where you can update a function without a code change.

After we deploy the Lambda function, we can see in the console that we have referenced as expected.

Lambda function referencing layer in console Lambda function referencing layer in console

Summary

It was interesting to have a play with Lambda layers and it was a bit of a challenge to get it all working. Given this, I suspect that I will not be reaching for them out of my toolbox any time soon.

In this post, we covered deploying and building, but we didn't cover testing. The companion repo includes some integration tests, but local testing is tricky. I prefer integration testing in AWS, so this isn't so much of a negative for me. However, it may be a deal-breaker for some.

All in all, I think I agree with the conclusions in the fine article "AWS Lambda Use Cases: When to use Lambda layers" by Yan Cui, one of which is to prefer npm as the default reuse approach.

That said, it was interesting to actually kick the tyres on layers. If you are curious, then clone the repo and have a play yourself.

ย