Home Automated Operations Building images and VMs in Azure with Terraform

Building images and VMs in Azure with Terraform

-

In this post, we’ll look at building images and VMs in Azure with Terraform. In our last post, we looked at how we would design the layout of our folders to hold our modules, introduced the AzureRM provider which introduced us to our first difference between AWS and Azure and discussed the differences in authentication. We also explained the differences required in the provider code to provide direct authentication where we have all the credentials stored in a single file and authentication using Hashicorp Vault to provide one-time use credentials and finally we looked at the variables needed. To review this post and the others in the series click on the links below.

Terraform-cover

Today we will be starting to look at the code to provide the infrastructure, so let’s remind ourselves of what we will be delivering in this Azure deployed LAMP Stack. And remind ourselves that we are looking to improve on the resilience and stability by introducing a couple of new concepts.

Azure LAMP stack architecture
Final Architecture of the Azure deployed LAMP Stack

Remember that Terraform is a declarative language and as such it will “sort out” the order that things need to be deployed taking account of the necessary dependencies so do not get too hung up on the concept of flow. This is more about explaining what is going on rather than describing the nitty-gritty internal deployment methods of Azure. For example, would you physically deploy the network devices before deploying the servers? Probably yes, as you would not have connectivity to configure the device interactions, but there is nothing to stop you deploying your operating system and local applications without network access, as long as the network is there before you reach the communication configuration stage. So with that in mind lets start.

Deploy the Resource Group

The first thing that we need to deploy is the Resource group this is the most similar construct to the AWS VPC.

# Create a resource group if it doesn’t exist
resource "azurerm_resource_group" "main" {
  name     = "AmazicResourceGroup"
  location = "northeurope"
}

Let’s compare this stanza to the AWS equivalent

resource "aws_vpc" "my_vpc" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  tags = {
    Name = "AmazicDevVPC"
  }
}

The first thing you will notice is that there is no networking component in the Azure resource group code block, this is because it’s not a network boundary unlike the AWS VPC. It is effectively just a box to store your application that is made up of storage, network, and compute resources.

Interesting story – whilst playing about with getting my environment set up with Vault authentication I created a different resource group as part of my testing and forgot to do a terraform destroy after everything was working. When I moved on to testing this code I found that I could not just issue a terraform destroy, it would error out informing me that a variable called “address” missing, to be fair this is a misleading error as there is no variable called address defined anywhere in the code. This obviously got me scratching my head. This is when I was introduced to the options that can be given to terraform destroy. One in particular drew my eye the -target option. This option is used to zero down on a subset of your environment to destroy. For example, you want to redeploy your database you would issue a “terraform destroy -target=azurerm_mysql_database.mydatabase” and the command would remove your database and any dependencies. It can also be used to remove errant “resource Groups.” 

Deploy a Virtual Machine on a Managed Disk with your preferred Linux OS distribution.

This is where things changed drastically, I wasted way too much time trying to build configure and deploy a Linux virtual machine in Azure and then change it into an image. The Azurerm provider does not support this. Things that were simple with the AWS provider became convoluted. The creation of a virtual machine is actually simple on Azure, but the entity is not the same as an EC2 instance.

The code below will create a virtual machine in Azure.

# Create virtual network
resource "azurerm_virtual_network" "image" {
    name                = "${var.vmname}VNET"
    address_space       = [var.addressprefix]
    location            = var.regionname
    resource_group_name = azurerm_resource_group.main.name
}

# Create subnet
resource "azurerm_subnet" "image" {
    name                 = "${var.vmname}Subnet"
    resource_group_name  = azurerm_resource_group.main.name
    virtual_network_name = azurerm_virtual_network.image.name
    address_prefix       = var.subnetprefix
}

# Create public IPs
resource "azurerm_public_ip" "image" {
    name                         = "${var.vmname}PublicIP"
    location                     = var.regionname
    resource_group_name          = "${azurerm_resource_group.main.name}"
    allocation_method            = "Dynamic"
}

