How to become an infrastructure-as-code ninja, using AWS CDK - part 7

In this article, we are going to expand on the service in ECS we have already defined in part 6 and make it a load-balanced service that can automatically scale out and scale in, based on load. We are going to use a test-driven design (TDD) approach to figure out what we want to do here.

A recap

First, a quick re-cap what we have done so far:

  • We are using AWS CDK to define an infrastructure, using Typescript
  • This infrastructure uses an existing VPC (Virtual Private Cloud - network infrastructure)
  • We run a container-based solution using AWS Elastic Container Service (ECS)
  • We have an ECS task definition that defines the container properties to use
  • We have an ECS service definition that uses this task definition, plus a desired state description, so ECS will make sure that our solution matches that desired state.

We can expose the container publicly and connect to the Apache web server (httpd) that we have used so far to test the infrastructure building blocks.

So far, we have three code files we have worked with:

  • bin/my-container-infrastructure.ts - the main program file
  • lib/containers/container-management.ts - where most of our container handling logic lives
  • test/containers/container-management.test.ts - The test code we have for our container management logic

Note about code availability

This article series uses Typescript as an example language. However, there are repositories with example code for multiple languages.

The repositories will contain all the code examples from the articles series, implemented in different languages with the AWS CDK. You can view the code from specific parts and stages in the series by checking out the code tagged with a specific part and step in that part. See the README file in each repository for more details.

The code (in Typescript) to this point is below here:

my-container-infrastructure.ts

import { App, Stack } from 'aws-cdk-lib';
import { Vpc } from 'aws-cdk-lib/aws-ec2';
import {
    addCluster,
    addService,
    addTaskDefinitionWithContainer,
    ContainerConfig,
    TaskConfig
} from '../lib/containers/container-management';

const app = new App();
const stack = new Stack(app, 'my-container-infrastructure', {
    env: {
        account: process.env.CDK_DEFAULT_ACCOUNT,
        region: process.env.CDK_DEFAULT_REGION,
    },
});

const vpc = Vpc.fromLookup(stack, 'vpc', {
    isDefault: true,
});

const id = 'my-test-cluster';
const cluster = addCluster(stack, id, vpc);

const taskConfig: TaskConfig = { cpu: 512, memoryLimitMB: 1024, family: 'webserver' };
const containerConfig: ContainerConfig = { dockerHubImage: 'httpd' };

const taskdef = addTaskDefinitionWithContainer(stack, `taskdef-${taskConfig.family}`, taskConfig, containerConfig);
addService(stack, `service-${taskConfig.family}`, cluster, taskdef, 80, 0, true);

container-management.ts

import { IVpc, Peer, Port, SecurityGroup } from 'aws-cdk-lib/aws-ec2';
import {
    Cluster,
    ContainerImage,
    FargateService,
    FargateTaskDefinition,
    LogDriver,
    TaskDefinition
} from 'aws-cdk-lib/aws-ecs';
import { RetentionDays } from 'aws-cdk-lib/aws-logs';
import { Construct } from 'constructs';

export const addCluster = function(scope: Construct, id: string, vpc: IVpc): Cluster {
    return new Cluster(scope, id, {
        vpc,
    });
}

export interface TaskConfig {
    readonly cpu: 256 | 512 | 1024 | 2048 | 4096;
    readonly memoryLimitMB: number;
    readonly family: string;
}

export interface ContainerConfig {
    readonly dockerHubImage: string;
}

export const addTaskDefinitionWithContainer = 
function(scope: Construct, id: string, taskConfig: TaskConfig, containerConfig: ContainerConfig): TaskDefinition {
    const taskdef = new FargateTaskDefinition(scope, id, {
        cpu: taskConfig.cpu,
        memoryLimitMiB: taskConfig.memoryLimitMB,
        family: taskConfig.family,
    });

    const image = ContainerImage.fromRegistry(containerConfig.dockerHubImage);
    const logdriver = LogDriver.awsLogs({ 
        streamPrefix: taskConfig.family,
        logRetention: RetentionDays.ONE_DAY,
    });
    taskdef.addContainer(`container-${containerConfig.dockerHubImage}`, { image, logging: logdriver });

    return taskdef;
};

