Create VM Images in Azure Automatically Using Packer

Today I wanted to try to create an image in Azure that can be used by virtual machines that I create in Azure. I decided that I was going to use Packer, a software that was created by HashiCorp. More information about Packer can be found at Link to Packer.

Prerequisites

  • A Azure account (If you don’t have a free account, this will cost some money)
  • An Azure DevOps account
  • A service connection from Azure DevOps to Azure. You need to know the secret to this.

Initial config

Start with creating a new repository in Azure DevOps. When it is created, clone it to your computer and open it up in your favourite editor. Then go to Azure DevOps go to the marketplace, and install the Packer extension. Now you should be set for starting this project.

Packer config file

First, create a folder in your repository called packer. You do not need to do this, but since I am planning on using for example Terraform to provision a virtual machine using this image later, I like to have it separated into two different folders. In the packer folder you created, create a file with the ending ‘pkr.hcl’. I have called mine for windows-image.pkr.hcl In this file you need three parts.

Packer

The first part is the packer part, where you define your plugins. For example if you are using Azure it will look something like this:

packer {
  required_plugins {
    azure = {
      source = "github.com/hashicorp/azure"
      version = "~>2.0.4"
    }
  }
}

Source

The second part will be the source part, where you define what type of image you want to create. It will look something like this:

source "azure-arm" "base" {
  os_type = "Windows"
  image_publisher = "MicrosoftWindowsServer"
  image_offer = "WindowsServer"
  image_sku = "2022-datacenter"

  managed_image_name = "${var.managed_image_name}-${local.timestamp}"
  managed_image_resource_group_name = "${var.managed_image_resource_group_name}"

  location  = "${var.location}"
  client_id = "${var.client_id}"
  client_secret = "${var.client_secret}"
  subscription_id = "${var.subscription_id}"

  communicator = "winrm"
  winrm_use_ssl = "true"
  winrm_insecure = "true"
  winrm_timeout = "3m"
  winrm_username = "packer"

  vm_size = "Standard_B1ms"
}

Provisioner

The last part is what you want to do with the image before it is created. This part is called provisioner. I would like to create an image that had putty, vscode, and winget installed. My provisioner part looked like this:

build {
  sources = ["source.azure-arm.base"]

  provisioner "powershell" {
    inline = [
      "Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))",
    ]
  }
  
    provisioner "powershell" {
    inline = [
      "choco upgrade winget.powershell -y",
      "choco upgrade putty -y",
      "choco upgrade vscode -y",
    ]
  }

  provisioner "powershell" {
    inline = [
        "# If Guest Agent services are installed, make sure that they have started.",
        "foreach ($service in Get-Service -Name RdAgent, WindowsAzureTelemetryService, WindowsAzureGuestAgent -ErrorAction SilentlyContinue) { while ((Get-Service $service.Name).Status -ne 'Running') { Start-Sleep -s 5 } }",

        "& $env:SystemRoot\\System32\\Sysprep\\Sysprep.exe /oobe /generalize /quiet /quit /mode:vm",
        "while($true) { $imageState = Get-ItemProperty HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Setup\\State | Select ImageState; if($imageState.ImageState -ne 'IMAGE_STATE_GENERALIZE_RESEAL_TO_OOBE') { Write-Output $imageState.ImageState; Start-Sleep -s 10  } else { break } }"
    ]
  }
}

Variables

Now add all the variables to the file. Mine variables look like this:

variable "client_id" {
  type =  string
  default = "$(client_id)"
  sensitive = true
}
variable "client_secret" {
  type =  string
  default = "$(client_secret)"
  sensitive = true
}
variable "subscription_id" {
  type =  string
  default = "$(subscription_id)"
  sensitive = true
}
variable "tenant_id" {
  type =  string
  default = "$(tenant_id)"
  sensitive = true
}
variable "object_id" {
  type =  string
  default = "$(object_id)"
  sensitive = true
}
variable "location" {
  type =  string
  default = "norwayeast"
  sensitive = true
}
variable "managed_image_name" {
  type =  string
  default = "win-server-2022"
  sensitive = true
}
variable "managed_image_resource_group_name" {
  type =  string
  default = "win-server-images"
  sensitive = true
}

