A Brief Introduction: AWS CDK
AWS Cloud Development Kit (CDK) is an open source Infrastructure as Code tool recommended and maintained by AWS to manage cloud infrastructure via code. It offers management using languages such as JavaScript, TypeScript, Python, C#, GoLang and Java which means you can take advantage of code constructs such as loops, variables and conditionals to orchestrate how your infrastructure such as EC2 instances, S3 Buckets, RDS instances and other AWS resources are managed, updated and deployed.
What is Infrastructure as Code?
IaC (Infra as Code) is an approach to managing resources needed to host and run your application or website using actual written code instead of, for example, manually ssh’ing into your web servers or logging into your Cloud console UI to turn buttons and knobs or do administration manually. It’s a modern, robust and more automated way to reduce user errors when doing this kind of manual maintenance or updating of your software infrastructure.
Traditionally, IaC management in AWS was done directly with AWS CloudFormation, which leverages YAML files and syntax. While this was a viable option, it could also be quite verbose, complicated and overwhelming with very large YAML files and a large array of properties and settings to memorize or look up.
AWS CDK actually still uses CloudFormation under the hood, but it abstracts the details and complexity away allowing you to use code in a variety of languages. The library comes with helper functions that encapsulate things like adding Allow IAM permissions to resources in one line of code, instead of having to construct a more complex and verbose YAML block, for instance.
Custom Resources
This brings us to the topic of this post which is a construct in AWS CDK called a Custom Resource. A Custom Resource is an entity in AWS CloudFormation which could be used to extend “out of the box” functionality available conventionally. For example, if you needed to run some logic or encapsulate an infrastructure operation that didn’t come “out of the box” with a Resource in CloudFormation, you could use a Custom Resource to combine available Resources or run custom logic as part of your deployment process.
What is a Resource?
Here, we’re talking about logical resources, which means a resource that represents a physical one – for example, an AWS::EC2::Instance resource in CloudFormation is a representation of an actual EC2 instance that would be deployed into your AWS account’s VPC. AWS CDK uses an abstraction called a Construct, which can represent a CloudFormation Logical Resource, but is a simpler object to deal with, coming with member functions and built in helpers to accomplish common infra management tasks that normally would be more cumbersome to implement in raw CloudFormation YAML templates.
With the background and terminology out of the way, now let’s understand why and when to use Custom Resources in CDK.
Let’s say you want to update a list of email addresses for a subscription to an AWS SNS topic, so that when you run a deployment, a list of emails is checked against to determine what set of users receive notifications or emails from the SNS topic when a message is published.
There is not a built in Resource to accomplish this, so you can make a Custom Resource backed by a AWS Lambda (a small serverless function) which could reach out to a data store (AWS Parameter Store, for instance), retrieve a list of emails and create a subscription in SNS for the emails when you release or update your application infrastructure. This would automate manual maintenance or updates to the SNS subscriptions where you’d otherwise have to login to AWS console, go through the steps in the SNS topics UI and start typing in email addresses and creating subscriptions manually.
A Concrete Example:
Implementing the above scenario, this could be the Lambda which you can define in your project (and commit to a repo for version tracking with git). It uses the AWS SNS client library and the SSM client library to communicate with Parameter Store to retrieve a list of stored emails and create an SNS subscription to a topic for them. This example will focus on the code to define and add the Custom Resource with the Lambda and assumes the other constructs such as your SNS topic have already been defined and created.
In the Lambda, note that you need to send a pre-determined and required response structure from the Lambda to let CloudFormation/CDK know that the Lambda completed (either successfully or otherwise). This is what the sendResponse
function does. Without it, CloudFormation/CDK will not know that the Lambda is done doing what it needs to do and will time out and Fail the deployment.
// someLambdaFolder/index.js const { SNSClient, SubscribeCommand } = require("@aws-sdk/client-sns"); const { SSMClient, GetParameterCommand } = require("@aws-sdk/client-ssm"); const https = require("https"); const snsClient = new SNSClient({ region: process.env.AWS_REGION }); const ssmClient = new SSMClient({ region: process.env.AWS_REGION }); function sendResponse( event, context, responseStatus, responseData, physicalResourceId ) { return new Promise((resolve, reject) => { const responseBody = JSON.stringify({ Status: responseStatus, Reason: `See CloudWatch Log Stream: ${context.logStreamName}`, PhysicalResourceId: physicalResourceId || context.logStreamName, StackId: event.StackId, RequestId: event.RequestId, LogicalResourceId: event.LogicalResourceId, Data: responseData, }); const responseUrl = new URL(event.ResponseURL); const options = { hostname: responseUrl.hostname, port: 443, path: responseUrl.pathname + responseUrl.search, method: "PUT", headers: { "content-type": "", "content-length": Buffer.byteLength(responseBody), }, }; const req = https.request(options, (res) => { console.log("Status code:", res.statusCode); resolve(); }); req.on("error", (error) => { console.error("sendResponse Error:", error); reject(error); }); req.write(responseBody); req.end(); }); } exports.handler = async (event, context) => { console.log("Event:", JSON.stringify(event)); try { if (event.RequestType === "Delete") { // No action needed on delete await sendResponse(event, context, "SUCCESS", {}, PhysicalResourceId); return; } // Passed in via properties on the custom resource const { EmailsParamName, TopicArn, PhysicalResourceId } = event.ResourceProperties; const ssmResult = await ssmClient.send( new GetParameterCommand({ Name: EmailsParamName }) ); const emailsRaw = ssmResult.Parameter.Value; const emails = emailsRaw .split(",") .map((e) => e.trim()) .filter((e) => e); console.log("Parsed emails:", emails); for (const email of emails) { await snsClient.send( new SubscribeCommand({ Protocol: "email", TopicArn: TopicArn, Endpoint: email, ReturnSubscriptionArn: true, }) ); console.log(`Subscribed ${email} to ${TopicArn}`); } await sendResponse(event, context, "SUCCESS", {}, PhysicalResourceId); } catch (error) { console.error(error); await sendResponse(event, context, "FAILED", { Error: error.message }, PhysicalResourceId); } };
// someLambdaFolder/package.json { "name": "lambda", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "@aws-sdk/client-sns": "^3.810.0", "@aws-sdk/client-ssm": "^3.810.0" } }
Now for the CDK implementation, you’d create a Lambda resource, and add permissions required to accomplish the task of communicating with Parameter Store (SSM) and the AWS SNS service:
// projectRoot/lib/stacks/someStack.ts import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"; // Use NodejsFuction construct to get auto-installed npm dependencies import * as cr from "aws-cdk-lib/custom-resources";
const subscriptionHandler = new NodejsFunction( this, "SnsSubscriptionHandlerLambda", { runtime: lambda.Runtime.NODEJS_20_X, handler: "handler", // Name of the exported function in index.js entry: path.join(lambdaPath, "someLambdaFolder", "index.js"), // point to where you define the lambda and logic in your project timeout: cdk.Duration.seconds(300), bundling: { externalModules: [], // Specifying this as an empty array includes all packages listed in the lambda folder's package.json and installs them when deployed. include a package-lock.json in the lambda folder if you want consistent versions minify: true, // Minify the code to reduce bundle size }, } );
// Grant Lambda permission to read SSM param & manage SNS subscriptions subscriptionHandler.addToRolePolicy( new iam.PolicyStatement({ actions: ["ssm:GetParameter", "sns:Subscribe"], resources: [ `arn:aws:ssm:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:parameter${cwEmailsParamName}`, alarmTopic.topicArn, ], }) ); // Grant CloudFormation permission to invoke the Lambda subscriptionHandler.addPermission("AllowCloudFormationInvoke", { principal: new iam.ServicePrincipal("cloudformation.amazonaws.com"), action: "lambda:InvokeFunction", sourceArn: cdk.Stack.of(this).stackId, // Get stackId from the parent stack });
Note, that you should generally use the NodejsFunction construct from `aws-cdk-lib/aws-lambda-nodejs` in CDK, which provides options to automatically bundle and install npm dependencies for you. Otherwise, you’d have to npm install them manually before deploying if using another Lambda construct. You would create a package.json file in the same folder as your Lambda definition file which CDK would pick up automatically and the dependencies defined in that file would be installed when deploying the Custom Resource Lambda.
Finally, you need two further elements to complete the Lambda backed Custom Resource implementation: a Provider, and a Custom Resource construct.
const provider = new cr.Provider(this, "SnsSubscriptionProvider", { onEventHandler: subscriptionHandler, }); new cdk.CustomResource(this, "SnsSubscriptionResource", { serviceToken: provider.serviceToken, // This is what is passed to the Lambda on invocation as arguments: properties: { EmailsParamName: cwEmailsParamName, TopicArn: alarmTopic.topicArn, // Ensures that the function is invoked on every deployment: PhysicalResourceId: cr.PhysicalResourceId.of(Date.now().toString()) }, });
The Provider construct is a wrapper that encapsulates and extends the Lambda resource construct you created so that you can use it as a backing for the Custom Resource (for example, it exposes a service token which is a unique Identifier/ARN for the Lambda which must be provided to identify which Lambda resource is to be invoked).
A key thing to remember is that the Lambda/Custom Resource is not invoked if none of the properties change, even if you update the code in the Lambda index.js (index.ts) file! For that reason, adding a dynamically changing unique value such as Date.now().toString()
or some kind of “Version” property which increments monotonically will ensure a re-invocation of the Lambda on every deployment if you need to act on fresh and changing data.
That’s Custom Resources in a nutshell. I thought it would be a good topic to cover and try to boil down into a brief post in case it helps others get a quick distilled overview of them and when they are useful.