ARTICLE

Provisioning the Autoscaling Group

From Terraform in Action by Scott Winkler

This article discusses provisioning the autoscaling group and other, associated services.

__________________________________________________________________

Take 37% off Terraform in Action by entering fccwinkler into the discount code box at checkout at manning.com.
__________________________________________________________________

Autoscaling Module

This article is about provisioning the autoscaling group, load balancer, IAM instance role, and everything else that the web server needs to run and serve up a healthy application. The inputs and outputs of the autoscaling module are illustrated by figure 1.

Like the networking module, the autoscaling module provisions a lot of resources. These are depicted by figure 2.

Trickling Down Data

From figure 3, it’s clear we need to inject three additional variable values: vpc, sg and db_config. The first two come from the networking module but the last comes from the database module. The way data bubbles up from the networking module and trickles down into the VPC module’s shown in figure 3. I’m only going to show this one, but the other data values are passed similarly.

The revised code for main.tf in the root module is shown in Listing 1.

Listing 1. main.tf in root module

module "autoscaling" {
source = "./modules/autoscaling"
namespace = var.namespace
ssh_keypair = var.ssh_keypair

vpc = module.networking.vpc #A
sg = module.networking.sg #A
db_config = module.database.db_config #A
}

module "database" {
source = "./modules/database"
namespace = var.namespace

vpc = module.networking.vpc
sg = module.networking.sg
}

module "networking" {
source = "./modules/networking"
namespace = var.namespace
}

#A input arguments for the autoscaling module, set by other module’s outputs

The input variables of the module are used to set the variables in variables.tf. First, create an autoscaling directory under ./modules, then put in four new files: main.tf, variables.tf , outputs.tf and cloud_config.yaml. The last one is a template file. Template files don’t need to end in “.txt”, and I generally use an extension that makes what the template file is clearer. Listing 2 presents the code for variables.tf, in the autoscaling module.

Listing 2. variables.tf

variable "namespace" {
type = string
}

variable "ssh_keypair" {
type = string
}

variable "vpc" {
type = any
}

variable "sg" {
type = any
}

variable "db_config" {
type = object( #A
{ #A
user = string #A
password = string #A
database = string #A
hostname = string #A
port = string #A
} #A
) #A
}

#A Enforcing a strict type schema for the db_config object. The value set for the variable must implement the type schema

Detailed Module Planning

The infrastructure in this article is trickier than other two modules. We’re going to be using an autoscaling group behind a load balancer, with a launch template for startup configuration. I like to draw a rough diagram of the dependencies between resources and modules before I start writing any code. An initial dependency diagram I came up with is depicted in figure 4.

I find these sorts of sketches to be useful when planning out the code of a Terraform module. Even after my code is written, I find them to be much more helpful than the diagrams generated by terraform graph at visualizing my infrastructure. Whenever I plan out the code for a Terraform module, I always consider inter-resource dependencies (i.e. what depends on what) because it helps me predict potential race conditions that require an explicit depends_on. These sketches are often incomplete or outright wrong, but they provide a starting point from which to work from. After we’re finished writing the code in this section, we’ll compare what my initial dependency diagram looks like versus what the final dependency diagram looks like.

Another thing I like to do is write the Terraform code from top-to-bottom, in a way that matches how my dependency diagram look like; i.e. resource having fewer dependencies are put at the top of the file, and resources having more dependencies are put at the bottom. You can reason that resources at the top of the file are created before the resources at the bottom of the file, kind of like “normal” procedural code. Technically you don’t need to do this, because Terraform optimizes and organizes resources for you when it generates an execution plan, but I find it helpful anyways. In particular, it helps me understand what my code’s doing and anticipate how it will behave at runtime. Many other Terraform developers in the community l do this without even realizing it, because it’s such a natural way of thinking. Now that we’re finished with the detailed planning, let’s start writing the code.

Getting Real with Template Files

Remember how I said that templates are useful for init scripts? Here is a case in point. Listing 3 showcases the code for creating a launch template based on some hardcoded input configuration, as well as an IAM instance role and a cloud init configuration.

Listing 3. main.tf

module "iam_instance_profile" { #A
source = "scottwinkler/iip/aws"
actions = ["logs:*", "rds:*"] #B
}

data "template_cloudinit_config" "config" {
gzip = true
base64_encode = true
part {
content_type = "text/cloud-config"
content = templatefile("${path.module}/cloud_config.yaml", var.db_config) #C
}
}

data "aws_ami" "ubuntu" {
most_recent = true
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-bionic-18.04-amd64-server-*"]
}
owners = ["099720109477"]
}

resource "aws_launch_template" "webserver" {
name_prefix = var.namespace
image_id = data.aws_ami.ubuntu.id #D
instance_type = "t2.micro"
user_data = data.template_cloudinit_config.config.rendered #D
key_name = var.ssh_keypair
iam_instance_profile {
name = module.iam_instance_profile.name #D
}
vpc_security_group_ids = [var.sg.websvr]
}

#A A module I published for creating iam_instance_profiles based on a list of permissions

#B rds:* is too open, you don’t want to do this in production

#C Content for the cloud init configuration comes from a template file

