Autoscaling Group with Application Load Balancer using Pulumi

aws-ts-autoscaling-group-recording.gif

Pulumi is a tool that allows you to use deploy cloud infrastructure using a real programming language. TypeScript is a common choice, but any Node.js compatible language is supported, as well as Python, .NET Core, and Go.

This tutorial will show you how to provision an Application Load Balancer (ALB) that points to an Autoscaling Group (ASG). A bastion host will also be provisioned in order to manage the web servers in the ASG which are located in a private subnet.

Prerequisites

Import Packages

Open index.ts and import the aws, awsx, and fs packages that contain modules that we’ll use to deploy AWS resources.

Also, create a config.ts file for user-specified configuration values and import that as well.

import * as aws from "@pulumi/aws";
import * as awsx from "@pulumi/awsx";
import * as fs from "fs";
import * as config from "./config";

Virtual Private Cloud

Create a VPC using Pulumi Crosswalk for AWS. The awsx.ec2.Vpc class makes it easy to deploy a fully functioning VPC with a public and private subnet in two availability zones. A NAT Gateway is deployed for outbound Internet access from the private subnets. The VPC can be customized, but the default configuration is sufficient for our purposes.

const vpc = new awsx.ec2.Vpc("vpc")

 
We’ll be deploying a bastion host in a public subnet. A bastion host, also known as a jump box, is a hardened server that has access to internal resources. The bastion security group will allow inbound SSH access from the Internet as well as outbound access to anywhere. Crosswalk provides helpers such as awsx.ec2.AnyIPv4Location() that simplifies the creation of security groups.

const bastionSG = new awsx.ec2.SecurityGroup("bastion-sg", {
    vpc: vpc
})

bastionSG.createIngressRule("bastion-ssh-access", {
    location: new awsx.ec2.AnyIPv4Location(),
    ports: new awsx.ec2.TcpPorts(22),
    description: "Allow SSH access from anywhere",
})

bastionSG.createEgressRule("bastion-outbound-access", {
    location: new awsx.ec2.AnyIPv4Location(),
    ports: new awsx.ec2.AllTraffic,
    description: "Allow outbound access to anywhere",
})

 
The security group for our web servers will be configured to only allow SSH access from the bastion security group. HTTP access is allowed from anywhere so that the servers can be reached by the load balancer.

const webserverSG = new awsx.ec2.SecurityGroup("webserver-sg", {
    vpc: vpc
})

// Only allow SSH access from our bastion host
webserverSG.createIngressRule("webserver-ssh-access", {
    location: { sourceSecurityGroupId: bastionSG.id },
    ports: new awsx.ec2.TcpPorts(22),
    description: "Allow SSH access from anywhere",
})

webserverSG.createIngressRule("webserver-http-access", {
    location: new awsx.ec2.AnyIPv4Location(),
    ports: new awsx.ec2.TcpPorts(80),
    description: "Allow HTTP access from anywhere",
})

webserverSG.createEgressRule("webserver-outbound-access", {
    location: new awsx.ec2.AnyIPv4Location(),
    ports: new awsx.ec2.AllTraffic,
    description: "Allow outbound access to anywhere",
})

Bastion Host

Retrieve the AMI ID of the most recent Amazon Linux 2 AMI. The aws.getAmi method allows us to query AWS and return the most recently updated AMI. We’ll use this AMI for both the bastion host and web servers.

const amiId = aws.getAmi({
    filters: [
        {
            name: "name",
            values: ["amzn2-ami-hvm-2.0.*-x86_64-gp2"],
        }
    ],
    mostRecent: true,
    owners: ["137112412989"],
}, { async: true }).then(ami => ami.id)

 
Before we deploy our bastion host, we’ll need to configure our SSH key pair. Once you’ve created a key pair in AWS, you can retrieve the public key. You can also import your own public key into AWS. Once you have the public key, create an awsPublicKey configuration value in Pulumi.

cat public_key.pub | pulumi config set awsPublicKey

 
In config.ts, we’ll read the configuration value and set it as a variable for use in our Pulumi program. While we’re at it, let’s also create instanceType, minSizeASG, and maxSizeASG variables for use later.

import * as aws from "@pulumi/aws";
import * as pulumi from "@pulumi/pulumi";

const config = new pulumi.Config()

export const awsPublicKey = config.require("awsPublicKey")

