Using the AWS CDK In Real Life

by
Tags: , , , , ,
Category: , ,

The AWS Cloud Development Kit (CDK) was released just over a year ago.  At the time, I was working on a web application deployed to AWS.  The infrastructure was simple.  There was a Node app, running behind nginx, in an EC2 instance.  That instance served a REST API as well as the client-side, static assets (a React bundle).  There was an RDS Postgres instance, an Application Load Balancer, and a Cognito User Pool.  The whole thing was deployed via CodeBuild and provisioned with Cloudformation.

All together, that’s not a complex deployment.  As the lead full-stack engineer, my responsibility was at the application level: the Node and React apps, the Postgres schema, and the nginx configuration.  Luckily, a colleague with more AWS experience took care of standing up the Cloudformation templates and CodeBuild pipelines.  That was done early on in the project.  I maintained them once he was gone, but there wasn’t much to do.

Still, as a developer with (dare I say) a reasonably good understanding of cloud infrastructure, Cloudformation (as well as Terraform) files have always scared me.  I’m not an SRE, so I rarely deal with them on a day-to-day basis.  I have always found that, for the uninitiated, reading these large, declarative configuration files is difficult.  It isn’t like reading code.  Things as simple as importing from other files, or referencing variables from other parts of a file, are more complex than they are in a programming language.  I am capable enough to deploy simple things with this pattern, but I have never felt comfortable.  Enter the CDK.

A “Real” Project

Naturally, I wasn’t about to recommend the CDK to any customers in August of 2019.  Not only had it just been released, but I’m not authoritative enough on the topic to suggest an alternative to tools like Cloudformation or Terraform.  But I was excited about it.  I’m a huge fan of both Typescript and Python, which were supported right away.  It just so happened that I also had a personal project that would make for a non-trivial cloud deployment.

Zoneminder is open source video surveillance software.  When my wife and I bought our first house three years ago, it came with ethernet lines run to the front and back doors for POE cameras.  POE stands for Power Over Ethernet…one wire provides both power and connectivity.  That meant I could buy cheap hardware instead of investing in a more expensive, consumer grade security system.  I had an old laptop laying around, so all I would need is some open source surveillance software to stream and analyze the video feeds.  Easy, right?

The rest of this article is not about setting up Zoneminder (which was a project unto itself, to say the least), but all of the infrastructure required to support it:

  • Security Groups
  • An IAM Role
  • An S3 Bucket
  • An EC2 instance with Zoneminder
  • Application Load Balancer
  • Route 53
  • SSM Parameter Store
  • Secrets Manager

If I don’t account for the countless hours spent diligently fiddling with settings and installation scripts, I guess, at one point, I had saved money.  Alas, I am afflicted with the stubbornness and tinkering addiction that many engineers suffer from.  So one day, when the laptop that was running Zoneminder in my basement finally kicked the bucket, it was a perfect opportunity to run it on AWS.

The Cloudformation Template

I didn’t go through the exercise of setting this up manually in Cloudformation.  Under the hood, that’s what the CDK does automatically.  This file is not checked into the Github repo, but here are all 457 lines.  You don’t need to read it if you’d prefer to scroll right by.  I’m just making a point:

