Cloud Lego with the AWS CDK

BLOG ARTICLE

Lego

Meeting developers where they are is a core tenet of our developer tools vision.

Werner Vogels - CTO Amazon Web Services

Lego. The quintessential toy known the world over for sparking creativity in children (and the odd expletive from barefooted parents) had its 90th anniversary this year.

While the humble lego brick is innocuous on its own, when combined together magic happens. It’s this bringing together of smaller parts to build something complex which is not unlike building cloud infrastructure with the AWS Cloud Development Kit… and just as much fun!

Why choose AWS CDK?

A core component of AWS is Cloudformation, an infrastructure-as-code service which is used to model collections of logical resources declaratively. Resources are modelled in a JSON or YAML formatted document called a Cloudformation template. Once a template is submitted to the Cloudformation service the resources defined by the template are created. The resulting collection of physical resources is called a ‘stack’.

The AWS CDK builds upon the foundation of Cloudformation by empowering development teams to write infrastructure-as-code using the general purpose programming languages they are already familiar with. The currently supported languages are TypeScript, JavaScript, Python, Java, C#/.Net, and soon Go (the code examples in this post are written in TypeScript!).

A CDK application is a program which outputs a bundle of files collectively referred to as a ‘cloud assembly’. The CDK toolchain not only handles the synthesis of Cloudformation templates, but also the bundling and uploading of artifacts to AWS such as Lambda functions, Docker images and static files.

The CDK helps abstract complex architectures behind simple interfaces. Rather than thinking of resources as a heterogeneous collection of potentially unrelated resources it lets developers group resources by their business function.

For example, to host a static website on AWS you would need to configure a Cloudfront distribution, a TLS certificate, a Route53 A-record and an S3 bucket. That’s four resources across four AWS services which need to be configured to work together! By using the AWS CDK you can group resources into higher-level components which abstract the complexity of configuring the resources contained within.

const myStaticSite = new StaticSite(
  this, 
  'MyStaticSite', 
  { 
    domainName: 'mywebsite.com',
    source: './path/to/static/files'
  }
);

These are called Constructs and they are the Lego bricks of the CDK.

What are constructs?

A construct is an encapsulation of one or more resources which together represent a component. Constructs typically exist at 3 seperate levels (you can think of a raw Cloudformation template as existing at level 0).

L1 constructs act as a thin one-to-one mapping of Cloudformation resources. In fact, the CDK construct library auto-generates these constructs from the AWS Cloudformation Resource Specification. L1 constructs are always named with the prefix Cfn (think Cloudformation).

L2 constructs build upon L1 constructs through composition. They act as an opinionated wrapper around the underlying L1 construct by configuring sensible defaults as well as providing methods for manipulating resource properties and assigning permissions.

Similarly, L3 constructs are a level higher again. These typically wrap multiple L1 and L2 constructs and the CDK documentation refers to these as patterns. When writing your own custom constructs you are ultimately working at this layer as you will be composing other lower-level constructs together.

CDK layers

Consider this Level 2 construct which creates a DynamoDB table:

const myTable = new dynamodb.Table(this, 'MyTable', {
  partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },
  sortKey: { name: 'SK', type: dynamodb.AttributeType.STRING },
  billingMode: dynamodb.BillingMode.PAY_PER_REQUEST
});

Now let’s look at what this looks like when synthesised as part of a Cloudformation template:

MyTable794EDED1:
  Type: AWS::DynamoDB::Table
  Properties:
    KeySchema:
    - AttributeName: PK
      KeyType: HASH
    - AttributeName: SK
      KeyType: RANGE
    AttributeDefinitions:
    - AttributeName: PK
      AttributeType: S
    - AttributeName: SK
      AttributeType: S
    BillingMode: PAY_PER_REQUEST
  UpdateReplacePolicy: Retain
  DeletionPolicy: Retain

Note the UpdateReplacePolicy and DeletionPolicy properties are included in the output but we didn’t specify them explicitly in the Table construct’s properties? This is an example of some ‘sensible defaults’ which some constructs create for us automatically. Setting these policies to Retain helps prevent accidental deletion and is commonly added on data-containing resources like database tables and S3 buckets. Having this set automatically is helpful as it’s an easy thing to overlook when writing Cloudformation templates by hand.

A more extreme example of sensible defaults is when creating a VPC with the CDK. This single line when synthesised outputs over 650 lines of Cloudformation! That’s 23 resources including subnets, route tables and NAT gateways correctly spread across the availability zones of the target account.

const myVpc = new ec2.Vpc(this, "MyVpc");

Instantiating constructs

Constructs expose both static methods and instance methods. Typically, a construct is instantiated using it’s class constructor.

const myBucket = new s3.Bucket(this, "MyBucket");

