Conditionally create an EBS volume using a snapshot

Fri Aug 19 2022
Reading Time: ~ 10 mins
aws
tech

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).

TLDR?

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.

Why 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.

The Challenge

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.

How do you think about a CDK stack?

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:

Phase 1 - Synthesis-Time

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')
    })
  }
  [...]

Phase 2 - Deploy-Time

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

Implementing CloudFormation Conditions in AWS CDK

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....

Conditional Launch Templates

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
  [...]

Forgetting the Golden Rule

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 Neat Solution

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.

Conclusion

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.


tags: awstech