export const addService = 
function(scope: Construct, 
         id: string, 
         cluster: Cluster, 
         taskDef: FargateTaskDefinition, 
         port: number, 
         desiredCount: number, 
         assignPublicIp?: boolean,
         serviceName?: string): FargateService {
    const sg = new SecurityGroup(scope, `${id}-security-group`, {
        description: `Security group for service ${serviceName ?? ''}`,
        vpc: cluster.vpc,
    });
    sg.addIngressRule(Peer.anyIpv4(), Port.tcp(port));

    const service = new FargateService(scope, id, {
        cluster,
        taskDefinition: taskDef,
        desiredCount,
        serviceName,
        securityGroups: [sg],
        circuitBreaker: {
            rollback: true,
        },
        assignPublicIp,
    });

    return service;
};

container-management.test.ts

import { Stack } from 'aws-cdk-lib';
import { Vpc } from 'aws-cdk-lib/aws-ec2';
import { Capture, Match, Template } from 'aws-cdk-lib/assertions';
import {
    addCluster,
    addService,
    addTaskDefinitionWithContainer,
    ContainerConfig,
    TaskConfig
} from '../../lib/containers/container-management';

test('ECS cluster is defined with existing vpc', () => {
    // Test setup
    const stack = new Stack();
    const vpc = new Vpc(stack, 'vpc');

    // Test code
    const cluster = addCluster(stack, 'test-cluster', vpc);

    // Check result
    const template = Template.fromStack(stack);
    
    template.resourceCountIs('AWS::ECS::Cluster', 1);

    expect(cluster.vpc).toEqual(vpc);
});

test('ECS Fargate task definition defined', () => {
    // Test setup
    const stack = new Stack();
    const cpuval = 512;
    const memval = 1024;
    const familyval = 'test';
    const taskCfg: TaskConfig = { cpu: cpuval, memoryLimitMB: memval, family: familyval };
    const imageName = 'httpd';
    const containerCfg: ContainerConfig = { dockerHubImage: imageName };

    // Test code
    const taskdef = addTaskDefinitionWithContainer(stack, 'test-taskdef', taskCfg, containerCfg);

    // Check result
    const template = Template.fromStack(stack);
    
    expect(taskdef.isFargateCompatible).toBeTruthy();
    expect(stack.node.children.includes(taskdef)).toBeTruthy();

    template.resourceCountIs('AWS::ECS::TaskDefinition', 1);
    template.hasResourceProperties('AWS::ECS::TaskDefinition', {
        RequiresCompatibilities: [ 'FARGATE' ],
        Cpu: cpuval.toString(),
        Memory: memval.toString(),
        Family: familyval,
    });

});

test('Fargate service created, with provided mandatory properties only', () => {
    // Test setup
    const stack = new Stack();
    const vpc = new Vpc(stack, 'vpc');
    const cluster = addCluster(stack, 'test-cluster', vpc);

    const cpuval = 512;
    const memval = 1024;
    const familyval = 'test';
    const taskCfg: TaskConfig = { cpu: cpuval, memoryLimitMB: memval, family: familyval };
    const imageName = 'httpd';
    const containerCfg: ContainerConfig = { dockerHubImage: imageName };
    const taskdef = addTaskDefinitionWithContainer(stack, 'test-taskdef', taskCfg, containerCfg);

    const port = 80;
    const desiredCount = 1;

    // Test code
    const service = addService(stack, 'test-service', cluster, taskdef, port, desiredCount);

    // Check result
    const sgCapture = new Capture();
    const template = Template.fromStack(stack);

    expect(service.cluster).toEqual(cluster);
    expect(service.taskDefinition).toEqual(taskdef);

    template.resourceCountIs('AWS::ECS::Service', 1);
    template.hasResourceProperties('AWS::ECS::Service', {
        DesiredCount: desiredCount,
        LaunchType: 'FARGATE',
        NetworkConfiguration: Match.objectLike({
            AwsvpcConfiguration: Match.objectLike({
                AssignPublicIp: 'DISABLED',
                SecurityGroups: Match.arrayWith([sgCapture]),
            }),
        }),
    });

    template.resourceCountIs('AWS::EC2::SecurityGroup', 1);
    template.hasResourceProperties('AWS::EC2::SecurityGroup', {
        SecurityGroupIngress: Match.arrayWith([
            Match.objectLike({
                CidrIp: '0.0.0.0/0',
                FromPort: port,
                IpProtocol: 'tcp',
            }),
        ]),
    });
});

