Containers and Virtualization The New AWS CDK Lead image: Lead Image © krisckam, 123RF.com
Lead Image © krisckam, 123RF.com
 

The AWS CDK for software-defined deployments

Dreaming of Clouds

Cloudy with a chance of better provisioning tools: The new Amazon Web Services Cloud Development Kit adds a powerful new tool to the DevOps toolkit. By Bradley Campbell

The financial benefits of the cloud seem quite obvious on the surface. One powerful point that deserves not only emphasis but in-depth analysis is software-defined deployments. With the advent of cloud platforms, API-driven services and infrastructure are now the norm, not the exception, making it possible not only to automate the packaging and deployment of the software, but to provision the infrastructure that hosts the applications deployed. Although tools like Amazon Web Services (AWS) CloudFormation and HashiCorp's Terraform are heavyweights, I really think the AWS Cloud Development Kit (CDK) deserves a serious look as a strong contender in this arena.

Bring on the Clouds!

With every major cloud provider come additional benefits – in addition to the immediately obvious financial benefits – like the as-a-service offerings. For example, the Kubernetes-as-a-service offerings, such as Google Kubernetes Engine [1], Amazon Elastic Container Service for Kubernetes (EKS) [2], and Microsoft Azure Kubernetes Service (AKS) [3], make it possible to provision immensely complex orchestration platforms and tools with point-and-click web interfaces or a simple command-line invocation. In this dimension, many additional benefits that might not be immediately obvious are worth mentioning:

Pareidolia

Seeing shapes in clouds isn't just a youthful pastime anymore. These days, API-driven cloud platforms give you the power to define the form and shape of your deployments in the cloud. All major cloud providers have command-line-based toolkits to script deployments in scripting languages like Bash or PowerShell. Although these languages and the products built with them are extremely powerful, they do leave room for improvement.

For example, when modifying aspects of previously defined deployments and infrastructure, you are typically left to different APIs and, hence, the command line (CLI) to make changes to your deployments. For sufficiently complex deployments, it becomes an increasingly complex task to maintain separate scripts (or, alternatively, a single monstrosity that accepts an ever-increasing number of flags and arguments) to handle any type of modification to a deployment, create new deployments, and destroy deployments that are no longer needed.

Further improvements in this space yielded a newer generation of tools that make use of declarative state machines to create (and further modify) deployments. In 2011, AWS introduced CloudFormation [4]. In its initial incarnation, CloudFormation allowed you to define a deployment through the use of JSON-based templates, which were then POSTed to an API endpoint.

Then, as now, services internal to AWS parse the template, both to evaluate logic embedded [5] in the templates and to figure out the order in which resources should be provisioned according to dependencies; provision services to match the template's specifications; handle success and failure scenarios; and maintain a state of the defined resources. In stark contrast to the aforementioned shell scripts of increasingly complex logic to handle create/modify/delete scenarios, the JSON (and subsequently YAML) templates of CloudFormation-defined deployments are static and declarative, allowing you to define your initial set of templates and provision your services.

When it is time to update (or destroy) your services, you update your template to your new specifications and once again POST it to the API. The services behind the API figure out the delta between your specified template and the state currently known to the service of your existing resource set; from there, the service creates, modifies, and deletes services and resources as necessary to bring your deployment in line with the specifications of your template.

Although CloudFormation gave AWS users exclusively a powerful tool, 2014 gave a wider infrastructure API consumer audience a powerful new tool in their tool belt with the arrival of Terraform [6]. Terraform brought a similar set of concepts: a declarative template definition language interpreted by a run time capable of understanding the differences between a current and desired state that leverages an abstracted set of provider APIs to make the current state match the desired state. The benefits of Terraform are numerous, and although the extent of some of those benefits relative to other tools might be a matter of opinion or circumstance, I find these benefits almost universally agreed upon:

Tool Fatigue

Is another tool really necessary in this space? Although I mostly use CloudFormation and Terraform in this article as points of comparison, the list of other tools that can provision infrastructure is long: Troposphere [12], Sceptre [13], Bash scripts, custom apps leveraging SDKs, Ansible [14], Selenium scripts automating the AWS console (OK, I've never done that, and it's probably a terrible idea – but it would work!), and the list goes on. Honestly, I blew off the AWS CDK [15] the first time I heard about it, because it seemed like yet another tool vying for my attention in an already crowded field that already has some very strong contenders. After some time, though, my curiosity won out over my skepticism, and I decided to take the CDK for a spin.

AWS CDK

Getting started with the CDK is pretty simple. In this article, I make a few assumptions:

Assuming you have an up-to-date node and npm installed, installing the AWS CDK base libraries is as simple as

$ npm install -g aws-cdk