# Create Network Security Group and rule
resource "azurerm_network_security_group" "image" {
    name                = "${var.vmname}NSG"
    location            = var.regionname
    resource_group_name = azurerm_resource_group.main.name
    
    security_rule {
        name                       = "HTTP"
        priority                   = 900
        direction                  = "Inbound"
        access                     = "Allow"
        protocol                   = "Tcp"
        source_port_range          = "*"
        destination_port_range     = "80"
        source_address_prefix      = "*"
        destination_address_prefix = "*"
    }

    security_rule {
        name                       = "HTTPS"
        priority                   = 901
        direction                  = "Inbound"
        access                     = "Allow"
        protocol                   = "Tcp"
        source_port_range          = "*"
        destination_port_range     = "443"
        source_address_prefix      = "*"
        destination_address_prefix = "*"
    }
    security_rule {
        name                       = "SSH"
        priority                   = 902
        direction                  = "Inbound"
        access                     = "Allow"
        protocol                   = "Tcp"
        source_port_range          = "*"
        destination_port_range     = "22"
        source_address_prefix      = "*"
        destination_address_prefix = "*"
    }
}

# Create network interface
resource "azurerm_network_interface" "image" {
    name                      = "${var.vmname}NIC"
    location                  = var.regionname
    resource_group_name       = azurerm_resource_group.main.name
    network_security_group_id = azurerm_network_security_group.image.id

    ip_configuration {
        name                          = "ipconfig${var.vmname}"
        subnet_id                     = azurerm_subnet.image.id
        private_ip_address_allocation = "Dynamic"
        public_ip_address_id          = "${azurerm_public_ip.image.id}"
    }
}

# Create virtual machine
resource "azurerm_virtual_machine" "image" {
    name                  = var.vmname
    location              = var.regionname
    resource_group_name   = azurerm_resource_group.main.name
    network_interface_ids = [azurerm_network_interface.image.id]
    vm_size               = var.vmsize

    storage_os_disk {
        name              = "${var.vmname}OSDisk"
        caching           = "ReadWrite"
        create_option     = "FromImage"
        managed_disk_type = "Premium_LRS"
    }

    storage_image_reference {
        publisher = "Canonical"
        offer     = "UbuntuServer"
        sku       = var.ubuntuosversion
        version   = "latest"
    }
    os_profile {
        computer_name  = var.vmname
        admin_username = var.loginusername
    }
    os_profile_linux_config {
        disable_password_authentication = true
        ssh_keys {
            path     = "/home/${var.loginusername}/.ssh/authorized_keys"
            key_data = var.authenticationkey
        }
    }
}

# Create managed disk
resource "azurerm_managed_disk" "data" {
    name                 = "${var.vmname}DataDisk1"
    location             = azurerm_resource_group.main.location
    resource_group_name  = azurerm_resource_group.main.name
    storage_account_type = "Premium_LRS"
    create_option        = "Empty"
    disk_size_gb         = var.vmdatadisksize
}
resource "azurerm_virtual_machine_data_disk_attachment" "data" {
    managed_disk_id    = azurerm_managed_disk.data.id
    virtual_machine_id = azurerm_virtual_machine.image.id
    lun                = "10"
    caching            = "ReadWrite"
}

What! That is a script all itself. And that is just to create the virtual machine. This works but the pain comes afterward. There is no simple method of uploading files to your Azure instance, the provisioner option is problematical due there being no concept of self in azure. So no self.public_ip.

You would have thought that from looking at the resource “azurerm_public_ip you would be able to pull out azurerm_public_ip.image.ipaddress, but that is not available if you want a dynamic assignment, which when you are creating a machine that will become an image is exactly what you want.

# Create public IPs
resource "azurerm_public_ip" "image" {
    name                 = "${var.vmname}PublicIP"
    location             = var.regionname
    resource_group_name  = azurerm_resource_group.main.name
    allocation_method    = "Dynamic"
}

Without a valid IP address, we cannot SSH into the newly created machine to upload the necessary configuration files for our HTTP server. There is a workaround that allows the passing of the FDQN but again that overcomplicates what should be a simple task.

Alternatively, we could have created a storage blog, loaded our files there, and use that mount as the source of file upload, but again that seems contrary to the concept of easy automation.

