With cloud deployment, everything you do to build an application becomes code, whether business logic, configuration, infrastructure, or some other component. Infrastructure as Code (IaC) enables you to provision your servers, security, and configuration files with version control, speed, reliability, and scalability. The beauty of IaC is that it can be shared and re-used to build similar environments.
Terraform is one of the most popular IaC tools due to its platform-agnostic behavior. It can be applied to any cloud platform (AWS, GCP, etc.) and can provision any desired resource on almost any provider (GitHub, Docker, etc.). It uses HashiCorp Configuration Language (HCL) to automate this whole process.
In this post, the first in a two-part series about Terraform, I’ll discuss how to use the various aspects of the Terraform configuration as effectively as possible. I’ll also review some of Terraform’s most advanced features, as well as several third-party tools that you can use with it.
How Terraform Works
Terraform uses a plugin-based architecture that enables developers to write new plugins or modify existing ones, according to their requirements. It has two major parts: Terraform Core and Terraform Plugins.
Terraform Core
Terraform Core is a CLI that is written in Go language. Its primary purpose is reading and interpolating configuration files and modules. It also manages state by enabling the state file to be stored either locally or remotely (such as with Amazon S3, Azure Blob Storage, and others).
Terraform Core generates an execution plan when you execute the command terraform plan
. It then shows the blueprint of what will be applied by Terraform and helps review it before provisioning.
Terraform also builds a resource dependency graph based on the configurations and parallelizes the creation and modification of these resources by traversing each node. Additionally, it communicates with all the plugins over RPC.
Terraform Plugins
Terraform Plugins are executed as a separate process, in which each provisioner provides an implementation of a specific service through the plugin. They are dynamically discovered by Terraform Core using Terraform’s discovery process during terraform init
.
Terraform Best Practices
Now that I’ve reviewed how Terraform works, let’s get into how to best utilize it for IaC.
Modules
Modules are a major building block for Terraform configuration, and, these days, I simply can’t imagine a Terraform script without them. Modules allow you to group together multiple resources in one container and enable the reuse of configuration. They can be called multiple times and can be shared with other configurations as well.
Here is an example of modules in AWS:
--Module
--- LoadBalancer
--- main.tf
--- variable.tf
--- EKS
--- main.tf
--- output.tf
--- variable.tf
--- SecurityGroup
--- main.tf
--- output.tf
--- variable.tf
Structuring a Terraform Configuration
Structuring a Terraform configuration is very important in order to maintain readability. As you add more and more resources, it can become difficult to understand the configuration flow, so it’s best practice to segregate the different aspects of the configuration in different files.
Here is one of example:
--- tf/
--- modules/
--- applications/
-- backend/
--- env/
--- dev.tfvars
--- qa.tfvars
--- prod.tfvars
--- main.tf
--- frontend/
--- env/
--- dev.tfvars
--- qa.tfvars
--- prod.tfvars
--- network/
--- main.tf
--- dns.tf
--- output.tf
--- variables.tf
State Persistence
By default, Terraform creates state files locally. This makes it difficult to share with others who might be working on the same Terraform configuration and to run Terraform script by other members. For this reason, it’s always best to store the state file in a centralized location, such as Amazon S3 or Azure Blob Storage. Some teams use GIT and enable version control, but this is not good practice, since the file is not encrypted and may expose sensitive data, such as AWS credentials.
Also, you should enable locking to this state file, which ensures that only one process is running on a state file and prevents data loss and data corruption of the state file. Backend resources are responsible for state locking, and they need to support locking for the storage they use. For example, Amazon S3 uses Amazon DynamoDB for consistency checks. With a single DynamoDB table, you can lock multiple remote state files.
Variable Files
Variables are the best way to manage the input data to configuration, especially when you’re managing multiple environments. There are several options in Terraform for passing the values:
- Variable.tf
-
-Var command-
line - Variable definition files (- .tfvars)
- Environment variables
Credentials Management
Never store credentials directly in a .tf file. This is a plain text file, and whoever has access to version control can easily gain access to the credentials. I recommend using environment variables as a first step to storing secrets.
$ export AWS_ACCESS_KEY_ID=”accesskey”
$ export AWS_SECRET_ACCESS_KEY=”secretkey”
$ export AWS_DEFAULT_REGION=”ap-southeast-2"
$ terraform plan
Then, when you run Terraform, it will pick up the secrets automatically.
You can also use encrypted files by utilizing AWS Key Management Service, Google Cloud Key Management, or Azure Key Vault. Just store the credentials in a file and encrypt them using these services.
Tagging Resources
Tagging is the best way to identify which resource is being used for which environment (for example, if you’re using multiple Amazon EC2 instances for an application and want to know which one is being used for the prod and dev environments, respectively). You can also use tags to segregate the usage of resources in each environment and for internal billing for each department.
Loops and Conditions
Terraform doesn’t support traditional if/else conditions, but it does allow ternary operation for conditions.
condition ? caseTrue : caseFalse
prod = "${var.environment == "PROD" ? "east" : ""}"
For more complex use cases, such as multiple-option conditions, I recommend using map and lookup options. For example, the requirement may be that if your region is us-east-1
, you need to scale the application to three instances, while if it is us-west-1
, you should scale to two instances.
/* In variables.tf */
variable "region_mapping" {
description = "mapping for scaling applications"
default = {
"us-east-1" = "3",
"us-west-1" = "2"
}
}
/* Define a lookup to get the instance count from the deployment region. */
resource "app" "app" {
region = "${lookup(var.region_mapping, var.region)}"
}
In Terraform >=0.12, you can now loop through the existing maps and list, but you cannot generate them.
Using a for expression in square brackets[] produces a list.
cidr_blocks = [
for num in var.subnets:
cidrsubnet(data.aws_vpc.config.cidr_block, 8, num)
]
Using a for expression in braces {} produces a map.
instance_ids = {
for instance in aws_instance.config:
instance.id => instance.private_ip
}
Terraform also supports for_each for creating dynamic nested blocks. This is better than using count arguments on resources.
Terraform Tools
Now that I’ve reviewed most of Terraform’s advanced features, I’ll discuss some of the third-party tools that enhance its capabilities even further.
Terraformer
Terraform script is very good at creating new IaC, but don’t forget about the existing infrastructure created manually over the last 3–4 decades. Terraform’s import feature pulls the existing infrastructure. However, this just generates the current state; you still have to manually create the Terraform files.
That’s where Terraformer, a CLI tool built in Go language, comes into play. Terraformer imports the already created infrastructure as HashiCorp TF files and includes the .tfstate
file. It also provides options for remote state sharing and exporting to specified bucket locations. It has a Terraform-like execution plan feature which allows you to see the blueprint of the configuration it’s going to pull before it executes on the existing infrastructure.
Terragrunt
Terragrunt was launched to solve some issues Terraform had in 2016. Its main purpose was to provide the locking feature for Terraform state and configure Terraform state as code. However, Terraform adopted these features in a later release, and Terragrunt began to focus on new challenges—namely, Terragrunt keeps backend configuration and CLI arguments DRY.
Backend Configuration DRY
When defining backend configuration in Terraform, you need to define key, bucket, and other parameters. However, Terraform doesn’t accept the variables or expressions in these parameters. So, if there are multiple modules, you’ll have to copy/paste in each module manually, which is prone to error.
You can use Terragrunt to keep your backend configuration DRY by defining it once in a root folder and inheriting that configuration in all modules. Let’s take a simple example.
Here is the folder layout:
stage
├── backend-app
│ └── main.tf
└── postgresql
└── main.tf
You can create a terragrunt.hcl file, defining the backend configuration once at root level, and then add one terragrunt.hcl file at each module to inherit it.
Root level file:
# stage/terragrunt.hcl
remote_state {
backend = "s3"
config = {
bucket = "example-terraform-state"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "ap-southeast-1"
encrypt = true
dynamodb_table = "example-lock-table"
}
}
Module level file:
# stage/postgresql/terragrunt.hcl
include {
path = find_in_parent_folders()
}
Final folder layout:
stage
├── terragrunt.hcl
├── backend-app
│ ├── main.tf
│ └── terragrunt.hcl
└── postgresql
├── main.tf
└── terragrunt.hcl
Terraform CLI Arguments DRY
When applying a Terraform configuration, you need to provide a common variable using the -var-file option.
$ terraform apply \
-var-file=../../base.tfvars \
-var-file=../region.tfvars
Remember these arguments every time you apply. Terragrunt provides the option to configure all these arguments in a terragrunt.hcl file, like this:
# terragrunt.hcl
terraform {
extra_arguments "common_vars" {
commands = ["plan", "apply"]
arguments = [
"-var-file=../../base.tfvars",
"-var-file=../region.tfvars"
]
}
}
Terragrunt offers many other features, such as immutable versioned Terraform modules and executing Terraform commands on multiple modules at once.
Testing Strategies
Terraform is a code, and has to be tested before you use it in production. Each team uses a different testing strategy, depending on DevOps maturity and knowledge of IaC.
Based on my experience as a DevOps engineer who has built and run Terraform modules in production for years, I recommend the following:
- Either use TFLint in your laptop or a CI/CD pipeline to validate both the structure and content of the Terraform configuration.
- Use GOSS, a YML-based open-source tool that can assert the test results (i.e., verifying if the SSH port 22 is closed or not).
- Do unit testing using RSpechttps://github.com/bsnape/rspec-terraform-based tools, such as Serverspec and Inpec. You can also use a TDD approach, but I personally feel this slows down the development work.
- For integration testing, use kitchen-framework, which DevOps engineers used with Chef in the past. Terratest, which can test anything that has API, is another option.
Summary
In this article, I discussed the various aspects of the Terraform configuration and how to use them as effectively as possible. Using this information, you’ll be able to write a productive Terraform configuration for any cloud platform.
In the next post in this series, I’ll explore some of Terraform’s use cases to show you how to simplify your DevOps environment.
========================================================
If you want to read all about Terraform 1.0, that has finally GAed - read it here.
Top comments (0)