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.