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.
How simple Terraform plans make hybrid and multi-cloud a reality: an introduction
Deploying a LAMP Stack with Terraform – AMIs, network & security
Deploying a LAMP Stack with Terraform – Databases & Webservers
How to create resilient Terraform code
Deploying and configuring HashiCorp Vault to service Terraform
Deploying a LAMP Stack with Terraform Modules
How to configure Azure for Terraform
Terraform prepare migrate to azure
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.
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.
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:
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.
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
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.