Writing infrastructure as code with AWS CDK

Anjith Paila
6 min readMar 25, 2021

Introduction

In this article, we will look at how we can write AWS infrastructure as code using the Cloud Development Kit. This feature may appeal to those who prefer a programmatic approach to infra code.

What is CDK?

CDK can be considered as an alternative to other infra-code tools such as cloud formation(though CDK still generates cloud formation in the background) and terraform(within the context of AWS as terraform can be used for hybrid cloud workloads). It is open-sourced by AWS and, as of today, supports the following programming languages: TypeScript, JavaScript, Java, C# and Python.

At its core CDK lets us define reusable components known as constructs and they can be composed together to build stacks and apps. Constructs are the main building blocks and they can be a single resource(DynamoDB table for example) or a combination of several resources. There are 3 different types of constructs provided by CDK: 1) L1 — these represent low-level resources such as S3 bucket and are named Cfn<resource name>; CfnBucket for example. 2) L2 — these also represent AWS resources but come with high-level API, reasonable defaults and boilerplate code. 3) Patterns — help us build common tasks using multiple resources. Stacks can be thought of as a unit of deployment(like Web layer, DB layer) and are implemented using CloudFormation stacks. All constructs must be defined within the scope of stacks and any number of stacks can be defined in the CDK app. App is a root-level container construct enveloping stacks and constructs.

Example Deployment:

Let’s build a sample serverless app deployment(using TypeScript) to see how these concepts look like in practice. Please be aware that doing the following steps using your AWS account might incur some cost; don’t forget to delete the created resources in the end.

Prerequisites:

1.Install Node.js(version 14 or later)

2. Install typescript(version 2.7 or later) with the following command

npm -g install typescript

3. Create a config file in your home directory if it doesn’t exist already(~/.aws/config) and give a default region like below:

[default]
region=<your default region>

4. Create a credentials file in your home directory if it doesn’t exist already(~/.aws/credentials) and configure access keys like below:

[default]
aws_access_key_id=<your acceess key>
aws_secret_access_key=<your secret key>

5. Install AWS CDK with the following command:

npm install -g aws-cdk

Deployment Steps:

Our sample serverless app consists of some lambda functions, an API gateway and a DynamoDB table. Basically, we will just create a simple HTTP API to create and get user information(Note: API and business logic doesn’t have any validations in place as our main theme here is to concentrate on infra code).

First, create and initialize our app directory(anywhere you prefer) using the following commands:

mkdir cdkdemo
cd cdkdemo
cdk init --language typescript

Once the above steps are done, load the cdkdemo app to your favourite IDE(use VSCode if possible). The main entry point file(cdkdemo.ts) to our app is located under bin directory. Now let's create resources directory under the project root directory where we will place lambda functions.

mkdir resources

Create the following JavaScript files(lambda business logic) under resources directory.

create-user.js

const AWS = require("aws-sdk");
var docClient = new AWS.DynamoDB.DocumentClient();
exports.handler = async function (event, context) {
const item = JSON.parse(event.body);
item.id = `${new Date().getTime()}`;
try {
await docClient
.put({
TableName: process.env.TABLE_NAME,
Item: item,
})
.promise();


return {
statusCode: 201,
body: JSON.stringify({ id: item.id }),
isBase64Encoded: false,
headers: {},
};
} catch (error) {
var body = error.stack || JSON.stringify(error, null, 2);
return {
error: JSON.stringify(body),
};
}
};

get-user.js

const AWS = require("aws-sdk");
var docClient = new AWS.DynamoDB.DocumentClient();
exports.handler = async function (event, context) {
try {
const resp = await docClient
.get({
TableName: process.env.TABLE_NAME,
Key: {
id: `${event.pathParameters.id}`,
},
})
.promise();


return {
statusCode: 200,
body: JSON.stringify(resp.Item),
isBase64Encoded: false,
headers: {},
};
} catch (error) {
var body = error.stack || JSON.stringify(error, null, 2);
return {
error: JSON.stringify(body),
};
}
};

Add API Gateway, Lambda and DynamoDB CDK packages to our app. Run the following command at the project root:

npm install @aws-cdk/aws-apigateway @aws-cdk/aws-lambda @aws-cdk/aws-dynamodb

Now let's write the actual infra code to create Gateway, DynamoDB table and Lambda functions. Create a file with the name sample-service.ts under lib folder.

sample-service.ts

