Terraforming AWS: Part II

Terraforming AWS: Part II

Note: Be sure to clone the companion project to follow along!

In Part I of this series, we worked through a lot of AWS fundamentals and bootstrapped a network including custom VPC, public and private subnets and route tables. Thanks to that foundational groundwork, this and subsequent parts will be able to focus on more condensed code and make quicker progress.

In this tutorial, we can move onto something more exciting: EC2 (Elastic Compute Cloud) and ALB (Application Load Balancing). If these are new concepts, don't worry! We'll give a quick intro before we dive into provisioning resources with Terraform. First, let's recall the diagram of our simple project:

Our Goal

Concepts

Our goal is to create a couple load balanced Linux servers. As AWS' "VM as a Service" offering, a natural starting point might seem like creating EC2 instances. Terraform does make that very easy (I am not repeating steps such as provider configuration covered in Part I, refer to that writeup or the sample project as needed):

resource "aws_instance" "example" {
  ami           = var.aws_ami
  instance_type = "t2.micro"
}
Provision an EC2 instance

With just a few lines, we could spin up a VM... then simply use count or copy/paste to add as many instances as needed. As you can imagine, there are a few challenges with this approach. In this simple example, we've hard-coded the instance type which reduces flexibility. We pass in an AMI (Amazon Machine Image, how EC2 instances consume operating systems) as a variable, but this would be brittle because AMIs can vary across regions. Lastly, even though the iterative approach with count would be DRYer, it would still be static configuration requiring hand edits to scale beyond initial capacity and doing nothing to address HA.

To solve those challenges, we need to incorporate auto scaling and load balancing. These concepts allow us to shrink and expand EC2 instances and distribute traffic amongst them. Historically, load balancing consisted solely of ELB (Elastic Load Balancing), but has evolved into NLB (Network Load Balancing) and ALB (Application Load Balancing). The original incantation is lesser used, and often referred to as Classic Load Balancing. For newer environments, you will want ALB (Layer 7) or NLB (Layer 4), since they offer more features and better performance. Refer to the documentatoin for a full comparison.

With EC2 instances, AMIs and an ALB, we are almost ready to begin spinning up our simple web cluster... The one other primitive we need to understand is the Security Group. Security groups can be associated with VPCs or EC2 instances, and act as virtual firewalls (think iptables rules). Mentioned briefly in Part I but repeated here since it is a common source of confusion, by default AWS requires you to specifically allow ingress traffic but allows all egress. However, Terraform takes a more secure stance and removes the default egress rule. This means you need to explicitly configure both ingress and egress rules for any resources managed by Terraform.

Putting it Together

To get our instances up and running, we need to select an AMI for them to run. In production, this could be a custom image built with HashiCorp's Packer, but AWS provides a number of pre-baked images for common distributions. The problem is, these images can vary across regions. If we want to use a public image while ensuring it remains up to date and works consistently, we can leverage a data source:

data "aws_ami" "ubuntu" {
  most_recent = true

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-bionic-18.04-amd64-server-*"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }

  # Canonical
  owners = ["099720109477"]
}
Selecting an AMI

This is a powerful concept... You can tweak or expand the filters as needed to get the desired result. For our simple case, this selects the latest Ubuntu AMI.

We said we were going to avoid provisioning explicit EC2 instances and let AWS handle that for us via auto scaling. As we saw with networking, building larger things with AWS involves bolting many smaller pieces together. The first thing we need is a Launch Configuration. In Terraform, aws_launch_configuration is configured very similarly to aws_instance since it manages EC2 instances for us. Let's see an example:

resource "aws_security_group" "http_ingress_instance" {
  vpc_id = aws_vpc.vpc.id

  ingress {
    from_port   = var.web_port
    to_port     = var.web_port
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    "Name" = "${var.env_name}-http-ingress-instance-sg"
  }
}

resource "aws_launch_configuration" "lc" {
  # avoid static name so resource can be updated
  name_prefix     = "${var.env_name}-"
  image_id        = data.aws_ami.ubuntu.id
  instance_type   = var.web_instance_type
  security_groups = [aws_security_group.http_ingress_instance.id]
  user_data = templatefile("userdata.sh", {
    web_port    = var.web_port,
    web_message = var.web_message,
    db_endpoint = aws_db_instance.rds.endpoint,
    db_name     = aws_db_instance.rds.name,
    db_username = aws_db_instance.rds.username,
    db_status   = aws_db_instance.rds.status
  })
}
Launch Configuration to manage our EC2 instances

The launch configuration will attach specified security groups (security_groups is a list!) to instances it provisions to control access, so we create http_ingress_instance and associate it to our VPC (without specifying vpc_id, it would be associated with the project's default VPC). The security just allows traffic on var.web_port (in variables.tf). The egress rule with from_port and to_port set to 0 and protocol = "-1" is a common idiom to allow all ports and protocols (you can't use any with Terraform).

Important to note, we avoid using name for aws_launch_configuration. This is because our goal is high availability. If we were to specify a static name, the resource could be replaced (vs updated) by Terraform in the future after scaling events and cause outages. This combined with the lifecycle block we'll see later ensures Terraform spins up new instances and migrates traffic to them before tearing down existing resources.

image_id points to our AMI, which is conveniently provided by our data source. Within the OS image, a User Data script is used for configuration. You could provide a pre-baked image as a means of configuration, but user data can be useful for small tweaks or configuration of public images. The templatefile function lets us substitute content we provide via variables into template strings within our script:

#!/bin/bash

cat >index.html <<EOF
<html>
<head>
  <title>Welcome Page</title>
</head>
<body>
  <h1>${web_message}</h1>
  <ul>
    <li><b>RDS endpoint:</b> <pre>${db_endpoint}</pre></li>
    <li><b>Database name:</b> <pre>${db_name}</pre></li>
    <li><b>Database user:</b> <pre>${db_username}</pre></li>
    <li><b>Database password:</b> <pre>Yeah right! :-)</pre></li>
    <li><b>Database status:</b> <pre>${db_status}</pre></li>
  </ul>
</body>
</html>
EOF

nohup busybox httpd -f -p ${web_port} &
Bringing up a simple web server

To avoid needing to access the Internet and install dependencies in our starter project (which would necessitate route table associations for our private subnets), we simply fire up BusyBox to serve our content. We'll see where the RDS details come from in a later article.

Now that we have a working launch config, we can build an Auto Scaling Group (ASG) to leverage it. This is where the real magic begins, letting us specify minimum and maximum instance counts to control our dynamic cluster. We also need to link the ASG to the private subnets we created in Part I, letting it know where to create resources (note the [*] or "splat" syntax since we created several subnets spanning all AZs in our region):

resource "aws_autoscaling_group" "asg" {
  # avoid static name so resource can be updated
  name_prefix = "${var.env_name}-asg-
  min_size    = var.web_count_min
  max_size    = var.web_count_max

  launch_configuration = aws_launch_configuration.lc.name
  vpc_zone_identifier  = aws_subnet.private_subnets[*].id

  target_group_arns     = [aws_lb_target_group.tg.arn]
  health_check_type     = "ELB"
  wait_for_elb_capacity = 1

  lifecycle {
    create_before_destroy = true
  }

  tag {
    key                 = "Name"
    value               = "${var.env_name}-instance"
    propagate_at_launch = true
  }
}
Auto Scaling Group with HA lifecycle

Update: Auto Scaling Groups can not apply dynamic tags. Injecting env_name works since it is static and known at run-time. A prior version of this article used value = "${var.env_name}-instance-${uuid()} in an attempt to give instances a unique name. While of arguable utility (who cares what the names look like when every EC2 instance already has a unique resource ID – pets vs cattle), this won't work since the UUID is generated at run-time rather than during scaling events as desired. One approach is to have user data or another process within your instances leverage the meta data endpoint to retrieve and tag instances after deployment. Refer to this Stack Overflow post for one possible approach.

We mentioned lifecycle above, a meta block which ensures all instances are not torn down at once during updates. Target Groups are a new concept we still need to configure, but as the name implies it simply specifies a set of targets to handle traffic. You can use default AWS instance health checks to enable routing of traffic, but leveraging ELB's higher-level checks is more flexible and provides better user experience. We also ensure at least one instance in the target group is passing health checks before routing traffic. Let's see the target group:

resource "aws_lb_target_group" "tg" {
  vpc_id   = aws_vpc.vpc.id
  port     = var.web_port
  protocol = "HTTP"

  health_check {
    path    = "/"
    port    = var.web_port
    matcher = "200"
  }

  lifecycle {
    create_before_destroy = true
  }

  tags = {
    "Name" = "${var.env_name}-web-lb-tg"
  }
}
Target Groups let us configure custom health checks

Here you see the potential of custom health checks! We can add additional blocks, adjust paths and ports as needed, look for additional status codes (e.g. matcher = "200,201"), and customize thresholds (by default three failed probes are considered failure, and three successful probes are required to bring resources in service). In our simple case (and for stateless cloud-native applications), we don't need to worry about sticky sessions. If you do, refer to the documentation for details on the stickiness block.

Now we just need to configure the ALB itself. Defining the ALB resource is simple, but we also need a listener and another security group. Earlier we defined a security group which is attached to instances directly. This one will allow traffic into the ALB itself. Since ALBs are often exposed to the public Internet, they do not pass any traffic unless explicitly configured to do so (secure by default).

resource "aws_lb" "alb" {
  load_balancer_type = "application"
  internal           = false
  security_groups    = [aws_security_group.http_ingress_lb.id]
  subnets            = aws_subnet.public_subnets[*].id

  tags = {
    "Name" = "${var.env_name}-web-lb"
  }
}

resource "aws_lb_listener" "listener" {
  load_balancer_arn = aws_lb.alb.arn
  port              = 80

  default_action {
    target_group_arn = aws_lb_target_group.tg.arn
    type             = "forward"
  }
}

resource "aws_security_group" "http_ingress_lb" {
  name   = "${var.env_name}-http-ingress-lb-sg"
  vpc_id = aws_vpc.vpc.id

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}
Finishing our ALB configuration

Using the now familiar splat syntax, we associate our ALB with our public subnets. The listener configuration exposes port 80 and uses a default action to forward traffic to our target group. This is another powerful construct, since you can specify multiple action blocks for great flexibility in traffic routing.

Almost There

We are getting very close to the target environment in our diagram above... We now have a dynamic and scalable cluster of EC2 instances, leveraging AMIs in a portable way with custom user data, and high availability controlled via flexible health checks.

Next time we'll jump into DBaaS with AWS' Relational Database Service. We'll spin up a lightweight multi-AZ instance fitting into the free tier, but also discuss considerations for hosting production databases. Be sure to keep an eye out for Part III, where we will wrap up our simple project with a fully functional website backed by MySQL.

Thanks for reading!

Show Comments