which installs the CDK Node.js libraries. With Node set up accordingly, it places the cdk executable in your $PATH, allowing you to run cdk commands from your command line. If you are curious to explore the multitude of subcommands and options now available, type cdk help. Alternatively, you can head over to the GitHub pages [19] set up by AWS for documentation.

The CDK makes starting a new project a cinch. To begin, I'll create a new project called hello-cdk in three simple steps from the terminal:

$ mkdir hello-cdk
$ cd hello-cdk
$ cdk init app --language=typescript

These commands create a new local directory named hello-cdk, descends into that directory, and then asks the CDK to create a new application there. The CDK looks at the name of the directory it is run from with the init command and uses that as the name of the app before setting up the base files and directories you need to get started and initializing a Git repo in the directory. In the end, you can see in Listing 1 what resides in your hello-cdk directory after the init.

Listing 1: Project Root Directory

drwxr-xr-x   - bradmatic  8 Jan 23:52 .
drwxr-xr-x   - bradmatic  8 Jan 23:52 |---- bin
.rw-r--r-- 194 bradmatic  8 Jan 23:52 |    |---- hello-cdk.ts
.rw-r--r--  37 bradmatic  8 Jan 23:52 |---- cdk.json
drwxr-xr-x   - bradmatic  8 Jan 23:52 |---- lib
.rw-r--r-- 246 bradmatic  8 Jan 23:52 |    |---- hello-cdk-stack.ts
drwxr-xr-x   - bradmatic  8 Jan 23:52 |---- node_modules
drwxr-xr-x   - bradmatic  8 Jan 23:52 |    |---- @aws-cdk
drwxr-xr-x   - bradmatic  8 Jan 23:52 |    |---- @types
...                                   |    |---- ...
drwxr-xr-x   - bradmatic  8 Jan 23:52 |    |---- zip-stream
.rw-r--r-- 79k bradmatic  8 Jan 23:52 |---- package-lock.json
.rw-r--r-- 345 bradmatic  8 Jan 23:52 |---- package.json
.rw-r--r-- 320 bradmatic 20 Dec  2018 |---- README.md
.rw-r--r-- 558 bradmatic 20 Dec  2018 |---- tsconfig.json

With many subdirectories under node_modules, I truncated the output. Primarily, I'll be focusing on the contents of the bin and lib directories.

What's in the Box?

The CDK just created a ton of files, so I'll unpack things a bit. First, the node_modules folder contains dependent libraries, plain and simple. As well, you will see quite a few config files. For right now, focus on the bin and lib directories and their contents. Listing 2 shows the content of bin/hello-cdk.ts.

Listing 2: bin/hello-cdk.ts

#!/usr/bin/env node
import cdk = require('@aws-cdk/cdk');
import { HelloCdkStack } from '../lib/hello-cdk-stack';
const app = new cdk.App();
new HelloCdkStack(app, 'HelloCdkStack');
app.run();

The import lines import the base CDK library and a stack-specific file from lib. Next, you define a new CDK app and instantiate a new instance of HelloCdkStack.

Listing 3 shows lib/hello-cdk-stack.ts, which has a clearly defined point to hook into the framework and define your resources. Although you could just throw code into this file and define resources, it's worthwhile to sit back, pause, and think about a few things before you do so – namely, reusability and modularity. I'll address these concerns individually.

Listing 3: lib/hello-cdk-stack.ts

import cdk = require('@aws-cdk/cdk');
export class HelloCdkStack extends cdk.Stack {
  constructor(parent: cdk.App, name: string, props?: cdk.StackProps) {
    super(parent, name, props);
    // The code that defines your stack goes here
  }
}

Reusability

Imagine that you are tasked with defining the entire stack for the application, from the base network infrastructure up, with whatever method you use to define your deployment (in this case, the CDK). In the case of AWS, that means you will very likely start with a virtual private cloud (VPC), subnets, security groups, route tables, route table associations, network address translation (NAT) gateway(s), and so on – just for networking.

A somewhat common pattern in most organizations when starting from scratch is to reuse a single VPC to host multiple applications (oftentimes grouped by type of environment, e.g., development, QA, test, production, etc.). In light of this, bundling the base networking infrastructure together with the application stack would greatly limit the possibility of reuse and means it would not be possible to deploy a new instance of the application – perhaps for development or testing purposes – apart from its backing resources. This arrangement also bears cost implications, because resources such as NAT gateways (and perhaps other necessary infrastructure elements) incur cost the moment they are provisioned.

Your first takeaway to keep in mind as you structure your code, then, is that you'd like somehow to keep shared resources separate from the resources that are specific to your application so that those shared resources aren't coupled to instances of your application stack.

Modularity