import * as core from "@aws-cdk/core";
import * as apigateway from "@aws-cdk/aws-apigateway";
import * as lambda from "@aws-cdk/aws-lambda";
import * as ddb from "@aws-cdk/aws-dynamodb";
import { AttributeType } from "@aws-cdk/aws-dynamodb";
import { RemovalPolicy } from "@aws-cdk/core";export class SampleService extends core.Construct {
constructor(scope: core.Construct, id: string) {
super(scope, id);


const ddbTable = new ddb.Table(this, "UserTable", {
tableName: "users",
removalPolicy: RemovalPolicy.DESTROY,partitionKey: {
name: "id",
type: AttributeType.STRING,
},
});


const createUserLambda = new lambda.Function(this, "CreateUserHandler", {
runtime: lambda.Runtime.NODEJS_14_X,
code: lambda.Code.fromAsset("resources"),
handler: "create-user.handler",
environment: {
TABLE_NAME: ddbTable.tableName,
},
});


const getUserLambda = new lambda.Function(this, "GetUserHandler", {
runtime: lambda.Runtime.NODEJS_14_X,
code: lambda.Code.fromAsset("resources"),
handler: "get-user.handler",
environment: {
TABLE_NAME: ddbTable.tableName,
},
});


//give permission to lambda functions
ddbTable.grantWriteData(createUserLambda);
ddbTable.grantReadData(getUserLambda);


const api = new apigateway.RestApi(this, "user-api", {
restApiName: "User Service",
});
const resourceRoot = "users";
const getResourceRoot = "{id}";
api.root.addResource(resourceRoot);
const getUserResource = api.root
.getResource(resourceRoot)
?.addResource(getResourceRoot);


const createUserIntegration = new apigateway.LambdaIntegration(
createUserLambda
);
const getUserIntegration = new apigateway.LambdaIntegration(getUserLambda);


api.root
.getResource(resourceRoot)
?.addMethod("POST", createUserIntegration);
getUserResource?.addMethod("GET", getUserIntegration);
}
}
tableName: "users",
partitionKey: {
name: "id",
type: AttributeType.STRING,
},
});


const createUserLambda = new lambda.Function(this, "CreateUserHandler", {
runtime: lambda.Runtime.NODEJS_14_X,
code: lambda.Code.fromAsset("resources"),
handler: "create-user.handler",
environment: {
TABLE_NAME: ddbTable.tableName,
},
});


const getUserLambda = new lambda.Function(this, "GetUserHandler", {
runtime: lambda.Runtime.NODEJS_14_X,
code: lambda.Code.fromAsset("resources"),
handler: "get-user.handler",
environment: {
TABLE_NAME: ddbTable.tableName,
},
});


//give permission to lambda functions
ddbTable.grantWriteData(createUserLambda);
ddbTable.grantReadData(getUserLambda);


const api = new apigateway.RestApi(this, "user-api", {
restApiName: "User Service",
});
const resourceRoot = "users";
const getResourceRoot = "{id}";
api.root.addResource(resourceRoot);
const getUserResource = api.root
.getResource(resourceRoot)
?.addResource(getResourceRoot);


const createUserIntegration = new apigateway.LambdaIntegration(
createUserLambda
);
const getUserIntegration = new apigateway.LambdaIntegration(getUserLambda);


api.root
.getResource(resourceRoot)
?.addMethod("POST", createUserIntegration);
getUserResource?.addMethod("GET", getUserIntegration);
}
}

Please pay close attention to this code. We are creating all our resources in this service class and thanks to TypeScript we will have compile time checking to ensure we are not missing any mandatory parameters etc. Awesome! Now let's add this service to our stack. Open cdkdemo-stack.ts file under lib and import our sample service into it like below:

import * as cdk from "@aws-cdk/core";
import { SampleService } from "../lib/sample-service";


export class CdkdemoStack extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// The code that defines your stack goes here
new SampleService(this, "SampleService");
}
}

As our app contains assets to be uploaded(lambda functions), we need to bootstrap the environment before deploying the app. This will create a staging S3 bucket that the CDK uses to deploy stacks containing assets. Run the following commands in sequence to bootstrap and deploy the apps.

cdk bootstrap
cdk deploy

Once the deployment is successful, we can get the API gateway URL from the console logs and test our API with curl or any other REST client.

Sample create and get user requests look like below.

Browse through AWS console to get an understanding of how CDK created resources. Once done, let’s delete the sample app using the following command:

cdk destroy

Conclusion

There we have it. With just a few lines of simple code, we were able to deploy a serverless app to AWS. Personally, I prefer a programmatic approach to a declarative approach for writing infra code as we have more power and control to do things. In my opinion, CDK is definitely a great improvement over plain CloudFormation templates and for teams where developers write infra code this will be a great advantage.

--

--