Lately, I have been doing a fair bit of CDK work with resources that CDK does not natively support. I was surprised to learn how powerful custom resources are with CDK but also how little documentation there was on how to build one with CDK. So, while this is not you want to do all the time, building a custom resource is a great tool to have in your toolbelt and can streamline your CDK codebase, infrastructure, and deployments when CDK does not support a resource that you need.
In this article, we will cover the following topics:
- What is a Custom Resource?
- Why Are Custom Resources Powerful with CDK?
- How to Create a Custom Resource with CDK
- The CDK Constructs
- The
AwsCustomResource
Construct Example - The
CustomResource
Construct Example
All code examples in this article are written in TypeScript. If you are not familiar with TypeScript, you can refer to the TypeScript documentation for more information.
What is a Custom Resource?
While CloudFormation is a powerful infrastructure as a code tool, it does have some limitations. One of those limitations is that it does not support all AWS services (I have some strong opinions on this but that is for another time). A custom resource is AWS's way of allowing you to extend CloudFormation to support services and resources that are not natively supported. A custom resource is a Lambda function that is invoked by CloudFormation during the deployment process. The Lambda function can then do whatever it needs to do to create, modify, or delete the resource(s). The Lambda function is also responsible for reporting back to CloudFormation if the resource was created, modified, or deleted successfully.
It is really just as simple as that, a Lambda function that is invoked by CloudFormation during the deployment process and reports back to CloudFormation. For being such a simple concept, custom resources are actually really powerful and can be used to solve a lot of problems.
Why Are Custom Resources Powerful with CDK?
In good o'l fashion CloudFormation, custom resources, while not too difficult, can be quite tedious to work with. You have to create a Lambda function, zip it up, upload it to S3, and then reference it in your CloudFormation template. This is not too bad if you are doing it once or twice but if you are doing it for every custom resource you need to create, it can get old fast. If you want to streamline that process, you will have to either use a tool like AWS SAM, a third-party tool or build your own tooling. While these are all great options, they all have their own drawbacks. AWS SAM retains much of the same complexity as CloudFormation and can also be quite tedious to work with. Third-party tools are great but they are third-party tools and you have to trust them with your infrastructure. Building your own tooling is great but it is a lot of work and you have to maintain it.
CDK keeps things simple. You can create a custom resource with just a few lines of code. CDK will take care of the rest. It will create the Lambda function, zip it up, upload it to S3, and reference it in your CloudFormation template. That is it. You can then use that custom resource in your CDK codebase just like any other resource. It is really that simple.
How to Create a Custom Resource with CDK
Now that we have the high-level stuff out of the way, we can get more into the nitty gritty code and how to actually create a custom resource with CDK. We will start with the basics and then we will get into some more advanced topics and use cases.
The CDK Constructs
At the heart of all of this is the CustomResource
and AwsCustomResource
constructs. These constructs are what will create the custom resource for us. The CustomResource
construct is the base construct that will create the custom resource. The AwsCustomResource
construct is a higher-level construct that will create the custom resource and can handle some pretty common use cases. We'll start with an example of AwsCustomResource
and then we will look at an example of CustomResource
if we need to do something that AwsCustomResource
cannot handle.
The docs for these constructs can be found here:
The AwsCustomResource
Construct Example
The AwsCustomResource
construct is a higher-level construct that will create the custom resource that can handle some pretty common use cases and can solve quite a few problems with ease. What makes the AwsCustomResource
special is that it will create the Lambda function for us and it will handle the response to CloudFormation for us. This means that we do not have to worry about creating the Lambda function or handling the response to CloudFormation.
For this example, we will create a custom resource that finds an elastic IP that is not managed in CDK or CloudFormation. Sometimes, you might be working with AWS infrastructure that is not managed in code or was created in another CloudFormation stack with no exports. These scenarios can be a pain to work with because you have to manually find the resources and then add them to your CDK codebase. For EIPs, they do not have an existing CDK lookup option. To make this process easier, we can create a custom resource that will find the elastic IP for us. This way, we can just add the elastic IP to our CDK codebase and let the custom resource handle the rest.
The Code
import * as cr from 'aws-cdk-lib/custom-resources';
.
.
.
const provider = new cr.AwsCustomResource(this, 'EIP', {
onCreate: {
service: 'EC2',
action: 'describeAddresses',
parameters: {
Filters: [
{
Name: 'domain',
Values: ['vpc'],
},
],
},
physicalResourceId: cr.PhysicalResourceId.of('EIP'),
},
policy: cr.AwsCustomResourcePolicy.fromSdkCalls({
resources: cr.AwsCustomResourcePolicy.ANY_RESOURCE,
}),
});
const eip = provider.getResponseField('Addresses.0.PublicIp');
The Code Explained
The AwsCustomResource
construct can handle OnCreate
, OnUpdate
, and OnDelete
events via the onCreate
, onUpdate
, and onDelete
properties. In our case, we only need to handle the OnCreate
event. To do this, we can use the onCreate
property. This property takes an object that defines the service
, action
, parameters
, and physicalResourceId
properties.
The service
and action
properties define the AWS service and action that will be invoked by the custom resource. If you want see the full list of services and actions that are supported, you can refer to the Actions, resources, and condition keys for AWS services documentation. In our case, we are using the EC2
service and the describeAddresses
action. This action will return all of the elastic IPs in our account.
the action
property defines the AWS service action that will be invoked by the custom resource. In our case, we are using the describeAddresses
action. This action will return all of the elastic IPs in our account.
The parameters
property defines the parameters that will be passed to the AWS service action.
The physicalResourceId
property defines the physical resource ID that will be returned to CloudFormation. This is important because CloudFormation will use this ID to determine if the custom resource was created, updated, or deleted successfully. In our case, we are using the PhysicalResourceId
construct to define the physical resource ID. This construct takes a string that will be used as the physical resource ID.
The policy
property defines the IAM policy that will be used by the custom resource. In our case, we are using the AwsCustomResourcePolicy
construct to define the IAM policy. This construct takes an object that defines the resources
property. The resources
property defines the resources that the custom resource will have access to. In our case, we are using the ANY_RESOURCE
property which will give the custom resource access to all resources.
The getResponseField
method is used to get a response field from the custom resource. This method takes a string that defines the response field that will be returned. In our case, we are using the Addresses.0.PublicIp
response field which will return the public IP of the first elastic IP that is found. This is derived from the response of the describeAddresses
action. You can refer to the DescribeAddresses documentation for more information on the response of the describeAddresses
action. This will look different for every action and will most likely have to refer to the AWS SDK documentation to find the response field that you need.
The CustomResource
Construct Example
It is not the most fun to end up in this situation, but sometimes you need to get your hands dirty and build something from scratch to suit your specific situation. Luckily, CustomResource
is actually really easy to work with and can be used to build some pretty powerful custom resources. Let's take a look at how to build a custom resource from scratch.
For this example, we will create a custom resource that generates the priority of an AWS ALB listener rule. This is a real-world example that I have used in the past. AWS ALB listener rules famously do not have a priority property. This means that if you want to add a listener rule to an ALB, you have to specify a priority. This is not a big deal if you are only adding one listener rule but if you are adding multiple listener rules, you have to make sure that the priorities are unique and in the correct order. This can be a pain to manage. To make this process easier, we can create a custom resource that will generate the priority for us. This way, we can just add the listener rule and let the custom resource handle the priority.
The Lambda Code
The first thing we need to do is create the Lambda function. This is the function that will be invoked by CloudFormation during the deployment process. This function can be written in any language that Lambda supports. For this example, we will be using TypeScript to keep consistent with the rest of our code.
// Import necessary modules and libraries
import {
CdkCustomResourceResponse,
CdkCustomResourceEvent,
Context,
Handler,
} from 'aws-lambda';
import * as AWS from 'aws-sdk';
// Define a constant range for ALB rule priorities.
// 50000 is the maximum priority value, see for more info:
// https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-listeners.html#listener-rules
const ALB_RULE_PRIORITY_RANGE = [1, 50000];
/**
* A lambda function that generates a random priority for an ALB listener rule and
* avoids conflicts with existing priorities.
* @param event The Lambda event.
* @param context The Lambda context.
*/
export const handler: Handler = async (
event: CdkCustomResourceEvent,
context: Context
): Promise<CdkCustomResourceResponse> => {
// Log the received event for debugging
console.log('Received event: ' + JSON.stringify(event, null, 2));
// Create a response object with initial properties
const response: CdkCustomResourceResponse = {
StackId: event.StackId,
RequestId: event.RequestId,
LogicalResourceId: event.LogicalResourceId,
PhysicalResourceId: context.logGroupName,
};
try {
// Check if the request type is 'Create'
// There is no need to handle 'Update' or 'Delete' requests in
// the context of listener rule priority generation. CloudFormation
// will handle the deletion of the custom resource automatically.
if (event.RequestType === 'Create') {
// Create an ELBv2 instance using AWS SDK
const elbv2: AWS.ELBv2 = new AWS.ELBv2();
// Get the listener ARN from the properties
const listenerArn: string = event.ResourceProperties.ListenerArn;
// Log the listener ARN
console.log(`Listener ARN: ${listenerArn}`);
// Describe the rules for the specified listener ARN using AWS SDK
// This requires the lambda to have "elasticloadbalancing:DescribeRules"
// permission in the IAM policy.
const result: AWS.ELBv2.DescribeRulesOutput = await elbv2
.describeRules({ ListenerArn: listenerArn })
.promise();
// Extract the priorities from the rule descriptions
const inUse = result.Rules!
.map((r: AWS.ELBv2.Rule) => r.Priority)
.filter((p: number | null) => Number.isInteger(p))
.map((p: number) => p);
// Log the in-use priorities
console.log(`In use priorities: ${inUse}`);
// Check if the generated priority is already in use, otherwise
// generate a new one by adding the collision step size to the
// generated priority until a free priority is found.
let priority;
while (!priority || inUse.includes(priority)) {
// Increment the priority by the collision step size until
// a free priority is found.
priority =
Number.parseInt(event.ResourceProperties.Priority) ||
Math.floor(
Math.random() *
(ALB_RULE_PRIORITY_RANGE[1] - ALB_RULE_PRIORITY_RANGE[0] + 1) +
ALB_RULE_PRIORITY_RANGE[0]
);
}
// Log the generated priority
console.log(`Generated priority: ${priority}`);
// Set the response data with the generated priority
response.Data = {
Priority: priority,
};
}
} catch (error) {
// Log the error
console.error(error);
// Set the response status code to FAILED
response.Status = 'FAILED';
if (error instanceof Error) {
// Set the response reason to the error message
response.Reason = error.message;
}
}
// Return the response object
return response;
};
The Lambda Code Explained
Now that we have the Lambda function, let's break it down and explain what is going on.
The Event
Probably the most important part of the custom resource Lambda function is the event. The event will communicate everything that is needed to define any custom resource you will be creating.
The event object comes in the following form (reference):
{
"RequestType" : "Create",
"ResponseURL" : "http://pre-signed-S3-url-for-response",
"StackId" : "arn:aws:cloudformation:us-west-2:123456789012:stack/stack-name/guid",
"RequestId" : "unique id for this create request",
"ResourceType" : "Custom::TestResource",
"LogicalResourceId" : "MyTestResource",
"ResourceProperties" : {
"Name" : "Value",
"List" : [ "1", "2", "3" ]
}
}
Property | Description |
---|---|
RequestType | The type of request. The following are the possible values: Create , Update , Delete
|
ResponseURL | The pre-signed Amazon S3 URL that can be used to send responses. |
StackId | The Amazon Resource Name (ARN) that identifies the stack that contains the custom resource. |
RequestId | The identifier that matches the request with the response. This value must be identical to the value of the RequestId property in the request. |
ResourceType | The resource type of the custom resource in the template. Custom resource type names can be up to 60 characters long and can include alphanumeric and the following characters: @- |
LogicalResourceId | The name of the custom resource in the template. This is provided to facilitate communication between the custom resource provider and the template developer. |
ResourceProperties | The custom resource resource properties that are defined in the CloudFormation template. The format of the properties is determined by the custom resource provider. |
In TypeScript, luckily we have a type for this object. The type is CdkCustomResourceEvent
. This type is defined in the aws-lambda
module. The type is defined as follows:
export interface CdkCustomResourceEvent {
RequestType: string;
ResponseURL: string;
StackId: string;
RequestId: string;
ResourceType: string;
LogicalResourceId: string;
ResourceProperties: {
[Key: string]: any;
};
OldResourceProperties?: {
[Key: string]: any;
} | undefined;
}
This can be used in your Lambda function by importing it from the aws-lambda
module.
import { CdkCustomResourceEvent } from 'aws-lambda';
The Response
The response object is what will be returned to CloudFormation after the Lambda function has been completed. This object will tell CloudFormation if the custom resource was created, updated, or deleted successfully. It will also contain any data that you want to return to CloudFormation. This data can be used in other resources in your CloudFormation template if is needed or wanted.
In TypeScript, luckily we have a type for this object. The type is CdkCustomResourceResponse
. This type is defined in the aws-lambda
module. Before we look at that, an example of the response object is below (reference):
{
"Status" : "SUCCESS",
"PhysicalResourceId" : "TestResource1",
"StackId" : "arn:aws:cloudformation:us-west-2:123456789012:stack/stack-name/guid",
"RequestId" : "unique id for this create request",
"LogicalResourceId" : "MyTestResource",
"NoEcho" : "false",
"Data" : {
"OutputName1" : "Value1",
"OutputName2" : "Value2",
}
}
Property | Description |
---|---|
Status | The status value sent by the custom resource provider in response to an AWS CloudFormation-generated request. You must send SUCCESS or FAILED . |
Reason | An optional message that should be displayed to the user. This message can be up to 1 KB in size. |
PhysicalResourceId | An identifier that can be the physical ID that is tied to the custom resource. This value must be unique for the custom resource provider. |
StackId | The Amazon Resource Name (ARN) that identifies the stack that contains the custom resource. |
RequestId | The identifier that matches the request with the response. This value must be identical to the value of the RequestId property in the request. |
LogicalResourceId | The name of the custom resource in the template. This is provided to facilitate communication between the custom resource provider and the template developer. |
NoEcho | Indicates whether to mask the output of the custom resource when retrieved by using the Fn::GetAtt function. If set to true , all returned values are masked with asterisks (***** ). The default value is false . |
Data | A map of the custom resource provider's outputs. |
Again, in TypeScript, we have a type for this object. The type is CdkCustomResourceResponse
. This type is defined in the aws-lambda
module. The type is defined as follows:
export interface CdkCustomResourceResponse {
PhysicalResourceId?: string;
Data?:
| {
[Key: string]: any;
}
| undefined;
[Key: string]: any;
}
This can be used in your Lambda function by importing it from the aws-lambda
module.
import { CdkCustomResourceResponse } from 'aws-lambda';
The Handler
If you have done any Lambda development, you are probably familiar with the handler. The handler is the function that is invoked by Lambda. It is the entry point to your Lambda function. In TypeScript, the handler is defined as follows:
export type Handler = (
event: any,
context: Context,
callback: Callback<any>
) => void;
and it typically looks like this:
export const handler: Handler = async (
event: any,
context: Context,
callback: Callback<any>
): Promise<void> => {
// Do something
};
In our case though, we can strongly type the event and response objects. This will make our code more readable and easier to maintain. To do this, we can use the CdkCustomResourceEvent
and CdkCustomResourceResponse
types that we discussed above. This will make our handler look like this:
export const handler: Handler = async (
event: CdkCustomResourceEvent,
context: Context
): Promise<CdkCustomResourceResponse> => {
// Do something
};
The Priority Generator
Alright, now that we have the event, response, and handler defined, we can get into the rest of the code. In our example above, we are creating a custom resource that will generate a priority for an ALB listener rule. This means that we will need to do the following:
- Get the listener ARN from the properties
- Get the existing listener rules priorities from the listener ARN
- Generate a priority that is not in use
- Return the generated priority
To do this, we can use the AWS SDK. We will need to import the aws-sdk
module and create an instance of the ELBv2
class. This will allow us to interact with the ELBv2 service. We can then use the describeRules
method to get the existing listener rules priorities from the listener ARN. This will look like this:
// Import necessary modules and libraries
import * as AWS from 'aws-sdk';
.
.
.
// Create an ELBv2 instance using AWS SDK
const elbv2: AWS.ELBv2 = new AWS.ELBv2();
.
.
.
// Describe the rules for the specified listener ARN using AWS SDK
// This requires the lambda to have "elasticloadbalancing:DescribeRules"
// permission in the IAM policy.
const result: AWS.ELBv2.DescribeRulesOutput = await elbv2
.describeRules({ ListenerArn: listenerArn })
.promise();
// Verify that the result is valid.
if (!result || !result.Rules) {
throw new Error('Something went wrong try to describe the rules...', result);
}
// Create a nice function to extract the priorities from the rule descriptions.
const inUse = result.Rules
.map((r: AWS.ELBv2.Rule) => r.Priority)
.filter((p: number | null) => Number.isInteger(p))
.map((p: number) => p);
// Log the in-use priorities
console.log(`In use priorities: ${inUse}`);
// Check if the generated priority is already in use, otherwise
// generate a new one by adding the collision step size to the
// generated priority until a free priority is found.
let priority;
while (!priority || inUse.includes(priority)) {
// Increment the priority by the collision step size until
// a free priority is found.
priority =
Number.parseInt(event.ResourceProperties.Priority) ||
Math.floor(
Math.random() *
(ALB_RULE_PRIORITY_RANGE[1] - ALB_RULE_PRIORITY_RANGE[0] + 1) +
ALB_RULE_PRIORITY_RANGE[0]
);
}
.
.
.
The Response
Now that we have the priority generated, we can return it to CloudFormation. To do this, we can set the Data
property on the response object. This will look like this:
// Set the response data with the generated priority
response.Data = {
Priority: priority,
};
The Error Handling
Now that we have the happy path working, we need to handle the error path. This is important because if we do not handle the error path, CloudFormation will not know if the custom resource was created, updated, or deleted successfully. To do this, we can wrap the priority generator in a try/catch block. In the catch block, we can set the Status
property on the response object to FAILED
and even set the Reason
property to the error message; both of which will be returned to CloudFormation. This will look like this:
try {
// Our happy path code
} catch (error) {
// Log the error
console.error(error);
// Set the response status code to FAILED
response.Status = 'FAILED';
if (error instanceof Error) {
// Set the response reason to the error message
response.Reason = error.message;
}
}
// Return the response object
return response;
};
All Together
Now that we have all of the pieces, we can put them all together. This will look like this:
// Import necessary modules and libraries
import {
CdkCustomResourceResponse,
CdkCustomResourceEvent,
Context,
Handler,
} from 'aws-lambda';
import * as AWS from 'aws-sdk';
// Define a constant range for ALB rule priorities.
// 50000 is the maximum priority value, see for more info:
// https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-listeners.html#listener-rules
const ALB_RULE_PRIORITY_RANGE = [1, 50000];
/**
* A lambda function that generates a random priority for an ALB listener rule and
* avoids conflicts with existing priorities.
* @param event The Lambda event.
* @param context The Lambda context.
*/
export const handler: Handler = async (
event: CdkCustomResourceEvent,
context: Context
): Promise<CdkCustomResourceResponse> => {
// Log the received event for debugging
console.log('Received event: ' + JSON.stringify(event, null, 2));
// Create a response object with initial properties
const response: CdkCustomResourceResponse = {
StackId: event.StackId,
RequestId: event.RequestId,
LogicalResourceId: event.LogicalResourceId,
PhysicalResourceId: context.logGroupName,
};
try {
// Check if the request type is 'Create'
// There is no need to handle 'Update' or 'Delete' requests in
// the context of listener rule priority generation. CloudFormation
// will handle the deletion of the custom resource automatically.
if (event.RequestType === 'Create') {
// Create an ELBv2 instance using AWS SDK
const elbv2: AWS.ELBv2 = new AWS.ELBv2();
// Get the listener ARN from the properties
const listenerArn: string = event.ResourceProperties.ListenerArn;
// Log the listener ARN
console.log(`Listener ARN: ${listenerArn}`);
// Describe the rules for the specified listener ARN using AWS SDK
// This requires the lambda to have "elasticloadbalancing:DescribeRules"
// permission in the IAM policy.
const result: AWS.ELBv2.DescribeRulesOutput = await elbv2
.describeRules({ ListenerArn: listenerArn })
.promise();
// Extract the priorities from the rule descriptions
const inUse = result.Rules!
.map((r: AWS.ELBv2.Rule) => r.Priority)
.filter((p: number | null) => Number.isInteger(p))
.map((p: number) => p);
// Log the in-use priorities
console.log(`In use priorities: ${inUse}`);
// Check if the generated priority is already in use, otherwise
// generate a new one by adding the collision step size to the
// generated priority until a free priority is found.
let priority;
while (!priority || inUse.includes(priority)) {
// Increment the priority by the collision step size until
// a free priority is found.
priority =
Number.parseInt(event.ResourceProperties.Priority) ||
Math.floor(
Math.random() *
(ALB_RULE_PRIORITY_RANGE[1] - ALB_RULE_PRIORITY_RANGE[0] + 1) +
ALB_RULE_PRIORITY_RANGE[0]
);
}
// Log the generated priority
console.log(`Generated priority: ${priority}`);
// Set the response data with the generated priority
response.Data = {
Priority: priority,
};
}
} catch (error) {
// Log the error
console.error(error);
// Set the response status code to FAILED
response.Status = 'FAILED';
if (error instanceof Error) {
// Set the response reason to the error message
response.Reason = error.message;
}
}
// Return the response object
return response;
};
The CDK Construct Code
Now that we have our Lambda which will act as the backend of our custom resource, we can actually create the custom resource (I know, shocking). Luckily, this is pretty easy to do with CDK. To do this, we can create a new object CustomResource
.
const lambda = new lambda.NodejsFunction(this, 'Lambda', {
entry: 'src/lambda.ts',
handler: 'handler',
runtime: lambda.Runtime.NODEJS_18_X,
timeout: cdk.Duration.seconds(30),
bundling: {
minify: true,
sourceMap: true,
},
});
const provider = new cr.Provider(this, 'Provider', {
onEventHandler: lambda,
});
const resource = new cdk.CustomResource(this, 'CustomResource', {
serviceToken: provider.serviceToken,
properties: {
ListenerArn: 'arn:aws:elasticloadbalancing:<region>:<account>:listener/<listener-id>',
},
});
const priority = resource.getAttString('Priority');
The Construct Code Explained
Unlike the AwsCustomResource
construct, the CustomResource
construct does not create the Lambda function for us. This means that we have to create the Lambda function ourselves. To do this, we can use the NodejsFunction
construct from the @aws-cdk/aws-lambda-nodejs
module. This construct will create the Lambda function for us.
We can then use the Provider
construct from the @aws-cdk/custom-resources
module to create the custom resource provider. A custom resource provider is where we pass the Lambda to the custom resource. What makes it special is that we can use one Lambda for multiple custom resources. via the service token. This means that we can use the same Lambda for multiple custom resources. This is great because it means that we do not have to create a new Lambda for every custom resource that we create.
Once that is done, we can pass it to the custom resource provider to the CustomResource
construct. This will create a custom resource for us. This construct takes an object that defines the serviceToken
and properties
properties. The serviceToken
property defines the service token of the custom resource provider. The properties
property defines the properties that will be passed to the Lambda function. In our case, we are passing the listener ARN to the Lambda function.
Now, all we have to do is get the priority from the custom resource. To do this, we can use the getAttString
method. This method takes a string that defines the attribute that will be returned. In our case, we are using the Priority
attribute which will return the priority that was generated by the custom resource.
Conclusion
Custom Resources are quite a powerful tool to have in your CDK toolbelt. While not necessary all the time, they can be used to solve a lot of problems.
If you are doing something simple that an AWS Action already covers well, you can use the AwsCustomResource
construct. This will allow you to create a custom resource with just a few lines of code. This is great for simple use cases.
If you are doing something more complex that requires a custom Lambda function, you can use the CustomResource
construct. This will allow you to create a custom resource with just a few lines of code. This is great for more complex use cases.
Hopefully, this article has helped you understand custom resources a little better and how to use them with CDK!
Top comments (0)