test('Container definition added to task definition', () => {
    // Test setup
    const stack = new Stack();
    const cpuval = 512;
    const memval = 1024;
    const familyval = 'test';
    const taskCfg: TaskConfig = { cpu: cpuval, memoryLimitMB: memval, family: familyval };
    const imageName = 'httpd';
    const containerCfg: ContainerConfig = { dockerHubImage: imageName };

    // Test code
    const taskdef = addTaskDefinitionWithContainer(stack, 'test-taskdef', taskCfg, containerCfg);

    // Check result
    const template = Template.fromStack(stack);
    const containerDef = taskdef.defaultContainer;

    expect(taskdef.defaultContainer).toBeDefined();
    expect(containerDef?.imageName).toEqual(imageName); // Works from v2.11 of aws-cdk-lib
    template.hasResourceProperties('AWS::ECS::TaskDefinition', {
        ContainerDefinitions: Match.arrayWith([
            Match.objectLike({
                Image: imageName,
            }),
        ]),
    });
});

What we will do now

In the previous part, there were some points about our design that we should address, which include:

  • Specifying a port number to access the service through addService is right now fine for a single container instance only. For multiple containers (desiredCount > 1) there would need to be a load balancer.
  • The design opens for traffic from everywhere, regardless of whether it uses public or private IP addresses

We should also add some mechanisms to scale the solution based on some load characteristics.

The points above tie in with each other. If we put the container service behind a load balancer, we want to restrict access so that it only goes via the load balancer. We also want to have a suitable number of containers running, based on the load on the service.

So we have a few tasks:

  • Add a load balancer in front of a service, when we set up a service in ECS
  • Make sure access is restricted to go via load balancer
  • Add scalability configuration for containers behind load balancer

We can break these up in more detailed steps later.

The trouble with default VPCs

Until now, our infrastructure experiments have assumed that there is a VPC in place, and we have also for simplicity used the default VPC, which is something most AWS accounts will have.

A problem with the default VPC is that there are only public subnets in that VPC. The reason for that is backward compatibility with the old EC2 Classic, back in those days when you did not need an explicit VPC to run your virtual machines in AWS. This is my guess anyway.

Either way, it is a shortcoming of this VPC. To enable us to use a different VPC with both private and public subnets, we will add a small code segment to optionally add a VPC in the stack. You can also point to an existing VPC that has both public and private subnets.


let vpc: IVpc;

let vpcName = app.node.tryGetContext('vpcname');
if (vpcName) {
  vpc = Vpc.fromLookup(stack, 'vpc', {
    vpcName,
  });
} else {
  vpc = new Vpc(stack, 'vpc', {
    vpcName: 'my-vpc',
    natGateways: 1,
    maxAzs: 2,
  });
}

Instead of simply looking up the default VPC, we have two options. Option one is to provide the name of an existing VPC, and in that case, we use that. If not VPC name is provided, we create a new VPC.

The created VPC will have both public and private subnets, will use two availability zones and have a shared NAT gateway for outbound traffic from private subnets. This is strictly to keep costs down.

If you have an existing VPC to use, you can provide the name of that VPC through a command line option:

cdk synth -c vpcname=thenameofmyvpc

This approach to sending contextual data to the AWS CDK App can be quite useful. The key takeaway for us here is that we can have a VPC with both private and public subnets.

Starting from the tests - TDD a load-balanced service

Let us start by looking at our tests. We currently have one test for the addService function, see below. There is a fair amount of set-up work here.