Firstly we need to create the network for the machine to reside in. now there are quite a lot of similarities in this code block with the AWS equivalents wit the exception of the first block.

So taking the route of least resistance and to be fair using the best tool of the job. We create the image VM from a packer deploy.

Packer Logo

If you remember back to our introductory post on packer you will recall that we created a VM for vSphere, so using that JSON file as a starting point; we will need to modify it for azure

{
    "variables": {
    },
    "builders": [{
      "type": "azure-arm",
      "subscription_id": "00000000-0000-0000-0000-000000000000",
      "client_id": "00000000-0000-0000-0000-000000000000",
      "client_secret": "<your Client secret here>", 
      "tenant_id": "00000000-0000-0000-0000-000000000000",
      "managed_image_resource_group_name": "AmazicDevResourceGroup",
      "managed_image_name": "Amazic-Image",
      "os_type": "Linux",
      "image_publisher": "OpenLogic",
      "image_offer": "CentOS",
      "image_sku": "7.3",
      "azure_tags": {
          "dept": "",
          "task": ""
      },
      "location": "northeurope",
      "vm_size": "Standard_A2_v2"
    }],
    "provisioners": [
           {
                "type": "file",
                "source": "./files/",
                "destination": "/tmp/"
           },      
             {
        "execute_command": "chmod +x {{ .Path }}; {{ .Vars }} sudo -E sh '{{ .Path }}'",
        "inline": [              
                      "yum update -y",
                      "yum -y install cloud-init cloud-utils cloud-utils-growpart httpd mysql php php-mysql",
                      "mv /tmp/10-growpart.cfg /etc/cloud/cloud.cfg.d/10-growpart.cfg",
                      "chown root:root /etc/cloud/cloud.cfg.d/10-growpart.cfg",
                      "mkdir -p /var/www/html/",
                      "mkdir -p ~/.ssh/",
                      "mv /tmp/index.php /var/www/html/",
                      "mv /tmp/private.key ~/.ssh/",
                      "chmod 600 ~/.ssh/private.key",
                      "chown -R centos:apache /var/www",
                      "usermod -a -G apache centos",
                      "systemctl enable httpd",
                      "systemctl enable cloud-config.service",
                      "systemctl enable cloud-final.service",
                      "systemctl enable cloud-init-local.service",
                      "systemctl enable cloud-init.service",
                      "echo \"{{user `ssh_username`}}:$(openssl rand -base64 32)\" | chpasswd",
                      "shred -u /etc/ssh/*_key /etc/ssh/*_key.pub",
                      "dd if=/dev/zero of=/zeros bs=1M",
                      "rm -f /zeros"
                ],
        "inline_shebang": "/bin/sh -x",
        "type": "shell"
      }      
    ]
  }

So let’s break down the finalized JSON file.

The only major change between the vSphere deployed version and the Azure deployed version is the builder code block. In the original file, we used the vSphere-iso builder, but as we are deploying to Azure we need to use the azure-arm builder.

    "builders": [{
      "type": "azure-arm",
      "subscription_id": "00000000-0000-0000-0000-000000000000",
      "client_id": "00000000-0000-0000-0000-000000000000",
      "client_secret": "<your Client secret here>", 
      "tenant_id": "00000000-0000-0000-0000-000000000000",
      "managed_image_resource_group_name": "AmazicDevResourceGroup",
      "managed_image_name": "Amazic-Image",
      "os_type": "Linux",
      "image_publisher": "OpenLogic",
      "image_offer": "CentOS",
      "image_sku": "7.3",
      "azure_tags": {
          "dept": "",
          "task": ""
      },
      "location": "northeurope",
      "vm_size": "Standard_A2_v2"
    }],

This code block is, on the whole, self-explanatory. The only major gotcha is that there is a requirement for the Resource Group provide as the input tor managed_image_name to be created prior to the deployment.

I have to say that I missed this on my first build attempt, thankfully the resultant error was very obvious which meant that the fix was easily found.

You must select a pre-existing image from the azure marketplace as a starting point, we chose the Open Logic image which provides a base Centos 7.3 image. We can see this with this section of code

      "image_publisher": "OpenLogic",
      "image_offer": "CentOS",
      "image_sku": "7.3",

