Recently I've had a lot of work that has involved powershell in a variety of tooling and the need to move powershell workloads into the cloud. Microsoft actually has a rather straightforward method for creating, editing and publishing Powershell functions. Today let's get started and work through making a powershell function that can read blobs in a storage account.
Setup
First we need to create a resource group to hold our project
builder@DESKTOP-72D2D9T:~$ az account set --subscription "Pay-As-You-Go"
builder@DESKTOP-72D2D9T:~$ az group create -n "psfntest1rg" --location centralus
{
"id": "/subscriptions/d955c0ba-13dc-44cf-a29a-8fed74cbb22d/resourceGroups/psfntest1rg",
"location": "centralus",
"managedBy": null,
"name": "psfntest1rg",
"properties": {
"provisioningState": "Succeeded"
},
"tags": null,
"type": "Microsoft.Resources/resourceGroups"
}
We will need the func command next. If you dont have .NET installed, do so now. We'll be using 3.1: https://dotnet.microsoft.com/download
$ wget https://packages.microsoft.com/config/ubuntu/21.04/packages-microsoft-prod.deb -O packages-microsoft-prod.deb
$ sudo dpkg -i packages-microsoft-prod.deb
$ rm packages-microsoft-prod.deb
$ sudo apt-get update && sudo apt-get install -y apt-transport-https && sudo apt-get update && sudo apt-get install -y dotnet-sdk-3.1
$ sudo apt-get install dotnet-runtime-3.1
And we also need the Azure Function Core Tools : https://docs.microsoft.com/en-us/azure/azure-functions/functions-run-local?tabs=linux%2Ccsharp%2Cbash
$ curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > microsoft.gpg
$ sudo mv microsoft.gpg /etc/apt/trusted.gpg.d/microsoft.gpg
$ sudo sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/microsoft-ubuntu-$(lsb_release -cs)-prod $(lsb_release -cs) main" > /etc/apt/sources.list.d/dotnetdev.list'
$ sudo apt-get update
$ sudo apt-get install azure-functions-core-tools-3
Create the function locally
Let's create a local powershell function next
builder@DESKTOP-QADGF36:~/Workspaces$ mkdir psfntest1
builder@DESKTOP-QADGF36:~/Workspaces$ cd psfntest1/
builder@DESKTOP-QADGF36:~/Workspaces/psfntest1$ func init mylocalfunction --powershell
Writing profile.ps1
Writing requirements.psd1
Writing .gitignore
Writing host.json
Writing local.settings.json
Writing /home/builder/Workspaces/psfntest1/mylocalfunction/.vscode/extensions.json
Now we create an HTTP trigger
builder@DESKTOP-QADGF36:~/Workspaces/psfntest1$ func new --name HttpExample --template "HTTP trigger" --authlevel "anonymous"
Select a number for worker runtime:
1. dotnet
2. dotnet (isolated process)
3. node
4. python
5. powershell
6. custom
Choose option: 5
powershell
Writing profile.ps1
Writing requirements.psd1
Writing .gitignore
Writing host.json
Writing local.settings.json
Writing /home/builder/Workspaces/psfntest1/.vscode/extensions.json
Select a number for language:
1. PowerShell
2. Powershell
Choose option: 1
Select a number for template:HTTP trigger
Function name: [HttpTrigger] Writing /home/builder/Workspaces/psfntest1/HttpExample/run.ps1
Writing /home/builder/Workspaces/psfntest1/HttpExample/function.json
The function "HttpExample" was created successfully from the "HTTP trigger" template.
Validation
First test.. we can just see that the OOTB http trigger works with "func start"
and hitting the URL (http://localhost:7071/api/HttpExample) then shows it trigger:
Interacting with Azure Resources
Next we may want to do something real with Azure, perhaps list a blob store or update a file.
To do this we need to enable the AZ modules by uncommenting the line in requirements.psd1 (line 7 below):
Next, let us create a storage account and upload a file so we have something to show:
$ az storage account create --name testingpsfuncsa -g psfntest1rg --sku Standard_LRS --location centralus
$ az storage container create -n mytestcontainer --account-name testingpsfuncsa
{
"created": true
}
$ az storage blob upload -c mytestcontainer --file test.txt --name test.txt --account-name testingpsfuncsa
Finished[#############################################################] 100.0000%
{
"etag": "\"0x8D95830B0D17DF1\"",
"lastModified": "2021-08-05T16:47:19+00:00"
}
Creating Hosted Instance
We need to create the function app instance (we'll need to host it somewhere)
$ az functionapp create -n psfntest1 -g psfntest1rg --runtime powershell --storage-account testingpsfuncsa --consumption-plan-location centralus
No functions version specified so defaulting to 2. In the future, specifying a version will be required. To create a 2.x function you would pass in the flag `--functions-version 2`
PowerShell Core version 6.2 has been deprecated. In the future, this version will be unavailable. Please update your command to use a more recent version. For a list of supported --runtime-versions, run "az functionapp create -h"
Application Insights "psfntest1" was created for this Function App. You can visit https://portal.azure.com/#resource/subscriptions/d955c0ba-13dc-44cf-a29a-8fed74cbb22d/resourceGroups/psfntest1rg/providers/microsoft.insights/components/psfntest1/overview to view your Application Insights component
{
"availabilityState": "Normal",
"clientAffinityEnabled": false,
"clientCertEnabled": false,
…
then download any settings that were created
$ func azure functionapp fetch-app-settings psfntest1
App Settings:
Loading FUNCTIONS_WORKER_RUNTIME = *****
Loading FUNCTIONS_EXTENSION_VERSION = *****
Loading AzureWebJobsStorage = *****
Loading WEBSITE_CONTENTAZUREFILECONNECTIONSTRING = *****
Loading WEBSITE_CONTENTSHARE = *****
Loading APPINSIGHTS_INSTRUMENTATIONKEY = *****
Connection Strings:
To test local, i'll create an Azure SP and save its creds into the file..
builder@DESKTOP-QADGF36:~/Workspaces/psfntest1$ az ad sp create-for-rbac -n idjfnapp --skip-assignment
The output includes credentials that you must protect. Be sure that you do not include these credentials in your code or check the credentials into your source control. For more information, see https://aka.ms/azadsp-cli
'name' property in the output is deprecated and will be removed in the future. Use 'appId' instead.
{
"appId": "c96578a3-b7b1-asdf-asdf-58167a4588c2",
"displayName": "idjfnapp",
"name": "c96578a3-b7b1-asdf-asdf-58167a4588c2",
"password": "L7_ofZnu5RasdfasdfasdfXIFFatO4XlA~Pg",
"tenant": "28c575f6-ade1-asdf-asdf-7e6d1ba0eb4a"
}
and in our Function App (run.ps1)
$azureAplicationId ="c96578a3-b7b1-asdf-asdf-58167a4588c2"
$azureTenantId= "28c575f6-ade1-asdf-asdf-7e6d1ba0eb4a"
$azureSubscription= "d955c0ba-13dc-asdf-asdf-8fed74cbb22d"
$azurePassword = ConvertTo-SecureString "L7_oasdfasdfsadfsadfA~Pg" -AsPlainText -Force
$psCred = New-Object System.Management.Automation.PSCredential($azureAplicationId , $azurePassword)
Connect-AzAccount -Credential $psCred -TenantId $azureTenantId -ServicePrincipal
Set-AzContext -Subscription "d955c0ba-13dc-asdf-asdf-8fed74cbb22d"
$storageAcc = Get-AzStorageAccount -ResourceGroupName $resourceGroupName -Name $storageAccName
$ctx = $storageAcc.Context
$blobs = Get-AzStorageBlob -Container $containerName -Context $ctx
foreach($blob in $blobs)
{
$body += "</br>" + $blob.Name
}
NOTE: you'll get an invalid Subscription ID error if you dont add some role for this Service Principal in your sub
[2021-08-05T17:11:41.758Z] Result: ERROR: No subscription found in the context. Please ensure that the credentials you provided are authorized to access an Azure subscription, then run Connect-AzAccount to login.
Once I gave the SP ability to view the RG and Storage Account:
There we see test.txt listed
we can tweak a bit since i realized after the fact it wasnt HTML:
foreach($blob in $blobs)
{
$body += "`n`t" + $blob.Name
}
Publish to Azure Function
Next, lets push to Azure as it is. This is surprisingly easy. The first load will make a container which can take a beat
builder@DESKTOP-QADGF36:~/Workspaces/psfntest1$ func azure functionapp publish psfntest1 --force
Getting site publishing info...
Creating archive for current directory...
Uploading 4.71 KB [###############################################################################]
Upload completed successfully.
Deployment completed successfully.
Syncing triggers...
Functions in psfntest1:
HttpExample - [httpTrigger]
Invoke url: https://psfntest1.azurewebsites.net/api/httpexample
The first run of this took minutes. But we can monitor it in Azure
The Log Stream watches the current instance(s):
We can also see Metrics (default is 24h so you'll want to reduce the last 30m to see a meaningful graph):
We can see it took up to 3.5m which caused one tab to time out. this would not be ideal for our end users.
"df6e795b51603337f58800b71c0e2c54820cdadea8c622e6ad1923df59e9f713","computerName": "DW0-HR0-2734-18","processUptime": 412380,"extensionBundle": {"id": "Microsoft.Azure.Functions.ExtensionBundle","version": "2.6.1"}}
2021-08-05T17:56:08.120 [Error] Timeout value of 00:05:00 exceeded by function 'Functions.HttpExample' (Id: '11adfdf5-415b-48be-a1df-61c0af4338fb'). Initiating cancellation.
2021-08-05T17:56:08.257 [Error] Executed 'Functions.HttpExample' (Failed, Id=11adfdf5-415b-48be-a1df-61c0af4338fb, Duration=300202ms)Timeout value of 00:05:00 was exceeded by function: Functions.HttpExample
2021-08-05T17:56:10.505 [Warning] A function timeout has occurred. Restarting worker process executing invocationId '11adfdf5-415b-48be-a1df-61c0af4338fb'.
2021-08-05T17:56:11.837 [Information] Worker process started and initialized.
2021-08-05T17:56:21.851 [Warning] Restart of language worker process(es) completed.
Handling Identity.
Clearly we don't want to hardcode an identity into the app. We would have to redeploy every time the secret was rotated and then add to that mask or remove the details from the code when we commit to GIT.
We will use a system managed identity which creates a new Object ID:
Next use Role Assignments to grant access:
Next we'll remove the lines that auth manually:
and push a new release
builder@DESKTOP-QADGF36:~/Workspaces/psfntest1$ func azure functionapp fetch-app-settings psfntest1
App Settings:
Loading FUNCTIONS_WORKER_RUNTIME = *****
Loading FUNCTIONS_EXTENSION_VERSION = *****
Loading AzureWebJobsStorage = *****
Loading WEBSITE_CONTENTAZUREFILECONNECTIONSTRING = *****
Loading WEBSITE_CONTENTSHARE = *****
Loading APPINSIGHTS_INSTRUMENTATIONKEY = *****
Loading WEBSITE_RUN_FROM_PACKAGE = *****
Connection Strings:
builder@DESKTOP-QADGF36:~/Workspaces/psfntest1$ func azure functionapp publish psfntest1 --force
Getting site publishing info...
Creating archive for current directory...
Uploading 4.72 KB [###############################################################################]
Upload completed successfully.
Deployment completed successfully.
Syncing triggers...
Functions in psfntest1:
HttpExample - [httpTrigger]
Invoke url: https://psfntest1.azurewebsites.net/api/httpexample
Validation
This was considerably faster to get a response. Perhaps just a few seconds:
as i hit several times, the usual response time was now 4-5 seconds
And we can be doubly sure that its using the managed identity by removing the unnecessary sp:
builder@DESKTOP-QADGF36:~/Workspaces/psfntest1$ az ad sp delete --id c96578a3-b7b1-459c-aee9-58167a4588c2
Removing role assignments
builder@DESKTOP-QADGF36:~/Workspaces/psfntest1$ az ad sp show --id c96578a3-b7b1-459c-aee9-58167a4588c2
Resource 'c96578a3-b7b1-459c-aee9-58167a4588c2' does not exist or one of its queried reference-property objects are not present.
Validation
A reload and test proves its using a managed identity:
Scaling and performance
We used the CentralUSPlan (Y1) which is the basic Consumption plan. This is fine for basic performance but there are limitations. We just get 1 app slot and there is no zone redundancy.
This could also get really expensive if we miscalculate the usage and people hammer the function.
In such situations, we may wish to just create an App Service Plan and move out of the consumption plan:
Clicking Change Size gives us options.
For instance, for a rarely used function, perhaps a simple report, we could stay in the slower by adequate Free tier:
For $10 we add a custom domain option and at $55/mo we now can use some deployment slots and more memory:
And of course the higher you go the more you get:
But I would argue that when you are getting beyond $500/mo you are in a well built AKS cluster size and there is likely value in considering re-engineering around containers.
Tring to Migrate Plans (did not work)
Let's show a migration by creating a Free tier App Service Plan:
Once created, we see the plan listed:
Now i would think i could use this, however there was no path that would work.
I could not migrate:
$ az functionapp update --resource-group psfntest1rg --name psfntest1 --plan MyNewPlan
You are trying to move to a plan that is not a Consumption or an Elastic Premium plan. Currently the switch is only allowed between a Consumption or an Elastic Premium plan.
and i could not create either due to fixed AlwaysOn (you can google and see others complain about this behaviour)
$ az functionapp create -n psfntest2b -g psfntest1rg --runtime powershell --storage-account testingpsfuncsa --plan MyNewPlan --assign-identity --functions-version 3 --debug
cli.knack.cli: Command arguments: ['functionapp',....
cli.azure.cli.core.sdk.policies: {"Code":"Conflict","Message":"There was a conflict. AlwaysOn cannot be set for this site as the plan does not allow it. For more information on pricing and features, please see: https://aka.ms/appservicepricingdetails ","Target":null,"Details":[{"Message":"There was a conflict. AlwaysOn cannot be set for this site as the plan does not allow it. For more information on pricing and features, please see: https://aka.ms/appservicepricingdetails "},{"Code":"Conflict"},{"ErrorEntity":{"ExtendedCode":"01020","MessageTemplate":"There was a conflict. {0}","Parameters":["AlwaysOn cannot be set for this site as the plan does not allow it. For more information on pricing and features, please see: https://aka.ms/appservicepricingdetails "],"Code":"Conflict","Message":"There was a conflict. AlwaysOn cannot be set for this site as the plan does not allow it. For more information on pricing and features, please see: https://aka.ms/appservicepricingdetails "}}],"Innererror":null}
cli.azure.cli.core.util: azure.cli.core.util.handle_exception is called with an exception:
Lastly, the cheapest non-consumption plan available was the Shared Infra at 240m a day for $10 a month
Be aware that the consumption has a rather generous free tier (if not in Premium Functions):
"Consumption plan pricing includes a monthly free grant of 1 million requests and 400,000 GB-s of resource consumption per month per subscription in pay-as-you-go pricing across all function apps in that subscription"
You can also recall we have covered using KEDA for publishing functions into a k8s cluster so you always have that option.
Inline Editor
I should point out that some languages like Powershell have a built in editor in the Azure portal for quick edits.
To use it, remove the RUN FROM PACKAGE setting:
update and save
We will be disabled until we publish:
So do that next:
builder@DESKTOP-QADGF36:~/Workspaces/psfntest1$ func azure functionapp fetch-app-settings psfntest1
App Settings:
Loading APPINSIGHTS_INSTRUMENTATIONKEY = *****
Loading AzureWebJobsStorage = *****
Loading FUNCTIONS_EXTENSION_VERSION = *****
Loading FUNCTIONS_WORKER_RUNTIME = *****
Loading WEBSITE_CONTENTAZUREFILECONNECTIONSTRING = *****
Loading WEBSITE_CONTENTSHARE = *****
Connection Strings:
Then remove it from "local.settings.json" . Next publish again, this time adding "--nozip"
builder@DESKTOP-QADGF36:~/Workspaces/psfntest1$ func azure functionapp publish psfntest1 --force --nozip
Getting site publishing info...
Creating archive for current directory...
Uploading 4.72 KB [###############################################################################]
Upload completed successfully.
Functions in psfntest1:
HttpExample - [httpTrigger]
Invoke url: https://psfntest1.azurewebsites.net/api/httpexample
We can now use Code + Test on specific function endpoints:
and test a post
Which brings up the log viewer and results to the output pain (error likely due to dependance on managed identity)
which i validated by saving and hitting again:
There is also now a preview feature of the "App Service Editor" which gives a more Visual Studio Code type experience:
But I found it did not have any Az modules and i could not install them so it wasn't too useful for me.
Summary
We quickly created a Powershell based Azure Function from the command line. It was easy to use the consumption plan to test a basic HTTP trigger and then add Azure modules to do more interesting work.
In our example, we used the Get Blob method to show blobs in a container and later updated it to move from a hard coded service principal to an Azure managed identity.
Lastly, we explored App Service Plans, cost considerations and inline editing of Powershell based functions.