AWS CDK Anti-Patterns: Deploy Multi-Environment/Stage from Single Codebase
AWS Cloud Development Kit(CDK) is an open-source framework that lets you define cloud infrastructure in code—using programming languages like TypeScript, Python, and Java—and provision it through AWS CloudFormation.
A challenge almost every team faces with CDK is when moving from a development to staging, and from staging to production stage. A single repository must be able to deploy the exact same infrastructure logic across multiple stages—multiple environments.
My previous article covers how to manage AWS accounts towards multi-staged CDK project. It’s helpful to know what we should avoid before knowing what we should do, when we handle the multi-stage deployment.
This article introduces developers to the anti-patterns, that many of them are likely to fall into, to deploy CDK app across multiple stages. I write the example codes in TypeScript.
Passing Process Environment
It’s common for us developers to pass different environment variables to different environments when we run a web application based on the target environment. We can do the same with CDK. To do so, we would run a command like CDK_STAGE=dev cdk deploy, and our Node.js executable would be as follows:
bin/app.ts
const app = new cdk.App();
const ckdStage = process.env.CDK_STAGE || 'dev';
const config = getStageConfig(ckdStage);
new MyApplicationStack(app, `MyApplication-${ckdStage}`, {
config
});
This works. But CDK has a cdk list command. This command lists the stacks available in a project. Generating a stack ID dynamically and passing a process environment variable, we will only see one stack with the command. If we don’t care about the list command, we could have as many stacks as we want, as long as we have configurations for those stacks. These ambiguity obscures our project, leading to the development inefficiency and, in the worst case scenario, a wrong deployment to the wrong stage.
Accessing process environment at construct level is not an anti-pattern. It’s terrible.
Passing Context
Context values are key-value pairs that can be associated with an app, stack, or construct. They may be supplied to your app from a file (usually either cdk.json or cdk.context.json in your project directory) or on the command line.
Defining context in json file is static. We cannot use a json context file for multi-stage deployment. To supply context on the command line we add --context option to the cdk command. It is unrealistic to pass all context values that all our constructs require for multi-stage deployment on the command line.
We can supply context values in code as a context property of the App constructor parameter. This context declaration seems plausible for multi-stage deployment. The code would look like the following:
bin/app.ts
#!/usr/bin/env node
import { App } from 'aws-cdk-lib/core';
const app = new App({
context: {
// [key: string]: any
}
});
lib/some-construct.ts
class SomeConstruct extends Construct {
constructor(scope: Construct, id: string) {
super(scope, id);
const someValue = this.node.getContext('someKey');
}
}
Passing context for multi-stage is similar to passing process environment. Context values are, however, visible to all child constructs, retrievable with the getContext() or tryGetContext() methods of Node class. That means we don’t have to pass props from constructs to constructs, keeping our code clean. Nevertheless, context requires validation. The value of context can be any type. We would never know the type of context value specific to the given key. Implementing validation is too much work. So is to wrap our all constructs adaptable to the globally defined context type.
Passing Props
Passing props from Stack down Constructs to Constructs is straightforward. Most online resources on multi-stage deployment show us this way. But this enforces us to create lots of custom interfaces for custom constructs.
lib/stack.ts
interface ChildConstruct1Properties extends Pick<OriginalConstruct1Properties, 'someKey'> {}
class ChildConstruct1 extends OriginalConstruct1 {
constructor(scope: Construct, id: string, props: ChildConstruct1Properties) {
super(scope, id, {
...props,
// …
});
}
}
interface ChildConstruct2Properties extends Pick<OriginalConstruct2Properties, 'someKey'> {}
class ChildConstruct2 extends OriginalConstruct2 {
constructor(scope: Construct, id: string, props: ChildConstruct2Properties) {
super(scope, id, {
...props,
// …
});
}
}
interface ParentConstructProperties extends ChildConstruct1Properties, ChildConstruct2Properties {}
class ParentConstruct extends Construct {
constructor(scope: Construct, id: string, props: ParentConstructProperties) {
super(scope, id);
const childConstruct1 = new ChildConstruct1(this, 'ChildConstruct1', {
// props for ChildConstruct1
});
const childConstruct2 = new ChildConstruct2(this, 'ChildConstruct2', {
// props for ChildConstruct1
});
}
}
interface MyStackProps extends StackProps, ParentConstructProperties {}
class MyStack extends Stack {
constructor(scope: Construct, id: string, { parentConstructProperties, ...props }: StackProps) {
super(scope, id, props);
const ParentConstruct = new ParentConstruct(this, 'ParentConstruct', {
// parentConstructProperties
});
}
}
bin/app.ts
const app = new cdk.App();
new MyStack(app, 'DevelopmentMyStack', { ...getStageConfig('development') });
new MyStack(app, 'StagingMyStack', { ...getStageConfig('staging') });
new MyStack(app, 'ProductionMyStack', { ...getStageConfig('production') });
We could wrap properties like below, yet the result doesn’t make much difference.
lib/stack.ts
interface ParentConstructProperties {
childConstruct1Properties: ChildConstruct1Properties;
childConstruct2Properties: ChildConstruct2Properties;
}
interface MyStack extends ParentConstructProperties {
parentConstructProperties: ParentConstructProperties;
}
Maintenance of properties down to the tree is dull. Construct tree being deep, it is hell.
Hard Coding
Straightforward as well but nothing scalable, nothing maintainable.
lib/stack.ts
declare const vpc: Vpc;
export class DevelopmentStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
new Instance(this, 'Instance', {
instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MICRO),
machineImage: MachineImage.latestAmazonLinux2023(),
vpc,
})
}
}
export class ProductionStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
new Instance(this, 'Instance', {
instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.XLARGE),
machineImage: MachineImage.latestAmazonLinux2023(),
vpc,
})
}
}
bin/app.ts
const app = new cdk.App();
new DevelopmentStack(app, 'Development');
new ProductionStack(app, 'Production');
No decent engineer would implement this way. It requires no explanation.