Having just talked about reusability, it's hard to shake the notion that this sounds a lot like reusability by another name: modularity. Although they are related concepts, they are not the same. Essentially, reuse is an outcome of modular code. As you structure code, focus on modularity and let the reuse flow naturally from it. I wanted to introduce the concept formally, though, because I'll be teasing apart the VPC and networking aspects of the stack from the application-specific resources by creating modules.

The other critical piece in designing a good, robust module is ensuring consistent and clean interfaces that hide (or abstract, if you prefer the academic term) the inner workings of the module that aren't necessary for the consumer of the module. The primary focus will be the construction of two modules: one for shared services (i.e., the networking elements), which I'll cover in this article, and one for a web application running behind a load balancer, to be covered in a future article.

Next, I'll look at how to orchestrate these individual modules to produce all of the infrastructure needed for the application, and finally, I'll look at some lifecycle considerations, while considering the tightly knit relationship between CloudFormation, the CDK code, and CDK resources.

VPC from the Ground Up, CDK Style

To keep your code not only logically but also physically modular, create a new file in the lib directory that will contain the code for the networking resources:

$ touch lib/hello-cdk-base.ts

Listing 4 contains the code that should go in this file [20]. When the time comes to build, you'll need the @aws-cdk/aws-ec2 npm module installed, so you should take care of that now:

$ npm install --save @aws-cdk/aws-ec2

Listing 4: lib/hello-cdk-base.ts

01 import cdk = require('@aws-cdk/cdk');
02 import ec2 = require('@aws-cdk/aws-ec2');
03
04 export class HelloCdkBase extends cdk.Stack {
05   public readonly vpc: ec2.VpcNetworkRefProps;
06
07   constructor(parent: cdk.App, name: string, props?: cdk.StackProps, env?: string) {
08     super(parent, name, props);
09
10     const maxZones = this.getContext('max_azs')[`${env}`]
11
12     const helloCdkVpc = new ec2.VpcNetwork(this, 'VPC', {
13       cidr: this.getContext('cidr_by_env')[`${env}`],
14       natGateways: maxZones,
15       enableDnsHostnames: true,
16       enableDnsSupport: true,
17       maxAZs: maxZones,
18       natGatewayPlacement: { subnetName: 'DMZ' },
19       subnetConfiguration: [
20         {
21           cidrMask: 24,
22           name: 'Web',
23           subnetType: ec2.SubnetType.Public,
24         },
25         {
26           cidrMask: 24,
27           name: 'App',
28           subnetType: ec2.SubnetType.Private,
29         },
30         {
31           cidrMask: 27,
32           name: 'Data',
33           subnetType: ec2.SubnetType.Isolated,
34         },
35       ],
36       tags: {
37         'stack': 'HelloCdkCommon',
38         'env': `${env}`,
39         'costCenter': 'Shared',
40         'deleteBy': 'NEVER',
41       },
42     });
43
44     this.vpc = helloCdkVpc.export();
45   }
46 }

Although I won't cover the complexities of VPCs, subnets, and all of the accompanying network elements that accompany them in depth, I will mention them for the sake of discussing how they appear in the CDK code.

Subnets, AZs, and NAT Gateways

A look through the VPC code in Listing 4 shows a build-out of a reference three-tier networking architecture with dedicated subnets for a front end, where resources like load balancers, proxies, or ingress controllers live. Accordingly, these subnets are designated Web (line 22). The ec2.SubnetType.Public designation used for the subnetType parameter for these particular subnets tells the CDK that this subnet will receive public traffic.

The natGateways parameter (line 14) tells the VpcNetwork constructor function how many AWS NAT Gateways you'd like to provision. By changing the number of availability zones (AZs) that are being used according to environment type (e.g., the "dev" environment will only use two AZs, compared with three for the "qa" and "prod" environments; see the "How Context Works in the AWS CDK" box for more details on specifying context), you're limiting cost in lower environments by restricting the number of AZs into which to deploy resources. Accordingly, the maxAZs parameter is what tells the constructor function the number of AZs you want to use; omitting this parameter will cause the aforementioned default behavior of utilizing all AZs available for a given region.

Listing 5: cdk.json

01 {
02   "app": "node bin/hello-cdk.js",
03   "context": {
04     "cidr_by_env": {
05       "dev": "10.100.0.0/16",
06       "qa": "10.200.0.0/16",
07       "prod": "10.300.0.0/16"
08     },
09     "max_azs": {
10       "dev": 2,
11       "qa": 3,
12       "prod": 3
13     }
14   }
15 }

From the code in the subnetConfiguration block, you can see three types of subnets: Public, Private, and Isolated. As mentioned, the Web subnets are of the Public type. The Web and App subnets are provisioned with /24 netmasks, meaning you have 251 usable IP addresses (256 addresses total minus five AWS-reserved addresses; see the "AWS Networking Primer" box for more details on networking within AWS). For the Data subnets (e.g., where databases and other data-related services might reside), smaller subnets are used: a /27, yielding 27 usable addresses per subnet (32 theoretical available addresses minus five AWS-reserved addresses).