Additionally, I have also a local variable in the config file that is creating the current date and time for when the image is created:

locals {
  timestamp = regex_replace(timestamp(), "[- TZ:]", "")
}

Azure DevOps pipeline

In your repository, go to the pipeline part in the menu on the left side of your screen and the Library part. Create a new Library with 5 variables/secrets.

secret variable name: Description:
client_id Client id of the service principal you are using
client_secret The client secret of the service principal you are using
subscription_id Your Azure subscription id
tenant_id Your Azure tenant id
object_id Client object of the service principal you are using

WARNING: Be aware that two different syntaxes are used for variables from Azure DevOps and variables used in the packer config file.

Now you can create a pipeline. This is the pipeline I am using:

trigger:
- main

pool:
  name: 'Homelab'

variables:
  - group: packer

jobs:
  - job: Packer
    displayName: Packer

    steps:
    - task: PackerTool@0
      inputs:
        version: '1.10.1'

    - checkout: self
      clean: true
      persistCredentials: true
      displayName: 'Checkout $(Build.Repository.Name)@$(Build.SourceBranch)'

    - task: Packer@1
      inputs:
        connectedServiceType: 'azure'
        azureSubscription: 'service-connection-to-sandbox-manual-sp'
        templatePath: '$(System.DefaultWorkingDirectory)/packer/windows-image.pkr.hcl'
        command: 'init'
      displayName: "packer init"

    - task: Packer@1
      inputs:
        connectedServiceType: 'azure'
        azureSubscription: 'service-connection-to-sandbox-manual-sp'
        templatePath: '$(System.DefaultWorkingDirectory)/packer/windows-image.pkr.hcl'
        command: 'validate'
      displayName: "packer validate"

    - task: Packer@1
      inputs:
        connectedServiceType: 'azure'
        azureSubscription: 'service-connection-to-sandbox-manual-sp'
        templatePath: '$(System.DefaultWorkingDirectory)/packer/windows-image.pkr.hcl'
        command: 'build'
      displayName: "packer build"

When you try to run the pipeline, you will be asked to give it permission to use the service connection and if you have a self-hosted runner/agent you need to give it access to the repository.

The result

When the pipeline is run you should see that Packer is creating some temporary resources in Azure: The start of the pipeline

Later in the pipeline, you will see that it is starting to provision with Powershell and it is successfully installing chocklatey for example: Provisioning in the pipeline

At the end of your pipeline, you will see that Packer is cleaning (deleting) up the temporary resources it created in Azure: Packer cleaning up

The whole process takes about 8 to 10 minutes, and the result can be seen in the resource group you named in the Packer config file: Azure result

Just to check if my image is working like I wanted, I created a virtual machine using the image. I can see that both putty and vscode are installed: Virtual machine with putty Virtual machine with vscode

Cost

When the image is created you only pay for the storage of the image. Do not forget to delete the image manually if you do not intend on using it, since storing it will cost some money. When I was playing around with Pakcer, I created about 10 different images and deleted them regularly during the time I was playing around, it cost me about 1 NOK, or about $0,1 (excluding the cost for the virtual machine I created just to see if it worked like I wanted.).

Conclution

I did struggle a lot with the provisioner part of the Packer config file. At first, I did try to install winget to install the other applications that I wanted to test with, but I ended up using chocolatey instead. The documentation for Packer could have been better. I struggled to find a full config file, and an Azure DevOps pipeline that was recently created, but it could be that I wasn’t looking in the correct spot in the documentation. Most likely there will be a part 2 of this, where I also include Terraform into this project.