test('Fargate service created, with provided mandatory properties only', () => {
    // Test setup
    const stack = new Stack();
    const vpc = new Vpc(stack, 'vpc');
    const cluster = addCluster(stack, 'test-cluster', vpc);

    const cpuval = 512;
    const memval = 1024;
    const familyval = 'test';
    const taskCfg: TaskConfig = { cpu: cpuval, memoryLimitMB: memval, family: familyval };
    const imageName = 'httpd';
    const containerCfg: ContainerConfig = { dockerHubImage: imageName };
    const taskdef = addTaskDefinitionWithContainer(stack, 'test-taskdef', taskCfg, containerCfg);

    const port = 80;
    const desiredCount = 1;

    // Test code
    const service = addService(stack, 'test-service', cluster, taskdef, port, desiredCount);

    // Check result
    const sgCapture = new Capture();
    const template = Template.fromStack(stack);

    expect(service.cluster).toEqual(cluster);
    expect(service.taskDefinition).toEqual(taskdef);

    template.resourceCountIs('AWS::ECS::Service', 1);
    template.hasResourceProperties('AWS::ECS::Service', {
        DesiredCount: desiredCount,
        LaunchType: 'FARGATE',
        NetworkConfiguration: Match.objectLike({
            AwsvpcConfiguration: Match.objectLike({
                AssignPublicIp: 'DISABLED',
                SecurityGroups: Match.arrayWith([sgCapture]),
            }),
        }),
    });

    template.resourceCountIs('AWS::EC2::SecurityGroup', 1);
    template.hasResourceProperties('AWS::EC2::SecurityGroup', {
        SecurityGroupIngress: Match.arrayWith([
            Match.objectLike({
                CidrIp: '0.0.0.0/0',
                FromPort: port,
                IpProtocol: 'tcp',
            }),
        ]),
    });
});

My suspicion at this point is that we may need multiple tests for the service part. Each test would have will have had the same setup work to do. With that in mind, I think we could refactor the service test into a group of tests that all run the same setup. Later, if we add more service tests, most of the set-up work will be in place already. Luckily, the Jest test framework we use with our AWS CDK Typescript code has support for that, using the beforeEach function. The refactored code looks like this:

describe('Test service creation options', () => {
    let stack: Stack;
    let cluster: Cluster;
    let taskdef: TaskDefinition;

    beforeEach(() => {
        // Test setup
        stack = new Stack();
        const vpc = new Vpc(stack, 'vpc');
        cluster = addCluster(stack, 'test-cluster', vpc);
    
        const cpuval = 512;
        const memval = 1024;
        const familyval = 'test';
        const taskCfg: TaskConfig = { cpu: cpuval, memoryLimitMB: memval, family: familyval };
        const imageName = 'httpd';
        const containerCfg: ContainerConfig = { dockerHubImage: imageName };
        taskdef = addTaskDefinitionWithContainer(stack, 'test-taskdef', taskCfg, containerCfg);
    });

    test('Fargate load-balanced service created, with provided mandatory properties only', () => {    
        const port = 80;
        const desiredCount = 1;
    
        // Test code
        const service = addService(stack, 'test-service', cluster, taskdef, port, desiredCount);
    
        // Check result
        const sgCapture = new Capture();
        const template = Template.fromStack(stack);
    
        expect(service.cluster).toEqual(cluster);
        expect(service.taskDefinition).toEqual(taskdef);
    
        template.resourceCountIs('AWS::ECS::Service', 1);
        template.hasResourceProperties('AWS::ECS::Service', {
            DesiredCount: desiredCount,
            LaunchType: 'FARGATE',
            NetworkConfiguration: Match.objectLike({
                AwsvpcConfiguration: Match.objectLike({
                    AssignPublicIp: 'DISABLED',
                    SecurityGroups: Match.arrayWith([sgCapture]),
                }),
            }),
        });

        template.resourceCountIs('AWS::EC2::SecurityGroup', 1);
        template.hasResourceProperties('AWS::EC2::SecurityGroup', {
            SecurityGroupIngress: Match.arrayWith([
                Match.objectLike({
                    CidrIp: '0.0.0.0/0',
                    FromPort: port,
                    IpProtocol: 'tcp',
                }),
            ]),
        });
    }); 
});

When you change the code, run npm test to make sure that the tests still work.

Next, let us think about what we want to do. We want to add a load balancer in front of our container service, so that we can scale it to a suitable about and set meaningful desired count larger than 1. Maybe the function call should not be called addService then, but addLoadBalancedService?

We can change the name of the function call, and do the corresponding change in the code itself to fix our now broken test, and test that now works again.

Next, since we should have a load balanced service, it would make sense to test that we get a load balancer in place. The underlying CloudFormation would generate a resource called AWS::ElasticLoadBalancerV2::LoadBalancer. So we can add a test that there will be such a resource after we have called our addLoadBalancedService function.

