Deploy Destroy Repeat: Automating Deployments with Terraform and Ansible
It is week 2 of my devops project challenges and the challenge of the week was to automate the deployment and configuration of the previous week’s challenge. It feels like the next step in the DevOps process, you have been able to package your applications, but you still need to setup infrastructure and many times repeatedly, doing this manually is not only tedious and time consuming but can lead to inconsistencies in the running environments for your applications, more importantly, it doesn’t scale.
In this week’s challenge we would be using the popular IaC tool Terraform in conjunction with the popular automation and configuration management tool Ansible to create a automated deployment process for our applictions. The goal is to be able to provision, configure and run all the necessary infrastructure required and then also pull them down when necessary, all with one command.
terraform apply -auto-approve
To achieve this, we are going to need to be familiar with a couple of tools which should be installed on our machine.
- Terraform: To automate infrastructure provisioning
- Ansible: To automate the configuration of our infrastructure and manage our application
- AWS CLI: Used for AWS get specific information on the state of our infrastructure
- Bash scripting: To write custom logic during automation
Application Overview
To provide a recap of week one challenge, we packaged our apps into docker containers, consisting of a frontend appliaction, backend application, a database and host of monitoring services, then put all these services behind a reverse proxy to route traffic to the appropriate target, so all our HTTP services are only exposed through the reverse proxy. This diagram shows the architecture of our app
The repo for this week’s challenge can be found here.
Terraform
Terraform is an open-source Infrastructure as Code (IaC) tool that allows you to define, provision, and manage infrastructure resources across various cloud providers and on-premises environments using a high-level configuration language called HCL. We would be using Terraform to create our EC2 instance and to trigger Ansible to begin configuring our provisioned instance.
But before that, we would need to build, tag and push the docker images for our frontend and backend application to a container registry so we can pull these easily. Fork the repo and clone to your local machine, copy over the files from the previous week’s challenge to our new repo run the following commands to build and push.
docker login # do this if you have not logged in
docker build -f Dockerfile.frontend .
docker build -f Dockerfile.backend .
docker images # note the image id for your frontend and backend images
docker tag <IMAGE_ID> <DOCKERHUB_USERNAME>/frontend:latest # replace <IMAGE_ID> and <DOCKERHUB_USERNAME>
docker tag <IMAGE_ID> <DOCKERHUB_USERNAME>/backend:latest # replace <IMAGE_ID> and <DOCKERHUB_USERNAME>
docker push <DOCKERHUB_USERNAME>/frontend:latest
docker push <DOCKERHUB_USERNAME>/backend:latest
Now that we have pushed our image to dockerhub, we have to modify our docker-compose.app.yml
to pull these images instead.
services:
frontend:
image: <DOCKERHUB_USERNAME>/frontend:latest
networks:
- app_network
backend:
image: <DOCKERHUB_USERNAME>/backend:latest
networks:
- app_network
# ...
This way, we only rely on the already built docker images, somewhat seperating our build and deploy stages. With these out of the way, we can start terraforming the chaos of infrastructure deployment.
We would proceed by writing HCL config files to define our infrastructure. Create a main.tf file and variables.tf file. Our main.tf file houses our infrastructue definitions and variables.tf contains our variables. We want to provision an EC2 instance with docker and docker-compose installed.
resource "aws_instance" "web_server" {
ami = var.ami
instance_type = "t2.micro"
key_name = var.key_name
vpc_security_group_ids = var.security_group_ids
associate_public_ip_address = var.eip == null
user_data = <<-EOF
#!/bin/bash
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker ubuntu
newgrp docker
curl -SL https://github.com/docker/compose/releases/download/v2.30.3/docker-compose-linux-x86_64 -o docker-compose
sudo chmod +x docker-compose
sudo mv docker-compose /usr/local/bin/docker-compose
EOF
tags = {
Name = "web_server"
}
}
resource "aws_eip_association" "eip_assoc" {
count = var.eip == null ? 0 : 1
instance_id = aws_instance.web_server.id
allow_reassociation = true
allocation_id = var.eip
}
in variables.tf
variable "security_group_ids" {
description = "The AWS security group to apply."
type = list(string)
}
variable "ami" {
description = "The AMI ID to use for the instance."
type = string
default = "ami-0866a3c8686eaeeba"
}
variable "key_name" {
description = "Key pair"
type = string
}
variable "eip" {
description = "Optional Elastic IP address to associate with instance if present"
type = string
default = null
}
variable "domain_name" {
description = "The domain name to use for the application."
type = string
}
We would use environment variables to set the values for these variables and to do that, values for these variables can be gotten from your AWS console e.g the AMI ID, key pair etc. Terraform requires the TF_VAR_
prefix for each defined variable, we also need to set our AWS IAM credentials to use the AWS plugin for terraform. You could put all these variables in a .env
file and source it in your current terminal session to make these variables reflect.
export AWS_ACCESS_KEY_ID="anaccesskey"
export AWS_SECRET_ACCESS_KEY="asecretkey"
export AWS_REGION="us-west-2"
export TF_VAR_ami="ami-eba"
export TF_VAR_security_group_ids='["sg-eba"]'
export TF_VAR_domain_name="mydomain.com"
export TF_VAR_eip="eipalloc-xxxxx" # optional to use elastic ip
The next step is to tell terraform to initialize our project by running the command:
terraform init
This command fetches all our plugins listed in the main.tf file. We can then run the plan command to understand how terraform is going to provision these resources
terraform plan
We will see a plan on what terraform will create and how it plans to do so. Now we can run our apply command to provision the resources.
terraform apply
It would display a prompt asking if you want to proceed, type yes and watch magic happen. In few minutes, you should have your EC2 instance running, and you can SSH into it to confirm it is accessible. We shall the proceed to configuring our server to run our apps with ansible.
Ansible
We would use ansible to pull our docker images to our host platform which already has docker installed, copy over the necessary config files for our monitoring services and reverse proxy, generate free SSL certificates to enable HTTPS and start our containers.
First, we need to generate an inventory file to tell ansible about our remote hosts, i.e the EC2 instance, we need the IP address of this instance and we can get that by modifying our terraform config file to include this step after provisioning the instance.
In the main.tf file, add the following:
resource "local_file" "ansible_inventory" {
content = <<-EOF
[web_servers]
${var.eip != null ? aws_eip_association.eip_assoc.public_ip : aws_instance.web_server.public_ip} ansible_user=ubuntu ansible_ssh_private_key_file=devops_challenge.pem
EOF
filename = "${path.module}/inventory.ini"
file_permission = 0644
}
This would create local file on our host machine named inventory.ini
which would contain the IP address of our newly created EC2 instance.
[web_servers]
34.116.202.250 ansible_user=ubuntu ansible_ssh_private_key_file=key_pair.pem
Make sure you have the key pair used to launch the EC2 instance saved as this would be needed when ansible wants to connect to your servers.
Run the terraform init
command again to fetch the local
provider for the local_file
resource and run the terraform plan && terraform apply
commands to see your new inventory file. This tells ansible what hosts are available to be configured.
Ansible uses playbooks which are just yaml
files to define plays which contains tasks to be run on each hosts, we would define a playbook to configure our instances but first we need to ensure our instances are up and available before ansible can begin, this is where the aws
CLI tool will be used to query for our instance state and wait till it is ready. Modify the local_file
resource like so:
resource "local_file" "ansible_inventory" {
content = <<-EOF
[web_servers]
${var.eip != null ? aws_eip_association.eip_assoc.public_ip : aws_instance.web_server.public_ip} ansible_user=ubuntu ansible_ssh_private_key_file=devops_challenge.pem
EOF
filename = "${path.module}/inventory.ini"
file_permission = 0644
provisioner "local-exec" {
command = <<-EOF
aws ec2 wait instance-status-ok --instance-ids ${aws_instance.web_server.id}
ansible-playbook -i inventory.ini -e "APP_SERVER_NAME=${var.domain_name}" ansible/deploy.yml
EOF
}
}
Create a new folder to house all our ansible related files and create a playbook called deploy.yml
. We would be using ansible roles to organize our ansible configuration and it requires a certain folder structure.
Create the following files
mkdir -p ansible/roles/web_servers/tasks
mkdir ansible/roles/web_servers/files
touch ansible/deploy.yml
touch ansible/roles/web_servers/tasks/main.yml
We just created a deploy
playbook and web_server
roles, roles can contain a tasks/
folder which defines the tasks the role would perform tasks in the tasks/main.yml
play. The files/
folder contains files needed for our roles to run, files like the docker-compose files, config files for prometheus, grafana etc and nginx configuration would need to be copied over to the remote host. We can mv those files over to the files/
directory.
We begin with the deploy
playbook which contains only one play:
- hosts: web_servers
become: yes
roles:
- web_servers
The play targets the web_servers
hosts that was defined in our inventory file and uses the web_servers
role to run tasks on this target. The roles/web_servers/tasks/main.yml
would then contain all our tasks for this play.
- name: Ensure parent directory exist
file:
path: "/home/ubuntu/app"
state: directory
mode: "0755"
- name: Copy files
copy:
src: "{{ item.src }}"
dest: "/home/ubuntu/app/{{ item.dest }}"
loop:
- src: "docker-compose.app.yml"
dest: "docker-compose.app.yml"
- src: "docker-compose.monitoring.yml"
dest: "docker-compose.monitoring.yml"
- src: "prometheus.yml"
dest: "prometheus.yml"
- src: "loki-config.yml"
dest: "loki-config.yml"
- src: "promtail-config.yml"
dest: "promtail-config.yml"
- src: "grafana.ini"
dest: "grafana.ini"
- src: "nginx"
dest: "."
- name: Create .env for docker-compose to load
copy:
dest: "/home/ubuntu/app/.env"
content: |
export COMPOSE_FILE=docker-compose.app.yml:docker-compose.monitoring.yml
export APP_SERVER_NAME="{{ APP_SERVER_NAME }}"
mode: 0644
- name: Request LetsEncrypt SSL Cert
script:
cmd: "init-certbot.sh"
become: yes
args:
chdir: "/home/ubuntu/app"
- name: Start Docker Containers
command: docker-compose up -d
become: yes
args:
chdir: "/home/ubuntu/app"
The tasks are pretty straight forward, since we already have docker installed on this host in our provisioning phase, we don’t need to install it with ansible. We create a folder to contain our files, copy over our files to this directory, pass the necessary environment variables, create SSL certificate for HTTPS and start our services. Easy peasy 😉.
I have written a script for the SSL certificate creation and also nginx configuration. At this point, we are close to done. But before we proceed we need to instruct ansible of our roles and where to find them. In the root folder, create an ansible.cfg
file with the following content
[defaults]
host_key_checking = False
roles_path = ./ansible/roles
This tells ansible about our roles and also for the sake of development, prevent strict host key checking which is a common issue when dealing with euphemeral hosts like cloud VMs.
We can proceed to run the command to provision and configure our resources
terraform apply -auto-approve
If this runs successfully, you should be able to access your app via the value of the TF_VAR_domain_name
variable, this setup ensures that with one command we create an EC2 instance, install docker, copy over necessary configuration files, configure nginx as a reverse proxy, request SSL certificates for HTTPS and start our containers.
You can destroy the resources with also one command which terminates the EC2 instance and stops all services from running.
terraform destroy -auto-approve
Conclusion
We have been able to see how terraform and ansible can work together to automate deployment processes, they can help speed up and streamline the process of deployments in the areas where they shine. Terraform is usually used to provision infrastructure and ansible to configure them. This gives us the ability to deploy infrastructure in an immutable fashion, multiple times a day in a reproducable and deterministic manner, we get to deploy, destroy and repeat!. The full solution to this challenge is contained in my repo