Run Scripts in ARM template deployments with Azure Container Instances

If you are working with Azure then ARM templates are a great way to define your resource and deploy them declaratively. However, if you find you need to do anything other than creating Azure resources as part of your deployment, then you are a bit stuck, as ARM templates don't offer any way to call external resources or run scripts.

An example that we will use for this rest of this article is something I needed to do recently. I used an ARM template to deploy a storage account and containers, but I then needed copy a file from a different storage account into this new one I had created. There is no easy way to do this directly in the ARM template, so we will take a look at how we can use Azure Container Instances to overcome this limitation.

For the rest of this article, I am assuming you are familiar with containers and how to create and run them. If this is not the case, I would recommend looking at some of the quick start guides for Azure Container Instances.

Usage

To be very clear from the start, what we are going to talk about is a bit of a hack. If you find you need to run lot's of scripts, orchestrate some workflow of which the ARM template is only a part, or need to undertake complex operations, then this isn't the best route to go down. If your in this scenario I would recommend looking at either using some release management/orchestration tool like Azure DevOps, Jenkins etc. that can orchestrate the whole process or tools like Terraform that allow you to make calls to external scripts directly.

If, however, you are looking to run a quick script that executes at the same time as your template then this may help you do what you need.

Concept

This concept revolves around Azure Container Instances (ACI). If you're not familiar with this, ACI is a way to deploy a container in Azure as a first class object, your not deploying any VM to run it on or any Kubernetes cluster for it to sit in, you are just deploying a container which runs in Azure. We can define an ACI instance in our ARM template, and we can choose what image it is going to run and what parameters we feed into it. Therefore, we can have the ARM template launch a container which runs whatever command we want to provide it with. In our example of needing to copy a file to a storage account, our container executes some AZCopy commands, providing it with the details of the storage account we want to transfer from and to.

Container Execution Time

One thing to be aware of is how launching a container from your ARM template works regarding execution time and dependencies. Firstly, you can define dependencies in your ACI resource, and these will be applied, so in our example, we have a dependency on the storage account, and the ACI container cannot be deployed until that is created, nothing new there.

It gets a bit more complicated when we want other resources to have a dependency on ACI. It is okay to declare these dependencies; however, the ARM fabric declares that the ACI container has completed deployment once it is in a running state. This does not necessarily mean that this is when your script or process has finished doing whatever it was doing, just that the container is now in a running state and has started executing your task. Because of this, it is possible your next ARM resource will start deploying before your task has finished.

If you have a small quick running task, then this may be no issue at all. If you are doing something complex, you need to bear this in mind. You could either ensure that dependant resources won't be impacted by the task not having finished or amend your container start-up task to ensure the container is not running until that is completed.

2018-11-25_13-31-14

Container Image Choice

We need to pick a container image that we are going to use to execute our task. The choice of image is going to be very dependant on what it is you are trying to do in your task, and you will need to use an image that has the required resources included. There are two things I would bear in mind when picking an image to use:

1. Generic or Specialised

When choosing what image to use you can go down two routes, either using a generic image that has the tools you need installed, and you pass the command you want to run as parameters or using an image that includes all the scripts and some (or all) of the data you want to use. The choice is going to depend on how complicated your process is.

If you take our example of wanting to copy a file between storage accounts, this is a straightforward process and involves running a single command (AZCopy) with some parameters. For this, we are going to use a simple container that has AZCopy installed, and that is it. We will pass the whole AZCopy command we want to run as the runtime Command for the container inside our template, the container remains generic.

This approach works great for this simple use case; however, if we needed to run a much more complex custom script, it would become much more difficult to pass this as parameters in the template. If we needed to pass files or non-string data to the container, it would become nearly impossible. In this case, we would instead look at creating a custom container image that already included the scripts, files and data that we need to use, and our ARM template would merely run this container and maybe pass in a few simple parameters if we needed.

The first approach is more straightforward and will often let you use existing container images to get going quicker. The later will be more work but is going to be necessary if you are undertaking complex tasks.

2. Container Registry

If there is already a container image out on DockerHub or other registries that contains the tools you need it is very tempting to use that. I would strongly advise against this. If you are using these images to deploy resources into your environments, you need to be sure that the images are doing what you expect them to do and no one has changed them to do anything nasty or insecure. I would instead recommend using an image stored in a repository that you own and control (this can be your DockerHub repo or a private repo) so that you know what is in that image and that it remains unchanged.

In our example, there is already a Docker image for AZcopy - https://hub.docker.com/r/hawaku/azcopy/. I'm not going to pull this directly, but fortunately, they have placed their Docker file on GitHub, so I can download this and review the content to make sure I know what it is doing. Once I am happy with this, I can build it myself and push it to my DockerHub repository for me to use.

2018-11-25_13-32-15

Yes, this approach is more work, and if the original author updates their image you are going to need to update your image and so on manually, but this is the price that needs to be paid to ensure things remain secure and under your control.

Implementation

Now we have determined the task we want to run and chosen a container image to use we can look at deploying our resources. As I mentioned in this example, we are looking at deploying a storage account and blob container and then copying a file from an existing storage account.

