5 things that look better in AWS CDK than in AWS CloudFormation
7 min read, last updated on 2021-03-11
Those who either read some of the posts here (this and that), or just worked with me know that I like AWS CloudFormation. Some people say it’s Stockholm Syndrome, some will say it’s kinda masochistic in some aspects, but nevertheless - I can efficiently move forward. I feel competent. This is my tool of choice. At least this one has rollback. 😁
Top 5 of the ugliest templates in AWS CloudFormation
However, as the ”AWS CloudFormation Evangelist” (please, don’t judge me 😅), I am perfectly aware of places where even an enthusiast may develop PTSD. In many cases, even I have a hard time, and I cannot approve some of the design choices. Especially considering that I would leave JSON syntax in AWS CloudFormation to automation and tooling (yes, I am looking at you JSON’s nested inside YAML).
My goal here is not only to whine - I want to show you how those elements can look much better with the use of AWS CDK. So let’s start!
Amazon VPC
Okay, I get it - if you got used to AWS CloudFormation this is a weak example, but take it as a slow start. Latter ones are more heavyweight examples.
const vpc = new Vpc(this, 'VPC', {
maxAzs: 3,
natGateways: 3
});
This is better than a few hundred lines of YAML, which may look easy and repetitive. However, if you have written templates for many VPCs, you know that doing it in the right way it’s not an easy task. I mean with proper tagging, naming, with as few parameters as possible, doing an automated and dynamic CIDR splitting, and many more.
That is the exact reason that attracted me to AWS CDK (and directly answered some pains): higher-level constructs help preserve best practices, plus they solve modularity and reusability challenges.
Amazon CloudWatch (especially dashboards)
If the previous one was easygoing, here is the one ugly motherf…🙊:
OneUglyCloudWatchAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmDescription: !Sub "Alarm for ${ServiceName} related with ..."
AlarmName: !Sub "NAMING-CONVENTION-${Stage}-SCALING-${ServiceName}"
ComparisonOperator: LessThanThreshold
DatapointsToAlarm: 3
EvaluationPeriods: 5
Threshold: 5
Metrics:
- Id: f1
Expression: t1 - SUM([c1, c2, c3, c4])
ReturnData: true
- Id: t1
MetricStat:
Metric:
Namespace: !Sub "${Prefix}/${ServiceName}"
MetricName: VeryImportantCustomMetric
Period: 300
Stat: Maximum
ReturnData: false
- Id: c1
MetricStat:
Metric:
Namespace: !Sub "${Prefix}/${ServiceName}"
MetricName: FactorA
Dimensions:
- Name: Dimension1
Value: Value1
Period: 300
Stat: Maximum
ReturnData: false
- Id: c2
MetricStat:
Metric:
Namespace: !Sub "${Prefix}/${ServiceName}"
MetricName: FactorB
Dimensions:
- Name: Dimension1
Value: Value2
Period: 300
Stat: Maximum
ReturnData: false
- Id: c3
MetricStat:
Metric:
Namespace: !Sub "${Prefix}/${ServiceName}"
MetricName: FactorC
Dimensions:
- Name: Dimension1
Value: Value3
Period: 300
Stat: Maximum
ReturnData: false
- Id: c4
MetricStat:
Metric:
Namespace: !Sub "${Prefix}/${ServiceName}"
MetricName: FactorD
Dimensions:
- Name: Dimension1
Value: Value4
Period: 300
Stat: Maximum
ReturnData: false
AlarmActions:
- Ref: AutoScalingPolicy
TreatMissingData: missing
Enough? 😂 I think so because this is just an alarm, and we have not touched dashboards. Why? I don’t want to lose the rest of the self-respect that I still have.
Alternative in AWS CDK is much, much, much better:
const metric = queue.metric("ApproximateNumberOfMessagesVisible");
metric.createAlarm(this, 'Alarm', {
threshold: 100,
evaluationPeriods: 3,
datapointsToAlarm: 2
});
And here you have a short snippet for Amazon CloudWatch Dashboard:
import {
Color,
Dashboard,
GraphWidget
} from "@aws-cdk/aws-cloudwatch";
export class MyTechnicalDashboard extends Dashboard {
constructor(scope: Construct, props: MyTechnicalDashboardsProps) {
super(scope, "MyTechnicalDashboard", props);
const fargateMemoryWidget = this.createFargateMemoryWidget();
this.addWidgets(
fargateMemoryWidget
);
}
private createFargateMemoryWidget() {
const fargateMemoryUtilizationMetric = new Metric({
metricName: "MemoryUtilized",
namespace: "ECS/ContainerInsights",
dimensions: {
TaskDefinitionFamily: "TASK-DEFINITION-NAME",
ClusterName: `NAMING-CONVENTION-${this.prefix}-${this.stage}`,
},
statistic: "Average",
period: Duration.minutes(1),
});
return new GraphWidget({
title: "FARGATE - MEM (MB)",
left: [fargateMemoryUtilizationMetric],
rightYAxis: { min: 0, max: 2148, showUnits: true },
leftYAxis: { min: 0, max: 2148, showUnits: true },
leftAnnotations: [
{ value: 2048, color: Color.RED, label: "MEM Max" }
],
});
}
}
Isn’t that much more readable? 💪
AWS CodePipeline and related services
Another example of the total obscureness is anything that touches AWS CodePipeline, AWS CodeBuild, or AWS CodeDeploy. It is a bummer because those services are not bad. However, in my opinion, AWS CloudFormation Massacre hurts its adoption.
Luckily, AWS sees that, and AWS CDK prepared an amazing library CDK Pipelines which initially focused only on providing continuous delivery for CDK Applications. However, it evolved in much more than that - because it provided a set of great examples of how to work with those services.
Here you have an trimmed example from a real world example that deploys AWS CloudWatch dashboards (kudos for this and previous sample goes to @Wojciech Dąbrowski):
const sourceAction = new cpa.GitHubSourceAction({
actionName: "GitHub",
output: sourceArtifact,
owner: "OWNER",
repo: "REPO",
oauthToken: cdk.SecretValue.secretsManager("github-token"),
branch: "main",
});
const synthAction = pipelines.SimpleSynthAction.standardNpmSynth({
sourceArtifact,
cloudAssemblyArtifact,
buildCommand: "npm run build",
subdirectory: "infrastructure/cdk",
rolePolicyStatements: [
new iam.PolicyStatement({
actions: [ "ecs:ListTasks" ],
resources: ["*"],
}),
],
synthCommand:
`npx cdk synth --context environment=${envName.toLowerCase()}`,
});
const pipeline = new pipelines.CdkPipeline(this, "Pipeline", {
cloudAssemblyArtifact,
sourceAction,
synthAction,
pipelineName: id,
});
const dashboardStage = new DeployDashboardsStage(this, "Deploy", {
envName: envName.toUpperCase(),
prefix: prefix.toUpperCase(),
envContext: envContext,
ecsMetricsCustomNamespace: ecsMetricsCustomNamespace,
});
pipeline.addApplicationStage(dashboardStage);
new DashboardsPipelineEventTrigger(this, "DashboardsEventRule", {
envName: envName,
pipeline: pipeline.codePipeline,
});
Amazon EventBridge
Amazon EventBridge as a service gives exciting capabilities (especially after the newest release of API Destinations). However, crafting those rules is not the most pleasant thing in YAML or JSON - to put it mildly: it’s a very error-prone process (sigh, it’s very indentation-sensitive).
On the other hand, in TypeScript type system drives us seamlessly through the whole process. Here you have an example from one of Pattern Match workshops that we did in the AWS CDK flavor:
import * as Events from '@aws-cdk/aws-events';
export class EventBusStack extends Stack {
public readonly eventBus: events.EventBus;
constructor(scope: Construct, id: string, props: StackProps) {
super(scope, id, props);
this.eventBus = new events.EventBus(this, 'CustomEventBus', {
eventBusName: `${this.node.tryGetContext('prefix')}-event-bus`
});
}
}
// ... and somewhere closer to the triggered resources:
new events.Rule(this, 'SubscribeOnImportantEvent', {
description: 'Subscription on the events published by ...',
eventBus: props.eventBus,
enabled: true,
eventPattern: {
detail: {
action: [ 'ActionName' ],
source: [ 'lambda.name' ]
}
},
targets: [
new targets.LambdaFunction(handler)
]
});
AWS Step Functions
Similar problem as with Amazon EventBridge, we have with AWS Step Functions. Amazon States Language is not the most beautiful DSL in the world. Plus, embedding it in AWS CloudFormation does not prettify that. 😅
But, this is another example where AWS CDK shines and helps tremendously (again the example from the workshop mentioned above):
import * as cdk from '@aws-cdk/core';
import * as lambda from '@aws-cdk/aws-lambda';
import * as sfn from '@aws-cdk/aws-stepfunctions';
import * as tasks from '@aws-cdk/aws-stepfunctions-tasks';
interface CustomProps extends cdk.StackProps {
ocrLambda: lambda.Function;
nlpLambda: lambda.Function;
ttsLambda: lambda.Function;
}
export class StateMachineStack extends cdk.Stack {
public readonly workflow: sfn.StateMachine;
constructor(scope: cdk.Construct, id: string, props: CustomProps) {
super(scope, id, props);
const step1 = new tasks.LambdaInvoke(
this,
'Extract text from image via OCR',
{
lambdaFunction: props.ocrLambda,
inputPath: '$',
outputPath: '$.Payload',
}
);
const step2 = new tasks.LambdaInvoke(
this,
'Detect language from extracted text',
{
lambdaFunction: props.nlpLambda,
inputPath: '$',
outputPath: '$.Payload',
}
);
const step3 = new tasks.LambdaInvoke(
this,
'Synthetize voice from text in a detected language',
{
lambdaFunction: props.ttsLambda,
inputPath: '$',
outputPath: '$.Payload',
}
);
const definition =
step1
.next(step2)
.next(step3);
this.workflow = new sfn.StateMachine(this, 'StateMachine', {
definition,
timeout: cdk.Duration.minutes(5)
});
}
}
Is that it? What’s next?
Of course that’s not everything. For me, the biggest missing thing currently is elements of AWS Glue family. I wonder why AWS CDK does not provide higher-level constructs for some elements, nor any examples of writing it properly. 🤔 But have no fear. I am developing something that will fix that situation. If you are interested in higher-level constructs for AWS Glue, please drop me a line in the comments below.
My list is purely subjective. I would love to hear more about your experiences with AWS CloudFormation monstrosities in the comments. I will be eager to try if AWS CDK will help there too! 🖖