6 min read

Tutorial: CI/CD for Azure using Terraform, Ansible and VSTS

This is part 1 of a 2-part series demonstrating how to continuously build and deploy Azure infrastructure for the applications running on Azure. The first article will show how open source tools, such as Terraform and Ansible, can be leveraged to implement Infrastructure as Code. The second article in the series will enhance the infrastructure deployment to build immutable infrastructure for the applications and adding Packer into the set of tools.
In part 1, we will walk though how to continually build and deploy a Java Spring Boot application and its required infrastructure and middleware using Visual Studio Team Services. We will apply software development practices to infrastructure build and configuration. To demonstrate Infrastructure as a Code principle we will use Terraform to codify and provision infrastructure, and Ansible to automate configuration and middleware.
Here is a picture of the flow:

  • Code or configuration change is committed to Git
  • VSTS Build builds and packages Spring Boot application using Gradle
  • VSTS Release provisions Infrastructure using Terraform
  • VSTS Release configures JDK, Tomcat and application on the provisioned servers

Prerequisites:

  • Configure custom VSTS agent with required tools, as described in this blog post: How to create a custom VSTS agent on Azure ACI with Terraform.
  • Service Principal with Contributor access to the subscription. Refer to this documentation for instructions.
  • Storage account and container to save Terraform state in (update “backend.tfvars” with the names). Terraform must store state about your managed infrastructure and configuration. This state is used by Terraform to map real world resources to your configuration, keep track of metadata, and to improve performance for large infrastructures.
  • Ansible task extension installed from VSTS marketplace.
  • SSH Key for Ansible connections to VMs.
  • All code and pipelines for this article could be found on GitHub.

Spring Boot Application Build

In this example, we first build and package a Spring Boot application using Gradle. You can import the full build definition from GitHub repository or create a Java Gradle project from scratch by following steps provided in documentation “Build your Java app with Gradle.”
Here is outline of the steps and commands customizations:

  1. Clone GitHub repo from this example or import to VSTS
  2. Create a build definition (Build & Release tab > Builds)
  3. Search and use “Gradle” definition.
    In the repository tab of build definition make sure the repository selected is the one where you pushed (Git).
    It will create the template of the steps required.
  4. In ”Copy Files” – customize the step to copy built war file and “iac” directory with configuration

 

On the Triggers tab, enable continuous integration (CI). This tells the system to queue a build whenever new code is committed. Save and Queue the build.

Infrastructure Provisioning

Release is built on the VSTS Agent that has the required Terraform and Ansible installed.

Terraform versions, plans and build infrastructure. Ansible automation provides agentless way of managing servers. All it requires is SSH connection and python installed. We will configure VSTS agent with the ssh key that is provided by Terraform during provisioning VMs on Azure and by Ansible to communicate to provisioned servers.
For Ansible to be able to communicate to VMs it has to know server IPs, provided to it in the form of inventory file. Once Terraform completes provisioning, we will output servers IPs into a file which is used by Ansible.
Here is the Release pipeline definition (it could be imported from GitHub as well):

  • Start by defining Empty Release Definition, and link the build prepared above as an artifact.
  • Use custom VSTS Agent from “ACI-Pool”
  • Define Variable Group with environment variables (see documentation) that provide connectivity to subscription:
    • ARM_SUBSCRIPTION_ID, ARM_TENANT_ID : Subscription (run ` az account list` in Azure CLI to find out tenantId and subscriptionId )
    • ARM_CLIENT_ID, ARM_CLIENT_SECRET: Service Principal is and password obtained when creating SP
    • ARM_ACCESS_KEY: Storage account access key
    • SSH_PUB_KEY: Public SSH Key (keypair generated `ssh-keygen -t rsa -b 4096 -C your_email@example.com` )

  1. Add the following steps in the Tasks pipeline:

a. Install SSH Key – Installs SSH key on the agent. Add public (SSH_PUB_KEY env variable) and private part of the previously generated keypair. And set Known hosts entry to “default.”

b. Shell Script – Terraform Init – point to Terraform init.sh script and pass environment variables $(ARM_CLIENT_ID) $(ARM_CLIENT_SECRET) $(ARM_SUBSCRIPTION_ID) $(ARM_TENANT_ID) $(ARM_ACCESS_KEY)


Terraform must initialize Azure Resource provider and configured backend for keeping the state (Azure storage in this example) before the use. Here is the snippet doing it from our Terraform template:

terraform {
 required_version = ">= 0.11"
backend "azurerm" {}
 }
# Configure the Microsoft Azure Provider
 provider "azurerm" {}