For subnet and subnet types, the natGatewayPlacement parameter tells the CDK where you want it to place your NAT gateways when you create your VPC. The DNS tags parameters (lines 36-41) give additional flexibility for the use of custom internal DNS domains within your VPC. The tags parameter, while providing conventional AWS tag sets on all the networking resources (which are abundantly useful in and of themselves within the AWS ecosystem for a multitude of reasons), also take on additional functionality within the context of the CDK, which I will cover in future discussions.

CDK Magic

The public, read-only vpc attribute (lines 4 and 5) is defined within the HelloCdkBase class itself. This attribute provides an interface for your class to export its VPC definition. Earlier, when talking of reuse, I mentioned the scenario in which additional application development teams might leverage your shared networking resources when defining their own applications with the CDK. This attribute provides the mechanism to make that functionality possible. At the end of the class, the VPC created within the class is made available outside of the class (through the attribute) by virtue of setting the value of the attribute to an export of helloCdkVpc (line 44).

Build It!

I'm as eager as you are to watch CDK do its magic, but first you need to update bin/hello-cdk.ts (Listing 2). Update yours such that it looks like the code in Listing 6.

Listing 6: New bin/hello-cdk.ts

#!/usr/bin/env node
import cdk = require('@aws-cdk/cdk');
import { HelloCdkBase } from '../lib/hello-cdk-base';
const app = new cdk.App();
const stackName = 'HelloCdkBase-' + app.getContext('ENV')
new HelloCdkBase(app, stackName, {}, app.getContext('ENV'));
app.run();

Here, you set your main CDK app to import only from lib/hello-cdk-base.ts, which is the file where your VPC code resides. In a future installment, I'll begin to build out the resources necessary for your application in the lib/hello-cdk-stack.ts file, at which point you'll re-include it in bin/hello-cdk.ts. You'll also see here the use of a context variable, ENV (again, see the "How Context Works in the AWS CDK" box regarding dynamic context), to create dynamic names for your apps.

Generating CloudFormation with the CDK

Although this next step won't actually build resources in your AWS account, if you've ever spent countless hours slogging it out creating CloudFormation templates, you'll see it is incredibly cool. From your CLI, run the commands:

$ mkdir -p "./cft/qa"
$ cdk synth -c ENV=qa "HelloCdkBase-qa" >./cft/qa/vpc.yml

You should now see a cft directory with a qa subdirectory in the root of your project. Within the qa subdirectory, you'll find a file called vpc.yml, which contains CloudFormation code that directly correlates to your TypeScript CDK code. Spend a few minutes looking back and forth between the YAML file and the TypeScript file: Which one would you rather spend a few minutes (or hours, depending on your choice) editing? My choice is definitely TypeScript.

Just Build It, Already

From your (AWS authenticated) CLI in the root of the project, run:

$ npm run build
$ cdk deploy -c ENV=qa

You will get some absolutely beautiful log output (a small sample of my own output from a run is included in Figure 1), and within about three minutes, you'll have a VPC, subnets, routes, route tables, route table associations, and NAT gateways – all the building blocks of an AWS networking setup – neatly provisioned in your account.

Sample CDK log output.
Figure 1: Sample CDK log output.

Figure 2 shows the VPC section of my AWS console, which shows the VPC created by the CDK. You'll also notice that it's appropriately assigned a 10.200.0.0/16 base CIDR, as defined for any qa environment in the cdk.json file (Listing 5).

VPC section of the AWS console.
Figure 2: VPC section of the AWS console.

Cleaning Up After Yourself

You now have code to provision a well-designed VPC, so what do you do now? Tear it down! Although it might seem counterintuitive to destroy these resources, your application CDK code is not quite ready, yet. (Come back for a future installment, where I show you how to build it out.) Until that code is ready, you can reap the cost-savings benefits of your infrastructure-as-code solution by tearing it down. From your CLI, the command

$ cdk destroy -c ENV=qa

should take care of destroying these resources until you're ready to come back and add on!

Conclusion

The AWS CDK provides an extremely flexible and powerful tool to fuel infrastructure-as-code solutions in the world of cloud-powered, API-driven infrastructure hosting. Even a jaded cloud architect can appreciate the power a tool like this has, to build bridges between development and cloud operations teams, where it seems previous tools have been less successful. Even better, it provides backward compatibility with CloudFormation, if needed. With support for C#, Java, and TypeScript, it offers programmers of different backgrounds the chance to leverage its power. I highly suggest giving the AWS CDK a try for your next project.