How do we implement this? We are trying to keep things simple, and fortunately AWS CDK has a construct that fits what we need well, called ApplicationLoadBalancedFargateService. This should set up a load balancer and create the service behind it, which is just what we want. We already have a task definition for the container we want to run as a service and this construct can accept that as input, which is just great!

Another change we can make to our function besides the name change is the optional parameter assignPublicIp. This is not relevant for a load balancer, since we will not have a specific IP address for the load balancer. But the underlying purpose of that parameter is that we should be able to tell if the endpoint we expose should be public (available through the internet) or private (only reachable in our own network). So we can also change the name of this parameter to better reflect our intent, instead of what it should do. So we can rename it to publicEndpoint instead, for example.

So now we have a new test and some new code to support this, but if we run npm test now, we get a failure! Our new construct will create security groups under the hood for us, and there will be security groups for the load balancer itself, as well as for the targets it will redirect traffic to. So our previous test for a single security group is no longer valid. We might not even care exactly how many service groups are created, as long as the functionality they are supposed to handle is in place (only allow specific network traffic to selected resources). We can also reasonably assume that those who have built ApplicationLoadBalancedFargateService have tested out that code well enough that we should not test their implementation. So we can skip our test for a single security group we had before.

So we adjust the test and we also adjust the underlying implementation of addLoadBalancedService, since we do not need to care about managing the security groups ourselves, at this point in time at least.

Now if we run npm test, we see the test succeed! Is that good enough? Actually, we should also be clear what ports to map, so we should also include some port mapping information. The load balancer port and the container ports may not be the same. This may be suitable to include in the container configuration for the container itself.

Finally, let us also make sure we have a test for the publicEndpoint parameter. Here, one relatively simple way to check is if the CloudFormation load balancer resource has its scheme set to internal if publicEndpoint is false, and it should be internet-facing if publicEndpoint is true.

Now we have a new set of code, see below:

container-management.test.ts

import { Stack } from 'aws-cdk-lib';
import { Vpc } from 'aws-cdk-lib/aws-ec2';
import { Capture, Match, Template } from 'aws-cdk-lib/assertions';
import {
    addCluster,
    addLoadBalancedService,
    addTaskDefinitionWithContainer,
    ContainerConfig,
    TaskConfig
} from '../../lib/containers/container-management';
import { Cluster, TaskDefinition } from 'aws-cdk-lib/aws-ecs';

test('ECS cluster is defined with existing vpc', () => {
    // Test setup
    const stack = new Stack();
    const vpc = new Vpc(stack, 'vpc');

    // Test code
    const cluster = addCluster(stack, 'test-cluster', vpc);

    // Check result
    const template = Template.fromStack(stack);
    
    template.resourceCountIs('AWS::ECS::Cluster', 1);

    expect(cluster.vpc).toEqual(vpc);
});


test('ECS Fargate task definition defined', () => {
    // Test setup
    const stack = new Stack();
    const cpuval = 512;
    const memval = 1024;
    const familyval = 'test';
    const taskCfg: TaskConfig = { cpu: cpuval, memoryLimitMB: memval, family: familyval };
    const imageName = 'httpd';
    const containerCfg: ContainerConfig = { dockerHubImage: imageName, tcpPorts: [80] };

    // Test code
    const taskdef = addTaskDefinitionWithContainer(stack, 'test-taskdef', taskCfg, containerCfg);

    // Check result
    const template = Template.fromStack(stack);
    
    expect(taskdef.isFargateCompatible).toBeTruthy();
    expect(stack.node.children.includes(taskdef)).toBeTruthy();

    template.resourceCountIs('AWS::ECS::TaskDefinition', 1);
    template.hasResourceProperties('AWS::ECS::TaskDefinition', {
        RequiresCompatibilities: [ 'FARGATE' ],
        Cpu: cpuval.toString(),
        Memory: memval.toString(),
        Family: familyval,
    });

});