after providing your credentials, tenant and subscription id’s, deploying this into Azure is as simple as abc or pbj to be more precise (packer-build-json-file)

Packer build centos.json

A successful build will result in output similar to the following:

Successful packer deployment
Output of a successful Packer build project.

Now that we have successfully deployed our machine, let us verify that is exists in Azure by checking out its entry in the Azure portal.

Azure Artifact
Deployed Packer image file.

Generalizing the image

the final stage is to take the deployed image and generalize it for templating. Depending upon whether you using a Windows or a Linux/macOS will slightly vary your methodology, from the perspective of Windows your generalization will be performed either using a Bash Script calling the various Azure CLI commands or via a PowerShell script. with Linux, WSL, or MacOS a simple bash script will suffice.

Windows: Batch file

REM # Variables for preparing the Virtual Machine
SET YOURSUBSCRIPTIONID=000000000-0000-0000-0000-000000000000
SET RESOURCEGROUPNAME=<Your Resource Group Here>
SET REGIONNAME=<Your Region Here?
SET VMNAME=<Your VM Name Here>

REM # Connect to Azure
CALL az login

REM # Set the Azure subscription
CALL az account set --subscription %YOURSUBSCRIPTIONID%

ECHO Stopping and deallocating the virtual machine named %VMNAME%
CALL az vm deallocate ^
--resource-group %RESOURCEGROUPNAME% ^
--name %VMNAME%

ECHO Generalizing the virtual machine named %VMNAME%
CALL az vm generalize ^
--resource-group %RESOURCEGROUPNAME% ^
--name %VMNAME%

Windows: Powershell Script:

it is recommended that if you do not have the Azure Powershell module installed that this script is run with Administrative permissions, if you already have the Azure PS modules then remove the first two lines and the script can be run under normal permissions, remember to consider your PS execution permissions too.

# Install Azure PowerShell module (needs admin privilege)
Install-Module -Name Az -AllowClobber

# Variables to edit
$YOURSUBSCRIPTIONID='00000000-0000-0000-0000-000000000000'
$RESOURCEGROUPNAME='<your resource group here>'
$REGIONNAME='<your region here>'
$VMNAME='<your virtual Image Name Here>'

# Connect to Azure
Connect-AzAccount

# Set the Azure subscription
Set-AzContext `
-SubscriptionId $YOURSUBSCRIPTIONID

# Stop and deallocate the Azure Virtual Machine
Stop-AzVM `
-ResourceGroupName $RESOURCEGROUPNAME `
-Name $VMNAME `
-Force

# Generalize the Azure Virtual Machine
Set-AzVM `
-ResourceGroupName $RESOURCEGROUPNAME `
-Name $VMNAME `
-Generalized

Linux / macOS or WSL

export YOURSUBSCRIPTIONID=00000000-0000-0000-00000-000000000000
export RESOURCEGROUPNAME=<Your resource group here>
export REGIONNAME=<Your Region here>

# Variables for preparing the Virtual Machine
export VMNAME=<your VM name here>
#############################################################################################

#############################################################################################

# Connect to Azure
az login

# Set the Azure subscription
az account set \
--subscription $YOURSUBSCRIPTIONID

# Stop and deallocate
echo Stopping and deallocating the virtual machine named $VMNAME
az vm deallocate \
--resource-group $RESOURCEGROUPNAME \
--name $VMNAME

# Generalize
echo Generalizing the virtual machine named $VMNAME
az vm generalize \
--resource-group $RESOURCEGROUPNAME \
--name $VMNAME

 

Summary

Summary

After a little false start where my AWS bias led me a merry dance, we came up with a much more elegant and repeatable solution. By simply replacing the builder section of the Centos JSON file we can deploy identical machines across multiple cloud and local environments. In our next post, we will start with the build-out of the rest of the LAMP Stack, including the auto-scaling group where we will use the pre-built out custom image file as the foundation of the scale-out template.

NEWSLETTER

Sign up to receive our top stories directly in your inbox

Join our community to receive the latest news, free content and upcoming events to your inbox.

TOP STORIES