Launching Training Machines In The Cloud

RSS Feed

Before we provide TeamCity or Octopus Deploy training to our customers, we have to launch a number of identical virtual machines for the candidates. All of the required software must be installed (TeamCity, Octopus Deploy, Sql Server etc) on the machines, and they must be identical to each other. A customer asked how they could do this in Azure, so we put together some PowerShell, an ARM template and this post to help automate their process.

 

Prepare The Image

One of the first things we need to do is prepare a virtual machine that will become our "image", that we will replicate a number of times. We do this by launching a standard instance inside Azure and customising it manually (it could be automated) - this means installing the software required. For our training purposes, we need to ensure the "image" has all of the continuous integration pipline tools we'd normally install and configure for our customers when providing them with our devops automation engineering services.

 

Capture The Image

Once we are happy that we've customised the machine with the devops toolset, we need to create generalised VM image of it so we can use this to automate the creation new instances quickly. To do this, we need to run Sysprep from the command line as shown.

running sysprep on the vm

Sysprep will now take a few minutes to process and will shutdown the VM upon completion. At this point the VM can't be started again, but if we need to make adjustments to the image we can launch an instance from the image and follow the same process again. We are now able to automate capturing the image using some PowerShell and store it away.

 

# Where is the VM we are taking an image of?
$VmTemplateName = '<vm-template-name>'
$ResourceGroupName = '<vm-resource-group>'

# Where are we saving the image to?
$ImageContainerName = '<image-container-name>'
$VHDNamePrefix = '<vhd-name-prefix>'

Stop-AzureRmVM -ResourceGroupName $ResourceGroupName -Name $VmTemplateName -Force
Set-AzureRmVM -ResourceGroupName $ResourceGroupName -Name $VmTemplateName -Generalized
Save-AzureRmVMImage -ResourceGroupName $ResourceGroupName -Name $VmTemplateName -DestinationContainerName $ImageContainerName -VHDNamePrefix $VHDNamePrefix

 

This should now save away an image for us into a storage account, in a directory structure similar to the following, making use of the values we set for $ImageContainerName and $VHDNamePrefix

output from azure powershell showing image storage path

 

Separate Training Resource Group

In order to manage our multiple instances, we're going to provision everything in a new resource group. This also makes it easier to tear everything down as we can just delete the resource group when we're done with it. However, before we can provision we need to copy the image into the resource group too as the image the VMs are built from has to be in the same resource group in Azure. To do this we just need to automate some additional PowerShell that will copy the image across to a new resource group name ($ExternalTrainingResourceGroupName)

 

# Where is the image we are copying?
$TrainingImageUri = 'https://training.blob.core.windows.net/system/Microsoft.Compute/Images/<image-container-name>/<vhd-name-prefix>-osDisk.d74a5905-46b2-4440-924a-04517a3a3445.vhd'

# Where are we copying it to?
$ExternalTrainingResourceGroupName = '<resource-group>'
$ExternalTrainingStorageAccountName = '<storage-account>'
$Location = 'North Europe'
$StorageContainerName = '<storage-container>'
$VHDName = '<vhd-name>'

#Create Below only if its first time
New-AzureRmResourceGroup -Name $ExternalTrainingResourceGroupName -Location $Location -Force
$externalStorageExists = Find-AzureRmResource -ResourceNameContains $ExternalTrainingStorageAccountName 

if ($externalStorageExists -eq $null)
{ 
    New-AzureRmStorageAccount -ResourceGroupName $ExternalTrainingResourceGroupName -Name $ExternalTrainingStorageAccountName -Location $Location -Verbose -Type Standard_LRS
    $storageContext = New-AzureStorageContext -StorageAccountName $ExternalTrainingStorageAccountName -StorageAccountKey (Get-AzureRmStorageAccountKey -ResourceGroupName $ExternalTrainingResourceGroupName -Name $ExternalTrainingStorageAccountName).Key1 
    New-AzureStorageContainer -Name $StorageContainerName  -Context $storageContext
}
else
{ 
    $storageContent = New-AzureStorageContext -StorageAccountName $ExternalTrainingStorageAccountName -StorageAccountKey (Get-AzureRmStorageAccountKey -ResourceGroupName $ExternalTrainingResourceGroupName -Name $ExternalTrainingStorageAccountName).Key1
}
Set-AzureStorageContainerAcl -Name $StorageContainerName -Permission Blob -Context $storageContext
$storageAccount = Get-AzureRmStorageAccount -ResourceGroupName $ExternalTrainingResourceGroupName -Name $ExternalTrainingStorageAccountName
$storageAccountUri = -Join($storageAccount.PrimaryEndpoints.Blob, $StorageContainerName, '/', $VHDName, '.vhd')
$storageAccountUri

Start-AzureStorageBlobCopy -AbsoluteUri $TrainingImageUri -DestContainer $StorageContainerName -DestBlob ($VHDName + '.vhd') -DestContext $storageContext
Get-AzureStorageBlobCopyState -Context $storageContext -Blob ($VHDName + '.vhd') -Container $StorageContainerName -WaitForComplete

 

 

Deploying The Training Machines

At this point, we have everything set to create a number of VMs based off of our custom image, inside the newly provisioned resource group. The client preferred using an ARM template to provision the machines so they can load this up inside the Azure portal, fill in a few parameters and deploy.

 