test('Container definition added to task definitio', () => {
    // Test setup
    const stack = new Stack();
    const cpuval = 512;
    const memval = 1024;
    const familyval = 'test';
    const taskCfg: TaskConfig = { cpu: cpuval, memoryLimitMB: memval, family: familyval };
    const imageName = 'httpd';
    const containerCfg: ContainerConfig = { dockerHubImage: imageName, tcpPorts: [80] };

    // Test code
    const taskdef = addTaskDefinitionWithContainer(stack, 'test-taskdef', taskCfg, containerCfg);

    // Check result
    const template = Template.fromStack(stack);
    const containerDef = taskdef.defaultContainer;

    expect(taskdef.defaultContainer).toBeDefined();
    expect(containerDef?.imageName).toEqual(imageName); // Works from v2.11 of aws-cdk-lib
    template.hasResourceProperties('AWS::ECS::TaskDefinition', {
        ContainerDefinitions: Match.arrayWith([
            Match.objectLike({
                Image: imageName,
            }),
        ]),
    });
});

describe('Test service creation options', () => {
    let stack: Stack;
    let cluster: Cluster;
    let taskdef: TaskDefinition;

    beforeEach(() => {
        // Test setup
        stack = new Stack();
        const vpc = new Vpc(stack, 'vpc');
        cluster = addCluster(stack, 'test-cluster', vpc);
    
        const cpuval = 512;
        const memval = 1024;
        const familyval = 'test';
        const taskCfg: TaskConfig = { cpu: cpuval, memoryLimitMB: memval, family: familyval };
        const imageName = 'httpd';
        const containerCfg: ContainerConfig = { dockerHubImage: imageName, tcpPorts: [80] };
        taskdef = addTaskDefinitionWithContainer(stack, 'test-taskdef', taskCfg, containerCfg);
    });

    test('Fargate load-balanced service created, with provided mandatory properties only', () => {    
        const port = 80;
        const desiredCount = 1;
    
        // Test code
        const service = addLoadBalancedService(stack, 'test-service', cluster, taskdef, port, desiredCount);
    
        // Check result
        const sgCapture = new Capture();
        const template = Template.fromStack(stack);
    
        expect(service.cluster).toEqual(cluster);
        expect(service.taskDefinition).toEqual(taskdef);
    
        template.resourceCountIs('AWS::ECS::Service', 1);
        template.hasResourceProperties('AWS::ECS::Service', {
            DesiredCount: desiredCount,
            LaunchType: 'FARGATE',
            NetworkConfiguration: Match.objectLike({
                AwsvpcConfiguration: Match.objectLike({
                    AssignPublicIp: 'DISABLED',
                    SecurityGroups: Match.arrayWith([sgCapture]),
                }),
            }),
        });

        template.resourceCountIs('AWS::ElasticLoadBalancingV2::LoadBalancer', 1);
        template.hasResourceProperties('AWS::ElasticLoadBalancingV2::LoadBalancer', {
            Type: 'application',
            Scheme: 'internet-facing',
        });
    
        //template.resourceCountIs('AWS::EC2::SecurityGroup', 1);
        template.hasResourceProperties('AWS::EC2::SecurityGroup', {
            SecurityGroupIngress: Match.arrayWith([
                Match.objectLike({
                    CidrIp: '0.0.0.0/0',
                    FromPort: port,
                    IpProtocol: 'tcp',
                }),
            ]),
        });
    }); 

    test('Fargate load-balanced service created, without public access', () => {    
        const port = 80;
        const desiredCount = 1;
    
        // Test code
        const service = addLoadBalancedService(stack, 'test-service', cluster, taskdef, port, desiredCount, false);
    
        // Check result
        const template = Template.fromStack(stack);

        template.resourceCountIs('AWS::ElasticLoadBalancingV2::LoadBalancer', 1);
        template.hasResourceProperties('AWS::ElasticLoadBalancingV2::LoadBalancer', {
            Type: 'application',
            Scheme: 'internal',
        });
    }); 
});

container-management.ts

import { IVpc } from 'aws-cdk-lib/aws-ec2';
import { Cluster, ContainerImage, FargateTaskDefinition, LogDriver, TaskDefinition, Protocol } from 'aws-cdk-lib/aws-ecs';
import { ApplicationLoadBalancedFargateService } from 'aws-cdk-lib/aws-ecs-patterns';
import { RetentionDays } from 'aws-cdk-lib/aws-logs';
import { Construct } from 'constructs';