#D Specifying implicit dependencies between 1) iam_instance_profile, aws_ami, and template_cloudinit_config and 2) aws_launch_template

Notice that the cloud init configuration is templated using the templatefile function. This function accepts two arguments, a path to a template file named cloud_config.yaml, and a variables object referenced from var.db_config. We use the special interpolation variable path.module to get a reference to the relative filesystem path. The result of this function is the configuration which the web server needs to be able to connect with the database when it starts up. The code for cloud_config.yaml is shown in Listing 4.

Listing 4. cloud_config.yaml

#cloud-config
write_files:
- path: /etc/server.conf
owner: root:root
permissions: "0644"
content: |
{
"user": "${user}", #A
"password": "${password}", #A
"database": "${database}", #A
"netloc": "${hostname}:${port}" #A
}
runcmd: #B
- curl -sL https://api.github.com/repos/scottwinkler/vanilla-webserver-src/releases/latest | jq -r ".assets[].browser_download_url" | wget -qi -
- unzip deployment.zip
- ./deployment/server
packages:
- jq
- wget
- unzip

#A the content of this file’s templated by the db_config object

#B downloading the web application code and starting the server

This is a pretty normal cloud init file. All it does is install some packages, create a configuration file (/etc/server.conf), fetch the application code (deployment.zip) and start the server.

Final Touches

Finally, we’re ready to add the code for the launch template, autoscaling group and load balancer to main.tf. The code in Listing 5 shows how to do this. Don’t worry too much about what the values mean, these can all be found in the AWS provider documentation, or the module README.md. Most are default or “safe” values chosen for the purposes of this exercise. Instead, pay attention to how data flows from other modules into the resources and modules declared here. This is what I mean by “trickling down”.

Listing 5. main.tf

module "iam_instance_profile" {
source = "scottwinkler/iip/aws"
actions = ["logs:*", "rds:*"]
}

data "template_cloudinit_config" "config" {
gzip = true
base64_encode = true
part {
content_type = "text/cloud-config"
content = templatefile("${path.module}/cloud_config.yaml", var.db_config)
}
}

data "aws_ami" "ubuntu" {
most_recent = true
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-bionic-18.04-amd64-server-*"]
}
owners = ["099720109477"]
}

resource "aws_launch_template" "webserver" {
name_prefix = var.namespace
image_id = data.aws_ami.ubuntu.id
instance_type = "t2.micro"
user_data = data.template_cloudinit_config.config.rendered
key_name = var.ssh_keypair
iam_instance_profile {
name = module.iam_instance_profile.name
}
vpc_security_group_ids = [var.sg.websvr]
}

resource "aws_autoscaling_group" "webserver" {
name = "${var.namespace}-asg" #A
min_size = 1
max_size = 3
vpc_zone_identifier = var.vpc.private_subnets
target_group_arns = module.alb.target_group_arns
launch_template {
id = aws_launch_template.webserver.id #B
version = aws_launch_template.webserver.latest_version #B
}
}

module "alb" {
source = "terraform-aws-modules/alb/aws"
version = "~> 4.0"
load_balancer_name = "${var.namespace}-alb"
security_groups = [var.sg.lb] #C
subnets = var.vpc.public_subnets
vpc_id = var.vpc.vpc_id
logging_enabled = false
http_tcp_listeners = [{ port = 80, protocol = "HTTP" }]
http_tcp_listeners_count = "1"
target_groups = [{ name = "websvr", backend_protocol = "HTTP", backend_port = 8080 }]
target_groups_count = "1"
}

#A Using the namespace variable to prevent resource name collisions

#B The autoscaling group always uses the latest launch template version

#C Security group gets set here after traveling from the networking module

WARNING: Exposing port 80 over HTTP for a publicly facing load balancer is unacceptable security for production level applications. Always use port 443 over HTTPS with an SSL/TLS certificate!

Now that we’re done, we can draw a new dependency diagram based on what exists Our new dependency diagram looks more like figure 5.

The discrepancy between how you plan a module versus what you end up with is a common occurrence when working with Terraform. Does it matter whether the autoscaling group registers itself with the load balancer or the load balancer does the registering? I’ say no, it doesn’t matter because its Terraforms’ job to manage dependencies, not yours. As long as the code does what it’s supposed to and it’s organized in a way which is easy to understand, you’ve done a great job.

TIP: besides organizing code top-to-bottom based from least to most dependencies, you can also organize code by grouping related resources together in the same file with a multi-line comment block header describing the groups purpose.

Lastly, there’s an output of the module, lb_dns_name, which we need to include. This output’s used to make it easier to find the DNS name after deploying and it’s bubbled up to the output of the root module. Only outputs from the root module show up in the command line after applying. Listing 6 has the code for outputs.tf.

Listing 6. outputs.tf

output "lb_dns_name" {
value = module.alb.dns_name
}

We can make this output available from the root module by adding another output value to passthrough the data.

Listing 7. outputs.tf in root module

output "db_password" {
value = module.database.db_config.password
}

output "lb_dns_name" {
value = module.autoscaling.lb_dns_name
}

That’s all for this article. If you want to learn more about the book, you can check it out on our browser-based liveBook reader here and in this slide deck.

Written by

Follow Manning Publications on Medium for free content and exclusive discounts.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store