2018-11-25_13-33-21

Our ARM template is pretty straightforward, we deploy the storage account and blob container, then we deploy an ACI container using the AZCopy image we created. We are passing in parameters that define:

  • The source storage account to copy from and to
  • The storage key for the new destination storage account (the source account has a public container, so no key required)
  • The name of the file to transfer
  • Any other switches required

Be aware of the syntax required to pass commands to an ACI container from the template, which is an array of strings with each attribute and value being a new string. It's easy to trip on this and get a container that will not run.

You will also notice that we have set the restart policy of the container to "Never". This is a bit different to how you would normally run a container because what we are looking to do here is run a task once and stop. By default, ACI will use the "Always" option for this value, which means that once your container moves from the "running" state to "terminated" it will get restarted and run again. This re-running is not what we want, we want to run our task once only, and then stop, so we need to use the "Never" option. If you don't use this option then not only will you keep running your script over and over, but you will be paying for that container to be permanently running. This can get pretty expensive, so make sure you set this, and you will only be paying for a few seconds of run time.

Finally, we are setting the size of the container in terms of CPU and Memory. Make sure you set this to be appropriate for the task you are running.

{
    "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
    "contentVersion": "1.0.0.0",
    "parameters": {
        "StorageAccountName": {
            "type": "string",
            "metadata": {
                "description": "Name of the storage account to create"
            },
            "defaultValue": "stcapturestg"
        },
        "ContainerName": {
            "type": "string",
            "metadata": {
                "description": "Name of the container in the storage account to store files"
            },
            "defaultValue": "filesContainer"
        }
    },
    "variables": {
        "storageAccountName": "[concat(parameters('StorageAccountName'),take(uniqueString(resourceGroup().id),5))]",
        "storageAccountId": "[resourceId('Microsoft.Storage/storageAccounts', variables('StorageAccountName'))]",
        "sourceStorageURL": "https://scartifactstorage.blob.core.windows.net/smartthingscollector",
        "ZipName": "SmartThingsCollector.zip",
        "containerImageName": "samcogan/azcopy"
    },
    "resources": [
        {
            "type": "Microsoft.Storage/storageAccounts",
            "apiVersion": "2018-02-01",
            "name": "[variables('StorageAccountName')]",
            "location": "[resourceGroup().location]",
            "tags": {
                "displayName": "[variables('StorageAccountName')]"
            },
            "sku": {
                "name": "Standard_LRS"
            },
            "kind": "StorageV2",
            "properties": {},
            "resources": [
                {
                    "type": "blobServices/containers",
                    "apiVersion": "2018-03-01-preview",
                    "name": "[concat('default/', parameters('ContainerName'))]",
                    "properties": {
                        "publicAccess": "Blob"
                    },
                    "dependsOn": [
                        "[variables('StorageAccountName')]"
                    ]
                }
            ]
        },
        {
            "type": "Microsoft.ContainerInstance/containerGroups",
            "apiVersion": "2017-10-01-preview",
            "name": "[concat(variables('StorageAccountName'),'-aci')]",
            "location": "[resourceGroup().location]",
            "dependsOn": [
                "[resourceId('Microsoft.Storage/storageAccounts', variables('StorageAccountName'))]"
            ],
            "condition": "[parameters('UploadFunctionFiles')]",
            "properties": {
                "containers": [
                    {
                        "name": "[concat(variables('StorageAccountName'),'-aci')]",
                        "properties": {
                            "command": [
                                "azcopy",
                                "--source",
                                "[variables('sourceStorageURL')]",
                                "--destination",
                                "[concat('https://',variables('storageAccountName'),'.blob.core.windows.net/',parameters('FunctionContainerName'))]",
                                "--dest-key",
                                "[listKeys(variables('storageAccountid') ,'2015-05-01-preview').key1]",
                                "--include",
                                "[variables('ZipName')]",
                                "--quiet"
                            ],
                            "image": "[variables('containerImageName')]",
                            "resources": {
                                "requests": {
                                    "cpu": 1,
                                    "memoryInGB": 1
                                }
                            }
                        }
                    }
                ],
                "osType": "Linux",
                "restartPolicy": "Never"
            }
        }
    ],
    "outputs": {}
}

When we run this deployment we see the storage account created, then the container is created. It takes a little while to download the container image, then it quickly runs and then terminates. We can watch this happen in the container events list in the portal.

2018-11-25_13-09-22

Once the container is terminated, we can check our storage account and see our files have been uploaded.

2018-11-25_13-10-13

Summary

Using ACI is a pretty quick, cheap and straightforward way to run tasks during your deployment process. So long as you are familiar with containers and creating container images you can get this running in a very short space of time and give yourself some extra flexibility in your deployments. However it is essential to know where to draw the line between needing a quick script to run, and when it becomes too complicated and you need to look at using a release pipeline or orchestration tool. Don't get caught on the trap of making intricate ARM templates with super complicated tasks running in containers that are hard for anyone else to understand or to debug.

Image Attribution

Container flickr photo by Glyn Lowe Photoworks shared under a Creative Commons (BY) license