{
  "Resources": {
    "zonemindersecurityZMlbsgFF53469C": {
      "Type": "AWS::EC2::SecurityGroup",
      "Properties": {
        "GroupDescription": "zoneminder/zoneminder-security/ZM-lb-sg",
        "GroupName": "zoneminder load balancer sg",
        "SecurityGroupEgress": [
          {
            "CidrIp": "0.0.0.0/0",
            "Description": "Allow all outbound traffic by default",
            "IpProtocol": "-1"
          }
        ],
        "SecurityGroupIngress": [
          {
            "CidrIp": "0.0.0.0/0",
            "Description": "from 0.0.0.0/0:443",
            "FromPort": 443,
            "IpProtocol": "tcp",
            "ToPort": 443
          },
          {
            "CidrIp": "0.0.0.0/0",
            "Description": "from 0.0.0.0/0:80",
            "FromPort": 80,
            "IpProtocol": "tcp",
            "ToPort": 80
          },
          {
            "CidrIp": "0.0.0.0/0",
            "Description": "from 0.0.0.0/0:9000",
            "FromPort": 9000,
            "IpProtocol": "tcp",
            "ToPort": 9000
          }
        ],
        "VpcId": "vpc-37c07e51"
      },
      "Metadata": {
        "aws:cdk:path": "zoneminder/zoneminder-security/ZM-lb-sg/Resource"
      }
    },
    "zonemindersecurityZMlbsgfromzoneminderzonemindersecurityZMlbsg5BA12372ALLTRAFFIC5E599DC0": {
      "Type": "AWS::EC2::SecurityGroupIngress",
      "Properties": {
        "IpProtocol": "-1",
        "Description": "from zoneminderzonemindersecurityZMlbsg5BA12372:ALL TRAFFIC",
        "GroupId": {
          "Fn::GetAtt": [
            "zonemindersecurityZMlbsgFF53469C",
            "GroupId"
          ]
        },
        "SourceSecurityGroupId": {
          "Fn::GetAtt": [
            "zonemindersecurityZMlbsgFF53469C",
            "GroupId"
          ]
        }
      },
      "Metadata": {
        "aws:cdk:path": "zoneminder/zoneminder-security/ZM-lb-sg/from zoneminderzonemindersecurityZMlbsg5BA12372:ALL TRAFFIC"
      }
    },
    "zonemindersecurityZMec2sg5603B741": {
      "Type": "AWS::EC2::SecurityGroup",
      "Properties": {
        "GroupDescription": "zoneminder/zoneminder-security/ZM-ec2-sg",
        "GroupName": "zoneminder ec2 instance sg",
        "SecurityGroupEgress": [
          {
            "CidrIp": "0.0.0.0/0",
            "Description": "Allow all outbound traffic by default",
            "IpProtocol": "-1"
          }
        ],
        "SecurityGroupIngress": [
          {
            "CidrIp": "71.175.104.76/32",
            "Description": "from 71.175.104.76/32:22",
            "FromPort": 22,
            "IpProtocol": "tcp",
            "ToPort": 22
          }
        ],
        "VpcId": "vpc-37c07e51"
      },
      "Metadata": {
        "aws:cdk:path": "zoneminder/zoneminder-security/ZM-ec2-sg/Resource"
      }
    },
    "zonemindersecurityZMec2sgfromzoneminderzonemindersecurityZMlbsg5BA123728077F61460": {
      "Type": "AWS::EC2::SecurityGroupIngress",
      "Properties": {
        "IpProtocol": "tcp",
        "Description": "from zoneminderzonemindersecurityZMlbsg5BA12372:80",
        "FromPort": 80,
        "GroupId": {
          "Fn::GetAtt": [
            "zonemindersecurityZMec2sg5603B741",
            "GroupId"
          ]
        },
        "SourceSecurityGroupId": {
          "Fn::GetAtt": [
            "zonemindersecurityZMlbsgFF53469C",
            "GroupId"
          ]
        },
        "ToPort": 80
      },
      "Metadata": {
        "aws:cdk:path": "zoneminder/zoneminder-security/ZM-ec2-sg/from zoneminderzonemindersecurityZMlbsg5BA12372:80"
      }
    },
    "zonemindersecurityZMec2sgfromzoneminderzonemindersecurityZMlbsg5BA12372900058CAC3A0": {
      "Type": "AWS::EC2::SecurityGroupIngress",
      "Properties": {
        "IpProtocol": "tcp",
        "Description": "from zoneminderzonemindersecurityZMlbsg5BA12372:9000",
        "FromPort": 9000,
        "GroupId": {
          "Fn::GetAtt": [
            "zonemindersecurityZMec2sg5603B741",
            "GroupId"
          ]
        },
        "SourceSecurityGroupId": {
          "Fn::GetAtt": [
            "zonemindersecurityZMlbsgFF53469C",
            "GroupId"
          ]
        },
        "ToPort": 9000
      },
      "Metadata": {
        "aws:cdk:path": "zoneminder/zoneminder-security/ZM-ec2-sg/from zoneminderzonemindersecurityZMlbsg5BA12372:9000"
      }
    },
    "zoneminderS3S3RoleE6DD4396": {
      "Type": "AWS::IAM::Role",
      "Properties": {
        "AssumeRolePolicyDocument": {
          "Statement": [
            {
              "Action": "sts:AssumeRole",
              "Effect": "Allow",
              "Principal": {
                "Service": "ec2.amazonaws.com"
              }
            }
          ],
          "Version": "2012-10-17"
        },
        "ManagedPolicyArns": [
          "arn:aws:iam::aws:policy/AmazonS3FullAccess"
        ],
        "RoleName": "ZM-S3FullAccessRole"
      },
      "Metadata": {
        "aws:cdk:path": "zoneminder/zoneminder-S3-S3Role/Resource"
      }
    },
    "zoneminderS3BucketEC29A82A": {
      "Type": "AWS::S3::Bucket",
      "Properties": {
        "BucketName": "zoneminder.mattgilbride.com",
        "LifecycleConfiguration": {
          "Rules": [
            {
              "ExpirationInDays": 30,
              "Status": "Enabled"
            }
          ]
        }
      },
      "UpdateReplacePolicy": "Delete",
      "DeletionPolicy": "Delete",
      "Metadata": {
        "aws:cdk:path": "zoneminder/zoneminder-S3-Bucket/Resource"
      }
    },
    "zoneminderec2ZoneminderInstanceConstructZMec2instanceInstanceProfile5A0A93AB": {
      "Type": "AWS::IAM::InstanceProfile",
      "Properties": {

        "Roles": [
          {
            "Ref": "zoneminderS3S3RoleE6DD4396"
          }
        ]
      },
      "Metadata": {
        "aws:cdk:path": "zoneminder/zoneminder-ec2-ZoneminderInstanceConstruct/ZM-ec2-instance/InstanceProfile"
      }
    },
    "zoneminderec2ZoneminderInstanceConstructZMec2instance59D943DF": {
      "Type": "AWS::EC2::Instance",
      "Properties": {
        "AvailabilityZone": "us-east-1b",
        "BlockDeviceMappings": [
          {
            "DeviceName": "/dev/sda1",
            "Ebs": {
              "VolumeSize": 10
            }
          }
        ],
        "IamInstanceProfile": {
          "Ref": "zoneminderec2ZoneminderInstanceConstructZMec2instanceInstanceProfile5A0A93AB"
        },
        "ImageId": "ami-0ac80df6eff0e70b5",
        "InstanceType": "t3a.medium",
        "KeyName": "zoneminder-ami",
        "SecurityGroupIds": [
          {
            "Fn::GetAtt": [
              "zonemindersecurityZMec2sg5603B741",
              "GroupId"
            ]
          }
        ],
        "SubnetId": "subnet-5fcaaf3a",
        "Tags": [
          {
            "Key": "Name",
            "Value": "zoneminder/zoneminder-ec2-ZoneminderInstanceConstruct/ZM-ec2-instance"
          }
        ],
        "UserData": {
          "Fn::Base64": "#!/bin/bash\napt-get install awscli -y\n\nexport DEBIAN_FRONTEND=noninteractive\n\napt-get update\napt-get upgrade -y\n\necho \"mysql-server-5.7 mysql-server/root_password password root\" | debconf-set-selections\necho \"mysql-server-5.7 mysql-server/root_password_again password root\" | debconf-set-selections\n\napt-get install lamp-server^ -y\n\nadd-apt-repository ppa:iconnor/zoneminder-1.34 -y\n# add-apt-repository ppa:iconnor/zoneminder-master -y\n\napt-get update\napt-get upgrade -y\napt-get dist-upgrade -y\n\nrm /etc/mysql/my.cnf\ncp /etc/mysql/mysql.conf.d/mysqld.cnf /etc/mysql/my.cnf\n\nsed -i '/\\[mysqld\\]/a sql_mode = NO_ENGINE_SUBSTITUTION' /etc/mysql/my.cnf\n\n# sets the mapped memory to 75% instead of 50% so zm can consume more memory\necho \"tmpfs /dev/shm tmpfs defaults,noexec,nosuid,size=75% 0 0\" >> /etc/fstab\n\nsystemctl restart mysql\n\napt-get install zoneminder -y\n\nchmod 740 /etc/zm/zm.conf\nchown root:www-data /etc/zm/zm.conf\nchown -R www-data:www-data /usr/share/zoneminder/\n\na2enmod cgi\na2enmod rewrite\na2enconf zoneminder\n\na2enmod expires\na2enmod headers\n\nsystemctl enable zoneminder\nsystemctl start zoneminder\n\nsed -i '/\\[Date\\]/a date.timezone = America/New_York' /etc/php/7.2/apache2/php.ini\ntimedatectl set-timezone America/New_York\n\nsystemctl reload apache2\n\nsystemctl restart mysql\nsystemctl restart apache2\n\n# zmeventserver\napt-get install -y make gcc cpanminus\n\ncpanm install Crypt::MySQL\ncpanm install Config::IniFiles\ncpanm install Crypt::Eksblowfish::Bcrypt\n\napt-get install -y libyaml-perl\ncpanm install Net::WebSocket::Server\n\napt-get -y install libjson-perl\n\ncpanm install LWP::Protocol::https\n\napt-get -y install python3-pip\n\napt-get -y install python3-opencv\n\npip3 install future\n\npip3 install opencv-contrib-python\n\ngit clone --single-branch --branch v5.15.6-matthewtgilbride https://github.com/matthewtgilbride/zmeventnotification.git\n\ncd zmeventnotification && INSTALL_YOLOV4=no ./install.sh --install-es --install-hook --install-config --no-interactive\n"
        }
      },
      "DependsOn": [
        "zoneminderS3S3RoleE6DD4396"
      ],
      "Metadata": {
        "aws:cdk:path": "zoneminder/zoneminder-ec2-ZoneminderInstanceConstruct/ZM-ec2-instance/Resource"
      }
    },
    "zoneminderalbZMlbA413971E": {
      "Type": "AWS::ElasticLoadBalancingV2::LoadBalancer",
      "Properties": {
        "Scheme": "internet-facing",
        "SecurityGroups": [
          {
            "Fn::GetAtt": [
              "zonemindersecurityZMlbsgFF53469C",
              "GroupId"
            ]
          }
        ],
        "Subnets": [
          "subnet-b7264eec",
          "subnet-5fcaaf3a",
          "subnet-bbf38096",
          "subnet-1456765d",
          "subnet-3e219802",
          "subnet-0452d408"
        ],
        "Type": "application"
      },
      "Metadata": {
        "aws:cdk:path": "zoneminder/zoneminder-alb/ZM-lb/Resource"
      }
    },
    "zoneminderalbZMlbZMhttpslistenerBF402651": {
      "Type": "AWS::ElasticLoadBalancingV2::Listener",
      "Properties": {
        "DefaultActions": [
          {
            "TargetGroupArn": {
              "Ref": "zoneminderalbZMlbtg845E443D"
            },
            "Type": "forward"
          }
        ],
        "LoadBalancerArn": {
          "Ref": "zoneminderalbZMlbA413971E"
        },
        "Port": 443,
        "Protocol": "HTTPS",
        "Certificates": [
          {
            "CertificateArn": "arn:aws:acm:us-east-1:818517237865:certificate/486f1040-3241-47c9-85e5-d9c9aafe5943"
          }
        ],
        "SslPolicy": "ELBSecurityPolicy-2016-08"
      },
      "Metadata": {
        "aws:cdk:path": "zoneminder/zoneminder-alb/ZM-lb/ZM-https-listener/Resource"
      }
    },
    "zoneminderalbZMlbZMwslistener436EB83D": {
      "Type": "AWS::ElasticLoadBalancingV2::Listener",
      "Properties": {
        "DefaultActions": [
          {
            "TargetGroupArn": {
              "Ref": "zoneminderalbZMwstg5C512B40"
            },
            "Type": "forward"
          }
        ],
        "LoadBalancerArn": {
          "Ref": "zoneminderalbZMlbA413971E"
        },
        "Port": 9000,
        "Protocol": "HTTPS",
        "Certificates": [
          {
            "CertificateArn": "arn:aws:acm:us-east-1:818517237865:certificate/486f1040-3241-47c9-85e5-d9c9aafe5943"
          }
        ],
        "SslPolicy": "ELBSecurityPolicy-2016-08"
      },
      "Metadata": {
        "aws:cdk:path": "zoneminder/zoneminder-alb/ZM-lb/ZM-ws-listener/Resource"
      }
    },
    "zoneminderalbZMhttplistener6877A761": {
      "Type": "AWS::ElasticLoadBalancingV2::Listener",
      "Properties": {
        "DefaultActions": [
          {
            "RedirectConfig": {
              "Host": "#{host}",
              "Path": "/#{path}",
              "Port": "443",
              "Protocol": "HTTPS",
              "Query": "#{query}",
              "StatusCode": "HTTP_301"
            },
            "Type": "redirect"
          }
        ],
        "LoadBalancerArn": {
          "Ref": "zoneminderalbZMlbA413971E"
        },
        "Port": 80,
        "Protocol": "HTTP"
      },
      "Metadata": {
        "aws:cdk:path": "zoneminder/zoneminder-alb/ZM-http-listener"
      }
    },
    "zoneminderalbZMlbtg845E443D": {
      "Type": "AWS::ElasticLoadBalancingV2::TargetGroup",
      "Properties": {
        "Port": 80,
        "Protocol": "HTTP",
        "Targets": [
          {
            "Id": {
              "Ref": "zoneminderec2ZoneminderInstanceConstructZMec2instance59D943DF"
            },
            "Port": 80
          }
        ],
        "TargetType": "instance",
        "VpcId": "vpc-37c07e51"
      },
      "Metadata": {
        "aws:cdk:path": "zoneminder/zoneminder-alb/ZM-lb-tg/Resource"
      }
    },
    "zoneminderalbZMwstg5C512B40": {
      "Type": "AWS::ElasticLoadBalancingV2::TargetGroup",
      "Properties": {
        "Port": 9000,
        "Protocol": "HTTP",
        "Targets": [
          {
            "Id": {
              "Ref": "zoneminderec2ZoneminderInstanceConstructZMec2instance59D943DF"
            },
            "Port": 9000
          }
        ],
        "TargetType": "instance",
        "VpcId": "vpc-37c07e51"
      },
      "Metadata": {
        "aws:cdk:path": "zoneminder/zoneminder-alb/ZM-ws-tg/Resource"
      }
    },
    "zoneminderdnsZMrsCBFB3B0F": {
      "Type": "AWS::Route53::RecordSet",
      "Properties": {
        "Name": "zoneminder.mattgilbride.com.",
        "Type": "CNAME",
        "HostedZoneId": "Z06071261T05GCWSPV6IZ",
        "ResourceRecords": [
          {
            "Fn::GetAtt": [
              "zoneminderalbZMlbA413971E",
              "DNSName"
            ]
          }
        ],
        "TTL": "60"
      },
      "Metadata": {
        "aws:cdk:path": "zoneminder/zoneminder-dns/ZM-rs/Resource"
      }
    },
    "zoneminderdnsZMrscameras9432F0E7": {
      "Type": "AWS::Route53::RecordSet",
      "Properties": {
        "Name": "camera.zoneminder.mattgilbride.com.",
        "Type": "CNAME",
        "HostedZoneId": "Z06071261T05GCWSPV6IZ",
        "ResourceRecords": [
          "71.175.104.76"
        ],
        "TTL": "60"
      },
      "Metadata": {
        "aws:cdk:path": "zoneminder/zoneminder-dns/ZM-rs-cameras/Resource"
      }
    },
    "zoneminderparamssecretszmUser34D4AFC6": {
      "Type": "AWS::SSM::Parameter",
      "Properties": {
        "Type": "String",
        "Value": "mtg5014",
        "Name": "zmUser"
      },
      "Metadata": {
        "aws:cdk:path": "zoneminder/zoneminder-params-secrets/zmUser/Resource"
      }
    },
    "zoneminderparamssecretszmApiUrlC84A3D66": {
      "Type": "AWS::SSM::Parameter",
      "Properties": {
        "Type": "String",
        "Value": "https://zoneminder.mattgilbride.com/zm/api",
        "Name": "zmApiUrl"
      },
      "Metadata": {
        "aws:cdk:path": "zoneminder/zoneminder-params-secrets/zmApiUrl/Resource"
      }
    },
    "zoneminderparamssecretszmPassword055F7162": {
      "Type": "AWS::SecretsManager::Secret",
      "Properties": {
        "GenerateSecretString": {
          "ExcludePunctuation": true
        },
        "Name": "zmPassword"
      },
      "Metadata": {
        "aws:cdk:path": "zoneminder/zoneminder-params-secrets/zmPassword/Resource"
      }
    }
  }
}