export const instanceType = "t3.micro"

export const minSizeASG = 1
export const maxSizeASG = 3

 
Now we can create the bastion host in a public subnet. When we created the VPC earlier, Crosswalk created an array with all public subnet IDs which is accessible at vpc.publicSubnetId. Once the VPC is created, output the DNS hostname so that we can test SSH access

const bastionHost = new aws.ec2.Instance("bastion-host", {
    tags: { "Name": "bastion-host" },
    instanceType: config.instanceType,
    ami: amiId,
    subnetId: vpc.publicSubnetIds[0],
    vpcSecurityGroupIds: [ bastionSG.securityGroup.id ],
    keyName: keyPair.keyName,
})

export const bastionHostname = bastionHost.publicDns

 
If you run pulumi up, you should be able to SSH into the bastion host once it’s provisioned.

$ ssh ec2-user@`pulumi stack output bastionHostname` -i private.pem
The authenticity of host 'ec2-hostname.compute.amazonaws.com (x.x.x.x)' can't be established.
ECDSA key fingerprint is SHA256:wuvHtCIrRd//k3JO2w.
Are you sure you want to continue connecting (yes/no)? yes
Warning: Permanently added 'ec2-hostname.compute.amazonaws.com,x.x.x.x' (ECDSA) to the list of known hosts.
       __|  __|_  )
       _|  (     /   Amazon Linux 2 AMI
      ___|\___|___|
https://aws.amazon.com/amazon-linux-2/
[ec2-user@hostname ~]$

Application Load Balancer

Pulumi Crosswalk also provides the awsx.lb.ApplicationLoadBalancer class to easily configure an Application Load Balancer (ALB). Once the ALB is created we can use the createTargetGroup and createListener methods to configure the load balancer to listen for HTTP traffic and forward requests to instances in the target group.

const webALB = new awsx.lb.ApplicationLoadBalancer("web-alb", {
    vpc: vpc
})

const albTG = webALB.createTargetGroup("alb-tg",{
    protocol: "HTTP",
    targetType: "instance",
})

const albListener = albTG.createListener("alb-listener", {
    protocol: "HTTP"
})

Autoscaling Group

We’ll define our web server configuration as a Launch Configuration in AWS so that the Autoscaling Group can launch identically configured instances when scaling out.

const webLC = new aws.ec2.LaunchConfiguration("web-lc", {
    imageId: amiId,
    instanceType: config.instanceType,
    securityGroups: [ webserverSG.id ],
    keyName: keyPair.keyName,
    namePrefix: "webserver-lc-",
    userData: fs.readFileSync("files/userdata.sh").toString(),
})

 
The userData field refers to userdata.sh which bootstraps the web server and installs httpd.

#!/bin/bash
yum update -y
yum install httpd -y
service httpd start
chkconfig httpd on
echo `hostname` > /var/www/html/index.html

 
Now we’re ready to create the Autoscaling Group and attach it to the Target Group. The attachment will ensure that any provisioned web servers are added to the load balancer automatically. The web servers will be deployed across our private subnets and tagged with the name “web-server” (note the propagateAtLaunch field). The minimum and maximum size of the ASG can be configured in config.ts.

const webASG = new aws.autoscaling.Group("web-server-asg", {
    vpcZoneIdentifiers: vpc.privateSubnetIds,
    launchConfiguration: webLC.name,
    tags: [
        {
            key: "Name",
            value: "web-server",
            propagateAtLaunch: true,
        },
    ],
    minSize: config.minSizeASG,
    maxSize: config.maxSizeASG,
})
// Create a new ALB Target Group attachment
new aws.autoscaling.Attachment("asg-attachment", {
    albTargetGroupArn: albTG.targetGroup.arn,
    autoscalingGroupName: webASG.name,
})

export const webURL = albListener.endpoint.hostname

 
After running pulumi up, you can test the application with curl. The load balancer should return the hostname of the internal web server which is being generated by httpd.

$ curl `pulumi stack output webURL`
ip-10-0-155-98.us-west-2.compute.internal

Conclusion

And that’s it! You have now provisioned an Autoscaling Group with an Application Load Balancer and Bastion Host. You can further customize the application by providing your own AMI and bootstrap script. When you’re finished, run pulumi destroy to clean up all resources.

A working example of this tutorial is available at https://github.com/bdiringer/aws-ts-autoscaling-group.