I've got a little downtime over the holidays and what better way to unwind than to learn something? I'm learning about a couple of technologies that have been on my mind for several weeks.
tl;dr
If you aren't interested in the journey and just want to see the code, go ahead and check out my repo.
Code samples have been updated to CDK v2.
Table of Contents
- AWS CDK
- Step Functions
- Resources
- Toolchain
- Initialize CDK
- Import Style
- TypeScript Build
- Linting
- CDK Anatomy
- Lambda Functions
- More Tests
- CDK Lambda
- CDK Step Functions
- IAM Roles
- Testing CDK
- Deploy and Run
- SAM
- Next Steps
AWS CDK
Amazon Cloud Developer Kit is a wrapper for CloudFormation, the AWS infrastructure as code solution. Instead of writing CloudFormation templates in yaml or json, you can write them in TypeScript (or C# or Java or Python). CDK provides higher-order abstractions that reduce extremely verbose CloudFormation templates into a few lines of TypeScript.
Step Functions
Step Functions allow you to orchestrate serverless components into a Finite State Machine or workflow.
If any of those concepts aren't clear yet, stay with me. I intend to explore them with code samples and examples.
Resources
AWS CDK Examples is a pretty good resource for learning CDK. It even has a step functions example, but while I was able to build the project, I wasn't able to figure out how to actually make it do anything and I wanted something that integrates with other serverless components besides.
Continuing to search, I came across this article. This one is a pretty good tutorial that combines five lambda functions into a flow using AWS Console (so not CDK). The tutorial is a little outdated. Not all of the pictures match the present state of the AWS Console. However, it's still a pretty good tutorial and in fact highly recommended for anyone who wants to get a little more familiar with the AWS Console.
Toolchain
I wanted to see if I could convert the lambdas and state machine in the example above to CDK. To get started I need a couple of things:
$ node --version
v12.14.0
$ cdk --version
1.19.0 (build 5597bbe)
It might be a good idea to have aws-cli and we'll give sam-cli a try a bit later, but this is good enough for now. If you need to install nodejs or cdk, it shouldn't be hard to figure out.
Initialize CDK
Following the guide, I'll bootstrap my project.
$ mkdir cdk-step-functions-example && cd cdk-step-functions-example
$ cdk init --language typescript
I'm rewarded with a skeleton project and some npm modules installed.
Import Style
The generated CDK project has a few conventions that I'd like to change. It's really just a matter of taste, but I don't like the import foo = require('foo');
syntax. I find it clutters my code and is less declarative than import { foo, bar } from 'foo';
. So I'm going to take the default stack declaration
import cdk = require('@aws-cdk/core');
export class CdkStepFunctionsExampleStack extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// The code that defines your stack goes here
}
}
and rewrite it to
import { Construct, Stack, StackProps } from '@aws-cdk/core';
export class CdkStepFunctionsExampleStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// The code that defines your stack goes here
}
}
I gave bin/cdk-step-functions-example.ts
the same treatment.
#!/usr/bin/env node
import "source-map-support/register";
import { App } from "@aws-cdk/core";
import { CdkStepFunctionsExampleStack } from "../src/cdk-step-functions-example-stack";
const app = new App();
new CdkStepFunctionsExampleStack(app, "CdkStepFunctionsExampleStack");
I don't guarantee that I'm right and AWS is wrong or that even anybody is right or wrong, but I'm going with the latter syntax for now. YMMV.
TypeScript Build
I'm also going to update my tsconfig.json
file, the file that tells TypeScript what decisions to make when it transpiles my TypeScript code to JavaScript. The choices CDK makes for me are mostly pretty good, but one thing I didn't like is that it transpiled my code inline instead of sending it to an output directory, which cluttered my source. I also enabled esModuleInterop
and resolveJsonModule
for reasons we'll arrive at later. Here's what I've got now:
{
"compilerOptions": {
"alwaysStrict": true,
"declaration": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"inlineSourceMap": true,
"inlineSources": true,
"lib": ["es2018"],
"module": "commonjs",
"noFallthroughCasesInSwitch": false,
"noImplicitAny": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"outDir": "build",
"resolveJsonModule": true,
"strict": true,
"strictNullChecks": true,
"strictPropertyInitialization": false,
"target": "ES2018",
"typeRoots": ["./node_modules/@types"]
},
"exclude": ["build", "cdk.out"]
}
I didn't love the idea of my source code being in lib
so I renamed that to src
. Now my source code is in src
and I build it to build
. To support this change, I need to modify my cdk.json
file to point to the new location of my output files: "app": "node build/bin/cdk-step-functions-example.js"
.
Linting
Finally, something I feel that's missing from the CDK generated project is linting! Since tslint is deprecated, I will use eslint and prettier. If I explained all my choices, this would be an article on linting, not CDK and Step Functions, so here are the codes. Change them as you please!
$ npm i -D @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint eslint-config-prettier eslint-plugin-prettier prettier
I'm adding an .eslintignore
file
*.d.ts
build
cdk.out
and an .eslintrc.js
file
module.exports = {
extends: [
'plugin:@typescript-eslint/recommended',
'prettier/@typescript-eslint',
'plugin:prettier/recommended',
],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 2019,
project: './tsconfig.json',
sourceType: 'module',
},
rules: {
"@typescript-eslint/interface-name-prefix": ["error", { "prefixWithI": "always" }],
"@typescript-eslint/no-floating-promises": ["error"],
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
}
};
and finally a .prettierrc.js
file.
module.exports = {
semi: true,
trailingComma: 'all',
singleQuote: true,
printWidth: 120,
tabWidth: 2,
};
I added a new npm script: "lint": "eslint --ext .ts ."
.
Now I can finally lint my code and fix existing issues.
$ npm run lint -- --fix
CDK Anatomy
Let's get back to CDK. I could talk about Constructs and Stacks, but AWS does a pretty good job of that right here. My CDK stack is just a class constructor that extends the base Stack
class. As I build out my stack, I'll add more code to the constructor method to represent lambdas and steps. But before I even add any more code, I can run cdk synth
to generate an empty CloudFormation template. I can also run the simple test included in the generated project with npm t
.
Lambda Functions
The original tutorial has us create the state machine first, then start adding lambdas and roles. I'm going to start with the lambdas.
The tutorial I'm working from has us pasting the handler functions into the AWS Console. I want to add my lambdas into my project so they can be bundled up with CDK and deployed along with the state machine. The tutorial writes lambdas for NodeJS 4 (which is deprecated) using callbacks and ES5 syntax. Since I'm already using TypeScript, I will write my lambdas in TypeScript and transpile them along with my CDK code. I'll also use SAM for local execution and write some tests as well.
To start I'm going to create a new directory under src
to house my lambdas and create a new ts file there to work on my lambda function.
$ mkdir src/lambda
$ touch src/lambda open-case.ts
The old ES5 code from the tutorial looked like this:
exports.handler = (event, context, callback) => {
// Create a support case using the input as the case ID, then return a confirmation message
var myCaseID = event.inputCaseID;
var myMessage = "Case " + myCaseID + ": opened...";
var result = {Case: myCaseID, Message: myMessage};
callback(null, result);
};
Let's start with that. Because TypeScript is a superset of JavaScript, this is valid TypeScript (depending on your linting and compiler rules), but it can improved by using an async function instead of a callback, const
and let
in lieu of var
and by removing unused arguments.
export const handler = async event => {
// Create a support case using the input as the case ID, then return a confirmation message
const myCaseID = event.inputCaseID;
const myMessage = 'Case ' + myCaseID + ': opened...';
return { Case: myCaseID, Message: myMessage };
};
Note that this must be an async function even though I'm not actually waiting on any promises. If it's not an async function, the lambda runtime will expect a callback function and nothing will be returned.
I still have a couple of issues. According to my tsconfig.json
, I don't allow implicit any types and according to my linting rules, functions must specify a return type. There are a number of ways to solve these problems, including changing the rules, but I will opt to take advantage of TypeScript and add a types.ts
file to be shared by my lambdas. This will have no effect on my transpiled code but will give me the type safety I want while I'm developing.
Looking over the tutorial functions, I came up with two types that should serve my purposes:
export interface ICase {
Case: string;
Message: string;
Status?: number;
}
export interface IInput {
inputCaseID: string;
}
My lambda with the types added looks like this. Now it passes linting and compiler checks.
import { ICase, IInput } from './types';
export const handler = async (event: IInput): Promise<ICase> => {
// Create a support case using the input as the case ID, then return a confirmation message
const myCaseID = event.inputCaseID;
const myMessage = 'Case ' + myCaseID + ': opened...';
return { Case: myCaseID, Message: myMessage };
};
I give the same treatment to the other four lambdas like so: src/lambda.
More Tests
So far I've got one dummy test that checks to see if my empty CDK project is indeed empty. I've introduced some application code and I can easily test it with the same test suite. One thing I'm going to change first thing is the project is set up to find tests in ./tests
. I like putting my tests next to my source code. Just a matter of personal preference. I'll update jest.config.js
to look for my tests in <rootDir>/src
instead of <rootDir>/test
and move the one test I have over, then remove the unused test
directory.
To test this function, I want to assert that when it receives an input of { "inputCaseID": "001" }
, it should respond with { "Case": "001", "Message": "Case 001: opened..." }
. I could inline the input, but (as I discovered later in the process) it's nice to have input files for SAM, so I ended up writing my test like this:
import input from '../../inputs/openCase.json';
import { handler } from './open-case';
test('Open Case Function Handler', async () => {
const result = await handler(input);
expect(result).toEqual({
Case: '001',
Message: 'Case 001: opened...',
});
});
Remember enabling resolveJsonModule
in my tsconfig.json
? This is why. The json file I'm importing is simply { "inputCaseID": "001" }
.
The rest of my lambda tests are in src/lambda and the json files are in inputs.
CDK Lambda
So I've got working (I think) lambdas. It's time to finally write some CDK. To prep for this task, I checked out a pretty good example in the aws cdk examples repo. Looks pretty straightforward. I'm going to need to instantiate each lambda in turn and then create a Step Functions Task object for each.
import { AssetCode, Function, Runtime } from '@aws-cdk/aws-lambda';
// other imports and stuff
const openCaseLambda = new Function(this, 'openCaseFunction', {
code: new AssetCode(lambdaPath),
handler: 'open-case.handler',
runtime: Runtime.NODEJS_12_X,
});
To import the lambda types, I need to npm i @aws-cdk/aws-lambda
. The rest of this is quite simple. The Function constructor takes three arguments, the scope, or current Stack I'm working on, the name I'm giving to the Function, and a config object. I'm putting a code path into the config object, a reference to the lambda handler and the runtime. I've picked the latest available NodeJS runtime.
lambdaPath
is a local var that I created in order to make sure my code grabs the transpiled version which is going to be in my build output directory. I defined it with const lambdaPath = `${__dirname}/lambda`;
.
CDK Step Functions
Next I install another CDK library and create a Task object using my new lambda as an argument.
import { Chain, Choice, Condition, Fail, StateMachine, Task } from '@aws-cdk/aws-stepfunctions';
import { InvokeFunction } from '@aws-cdk/aws-stepfunctions-tasks';
// other stuff
const openCase = new Task(this, 'Open Case', {
task: new InvokeFunction(openCaseLambda),
});
A Task is a step in a state machine where some work is performed. The Task class extends State, the base class for Step Functions. We'll need to work with two more State extensions, Fail and Choice. Fail is an end state representing failure of the job. Choice is a branching step. The declarations for both are quite simple.
const jobFailed = new Fail(this, 'Fail', {
cause: 'Engage Tier 2 Support',
});
const isComplete = new Choice(this, 'Is Case Resolved');
Completing the other four Tasks for my other four lambdas, I now have five Tasks, a Fail and a Choice. My CDK is valid, but will still produce an empty CloudFormation template. I need to tie all this together into a state machine. I'll need to implement two more classes, Chain and StateMachine.
The Chain is where the steps of my state machine are defined.
const chain = Chain.start(openCase)
.next(assignCase)
.next(workOnCase)
.next(
isComplete
.when(Condition.numberEquals('$.Status', 1), closeCase)
.when(Condition.numberEquals('$.Status', 0), escalateCase.next(jobFailed)),
);
I start with openCase
, move directly to assignCase
, then workOnCase
. I move from function to function passing any new information along. workOnCase
has two possible outcomes. Either the case is closed or it must be escalated to tier 2 support. Here's where my Choice step comes into play. The Choice receives arguments the same as my lambdas but without actually needing to invoke a lambda. It makes a decision based on the input given and calls the appropriate next function in the chain. In this case, we'll go to closeCase
if the case was closed or escalateCase
followed by jobFailed
if it wasn't.
All that's left is to define my state machine.
new StateMachine(this, 'StateMachine', {
definition: chain,
});
Pretty simple. You can find the completed stack in my github repo.
Now we can see some CDK magic. When I cdk synth
my 81 line TypeScript file becomes a 417 line yaml file. Assuming I have a credentialed AWS account, all I have to do to deploy is cdk deploy
.
IAM Roles
There's a section in the tutorial I used as a base that describes how to create an IAM Role for your state machine. As long as we have no special IAM needs, we don't need to do that with CDK! CDK assumes you want the minimum roles needed for your state machine to function and creates them automatically. When I deploy my stack, I find I have six new roles created.
If my stack wanted to persist data to a database such as DynamoDB and implement that logic in some of the lambda functions, then I would need to include roles in my CDK code as CDK doesn't parse the lambda body. However, the code required is quite intuitive.
Testing CDK
I already wrote tests for each of the lambdas. By now my "assert CDK is empty" test is going to be failing. I'd like it to pass. CDK comes with its own testing library with functions like matchTemplate
. You can read more about it here. I ran the test that came with the generated project, copied the failure output, cleaned it up a little and put it back in my test. I also changed MatchStyle
from EXACT
to NO_REPLACES
which means my test will pass if some of the values (like generated S3 paths) change, but it'll fail if I add new resources or change any of the existing ones.
I'm not really convinced of the usefulness of this test but it didn't take long to write. If I started putting conditional logic or looping into my CDK, I'm sure it'll prove valuable.
Deploy and Run
The great thing about serverless is you can deploy your stack and run it a few times for free. No need to even clean up. That said, if you're setting up an AWS account for the first time, it might be a good idea to go to the billing section where you can set alarms and limits to make sure you aren't surprised by the cost of an experiment run amok.
If you haven't used aws cli before, you'll need to configure your account for local use. Note that there are a couple of gotchas when it comes to using CDK from the command line.
If you've never used CDK before, you'll need to cdk bootstrap
which sets up an S3 bucket for staging artifacts. If you skip this step, you'll be informed that you have to do it, so no worries if you aren't sure. Then cdk deploy
makes the magic happen. It doesn't take long to deploy - about a minute for me.
Now that my stack has deployed, I can explore it in the AWS Console and give it a try. Navigate to the Step Function in the console and click "Start execution".
I'll use { "inputCaseID": "001" }
as the payload for my first run, click "Start execution" and I'm rewarded with:
It failed! That's okay. The logic in work-on-case.ts
randomizes the outcome. It'll pass half the time and fail half the time. If I run it a few more times, I'll see it pass:
Clicking around in the console is a great way to really learn how step functions work. I can see the inputs and outputs for each step. I can also edit my state machine right there in the console, but of course I don't want to do this because I'd rather do it with CDK!
SAM - Serverless Application Model
Since I'm using lambdas, it's a good idea to talk about SAM. In working through the steps above, I created lambdas, wrote unit tests for them and then deployed them without ever actually invoking them as lambda functions (unit tests are still worthwhile, but don't guarantee success as they don't use the lambda runtime).
SAM is another abstraction on top of CloudFormation and it has its own CLI. It's probably best to let the FAQ speak for itself. There's some more information about how to integrate the two in the CDK documentation.
SAM doesn't understand CDK code, but CDK can produce a template that SAM can work from. If I want to run one of my lambdas with SAM, I need to install the CLI, then execute these commands:
$ npm run build
$ cdk synth --no-staging > template.yaml
$ sam local invoke openCaseFunction64DF09DE -e ./inputs/openCase.json
Invoking open-case.handler (nodejs12.x)
Fetching lambci/lambda:nodejs12.x Docker container image......
Mounting /Users/matt.morgan/mine/cdk-step/build/src/lambda as /var/task:ro,delegated inside runtime container
START RequestId: d37ad83f-15c0-1794-bddf-88a433b3db92 Version: $LATEST
END RequestId: d37ad83f-15c0-1794-bddf-88a433b3db92
REPORT RequestId: d37ad83f-15c0-1794-bddf-88a433b3db92 Init Duration: 157.85 ms Duration: 6.35 ms Billed Duration: 100 ms Memory Size: 128 MB Max Memory Used: 39 MB
{"Case":"001","Message":"Case 001: opened..."}
The json files I created earlier are very useful.
Next Steps
A state machine that can only be invoked from the console is pretty much only useful for learning. It would be interesting to explore different triggers as well as some persistence layer. The state machine should also have logging, traceability and analytics.
So far CDK has met my expectations as a new way to think about infrastructure as code and I'm eager to apply it to real-world use cases. If any readers have experience with CDK, please share in the comments.
Cover Image - https://www.dreamstime.com/monster-illustrations-black-white-monsters-creatures-battle-public-domain-image-free-83001740
Top comments (7)
This post is full of absolutely useful tips regarding many different topics, but at the same time is clearly structured and fun to read :D Congratulations on the achievement, and thanks!
Matt,
Thanks for taking the time to document this. I has to do some mental gymnastics to figure out how to get started with Step Functions and CDK. Thanks for getting me on the right path. Much appreciated!
Thanks for the kind words! I wrote this to help with my own understanding. Very pleased to learn it's bringing enlightenment to Matts everywhere!
In your SAM step, one of the commands references
openCaseFunction64DF09DE
. Where does that identifier come from?That's the Unique ID. It's explained here: docs.aws.amazon.com/cdk/latest/gui.... It's basically just a way for CDK to be sure there's a unique name for the resource.
You can see how this is generated here: github.com/aws/aws-cdk/blob/0f0e20.... It seems deterministic - if you run my stack, you get the same IDs - though I doubt that's guaranteed.
i was struggling with finding CDK resource and now i find resource what i want. thx.
You're welcome! If you're still looking for resources, check out github.com/eladb/awesome-cdk.