I'm a big fan of the AWS CDK and I've been using it a lot over the last few month while building out projects. One challenge I recently encountered was creating a volume based on some deploy-time parameter in AWS CloudFormation. The idea was to create a volume given a snapshot ID. If the snapshot ID was default
I'd create the volume from scratch (i.e. a new volume).
This solution highlights how you would use a CfnCondition
to conditionally create an AWS EBS volume from a snapshot or from scratch. The solution pivots around the Aws.NO_VALUE
that is passed into the Fn.conditionIf
intrinsic function which eventually either activates the BlockDeviceMapping.Ebs.SnapshotId
parameter in AWS CloudFormation.
[...]
// Snapshot CFN SSM Parameter
const snapshot = StringParameter.fromStringParameterName(this, 'SnapshotId', '/ops/snapshot_id')
// Should we use a snapshot for this EBS volume?
const useSnapshot = new CfnCondition(this, 'UseSnapshot', {
expression: Fn.conditionNot(Fn.conditionEquals(snapshot.stringValue, 'default'))
})
// If using snapshot pass in SnapshotId otherwise AWS::NoValue
const useSnapshotId = Fn.conditionIf(useSnapshot.logicalId, snapshot.stringValue, Aws.NO_VALUE).toString()
const volume = BlockDeviceVolume.ebsFromSnapshot(useSnapshotId, { volumeType: EbsDeviceVolumeType.GP3 })
new LaunchTemplate(this, 'LaunchTemplate', {
launchTemplateName: 'my-launch-template',
machineImage: EcsOptimizedImage.amazonLinux2(AmiHardwareType.ARM),
instanceType: new InstanceType('r6g.medium'),
blockDevices: [{
deviceName: '/dev/sda1',
volume: volume
}]
})
[...]
Read on for the full story and how to think critically about the use of AWS CDK.
When it comes to building out infrastructure on AWS usually it's a good idea to start with the console. The console is a great way to test ideas and get things moving fast. The challenge comes in when you start to have a number of services connecting together. More resources and services means it becomes harder to keep track of what components and resources belong to different parts of your application.
The AWS Cloud Development Kit (CDK) allows you to codify your application infrastructure using a common programming language like Typescript, Python, Golang etc. This means that you are able to leverage programming features to do loops and complicated logic. My language of choice is Typescript because its strong typing allows me to use VSCode to give me hints as to the values for certain parameters.
It took me a couple of iterations and a lot of digging to get to this solution and I'd often have to tear myself from my monitor with this challenge unsolved.
To be more specific I wanted to have a stack that created a EC2 Launch Template. This stack would be used later in my stacks & CI / CD pipeline to create an Auto Scaling Group (this is arbitrary but helps give some context). The launch template would always deploy an EC2 instance with an EBS volume attached but if I provided an EBS Snapshot ID when creating / updating the stack, the AWS CloudFormation stack would instead launch the EBS volume from the provided snapshot ID.
We have the CDK docs, we have a powerful programming language, so this should be easy.... or so I thought.
class LaunchTemplateStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props)
const snapshot = StringParameter.fromStringParameterName(this, 'SnapshotId', '/ops/snapshot_id')
const ebsNew = BlockDeviceVolume.ebs(100, { volumeType: EbsDeviceVolumeType.GP3 })
const ebsExists = BlockDeviceVolume.ebsFromSnapshot(snapshot.stringValue, { volumeType: EbsDeviceVolumeType.GP3 })
new LaunchTemplate(this, 'LaunchTemplate', {
launchTemplateName: 'my-launch-template',
machineImage: EcsOptimizedImage.amazonLinux2(AmiHardwareType.ARM),
instanceType: new InstanceType('r6g.medium'),
blockDevices: [{
deviceName: '/dev/sda1',
volume: (snapshot.stringValue == 'default') ? ebsNew : ebsExists
}]
})
[...]
}
}
Here I'm using an SSM Parameter to store the value of the snapshot. If the value is default
I will use a new EBS volume. If not I'll create the volume from the given snapshot.
To understand why this doesn't result in a stack that you can flip between using a snapshot or using an new volume we need to get into the weeds of how AWS CDK stacks are synthesized and deployed.
The golden rule to think about AWS CDK stacks is to remember that you are still making use of AWS CloudFormation under-the-hood. When it comes to deploying a stack that you've built in a particular programming language there are two phases you need to think about:
When you perform aws cdk synth
in your CLI, the magic of the programming language is at work to essentially create the AWS CloudFormation template in the cdk.out
folder. In this step you may make use of variables that are in the programming language of choice. It is actually this step where the ternary statement is being evaluated in the above example. I.e every time I try deploy this stack, it will always use the new volume.
Example - Creating an Launch Template
Another example of this is a loop that creates two roles in our CloudFormation stack. We can verify this by outputting the result of the cdk synth
step.
[...]
const roleNames = ['role1', 'role2']
for (let roleName of roleNames) {
new Role(this, `Role-${roleName}`, {
assumedBy: new ServicePrincipal('lambda.amazonaws.com')
})
}
[...]
The deploy phase happens when the resulting CloudFormation template is passed to AWS CloudFormation console. Here we are back in the realm of what AWS CloudFormation can handle. AWS CloudFormation can handle conditions but they are a little more clunky than the programming language.
In AWS CDK you can make use of CloudFormation conditions using intrinsic functions and condition expressions
Ok, so reading the docs and doing some digging into the functions available to AWS CloudFormation results in the following:
[...]
// Snapshot CFN SSM Parameter
const snapshot = StringParameter.fromStringParameterName(this, 'SnapshotId', '/ops/snapshot_id')
// Should we use a snapshot for this EBS volume?
const useSnapshot = new CfnCondition(this, 'UseSnapshot', {
expression: Fn.conditionNot(Fn.conditionEquals(snapshot.stringValue, 'default'))
})
// Create options for either a new volume or a volume from a snapshot.
const ebsNew = BlockDeviceVolume.ebs(100, { volumeType: EbsDeviceVolumeType.GP3 })
const ebsExists = BlockDeviceVolume.ebsFromSnapshot(snapshot.stringValue, { volumeType: EbsDeviceVolumeType.GP3 })
const volume = Fn.conditionIf(useSnapshot.logicalId, ebsExists, ebsNew)
new LaunchTemplate(this, 'LaunchTemplate', {
launchTemplateName: 'my-launch-template',
machineImage: EcsOptimizedImage.amazonLinux2(AmiHardwareType.ARM),
instanceType: new InstanceType('r6g.medium'),
blockDevices: [{
deviceName: '/dev/sda1',
volume: volume // <---------- This line causes an error
}]
})
[...]
In this example we see that Typescript throws an error about the types not matching. This is correct because the type being output by Fn.ConditionIf
is a string
and the volume is expecting a BlockDeviceVolume
type. More challenges....
The next thing I tried was the brute force approach, two launch template resources that would alternate based on the condition attached to the resource. This worked fine but had a limitation that after the branch point of the condition, every other resources that depended on the LaunchTemplate
would need to be created for both cases. I.e. if I wanted to now output an SSM Parameter with the launch template version and id, I'd need to declare them twice and add more conditionals.
This lead to very large templates with loads of redundancy.
[...]
// Snapshot CFN SSM Parameter
const snapshot = StringParameter.fromStringParameterName(this, 'SnapshotId', '/ops/snapshot_id')
// Should we use a snapshot for this EBS volume?
const useSnapshot = new CfnCondition(this, 'UseSnapshot', {
expression: Fn.conditionNot(Fn.conditionEquals(snapshot.stringValue, 'default'))
})
const useNew = new CfnCondition(this, 'UseNew', {
expression: Fn.conditionEquals(snapshot.stringValue, 'default')
})
// Create options for either a new volume or a volume from a snapshot.
const ebsNew = BlockDeviceVolume.ebs(100, { volumeType: EbsDeviceVolumeType.GP3 })
const ebsExists = BlockDeviceVolume.ebsFromSnapshot(snapshot.stringValue, { volumeType: EbsDeviceVolumeType.GP3 })
const launchTemplateNew = new LaunchTemplate(this, 'LaunchTemplate', {
...
blockDevices: [{
deviceName: '/dev/sda1',
volume: ebsNew
}]
})
const launchTemplateNewCfn = launchTemplateNew.node.defaultChild as CfnLaunchTemplate
launchTemplateNewCfn.cfnOptions.condition = useSnapshot
const launchTemplateExists = new LaunchTemplate(this, 'LaunchTemplate', {
...
blockDevices: [{
...
volume: ebsExists
}]
})
const launchTemplateExistsCfn = launchTemplateExists.node.defaultChild as CfnLaunchTemplate
launchTemplateExistsCfn.cfnOptions.condition = useNew
[...]
What I'd done with this problem is that I'd forgotten the golden rule. I need to think about how the application behaves as it synthesizes to AWS CloudFormation. This lead me down a route of exploration into AWS CloudFormation. The question I needed to answer was, can I do this in pure AWS CloudFormation? Then I can extrapolate into the tools that AWS CDK provides me.
If you check the docs for BlockDeviceMapping
we can actually see that the Ebs
parameters are either String
, Integer
or Boolean
.
This gives me a slight hint. If we can map our snapshot.stringValue
to the SnapshotId
in the CloudFormation resource, then we can get the snapshot attached. But what about the default case? If you try to create a stack where SnapshotId
is default
, AWS CloudFormation will throw an error. The other thing is that we don't want to have to manually create the CfnLaunchTemplate
Layer 1 construct.
The breakthrough came when looking through the CloudFormation docs. I found a section on Pseudo parameters - specifically the AWS::NoValue
parameter. This parameter is essentially a String
type that tells CloudFormation to ignore the parameter when parsing.
I knew that BlockDeviceVolume.ebsFromSnapshot
would eventually map the snapshotId: string
parameter to the CloudFormation resource structure without doing anything to the type. So this was my ticket.
[...]
// Snapshot CFN SSM Parameter
const snapshot = StringParameter.fromStringParameterName(this, 'SnapshotId', '/ops/snapshot_id')
// Should we use a snapshot for this EBS volume?
const useSnapshot = new CfnCondition(this, 'UseSnapshot', {
expression: Fn.conditionNot(Fn.conditionEquals(snapshot.stringValue, 'default'))
})
// If using snapshot pass in SnapshotId otherwise AWS::NoValue
const useSnapshotId = Fn.conditionIf(useSnapshot.logicalId, snapshot.stringValue, Aws.NO_VALUE).toString()
const volume = BlockDeviceVolume.ebsFromSnapshot(useSnapshotId, { volumeType: EbsDeviceVolumeType.GP3 })
new LaunchTemplate(this, 'LaunchTemplate', {
launchTemplateName: 'my-launch-template',
machineImage: EcsOptimizedImage.amazonLinux2(AmiHardwareType.ARM),
instanceType: new InstanceType('r6g.medium'),
blockDevices: [{
deviceName: '/dev/sda1',
volume: volume
}]
})
[...]
I get the nod of approval from the Typescript type checker and I'm able to do cdk synth
to verify that AWS CDK is happy with the configuration. Deploying the result I get a CloudFormation template with the correct Fn::If
functionality to be able to switch between the snapshot value and the AWS::NoValue
value.
The neatness of the solution meant I could use the resulting LaunchTemplate
object later on in my template to pass into my Auto Scaling Group.
This problem highlighted to me the power of thinking from the ground up and re-framing the problem from one domain (AWS CDK) and into the other (AWS CloudFormation). The use of Aws.NO_VALUE
also has little explicit documentation so it required that I do some digging on the aws/aws-cdk Github repo.
Nevertheless I was happy to get to the result and be able to sleep well again knowing I had neat AWS CDK code.