Terraform initialization can be done by simply running “terraform init” command.
To avoid hard coding backend storage in terraform template, we are using partial configuration and providing the required backend configuration in variables file – “backend.tfvars”. Here is a configuration example that uses Storage account we created as part of prerequisites:

storage_account_name = "<unique storage accountname>"
container_name = "terraform-state"
key = "demo-java.terraform.tfstate"

To initialize Terraform shell script will run init command with provided backend configuration:

#!/bin/bash
terraform init -backend-config=backend.tfvars

Upon successful run it will have following output indication terraform has been initialized.

c. Shell Script – Terraform apply
Terraform apply will apply the changes required to reach the desired state of the configuration as defined by “main.tf” Add  $(SSH_PUB_KEY) for Terraform to provision VM’s with it.


Terraform generates an execution plan describing what it will do to reach the desired state, and then executes it to build the described infrastructure. As the configuration changes, Terraform is able to determine what changed and create incremental execution plans that can be applied.
In the example below, Terraform detected that some changes are required in the infrastructure:

The shell file executes terraform build and generates inventory file with the details of the provisioned VMs. Ansible will use it to configure the application.

terraform apply -auto-approve
export vmss_ip=$(terraform output vm_ip)
 echo "host1 ansible_ssh_port=50000 ansible_ssh_host=$vmss_ip" > inventory
 echo "host2 ansible_port=50001 ansible_ssh_host=$vmss_ip" >> inventory

The full Terraform template can be found on GitHub here.
It provisions resource group, virtual network, subnet, public IP, load balancer and NAT rules and VM availability set.
For example, here is the VM resource template:

resource "azurerm_virtual_machine" "vm" {
 name = "vm${count.index}"
 location = "${azurerm_resource_group.rg.location}"
 resource_group_name = "${azurerm_resource_group.rg.name}"
 availability_set_id = "${azurerm_availability_set.avset.id}"
 network_interface_ids = ["${element(azurerm_network_interface.nic.*.id, count.index)}"]
 count = 2
 vm_size = "Standard_D1"
storage_os_disk {
 name = "osdisk${count.index}"
 create_option = "FromImage"
 }
storage_image_reference {
 publisher = "RedHat"
 offer = "RHEL"
 sku = "7.3"
 version = "latest"
 }
os_profile {
 computer_name = "myvm"
 admin_username = "azureuser"
 admin_password = "xxxx"
 }
os_profile_linux_config {
 disable_password_authentication = true
ssh_keys {
 path = "/home/azureuser/.ssh/authorized_keys"
 key_data = "xxxx"
 }
 }
tags {
 environment = "Terraform Demo"
 }
 }

Template defines the following output variables that are used by pipeline files:

output "vm_ip" {
 value = "${azurerm_public_ip.demo_public_ip.fqdn}"
 }
output "vm_dns" {
 value = "http://${azurerm_public_ip.demo_public_ip.fqdn}"
 }

d. Ansible playbook run – Ansible is a tool that greatly simplifies configuration management tasks. Playbook is the desired state configuration expressed in YAML.

 
In this example the playbook “site.yml” uses role “tomcat” to install required JDK, Tomcat 7.0, configure Tomcat and deploy the SpringMusic application . Here are tasks that undeploy previous version of the application and install the new one:

- name: unDeploy sample app
  file: path=/usr/share/tomcat/webapps/spring-music.war owner=tomcat group=tomcat state=absent
- name: wait for tomcat to undeploy the app
  wait_for: path=/usr/share/tomcat/webapps/spring-music/ state=absent
- name: Deploy sample app
  copy: src=../../build/libs/spring-music.war dest=/usr/share/tomcat/webapps/spring-music.war owner=tomcat group=tomcat
  notify: restart tomcat

Ansible, like Terraform, operates to reach desired state on the configuration. Below is an example output of the pipeline when Tomcat was already installed on the provisioned servers and only the application was changed:

The result is following resources created, up and running.

And you can see the application at http:///spring-music/

Conclusion

In this example we demonstrated a simple flow that provides application deployment and infrastructure automation. While implementing Infrastructure as Code minimizes configuration drift, it is still possible that some server might deviate due to manual changes and patches. Another consideration is that scale-out process configuration should be done on the new machines and it might take time for complex playbooks. This and some other considerations will be addressed in Part 2 of the tutorial, focused on building  immutable infrastructure.
Check out Part 2, “Immutable infrastructure for Azure, using VSTS, Terraform, Packer and Ansible,” here.
Note: There is a “Terraform” task available on VSTS marketplace, it has great capabilities of running templates and using storage account as a backend, but it’s currently Windows only, while Ansible task is Linux based task – for that reason this example was written to use shell scripts.