Constructs are always instantiated with at least 2 parameters. The first parameter, this, is the scope of the construct. Think of this as a reference to the parent of the construct you are instantiating. The scope keyword is specific to the language you are writing your CDK application with. For example, in Javascript/TypeScript the this keyword is used as shown above. In Python you would use the self keyword.

The second parameter is an ID. Each construct instantiated within the same scope must be given a unique identifier. When the CDK synthesises your application into a Cloudformation template the ID is used to generate the logical identifier for the resource.

Some constructs can also be created via static methods. A common example is when you need to reference an existing resource by its arn.

const myBucket = s3.Bucket.fromBucketArn(
  this, 
  "MyBucket", 
  "arn:aws:s3:::com.example"
);

Roles and permissions

Managing access on and between AWS services is one area that CDK really shines, due to its object-based nature and built in permission awareness you can easily operate at a higher level of abstraction to grant permissions between AWS resources.

Here we call the grantReadWrite method on our Bucket instance passing in our Lambda function. What’s great is this simple function call will create the appropriate IAM role for the Lambda function as well as the associated policy allowing read/write access to the bucket.

const myBucket = new s3.Bucket(this, 'Bucket', {
  bucketName: 'example-bucket',
});

const myFunction = new lambda_nodejs.NodejsFunction(this, 'my-function');

myBucket.grantReadWrite(myFunction);

The CDK handles wiring up the role, policy and the resources they reference under-the-hood by adding the Ref intrinsic functions where necessary, something you would otherwise need to configure manually when writing Cloudformation templates by hand.

Escape hatches

While the constructs provided by the CDK library are often great as-is you may sometimes encounter issues where the construct doesn’t fully expose features of the underlying resources. Escape hatches to the rescue!

Escape hatches provide a way to override properties of the underlying resources by bypassing the API of the construct.

The following example manipulates an EventBridge Rule by overriding it’s eventPattern. This is needed as the Rule construct, at the time of writing, has a bug where source is expected to always be an array of strings however, this prevents doing content filtering where the values must be defined as objects. Here we get a reference to the underlying L1 construct and manipulate it directly rather than going via the L2’s more type-strict interface.

const rule = new events.Rule(this, 'MyRule', {
    ruleName: 'CatchAllRule',
    eventBus: props.eventBus,
    eventPattern: { source: [''] }, // NOTE: This is overridden below!
    targets: [new eventsTargets.LambdaFunction(func)],
});
(rule.node.defaultChild as events.CfnRule).eventPattern = {
    source: [{ prefix: '' }],
};

In some instances, properties may be missing entirely from the L1 construct. This might occur if an AWS service has added a new feature recently which the version of the CDK you are using is unaware of. In this case you call the addOverride method to add or manipulate properties of the resource.

const cfnRule = rule.node.defaultChild as events.CfnRule;
cfnRule.addOverride('Properties.SomeNewProperty', 'New');

Alternatively, instantiating CfnResource is considered an escape hatch as this behaves the same as writing the entire resource in a Cloudformation template by hand. Usually you would only do this after exhausting other options.

const rule = new cdk.CfnResource(this, 'MyRule', {
  type: 'AWS::Events::Rule',
  properties: {
    RuleName: 'CatchAllRule',
    EventBus: props.eventBus,
    EventPattern: {
      source: [{ prefix: '' }]
    },
    Targets: [
      // Omitted for brevity
    ]
  }
});

For more information see Escape Hatches in the CDK docs.

Parameters and Outputs

Cloudformation templates support a top-level Parameters key which is used to declare variables (and their defaults) which can be referenced elsewhere in the template via the Ref intrinsic function. Parameters are not recommended when working with the CDK as they are mostly not needed.

Typically your CDK application asks for any required inputs from the user and these values are baked into the resulting Cloudformation template. If you still do need to output Parameters into the resulting Cloudformation template this can be achieved by instantiating the CfnParameter construct.

Similarly outputs can be created trivially via the CfnOutput construct.

new CfnOutput(this, 'MyOutput', { value: 'Hello world' });

Tagging

Applying tags to constructs could not be simpler.

Tags.of(myConstruct).add('key', 'value');

What’s interesting with this is tags are applied to all children of the construct automatically!

It’s important to note not all constructs support tagging at all or do not yet support applying tags via Cloudformation. For the latter, you can still fall-back to applying tags via Cloudformation custom resources, in-fact the CDK provides a simple framework for creating custom resources via the custom resources package.

Summary

The AWS CDK is quickly becoming a popular choice in enabling engineering teams to define infrastructure-as-code using the programming languages they already know and love.

The simple lego-like model of building complex infrastructure quickly while having out-of-the-box sensible defaults makes it a joy to work with.

To learn more about the AWS CDK check out the video below by Amazon Web Services CTO, Werner Vogels.

For a more thorough introduction to the AWS CDK check out the official developer guide.

Cover image by Xavi Cabrera via Unsplash.