{
  "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "numberOfMachines": {
      "type": "int",
      "defaultValue": 2,
      "metadata": {
        "description": "The number of VMs to launch"
      }
    },
    "osDiskVhdUri": {
      "type": "string",
      "defaultValue": "https://<resource-group>.blob.core.windows.net/<storage-account>/<vhd-name>.vhd",
      "metadata": {
        "description": "Uri of the existing VHD"
      }
    },
    "osType": {
      "type": "string",
      "defaultValue": "Windows",
      "metadata": {
        "description": "Type of OS on the existing vhd"
      }
    },
    "vmSize": {
      "type": "string",
      "defaultValue": "Standard_D1_v2",
      "metadata": {
        "description": "Size of the VM"
      }
    },
    "vmName": {
      "type": "string",
      "defaultValue": "TRAINING",
      "metadata": {
        "description": "Name Prefix of the new VMs"
      }
    }
  },
  "variables": {
    "api-version": "2015-06-15",
    "addressPrefix": "10.0.0.0/16",
    "subnetName": "TrainingSubnet",
    "subnetPrefix": "10.0.0.0/24",
    "publicIPAddressName": "TrainingIP",
    "publicIPAddressType": "Dynamic",
    "virtualNetworkName": "TrainingNetwork",
    "vnetID": "[resourceId('Microsoft.Network/virtualNetworks',variables('virtualNetworkName'))]",
    "subnetRef": "[concat(variables('vnetID'),'/subnets/',variables('subnetName'))]",
    "nifName": "TraininNif"
  },
  "resources": [
    {
      "apiVersion": "[variables('api-version')]",
      "type": "Microsoft.Network/virtualNetworks",
      "name": "[variables('virtualNetworkName')]",
      "location": "[resourceGroup().location]",
      "properties": {
        "addressSpace": {
          "addressPrefixes": [
            "[variables('addressPrefix')]"
          ]
        },
        "subnets": [
          {
            "name": "[variables('subnetName')]",
            "properties": {
              "addressPrefix": "[variables('subnetPrefix')]"
            }
          }
        ]
      }
    },
    {
      "apiVersion": "[variables('api-version')]",
      "type": "Microsoft.Network/publicIPAddresses",
      "name": "[concat(variables('publicIPAddressName'), '-', copyIndex(1))]",
      "location": "[resourceGroup().location]",
      "copy": {
        "name": "publicIpLoop",
        "count": "[parameters('numberOfMachines')]"
      },
      "properties": {
        "publicIPAllocationMethod": "[variables('publicIPAddressType')]"
      }
    },
    {
      "apiVersion": "[variables('api-version')]",
      "type": "Microsoft.Network/networkInterfaces",
      "name": "[concat(variables('nifName'), '-', copyIndex(1))]",
      "location": "[resourceGroup().location]",
      "copy": {
        "name": "nifLoop",
        "count": "[parameters('numberOfMachines')]"
      },
      "dependsOn": [
        "publicIpLoop",
        "[concat('Microsoft.Network/virtualNetworks/', variables('virtualNetworkName'))]"
      ],
      "properties": {
        "ipConfigurations": [
          {
            "name": "[concat('vm-ipconfig-', copyIndex(1))]",
            "properties": {
              "privateIPAllocationMethod": "Dynamic",
              "publicIPAddress": {
                "id": "[concat(resourceId('Microsoft.Network/publicIPAddresses',variables('publicIPAddressName')), '-', copyIndex(1))]"
              },
              "subnet": {
                "id": "[variables('subnetRef')]"
              }
            }
          }
        ]
      }
    },
    {
      "apiVersion": "[variables('api-version')]",
      "type": "Microsoft.Compute/virtualMachines",
      "name": "[concat(parameters('vmName'), '-', copyIndex(1))]",
      "location": "[resourceGroup().location]",
      "copy": {
        "name": "vmLoop",
        "count": "[parameters('numberOfMachines')]"
      },
      "dependsOn": [
	"nifLoop"
      ],
      "properties": {
        "hardwareProfile": {
          "vmSize": "[parameters('vmSize')]"
        },
        "osProfile": {
            "computerName": "[concat(parameters('vmName'), '-', copyIndex(1))]",
            "adminUsername": "Evolve",
            "adminPassword": "EvolveSoftw@re"
        },
        "storageProfile": {
          "osDisk": {
            "name": "[concat(parameters('vmName'),'-osDisk-', copyIndex(1))]",
            "osType": "[parameters('osType')]",
            "caching": "ReadWrite",
            "createOption": "FromImage",
            "image": {
              "uri": "[parameters('osDiskVhdUri')]"
            },
            "vhd": {
              "uri": "[concat(reference(concat('Microsoft.Storage/storageAccounts/<storage-account>'), variables('api-version')).primaryEndpoints.blob, 'vhds/',concat(parameters('vmName'), '-', copyIndex(1)), uniquestring(resourceGroup().id), 'osDisk.vhd')]"
            }
          }
        },
        "networkProfile": {
          "networkInterfaces": [
            {
              "id": "[concat(resourceId('Microsoft.Network/networkInterfaces', variables('nifName')), '-', copyIndex(1))]"
            }
          ]
        }
      }
    }
  ]
}

The ARM template will automate the provisioning of a Virtual Network, Network interfaces, Public IP addresses, and VMs ready for the continuous integration training workshop.