Reading time·7 min
The purpose of identifiers in CDK is to create a unique resource ID, such as
the Logical Identifier
of resources provisioned using CloudFormation.
Identifiers must be unique in the scope they are created in, however, they don't
have to be globally unique in the entire CDK application.
Identifiers in CDK are of 3 types:
A Construct in CDK is a cloud component. Constructs encapsulate logic for creating a component that could consist of one or more resources.
Constructs allow us to write useful abstractions on top of CloudFormation and reduce some of the duplications in resource definitions.
For example, an S3 bucket construct could look similar to the following.
import * as cdk from 'aws-cdk-lib'; import {Bucket} from 'aws-cdk-lib/aws-s3'; import {Construct} from 'constructs'; export class UploadsBucketConstruct extends Construct { public readonly s3Bucket: Bucket; constructor(scope: Construct, id: string) { super(scope, id); this.s3Bucket = new Bucket(this, id); } }
The first parameter we specify in the constructor function is the scope. In
JavaScript, we use the this
keyword to denote the scope in CDK.
The second parameter the constructor method takes is the id
, this is the
Construct ID.
We can use this construct in the following way:
export class CdkIdentifiersStack extends cdk.Stack { constructor(scope: cdk.App, id: string, props?: cdk.StackProps) { super(scope, id, props); const {s3Bucket: s3BucketFirst} = new UploadsBucketConstruct( this, 's3-bucket', ); } }
Notice that the Construct ID we are passing to our UploadsBucketConstruct
is
s3-bucket
.
Before we deploy, we must also instantiate the stack in the scope of our CDK App:
import * as cdk from 'aws-cdk-lib'; import {CdkIdentifiersStack} from '../lib/cdk-starter-stack'; const app = new cdk.App(); new CdkIdentifiersStack(app, 'cdk-identifiers-stack-dev', { stackName: 'cdk-identifiers-stack-dev', });
If I deploy the CDK stack at this point we get the following CloudFormation stack:
Notice the Cloudformation Logical ID is s3bucket5E7B98C4
, whereas our CDK
Construct ID was just s3bucket
- an 8-digit hash got appended to our CDK
Construct ID to form the CloudFormation Logical ID, more on that later in the
article.
Let's try to provision two S3 buckets with the same Construct ID at the same
scope (the scope of our CdkIdentifiersStack
):
export class CdkIdentifiersStack extends cdk.Stack { constructor(scope: cdk.App, id: string, props?: cdk.StackProps) { super(scope, id, props); const {s3Bucket: s3BucketFirst} = new UploadsBucketConstruct( this, 's3-bucket', ); + const {s3Bucket: s3BucketSecond} = new UploadsBucketConstruct( + this, + 's3-bucket', + ); new cdk.CfnOutput(this, 'region', {value: cdk.Stack.of(this).region}); new cdk.CfnOutput(this, 'bucketName', { value: s3BucketFirst.bucketName, }); } }
After we try to npx aws-cdk deploy
, we get an error:
At this point we know that defining two Constructs with the same Construct ID in the same scope results in an error.
However, what would happen if we were to change the Construct ID
of our first
bucket?
export class CdkIdentifiersStack extends cdk.Stack { constructor(scope: cdk.App, id: string, props?: cdk.StackProps) { super(scope, id, props); const {s3Bucket: s3BucketFirst} = new UploadsBucketConstruct( this, - 's3-bucket', + 'new-s3-bucket', ); new cdk.CfnOutput(this, 'region', {value: cdk.Stack.of(this).region}); new cdk.CfnOutput(this, 'bucketName', { value: s3BucketFirst.bucketName, }); } }
Let's run the cdk diff command before we deploy the changes:
In the screenshot, we see that our old bucket with the logical id of
s3bucket5E7B98C4
would become an orphan, and we would just create a new bucket
with a new logical id of news3bucketCF1C73C0
.
The reason our bucket is orphaned and not deleted is the default behavior for the Bucket construct. By default when a bucket is removed from a stack it becomes orphaned but remains in the AWS account - docs.
However, relying on default behavior and not understanding CDK identifiers is not a good approach.
Let's go through with the deployment of the bucket with the changed Construct ID and see the result in our Cloudformation stack:
After the deployment of the stack, we can see that the logical ID of the
bucket in our stack is now news3bucketCF1C73C
which is a completely different
s3 bucket than the one we had before.
Since no one really changes the Construct identifiers of resources in the way we just did, let's look at a more common way to change identifiers, by simply refactoring code.
A Path is a collection of IDs starting with the ID, used to initialize the
root Stack and going down the constructs. The Path joins these IDs with /
characters.
For example, in our application, the path of the s3BucketFirst
resource would
be cdk-identifiers-stack-dev/new-s3-bucket/new-s3-bucket
.
So the path is stackName/topLevelId/nestedId
. The
stack name is cdk-identifiers-stack-dev
, which
we define, when we initialize the stack:
const app = new cdk.App(); new CdkIdentifiersStack(app, 'cdk-identifiers-stack-dev', { stackName: 'cdk-identifiers-stack-dev', });
Then we pass the new-s3-bucket
id to the UploadsBucketConstruct
, which ends
up passing it to the Bucket
construct:
// Using the construct const {s3Bucket: s3BucketFirst} = new UploadsBucketConstruct( this, 'new-s3-bucket', ); // The construct implementation reuses // the passed in ID this.s3Bucket = new Bucket(this, id);
That's how our path ended up being
cdk-identifiers-stack-dev/new-s3-bucket/new-s3-bucket
.
Since we know that the identifiers must be unique at the same scope, we also know that the path, which is the combination of the identifiers, starting at the root stack will also be unique.
The way to get a path of a construct in CDK is by accessing the path
property
on the construct node:
const {s3Bucket: s3BucketFirst} = new UploadsBucketConstruct( this, 'new-s3-bucket', ); const s3BucketPath = s3BucketFirst.node.path; console.log('path is: ', s3BucketPath);
The stack name, in our case cdk-identifiers-stack-dev
doesn't add any
uniqueness for resources in the same stack so it isn't used to form the
CloudFormation logical ID.
In fact, if we deploy a second stack named cdk-identifiers-stack-prod
the
Cloudformation logical id of the bucket would be the exact same as in our
cdk-identifiers-stack-dev
deployment:
Notice how the path has the construct identifier new-s3-bucket
twice. Once for
our own UploadsBucketConstruct
and once for the s3.bucket
construct.
However, if we look at the logical id in CloudFormation we only have
news3bucket
once and then an 8-character hash.
The reason is in the implementation of the allocateLogicalId
function in the
cdk code.
Since we passed in the same construct identifier to our own and the AWS Bucket constructs, the last components of the path were the same and they were de-duplicated, resulting in a more human-readable Logical ID in CloudFormation.
Now that we know how the Path is formed - starting at the root stack and going down the construct scopes, it is very important to note that refactoring your code and extracting duplicated logic into a construct might alter your path and end up altering your CloudFormation Logical IDs, which results in deleting resources and replacing them with new ones with the new logical ID.
CloudFormation only allows for identifiers that are alphanumeric -
[a-zA-Z0-9]
, which means the Logical IDs in CloudFormation can't contain /
characters.
However, as we saw our paths contain slash characters, i.e.
cdk-identifiers-stack-dev/new-s3-bucket/new-s3-bucket
.
If we were to just join on the slash characters of the path to form the unique IDs, we could have collisions if we named our constructs like:
new-s3-bucket/new-s3-bucket
new-s3-/bucketnew-s3-bucket
Just joining the components on the /
character to make our identifiers
conform to CloudFormation's requirement of only alphanumeric characters would
not prove uniqueness.
There's an unlikely scenario that the combination of ids could still clash.
The solution the CDK team implemented is to append an 8-digit hash to the
components from the path in order to create a unique identifier. So that's
where the 5E7B98C4
part comes from in the appendix of the S3 bucket's Logical
ID:
The unique IDs are used as Cloudformation Logical IDs.
Since CDK gets compiled into CloudFormation, a good starting point is to look at the result of a CDK deployment:
In the first column of the screenshot, we can see the Logical ID
of the
resources.
We can specify a resource's Logical ID in Cloudformation like so:
Resources: MyLogicalId: Type: AWS::DynamoDB::Table Properties: # ... properties of the table
This means that if we were to rename MyLogicalId
to NewLogicalId
, our
DynamoDB table would get deleted and a new table would get created, only without
all the records we had stored up to that point.
Resources: NewLogicalId: Type: AWS::DynamoDB::Table Properties: # ... properties of the table
If we change the ID of a Lambda function or an IAM Role, we might get some interruption, but it wouldn't be as bad as changing the logical ID of a data store like S3, Dynamodb, RDS, etc and potentially losing data (if we don't have a prevention mechanism in place).
The most important thing to note about IDs in CDK is that simply refactoring your code and extracting logic into a Construct can end up altering the resource's path and logical ID.
If you change a resource's Logical ID, the resource gets deleted and a new resource with the new Logical ID gets created, which is especially important to consider when dealing with stateful components such as databases.
You can learn more about the related topics by checking out the following tutorials: