AWS Application Load Balancer with CDK

At some point we may want to make our machines more reliable. Application Load Balancer (ALB) provides a OSI Layer 7 Load balancer. With this we can handle the traffic for our EC2, microservices and containers. We can also handle different types of traffic for our webserver and API in the same domain.

For this setup I'm going to create a public facing ALB.

Some key considerations are:

  • How much traffic do you expect to handle?
  • Do you want path/host/header/query based routing (such as if my URL has /api send the traffic to the API servers)?
  • Do I want a WAF attached?
  • Do I want IPv6 support?
  • Do I want to use SNI?
  • What kind of protocol do I want to use (such as all traffic is going to be HTTP/HTTPS)?
  • Do I want HTTP/2 support?
  • How much latency can I afford?
  • How do I get started?
  • Is there an existing pattern I can use?

Step 1 : use an existing VPC or create your own. Import the constructs we'll be using.

import * as cdk from '@aws-cdk/core';
import * as logs from "@aws-cdk/aws-logs";
import * as elasticloadbalancingv2 from "@aws-cdk/aws-elasticloadbalancingv2";
import * as ec2 from "@aws-cdk/aws-ec2";
import * as route53 from "@aws-cdk/aws-route53";
import * as route53Targets from "@aws-cdk/aws-route53-targets";
import * as asg from "@aws-cdk/aws-autoscaling";
import { Fn } from '@aws-cdk/core';
...
// existing vpc
const vpc = ec2.Vpc.fromLookup(this, 'Vpc', {
    isDefault: true,
  });

Step 2: get some of the information your ALB needs for High Availability (read the doco for more information.

const subnets = vpc.publicSubnets;
const availabilityZones = Fn.getAzs();

Step 3: allow the traffic in you'll be handling such as HTTP/HTTPS traffic.

const securityGroup = new ec2.SecurityGroup(this, 'SecurityGroup', {
    vpc,
    allowAllOutbound: true,
    securityGroupName: 'my-sec-grp',
  });
securityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), 'allow http traffic');
securityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(443), 'allow https traffic');

Step 4: Create our load balancer. We'd setup deletion protection as well if this was Prod. As it's all throw away, we'll leave it with most of the defaults. HTTP/2 allows multiple requests to be sent on the same connect and compresses the header data so we'll leave that on (default). This ALB is public facing so we'll change the default for that.


 const alb = new elasticloadbalancingv2.ApplicationLoadBalancer(this, 'Alb', {
    vpc,
    internetFacing: true,
    loadBalancerName: 'my-alb',
    securityGroup,
    vpcSubnets: {
      availabilityZones,
      subnets,
    },
});

Step 5: As we may have a Domain name we want to attach on we'll setup logging for the DNS queries. We'll set the CAA as Amazon to restrict who can issue certificates to our domain. As Hosted Zones can take a while or you may not want to be billed for a new Zone, you can run an import instead.

const logGroup = new logs.LogGroup(this, 'LogGroup', {
    logGroupName: 'zone-lg',
  });
const publicZone = new route53.PublicHostedZone(this, 'PublicHostedZone', {
    zoneName: 'my.domain.com',
    caaAmazon: true,
    queryLogsLogGroupArn: logGroup.logGroupArn,
  });
// OR import
const publicZone = route53.HostedZone.fromHostedZoneId(this, 'PublicHostedZone', 'MYHOSTEDZONEID');

const publicZone = route53.HostedZone.fromHostedZoneAttributes(this, 'PublicHostedZone', {
            hostedZoneId: 'MYHOSTEDZONEID',
            zoneName: 'google.com', // might be taken
        });

Step 6: Route 53 doesn't know abut our ALB which we'll place the servers behind yet. Lets make an Alias A Record so it knows where to send the traffic

const albAlias = new route53Targets.LoadBalancerTarget(alb);
new route53.ARecord(this, 'ARecord', {
    zone: publicZone,
    target: route53.RecordTarget.fromAlias(albAlias),
});

Step 7: Lets setup a Target Group for the ALB to be able to point to the servers.


const targetGroup = new elasticloadbalancingv2.ApplicationTargetGroup(this, 'ATG', {
  deregistrationDelay: cdk.Duration.seconds(30),
  healthCheck: {
      enabled: true,
                healthyHttpCodes: "200-299", 
                healthyThresholdCount: 3,
                interval: cdk.Duration.seconds(30),
                path: '/index.html',
                port: "80",
                protocol: elasticloadbalancingv2.Protocol.HTTP,
                timeout: cdk.Duration.seconds(30), 
                unhealthyThresholdCount: 3,
            },
            port: 80,
            protocol: elasticloadbalancingv2.ApplicationProtocol.HTTP,
            slowStart: cdk.Duration.seconds(30),
            stickinessCookieDuration: cdk.Duration.seconds(30),
            targetGroupName: 'my-tg',
            targetType: elasticloadbalancingv2.TargetType.INSTANCE,
            vpc,
        });

Step 7: Route 53 Health checks will enable us to see when our service is down. Well no, it's pointed to our ALB so this Health check is checking our ALB. Route 53 could handle a failover to another region/ ALB.

// as the CloudFormation will accept any old string we can make use of enum's to make sure the CloudFormation is valid.
export enum RouteType {
    TCP = 'TCP',
    HTTPS = 'HTTPS',
    HTTP = 'HTTP'
}

...
const healthCheck80 = this.domainNameHealthCheck({
            hostName: publicZone.zoneName,
            port: 80,
            type: RouteType.HTTP,
            path: '/example/index.html',
            requestInterval: 30,
            failureThreshold: 3,
            name:'http',
        });

        const healthCheck443 = this.domainNameHealthCheck({
            hostName: publicZone.zoneName,
            port: 443,
            type: RouteType.HTTPS,
            path: '/example/index.html',
            requestInterval: 30,
            failureThreshold: 3,
            name:'https',
        });

...

domainNameHealthCheck(config: {
        type: RouteType,
        hostName: string,
        port: number,
        path: string,
        requestInterval: number
        failureThreshold: number
        name:string
    }) {
        return new route53.CfnHealthCheck(this, `HealthCheck${config.name}`, {
            healthCheckConfig: {
                port: config.port,
                type: config.type,
                resourcePath: config.path,
                fullyQualifiedDomainName: config.hostName,
                requestInterval: config.requestInterval,
                failureThreshold: config.failureThreshold,
            },
        });
    }

Step 8: Add a Target Group . We want to route requests to the targets we register. As we'll be doing path based routing, the listener will need a target group to route the request.

const targetGroup = new elasticloadbalancingv2.ApplicationTargetGroup(this, 'ATG', {
            deregistrationDelay: cdk.Duration.seconds(30),
            healthCheck: {
                enabled: true,
                healthyHttpCodes: "200-299", 
                healthyThresholdCount: 3,
                interval: cdk.Duration.seconds(30),
                path: '/example/index.html',
                port: "80",
                protocol: elasticloadbalancingv2.Protocol.HTTP,
                timeout: cdk.Duration.seconds(30), 
                unhealthyThresholdCount: 3,
            },
            port: 80,
            protocol: elasticloadbalancingv2.ApplicationProtocol.HTTP,
            slowStart: cdk.Duration.seconds(30),
            stickinessCookieDuration: cdk.Duration.seconds(30),
            targetGroupName: 'my-tg',
            targetType: elasticloadbalancingv2.TargetType.INSTANCE,
            vpc,
        });

Step 9: Add a Listener. Add the Target group to route the traffic. And we can add the action to route traffic for /example/*.

const httpListener = new elasticloadbalancingv2.ApplicationListener(this, 'ALBHttpListener', {
            loadBalancer: alb,
            port: 80,
            protocol: elasticloadbalancingv2.ApplicationProtocol.HTTP,
        });
        httpListener.addTargetGroups('tg', {
            targetGroups:[
                targetGroup
            ],
        });
        httpListener.addAction('1', {
            action: elasticloadbalancingv2.ListenerAction.redirect({
                protocol: elasticloadbalancingv2.Protocol.HTTP,
                port: "80",
                host: "#{host}",
                path: "/example/#{path}",
                query: "#{query}",
            }),
        });

So there was a bit of setup involved in this. The neat thing is we now can handle different types of traffic such as website and API traffic that may call different servers.

However we don't have the machines scaling at the moment.

Step 10: Add in an Auto Scaling Group (ASG). Note: the golden image will ideally be something you prepare before hand to handle the requests.

const goldenImage = ec2.MachineImage.latestAmazonLinux();

        const autoScalingGroup = new asg.AutoScalingGroup(this, 'ASG', {
            instanceType: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE2, ec2.InstanceSize.MICRO),
            machineImage: goldenImage,
            vpc,
            autoScalingGroupName: 'my-asg',
            keyName: 'my-key',
            healthCheck: asg.HealthCheck.ec2({
                grace: cdk.Duration.seconds(30),
            }),

        });
        autoScalingGroup.scaleOnCpuUtilization('1', {
            targetUtilizationPercent: 80,
        });

Step 11: Make sure you attach the ASG to your ALB. Now we should handle our traffic assuming CPU was the right metric to monitor. It's far better if you can monitor the actual application. This only covers Dynamic scaling. It's possible you don't need this yet and Manual Scaling can be another option to consider.


autoScalingGroup.attachToApplicationTargetGroup(targetGroup);

Step 12: Cleanup. You don't want to be billed for what you're not using so remember to cleanup the CloudFormation after it was deployed.

cdk destroy