export const addCluster = function(scope: Construct, id: string, vpc: IVpc): Cluster {
    return new Cluster(scope, id, {
        vpc,
    });
}

export interface TaskConfig {
    readonly cpu: 256 | 512 | 1024 | 2048 | 4096;
    readonly memoryLimitMB: number;
    readonly family: string;
}

export interface ContainerConfig {
    readonly dockerHubImage: string;
    readonly tcpPorts: number[];
}

export const addTaskDefinitionWithContainer = 
function(scope: Construct, id: string, taskConfig: TaskConfig, containerConfig: ContainerConfig): TaskDefinition {
    const taskdef = new FargateTaskDefinition(scope, id, {
        cpu: taskConfig.cpu,
        memoryLimitMiB: taskConfig.memoryLimitMB,
        family: taskConfig.family,
    });

    const image = ContainerImage.fromRegistry(containerConfig.dockerHubImage);
    const logdriver = LogDriver.awsLogs({ 
        streamPrefix: taskConfig.family,
        logRetention: RetentionDays.ONE_DAY,
    });
    const containerDef = taskdef.addContainer(`container-${containerConfig.dockerHubImage}`, { image, logging: logdriver });
    for (const port of containerConfig.tcpPorts) {
        containerDef.addPortMappings({ containerPort: port, protocol: Protocol.TCP });
    }

    return taskdef;
};

export const addLoadBalancedService = 
function(scope: Construct, 
         id: string, 
         cluster: Cluster, 
         taskDef: FargateTaskDefinition, 
         port: number, 
         desiredCount: number, 
         publicEndpoint?: boolean,
         serviceName?: string): ApplicationLoadBalancedFargateService {

    const service = new ApplicationLoadBalancedFargateService(scope, id, {
        cluster,
        taskDefinition: taskDef,
        desiredCount,
        serviceName,
        circuitBreaker: {
            rollback: true,
        },
        publicLoadBalancer: publicEndpoint,
        listenerPort: port,
    });

    return service;
};

my-container-infrastructure.ts

import { App, Stack } from 'aws-cdk-lib';
import { IVpc, Vpc } from 'aws-cdk-lib/aws-ec2';
import { 
  addCluster, 
  addLoadBalancedService,
  addTaskDefinitionWithContainer, 
  ContainerConfig, 
  TaskConfig 
} from '../lib/containers/container-management';

const app = new App();
const stack = new Stack(app, 'my-container-infrastructure', {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
});

let vpc: IVpc;

let vpcName = app.node.tryGetContext('vpcname');
if (vpcName) {
  vpc = Vpc.fromLookup(stack, 'vpc', {
    vpcName,
  });
} else {
  vpc = new Vpc(stack, 'vpc', {
    vpcName: 'my-vpc',
    natGateways: 1,
    maxAzs: 2,
  });
}


const id = 'my-test-cluster';
const cluster = addCluster(stack, id, vpc);

const taskConfig: TaskConfig = { cpu: 512, memoryLimitMB: 1024, family: 'webserver' };
const containerConfig: ContainerConfig = { dockerHubImage: 'httpd', tcpPorts: [80] };
const taskdef = addTaskDefinitionWithContainer(stack, `taskdef-${taskConfig.family}`, taskConfig, containerConfig);
addLoadBalancedService(stack, `service-${taskConfig.family}`, cluster, taskdef, 80, 2, true);

Run npm test and make sure all the tests pass. You can also to deploy the solution now, using cdk deploy. Expect the deployment to take some time, especially if you deploy everything from scratch and if you have it set up to create a new VPC.

You notice that when the deployment is completed, there will be two outputs representing the load balancer endpoint. This results from using ApplicationLoadBalancedFargateService, which adds these outputs by default to the stack.

A bit of auto-scaling

If we have a load balancer in front of our service to distribute the load among multiple containers, it would make sense to scale up and down how many containers will handle that load. Fortunately, after some investigation, we can see that the FargateService class has a function called autoScaleTaskCount, which allows us to set the minimum and maximum number of tasks. It also allows us to specify thresholds for CPU and memory to determine if the service should scale down or up the number of tasks running in the service.

There is no corresponding function on ApplicationLoadBalancedFargateService though, but we can access the underlying FargateService using a service property on ApplicationLoadBalancedFargateService.

