Debugging an AWS Lambda handler function deployed via simulated CloudFormation

Here’s a minimal CDK app with a Lambda function that reads an Object from an S3 Bucket:

import * as cdk from "aws-cdk-lib";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as s3 from "aws-cdk-lib/aws-s3";

const app = new cdk.App();
const stack = new cdk.Stack(app, "FooStack", {
  env: { account: "111111111111", region: "eu-west-2" },
});

const dataBucket = new s3.Bucket(stack, "DataBucket", {
  bucketName: "foo-data-bucket",
});

const fooFunction = new lambda.Function(stack, "FooFunction", {
  functionName: "foo-reader",
  runtime: lambda.Runtime.NODEJS_22_X,
  handler: "index.handler",
  code: lambda.Code.fromAsset("path/to/handler"),
});

dataBucket.grantRead(fooFunction);

app.synth();

You can deploy the template that cdk synth produces into a simulated AWS environment with simulated CloudFormation in Yulin:

import { SimAws } from "@kensio/yulin";

const simAws = new SimAws();

const stack = await simAws.cloudFormation().deployTemplateFile({
  templatePath: "cdk.out/FooStack.template.json",
});

await stack.waitForDeployComplete();

Inline source code in Code.ZipFile in a template is packaged and run in the Node.js vm module internally, the same as function code passed to CreateFunctionCommand.

Asset-bundled code takes a longer route. lambda.Code.fromAsset() stages the handler in the CDK cloud assembly, and the synthesized template points at the CDK bootstrap staging bucket by S3Bucket and S3Key.

Deploying the template file publishes that cloud assembly’s assets into the staging bucket in simulated S3 before any resource is created, the way a real cdk deploy runs cdk-assets ahead of CloudFormation. A staged directory is zipped on the way in so the handler’s own require calls still resolve, and the function then fetches its code from simulated S3 like any other.

That relies on deploying the synthesized template file, since the cloud assembly is found next to it.

So the deployed function does run your handler code. It runs it as CommonJS in a vm context though. That means everything the handler interacts with has to be inside the asset, and what runs is the bundled output rather than your handler source code.

That’s fine when the function is incidental to what you’re testing. What if you want the deployed function to run your handler as it stands, so you can step through it in a debugger and gather test coverage metrics for it?

You can bind a real in-process handler to the template function with the bindings property, the same way you can bind a CloudFront Function handler.

Here’s an ordinary Lambda handler module:

import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import type { Handler } from "aws-lambda";

const s3Client = new S3Client({});

export const handler: Handler<{ objectKey: string }, string> = async (
  event,
) => {
  const output = await s3Client.send(
    new GetObjectCommand({
      Bucket: "foo-data-bucket",
      Key: event.objectKey,
    }),
  );

  return (await output.Body?.transformToString()) ?? "";
};

Yulin’s simulated handler type mirrors the Handler type from the aws-lambda typings, so the real handler function goes in as it is:

import { InvokeCommand } from "@aws-sdk/client-lambda";
import { S3Client } from "@aws-sdk/client-s3";
import { SimAws } from "@kensio/yulin";
import { SimSdk } from "@kensio/yulin/sdk";

import { handler } from "./path/to/foo-reader.js";

const simAws = new SimAws();
const simSdk = new SimSdk({ simAws });
simSdk.intercept(S3Client);

const stack = await simAws.cloudFormation().deployTemplateFile({
  templatePath: "cdk.out/FooStack.template.json",
  bindings: [
    {
      logicalId: "FooFunction",
      handler,
    },
  ],
});

await stack.waitForDeployComplete();

const output = await simAws.lambda().invoke(
  new InvokeCommand({
    FunctionName: "foo-reader",
    Payload: JSON.stringify({ objectKey: "foobar.txt" }),
  }),
);

simSdk.restoreAll();

The binding replaces the template code wholesale, so nothing is fetched from the staging bucket for that function at all. A bound function can leave out Code and Handler in the template entirely, and functions in the same template without a binding keep running their own code.

A binding is also the way to stand in for a function Yulin can’t run itself. Sim Lambda simulates the Node.js runtimes, so a template function declaring a Python or Java Runtime is skipped with a diagnostic, unless a binding backs it with an in-process handler.

logicalId matches the CDK construct ID as well as the literal template logical ID, which is what makes FooFunction work here. CDK gives the resource a hashed logical ID such as FooFunction2A1B3C4D and records the construct path in aws:cdk:path metadata, which the binding is resolved against. A binding can also target a function by functionName, by arn, or by the full cdkPath.

The handler’s own S3Client is a real SDK client, so it needs intercepting to reach simulated S3. Intercepting the class covers every instance of it, including the one this module constructs when it’s imported.

A bound handler runs with the function’s execution Role as the ambient simulated caller, so simulated IAM authorises the GetObjectCommand inside it against whatever dataBucket.grantRead(fooFunction) produced in the template.

If you take the grant out of the CDK app, the invocation comes back as a function error, the way an execution role denial surfaces inside a handler on real Lambda.

Now the simulated Lambda function runs the original handler source code, in the same Node.js process as the rest of the test. That means you can set a breakpoint inside it and gather coverage for it while it runs behind simulated CloudFormation.


Tech mentioned