Now, using JSON (or YAML) does have the advantage of being “declarative”.  That means that you don’t need to “run” anything to find out what resources will be created.  This file represents the architecture statically.  The disadvantage of this approach?  It is much harder to discern how these pieces fit together.  To reference something created in this file in another resource, we use a Ref:

"Roles": [ { "Ref": "zoneminderS3S3RoleE6DD4396" } ]

In a normal template, we would use a more human readable name than zoneminderS3S3RoleE6DD4396 .  Nonetheless, figuring out how all the pieces fit together is a matter of finding all of these Ref blocks and looking up what they point to.  In my humble opinion, that’s much harder than what follows.

The CDK Code

tl;dr – Check out the project on Github

Rather than declarative templates, the CDK allows you to describe infrastructure in a general purpose programming language.  Hopefully, the same one you are using to develop applications (Typescript, Javascript, Python, Java, or C#/.Net as of today).  For me, that means a few things:

  1. There is no new syntax to learn.
  2. I can organize and reason about my infrastructure using higher level abstractions.
  3. I can use my language’s type system to help figure out my infrastructure.
  4. I can add special processing steps to a deployment within the same code that manages that deployment.

Let’s break those down.

There is no new syntax to learn.

This argument is the weakest, but also the most obvious.  For me, an application developer, it’s a big advantage.  Rather than figure out the intricacies of Cloudformation’s (or Terraform’s) “configuration language”, I just use a fully featured language.  That means no googling how to create entities in a loop.  This may not be a big deal if your organization has a dedicated infrastructure team.  But if it doesn’t, or you are an organization of one, it is.

I can organize and reason about my infrastructure using higher level abstractions.

This argument is the strongest, and the thing I’m most excited about.  General purpose languages provide more powerful abstractions than configuration DSLs.  Abstractions help us to manage complexity, and cloud infrastructure is complicated.  Here are some examples:

Switching things on and off using simple variables

When iterating on the Zoneminder box, I normally want to run my installation scripts from scratch to make sure the process works.  Those scripts take quite a long time to run, so I don’t want to wait for them when I’m working on something else.  Let’s take a look at how I handle that in code:

constructs/zoneminder.instance.construct.ts

export class ZoneminderInstanceConstruct extends Construct {

  ...


  if (installZoneminder) {
    const zoneminderInstall = readFileSync(path.resolve(process.cwd(), 'zoneminderinstall.sh'), { encoding: 'utf-8' })
    userData.addCommands(zoneminderInstall)
  }

  if (installEventServer) {
    const zmeventserverInstall = readFileSync(path.resolve(process.cwd(), 'zmeventserverinstall.sh'), { encoding: 'utf-8' })
    userData.addCommands(zmeventserverInstall)
  }

  ...

}

index.ts

const { ec2Instance } = new ZoneminderInstanceConstruct(this, `${id}-ec2`, {
  ...
  installEventServer: true,
  installZoneminder: true,
  // Ubuntu 18.04
  ami: 'ami-0ac80df6eff0e70b5',
  ...
})

When I’m working on some other part of the architecture, I can change the ami property to the ID of an AMI with everything pre-installed, set installZoneminder and installEventServer to false, and away we go.  And importantly for me, this is quite simple to understand.  I am building these abstractions with a familiar, fully featured language.  The interface is easy to understand.  The if statement is easy to read.  That makes me happy.

Reasoning about dependencies as inputs and outputs

How about understanding how things fit together?  As I said before, that has always been a pain point for me when using “configuration languages”.  Let’s look again at the main ZoneminderStack in index.ts:

const vpc = Vpc.fromLookup(this, "ZM-vpc", { isDefault: true });
const localIp = process.env.LOCAL_IP as string;

const domainName = StringParameter.valueFromLookup(this, 'domainName')

const { ec2SecurityGroup, loadBalancerSecurityGroup } = new SecurityConstruct(this, `${id}-security`, {
  vpc,
  localIp
})

const { s3Role } = new S3Construct(this, `${id}-S3`, { domainName })

const { ec2Instance } = new ZoneminderInstanceConstruct(this, `${id}-ec2`, {
  vpc,
  ec2SecurityGroup,
  sshKeyName: 'zoneminder-ami',
  ebsVolumeSize: 10,
  installEventServer: true,
  installZoneminder: true,
  // Ubuntu 18.04
  ami: 'ami-0ac80df6eff0e70b5',
  role: s3Role,
})

You don’t need to know Typescript well to see that the new SecurityConstruct(...) consumes a VPC and local IP address, and exposes two Security Groups.  The S3Construct needs a domain name, and exposes s3Role, which is then consumed by ZoneminderInstanceConstruct, along with one of those Security Groups.

Later in the same file, the ALB Construct needs the VPC, the other Security Group, and the EC2 Instance.  The DNS Construct needs the ALB, the local IP address, and domain name.  Finally, the Construct creating a Parameter and Secret needs the domain name, and a user name.

And I don’t think it’s hard to imagine how much more convenient things can get as our infrastructure gets more complicated.  As the complexity grows, we can use more sophisticated abstractions to handle it.  That is a good thing.  It makes me even happier.

Organizing things with a real module system

Here is how I ultimately set up my deployment code:

  • index.ts is the main ZoneminderStack definition and deployment using a CDK App.
  • constructs contains files that export a single “Construct”, each concerned with a particular piece of architecture:
    • alb.construct.ts is the Application Load Balancer, including listeners and target groups for HTTP/HTTPS connections.
    • dns.construct.ts – sets up Route 53 Record Sets for Zoneminder as well as the cameras themselves
    • parameter-secret.construct.ts creates Parameter Store and Secrets Manager values that I’ll need to reference elsewhere
    • s3.construct.ts creates an S3 Bucket for video backup, and an IAM Role that has S3 access
    • security.construct.ts has the Security Groups needed for the EC2 Instance and ALB
    • zoneminder.instance.construct.ts is the EC2 Instance running Zoneminder, with relevant configuration scripts set up as User Data

Each construct exposes a clear interface defining its inputs, and has publicly accessible class members defining its outputs:

interface S3ConstructProps {
  domainName: string
}

export class S3Construct extends Construct {
  public s3Role: Role;

  constructor(scope: Construct, id: string, { domainName }: S3ConstructProps) {
    ...
  }
}

hmm…🧐 This is all much better than searching for "Ref": "..." in a giant YAML file, don’t you think?

I can use my language’s type system to help me figure out my infrastructure.

For me, navigating type declarations is a much faster feedback loop than switching between code and documentation.  I can navigate the landscape using my favorite IDE, and check for sanity by just running the compiler.  Let’s look at the most basic example, an EC2 Instance.  If we just need a basic EC2 instance running, the InstanceProps interface tells us what properties are required:

export interface InstanceProps {
    /**
     * VPC to launch the instance in.
     */
    readonly vpc: IVpc;
    /**
     * Type of instance to launch
     */
    readonly instanceType: InstanceType;
    /**
     * AMI to launch
     */
    readonly machineImage: IMachineImage;
}

And of course, if we aren’t sure how to construct an IVpc, InstanceType, or IMachineImage, those declarations are navigable as well.  And if I don’t construct them right, the compiler will tell me.  In my particular case, there were a few additional requirements of my EC2 instance including a Security Group, an IAM Role to allow S3 uploads,  and some User Data to install Zoneminder on the initial launch.  Figuring all that out by simply navigating the type declarations and comments was, quite frankly, easy.  It’s a much better developer experience than tabbing between documentation in a browser window and a massive YAML file in an editor.

I can add special processing steps to a deployment with the same code that manages that deployment.

Perhaps this one is a bit dangerous.  However, one annoyance I have run into when iterating on an AWS deployment is the fact that one cannot automatically empty an S3 bucket that is about to be destroyed.  If the bucket has stuff in it (which it almost certainly will), the destroy operation will fail.  You have the option to “orphan” the bucket on destroy, but then a subsequent deploy will fail when it encounters an existing bucket with the same name.  Lovely.

This is an annoyance that is easily removed in a CDK project.  Why?  Well, since we are writing regular code, we can use the SDK and the CDK side by side, with very little friction.  Check out cleanup-bucket.ts.  It’s a pretty simple script using the SDK to lookup our bucket and empty it.  Our CDK project works like any other Typescript/Javascript project set up with yarn (or npm, if you prefer).  Thus, I can simply invoke this script before every destroy operation on my stack.

package.json

{
  ...
  "scripts": {
    ...
    "destroy": "yarn pre:destroy && tsc && cdk destroy",
    "pre:destroy": "ts-node cleanup-bucket.ts",
    ....
  }
}

Now, calling yarn destroy first calls my script to empty the bucket, then destroys the stack.  Easy.  That makes me happy.

It’s not all Rainbows and Unicorns

Even a year later, much of the CDK is very immature.  Most releases have breaking changes.  Many resources are not yet fully supported.  Take a look at the comments above the S3 Bucket class (perhaps the most basic, fundamental AWS resource):

/**
 * An S3 bucket with associated policy objects
 *
 * This bucket does not yet have all features that exposed by the underlying
 * BucketResource.
 */
export declare class Bucket extends BucketBase { ...

That is scary.

Here’s another example: I run pretty elaborate installation scripts on my EC2 instance.  There are a number of reasons why AWS:Cloudformation:Init is typically preferred over UserData, which I have employed.  While I’d love to use the former option, it is, as of yet, still not supported out of the box by the CDK.

The CDK is Baby Yoda

Some day it will equip us with “The Force”; enabling us to use full-blown programming paradigms to deploy our cloud infrastructure.  But it needs some time to grow up before it is telling us “do or do not, there is no try” in production.