Unfortunately, we cannot access all these scaling settings on FargateService once we have set them, so to verify that these settings have been set, we would resort to check CloudFormation resources. In this case, it is resources of the ApplicationAutoScaling that is used by CloudFormation.

Frankly, I am hesitant about the value of some of these tests, since this may be borderline to test the CDK implementation rather than testing our own logic. I kept them here more to be a learning experience. Even with these tests, we would want to do some actual integration tests to see that the real behaviour once deployed is what we expect. There is some early experimental work in AWS CDK for building integration tests, but that is out of scope for this article.

For now, we can at least know that there are some settings in place for the auto-scaling, but more work would be needed to test its actual behaviour.

Extract from container-management.test.ts

    test('Scaling settings of load balancer', () => {
        const port = 80;
        const desiredCount = 2;
        const service = addLoadBalancedService(stack, 'test-service', cluster, taskdef, port, desiredCount, false);
    
        // Test code
        const config = {
            minCount: 1,
            maxCount: 5,
            scaleCpuTarget: { percent: 50 },
            scaleMemoryTarget: { percent: 50 },
        };

        setServiceScaling(service.service, config);
        
    
        // Check result
        const scaleResource = new Capture();
        const template = Template.fromStack(stack);
        template.resourceCountIs('AWS::ApplicationAutoScaling::ScalableTarget', 1);
        template.hasResourceProperties('AWS::ApplicationAutoScaling::ScalableTarget', {
            MaxCapacity: config.maxCount,
            MinCapacity: config.minCount,
            ResourceId: scaleResource,
            ScalableDimension: 'ecs:service:DesiredCount',
            ServiceNamespace: 'ecs',
        });

        template.resourceCountIs('AWS::ApplicationAutoScaling::ScalingPolicy', 2);
        template.hasResourceProperties('AWS::ApplicationAutoScaling::ScalingPolicy', {
            PolicyType: 'TargetTrackingScaling',
            TargetTrackingScalingPolicyConfiguration: Match.objectLike({
                PredefinedMetricSpecification: Match.objectEquals({
                    PredefinedMetricType: 'ECSServiceAverageCPUUtilization',
                }),
                TargetValue: config.scaleCpuTarget.percent,
            }),
        });

        template.hasResourceProperties('AWS::ApplicationAutoScaling::ScalingPolicy', {
            PolicyType: 'TargetTrackingScaling',
            TargetTrackingScalingPolicyConfiguration: Match.objectLike({
                PredefinedMetricSpecification: Match.objectEquals({
                    PredefinedMetricType: 'ECSServiceAverageMemoryUtilization',
                }),
                TargetValue: config.scaleMemoryTarget.percent,
            }),
        });
    });

Extract from container-management.ts


export interface ScalingThreshold {
    percent: number;
}
export interface ServiceScalingConfig {
    minCount: number;
    maxCount: number;
    scaleCpuTarget: ScalingThreshold;
    scaleMemoryTarget: ScalingThreshold;

}


export const setServiceScaling = function(service: FargateService, config: ServiceScalingConfig) {
    const scaling = service.autoScaleTaskCount({
        maxCapacity: config.maxCount,
        minCapacity: config.minCount,
    });

    scaling.scaleOnCpuUtilization('CpuScaling', {
        targetUtilizationPercent: config.scaleCpuTarget.percent,
    });

    scaling.scaleOnMemoryUtilization('MemoryScaling', {
        targetUtilizationPercent: config.scaleMemoryTarget.percent,
    });

}

Extract from my-container-infrastructure.ts

const service = addLoadBalancedService(stack, `service-${taskConfig.family}`, cluster, taskdef, 80, 2, true);
setServiceScaling(service.service, {
  minCount: 1,
  maxCount: 4,
  scaleCpuTarget: { percent: 50 },
  scaleMemoryTarget: { percent: 70 },
});

You can run npm test to make sure all tests pass, and run cdk deploy to see that it also deploys ok. We are at enough lines of code now that I would recommend to review the code at https://github.com/cloudgnosis/iac-ninja-aws-cdk-ts.


This is the end of part 7 of the infrastructure-as-code ninja series. If you enjoyed this material, you can check out the other parts of this series and other articles at Tidy Cloud AWS. Send comments, questions, and suggestions!