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.


Figure 1. inputs and outputs of the autoscaling module


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


Figure 2. managed resources provisioned by the autoscaling module


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.


Figure 3. Data flow for how vpc id makes its way from the terraform-aws-vpc to the terraform-aws-alb module


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.


Figure 4. initial dependency diagram of managed and unmanaged resources in autoscaling module


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.


Figure 5. revised dependency graph with the autoscaling group depending on the load balancer


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.