Automating Azure Container Instances with PowerShell Azure Functions
Recently I blogged about how you can automate the creation of a Minecraft server on Azure Container Instances. I showed how we could use PowerShell to start and stop the container instance, allowing us to keep costs to a minimum.
What I want to do next is provide my children with an easy way to start and stop the Minecraft server, just by clicking a secret link, rather than me needing to do it for them with Azure PowerShell.
I also would like to automate shutdown of the server, so that if they forget to switch it off, it will switch off automatically a few hours after being started.
There are plenty of ways we could go about automating this. I recently blogged about automating Azure Container Instances with C# Azure Functions, and Logic Apps would also be a perfectly good solution.
But I decided that this would be the ideal opportunity for me to try out the new PowerShell support for Azure Functions v2. This comes with the PowerShell Az module ready installed, and automatically authenticates with Azure using a managed identity, so it makes it trivially easy to automate Azure resource management tasks.
In fact, Anthony Chu has already provided a great sample of using PowerShell Azure Functions to create Azure Container Instances, as well as showcasing how to integrate with queues. My needs were slightly different: I want to schedule container shutdown, and I'm starting existing containers, not creating them from scratch.
Step 1 - Creating an Azure Functions PowerShell App
The first thing we need to do is create an Azure Functions App to host our PowerShell. Here's a script showing how to use the Azure CLI to create a Function App with powershell
set as the worker type. It also creates a Storage Account and App Insights instance to use.
Finally, I add the resource group name and container name to the Function App as app settings. These can be accessed as environment variables in the Functions PowerShell script later.
$resourceGroup = "MinecraftTest"
$location = "westeurope"
az group create -n $resourceGroup `
-l $location
$functionAppName = "MinecraftFuncsTest"
$funcStorageAccountName = "minecraftfuncstest"
az storage account create `
-n $funcStorageAccountName `
-l $location `
-g $resourceGroup `
--sku Standard_LRS
$appInsightsName = "MinecraftInsights"
az resource create `
-g $resourceGroup -n $appInsightsName `
--resource-type "Microsoft.Insights/components" `
--properties '{\"Application_Type\":\"web\"}'
az functionapp create `
-n $functionAppName `
--storage-account $funcStorageAccountName `
--consumption-plan-location $location `
--app-insights $appInsightsName `
--runtime powershell `
-g $resourceGroup
az functionapp config appsettings set -n $functionAppName -g $resourceGroup `
--settings "ContainerName=$containerName" "ResourceGroup=$resourceGroup"
Step 2 - Configure a Managed Identity
The Function App needs permission to start and stop container groups and for that we'll need to create a managed identity. I've written before about how to set this up, so I'll just show the code here. I'm granting the managed identity Contributor
access for the specific ACI "container group" I want to start and stop, whose name is already stored in $containerGroup
. But I've also shown how to define a broader resource group scope in case you wanted your Function App to be able to create additional container groups in that resource group.
az functionapp identity assign `
-n $functionAppName -g $resourceGroup
$principalId = az functionapp identity show -n $functionAppName `
-g $resourceGroup --query principalId -o tsv
$subscriptionId = az account show --query "id" -o tsv
$resourceGroupScope = "/subscriptions/$subscriptionId/resourceGroups/$resourceGroup"
$containerScope = "/subscriptions/$subscriptionId/resourceGroups/$resourceGroup/providers/Microsoft.ContainerInstance/containerGroups/$containerName"
az role assignment create --role "Contributor" `
--assignee-object-id $principalId `
--scope $containerScope
Step 3 - Create a PowerShell Function App project
There are a few ways to create a new PowerShell project, but probably the easiest is to use the Azure Functions extension in Visual Studio Code. There are good instructions here on the official Microsoft docs, so I won't go into any more detail other than saying its just a matter of clicking "Create New Project" and selecting the PowerShell runtime.
Options for scheduling shutdown
There are a few different ways we could tackle the challenge of shutting down the container group. My first idea was to send a future scheduled message on an Azure Storage Queue whenever we start a container. Sadly, the Azure Functions bindings don't offer a way to send scheduled messages to Storage Queues, so it turns out to be a bit complex to implement.
Another obvious option would be to use Azure Durable Functions workflows. Every time we start the Minecraft server, it could start a workflow, and then sleep for a few hours before shutting it down. It could also easily allow an external event to shut it down early on demand. I'd certainly use this approach if I was using C#, but it's not possible with PowerShell at the moment.
The final and simplest option is to just have a timer, and when it fires it decides if the container should be shut down. I settled on an approach where I put a text file in blob storage with the scheduled shutdown time, and if the timer fires after that time, it stops the container. Once the container has been stopped, we clear out the shutdown time in blob storage to save us from doing unnecessary work trying to talk to an already stopped container group.
Step 4 - Common functions
I wanted to put my reusable PowerShell utility functions into a shared script, and after a couple of failed attempts, I settled on putting them in profile.ps1
which runs once whenever a new server starts hosting our Function App.
This is also where code for the automatic connection to Azure lives (assuming you've given your Function App a Managed Identity). Here are my Get-AccessToken
and Send-ContainerGroupCommand
functions I discussed in my previous post.
if ($env:MSI_SECRET -and (Get-Module -ListAvailable Az.Accounts)) {
Connect-AzAccount -Identity
}
function Get-AccessToken($tenantId) {
$azureRmProfile = [Microsoft.Azure.Commands.Common.Authentication.Abstractions.AzureRmProfileProvider]::Instance.Profile;
$profileClient = New-Object Microsoft.Azure.Commands.ResourceManager.Common.RMProfileClient($azureRmProfile);
$profileClient.AcquireAccessToken($tenantId).AccessToken;
}
function Send-ContainerGroupCommand($resourceGroupName, $containerGroupName, $command) {
$azContext = Get-AzContext
$subscriptionId = $azContext.Subscription.Id
$commandUri = "https://management.azure.com/subscriptions/$subscriptionId/resourceGroups/$resourceGroupName/providers/Microsoft.ContainerInstance/containerGroups/$containerGroupName/$command" + "?api-version=2018-10-01"
$accessToken = Get-AccessToken $azContext.Tenant.TenantId
$response = Invoke-RestMethod -Method Post -Uri $commandUri -Headers @{ Authorization="Bearer $accessToken" }
$response
}
Step 4 - The starter function
The container starter function is simply a HTTP triggered function with "function" level security - so you can only call it if you have the secret code.
The function simply calls our Send-ContainerGroupCommand
method, calculates the time to schedule an automatic shutdown (hard-coded to four hours in the future) and then returns an OK response.
I added an additional blob output binding to the function which is used to write that scheduled shutdown time into a text file in blob storage.
param($Request, $TriggerMetadata)
$containerName = $env:ContainerName
$resourceGroup = $env:ResourceGroup
Send-ContainerGroupCommand -resourceGroupName $resourceGroup -containerGroupName $containerName -command "start"
$schedule = [System.DateTimeOffset]::Now.AddHours(4).ToString("o")
$status = [HttpStatusCode]::OK
$body = "Started $containerName will shut-down after $schedule"
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
StatusCode = $status
Body = $body
})
Push-OutputBinding -Name ScheduleBlob -Value $schedule
To enable the blob output binding I had to add the following JSON to the function.json
{
"name": "ScheduleBlob",
"type": "blob",
"direction": "out",
"path": "scheduler/shutdown.txt",
"connection": "AzureWebJobsStorage"
}
Step 5 - The stop function
I also created a function to stop the container on demand. This does almost the same thing, and overwrites the scheduled shutdown file contents in blob storage to save our scheduled function from attempting to stop it again.
param($Request, $TriggerMetadata)
$containerName = $env:ContainerName
$resourceGroup = $env:ResourceGroup
Send-ContainerGroupCommand -resourceGroupName $resourceGroup -containerGroupName $containerName -command "stop"
$status = [HttpStatusCode]::OK
$body = "Stopped $containerName"
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
StatusCode = $status
Body = $body
})
Push-OutputBinding -Name ScheduleBlob -Value "STOPPED"
Step 6 - the scheduled function
Using the Azure Functions extension for Visual Studio Code, it's easy to add a timer triggered function. I added a blob input and output binding, so each time the timer fired, I could read the scheduled shutdown time, and update it if necessary. I set my timer trigger to run every 4 hours.
Here's the function.json
file:
{
"bindings": [
{
"name": "Timer",
"type": "timerTrigger",
"direction": "in",
"schedule": "0 0 */4 * * *"
},
{
"name": "InBlob",
"type": "blob",
"direction": "in",
"path": "scheduler/shutdown.txt",
"connection": "AzureWebJobsStorage",
"dataType": "string"
},
{
"name": "OutBlob",
"type": "blob",
"direction": "out",
"path": "scheduler/shutdown.txt",
"connection": "AzureWebJobsStorage",
"dataType": "string"
}
]
}
And here's the PowerShell, that parses the scheduled shutdown date, requests the container stops if its past its due date, and updates the blob contents to DONE
if we've shut down the container group.
param($Timer, $InBlob)
[System.DateTimeOffset]$scheduleDate = New-Object System.DateTimeOffset
$OutValue = $InBlob
# Check that directory name could be parsed to DateTime
if ([System.DateTimeOffset]::TryParse($InBlob, [ref]$scheduleDate)) {
if ([System.DateTimeOffset]::Now -gt $scheduleDate) {
$containerName = $env:ContainerName
$resourceGroup = $env:ResourceGroup
Write-Host "Requesting shutdown of $containerName in resource group $resourceGroup."
Send-ContainerGroupCommand -resourceGroupName $resourceGroup -containerGroupName $containerName -command "stop"
$OutValue = "DONE"
}
else {
Write-Host "Shutdown due in future [$InBlob]"
}
}
else {
Write-Host "No shutdown scheduled date [$InBlob]"
}
if ($OutValue) {
Push-OutputBinding -Name OutBlob -Value $OutValue
}
Taking it further
Overall, working with PowerShell in Azure Functions is relatively straightforward even for someone like me who's not a PowerShell expert. I think that the addition of PowerShell support to Azure Functions is a great step forward and will open the door to lots of interesting automation scenarios that are fiddly to implement in other languages.
This solution should work just fine as it is, but if I were to take it further, it should probably have better error handling, and it would be nice to create a dashboard web page, where you could see the current state of the container group, view its logs, and request a start or stop.
Finally, the use of a timer trigger for shutdown was a bit of a compromise. Durable Functions would be the right way to go on this and would be a good improvement when PowerShell support becomes available.
Comments
Hey Mark, great posts. I'm using them to learn more in this area. The first post I could follow pretty well but here there is more new stuff. The "Step 1 - Creating an Azure Functions PowerShell App"-script gives me errors.
OskarTo me this looks wrong:
az group create -n $resourceGroup" `
-l $location
There is no starting or closing "
Is something wrong here or am I running this in the wrong place?
good spot - the quotes should be deleted. I'll try to get the post updated
Mark HeathI tried to fix it myself before posting but I'm still learning and apparently didn't get the syntax right. Did your kids experience any performance issues with the server? Because it's a bit of a bumpy ride for us so far.
Oskarto be honest they haven't used it that much, but it seemed ok for a small number of users. They recently asked me to get it to update to a newer version of minecraft, so I need to revisit it soon to solve that for them
Mark Heath