Written by Carl Li.
Introduction
In the AWS world, there is lots of love for CDK, the most prominent reason being its ability to enable Developers and Cloud Architects to streamline Infrastructure as code (IaC) using familiar programming languages like TypeScript or Python. With CDK, they can leverage the power of abstraction, constructs and deploy AWS resources efficiently.
Anti-patterns represent common solutions to recurring problems that are ineffective or counterproductive. They often emerge when attempting to apply familiar tools to unfamiliar problems or when best practices are overlooked in favor of quick fixes.
In today's cloud-centric world, choosing the right tool to orchestrate multiple CDK environments - such as development, testing, staging, and production - presents unique challenges. Choosing the wrong tool can lead to significant headaches down the line, as undoing and replacing a tech stack often involves time-consuming and complex fixes that could have been avoided with careful initial research and selection.
In this post, we’ll look into why wrapping CDK (which itself is an abstraction layer over AWS CloudFormation) with Ansible (which is a fantastic configuration management tool) can lead to increased complexity without significant benefits, creates confusing issues and complexities and makes the overall solution harder to manage and debug.
We will also provide suggestions on what alternative tools can be utilised for orchestrating CDK for different environments.
Why is Ansible bad with CDK?
Passing incorrect variable type to CDK
Ansible is designed as a declarative configuration management tool. Variables in Ansible are often scoped to the playbook, inventory, or role and are intended to configure system state rather than dynamically interact with application logic at runtime. Consequence of this means most variables are passed as “string”. The first example below shows passing a boolean creates a type error and the second example below shows passing a JSON dictionary generates an incorrectly synthed Cloudformation template.
Example: Passing incorrect variable type to CDK for boolean
Set below vars as ansible environment (<fifo> and <versioned> are boolean variables) and passing them to CDK typescript
SqsS3:
fifo: true
versioned: true
queuename: testqueue.fifo
tasks:
- ansible.builtin.set_fact:
additional_env_vars:
FIFO: "{{ SqsS3.fifo | mandatory }}"
VERSIONED: "{{ SqsS3.versioned | mandatory }}"
QUEUNENAME: "{{ SqsS3.queuename | mandatory }}"
Run ansible playbook task to synth below CDK code
import { Stack, StackProps, RemovalPolicy } from 'aws-cdk-lib';
import {Construct} from 'constructs';
import * as sqs from "aws-cdk-lib/aws-sqs";
import * as s3 from "aws-cdk-lib/aws-s3";
export interface SqsS3Props extends StackProps {
fifo: boolean;
versioned: boolean;
queuename: string;
}
export class SqsS3 extends Stack {
constructor(scope: Construct, id: string, props: SqsS3Props) {
super(scope, id, props);
const { fifo, versioned, queuename } = props;
new sqs.Queue(this, 'TestQueue', {
fifo: fifo,
queueName: queuename
});
new s3.Bucket(this, 'CMDAnsible', {
versioned: versioned,
removalPolicy: RemovalPolicy.DESTROY
});
}
}
The “cdk synth” returns with below complaining the variable <fifo> is not a boolean
CfnSynthesisError: Resolution error: Supplied properties not correct for "CfnQueueProps"
fifoQueue: "True" should be a boolean.
What happened is Ansible basically passed the ‘true’ as string when <fifo> expects a boolean.
One could correct this in the code below but this delivers a very poor developer experience as well as being error prone (if developers forget to do it in every situation).
export class SqsS3 extends Stack {
constructor(scope: Construct, id: string, props: SqsS3Props) {
super(scope, id, props);
const { fifo, versioned, queuename } = props;
new sqs.Queue(this, 'TestQueue', {
// convert from string to boolean because Ansible pass in boolean as string
fifo: !!(fifo),
queueName: queuename
});
new s3.Bucket(this, 'CMDAnsible', {
// convert from string to boolean because Ansible pass in boolean as string
versioned: !!(versioned),
removalPolicy: RemovalPolicy.DESTROY
});
}
}
Example: Passing incorrect variable type to CDK for json dictionary
Set below vars as ansible environment (<downloadCron> a simple JSON dictionary) and passing them to CDK typescript
Events:
downloadCron: {'minute': '0', 'hour': '21-8', 'weekDay': 'SUN-FRI'}
tasks:
- ansible.builtin.set_fact:
additional_env_vars:
DOWNLOADCRON: "{{ Events.downloadCron | mandatory }}"
Run ansible playbook task to synth below CDK code
import { Stack, StackProps } from 'aws-cdk-lib';
import {Construct} from 'constructs';
import * as events from "aws-cdk-lib/aws-events";
export interface RuleProps extends StackProps {
downloadCron: { [Key: string]: string };
}
export class Rule extends Stack {
constructor(scope: Construct, id: string, props: RuleProps) {
super(scope, id, props);
const downloadCron = props;
new events.Rule(this, 'downloadDailyRule', {
schedule: events.Schedule.cron({
minute: downloadCron.minute,
hour: downloadCron.hour,
weekDay: downloadCron.weekDay,
}),
});
}
}
The “cdk synth” successfully and hoorah, right? Look at the cron expression highlight in () below
Resources:
downloadDailyRule427E7EFB:
Type: AWS::Events::Rule
Properties:
ScheduleExpression: cron(* * * * ? *)
State: ENABLED
Metadata:
aws:cdk:path: SqsS3/downloadDailyRule/Resource
Isn't it supposed to be cron(0 21-8 ? SUN-FRI )??? Now a cron rule that is supposed to run every hour during office hours in Eastern Australia instead runs every minute!!
The fix for this is not pretty!!
First set the Ansible fact, serialize the data structure to JSON in Ansible using pass to_json filter
tasks:
- ansible.builtin.set_fact:
additional_env_vars:
DOWNLOADCRON: "{{ Events.downloadCron | to_json }}"
As it’s a json string passing to CDK code level, json.parse the string to convert to JavaScript objects.
import { Stack, StackProps } from 'aws-cdk-lib';
import {Construct} from 'constructs';
import * as events from "aws-cdk-lib/aws-events";
export interface RuleProps extends StackProps {
// downloadCron is now string type
downloadCron: string;
}
export class Rule extends Stack {
constructor(scope: Construct, id: string, props: RuleProps) {
super(scope, id, props);
const downloadCron = props;
// Parse data to json object
const downloadCronObj: { [Key: string]: string } = JSON.parse(downloadCron);
new events.Rule(this, 'downloadDailyRule', {
schedule: events.Schedule.cron({
minute: downloadCronObj.minute,
hour: downloadCronObj.hour,
weekDay: downloadCronObj.weekDay,
}),
});
}
}
While lots of system engineers turned cloud engineers may know this Ansible inherent behavior with variables, a developer may not have an idea about this. This results in lots of frustrating time spent debugging type errors or even worse an incorrect configuration deployed.
Missing its mark on providing rapid feedback (Fail Fast)
One of the key DevOps principles is “Fail Fast” which values rapid feedback and learning from mistakes. Ansible is missing its mark on promoting this with its nature as a configuration tasks handler.
The problem: stdout of each Ansible task is captured in memory and then displayed after the task concludes. In terms of CDK deploy, it means the cdk output will not be visible until the command “cdk deploy” process terminates with an exit code.
Let me illustrate below:
E.g. Run “cdk deploy” with Ansible task to update a RDS Cluster- name: Deploy the cdk source for the key
environment: "{{ cdk_vars }}"
community.general.make:
chdir: ../../cdk/{{ cdk_dir }}/
target: deploy
register: cdk_deploy_out
when: ansible.builtin.debug is undefined
tags: cdk_deploy
deploy:
@echo "Deploying cdk stack: ${CDK_STACK}"
npm run cdk deploy ${CDK_STACK} ${EXTRA_ARGS}
User will see below output
TASK [Deploy the cdk source for the key chdir=../../cdk/{{ cdk_dir }}/, target=deploy] ****************************************************************************************************************************
Nothing changed, no rolling Cloudformation Events.
TASK [Deploy the cdk source for the key chdir=../../cdk/{{ cdk_dir }}/, target=deploy] ****************************************************************************************************************************
While run the same stack with “cdk deploy” immediately shows the rolling output for CloudFormation events:
RdsStack: deploying... [1/1]
RdsStack: creating CloudFormation changeset...
[█████████████████████████████████▏························] (4/7)
12:25:41 PM | UPDATE_IN_PROGRESS | AWS::CloudFormation::Stack | RdsStack
12:25:51 PM | UPDATE_IN_PROGRESS | AWS::RDS::DBInstance | MyRDSInstance
If the stack deploy fails and rollback, developer can be notified earlier and start fixing up his/her cdk code.
RdsStack: deploying... [1/1]
RdsStack: creating CloudFormation changeset...
[█████████████████████████████████▏························] (4/7)
12:35:31 PM | UPDATE_FAILED | AWS::CloudFormation::Stack | RdsStack
12:39:54 PM | UPDATE_ROLLBACK_IN_PROGRESS | AWS::RDS::DBInstance | MyRDSInstance
Above is not the most severe example, rollback can take up to hours for infrastructures like ASG rolling updates, ECS Services or custom resources.
What alternatives then?
For passing variables from upstream, one great alternative is to utilise the programming language’s own ecosystem of tools and libraries for managing configuration and environment variables, which are generally more suited to application development needs than Ansible’s infrastructure-focused tooling
For cdk typescript, I would strongly suggest using the "@types/config" module. It supports json and yaml when combined with the “js-yaml” module. Saying that, “dotenv” and “dotenvx” are other alternatives which are very widely used by typescript/javascript developers with the latter able to add encryption to your .env files.
A quick illustration of how "@types/config" works, under cdk stack directory you setup e.g.
├── config
├── default.yaml
├── development.yaml
├── playpen.yaml
└── production.yaml
config/playpen.yaml
---
rds:
vpcId: vpc-0660ee06739712345
In your cdk code, load config module and use config.get to parse the variables defined
import { Stack, StackProps, RemovalPolicy } from 'aws-cdk-lib';
import {Construct} from 'constructs';
import config from 'config';
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as rds from "aws-cdk-lib/aws-rds";
export class RdsStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
const vpcId: string = config.get('rds.vpcId');
Let say I’ll like to deploy to playpen environment, just set the correct NODE_ENV
$ export NODE_ENV=playpen
$ cdk deploy
For scripting and orchestration, Ansible's scripting capabilities (using the script or command modules) are limited compared to most programming environments like Python/Javascript. For orchestration like getting existing VPC Id or Route53 Zone Id, generating ssh key, certificates etc. to pass on for CDK to consume, most scripting language and modern CICD pipeline (e.g. GitHub Actions/GitLab Workflow) with your preferred containers and pipeline runners will be more streamlined and more effective to implement.
Closing
While Ansible is excellent for infrastructure automation and configuration management, it is not optimised for passing variables to programming languages like JavaScript due to differences in execution context, data formatting requirements, and the nature of tasks each tool is designed to handle. For tasks requiring tight integration with JavaScript or other programming languages, using tools specifically designed for application development and runtime environments is typically more effective.