Deploying Azure Functions on a Linux Service Plan

Some time ago, about 7 months, I had to build a service that creates a PDF document from HTML. The library of choice was IronPDF. Creating PDF documents with this library is a breeze, but we stumbled across a small issue.

The HTML-to-PDF-converter-service is hosted inside an Azure Function, for reasons. We noticed creating the documents took quite a lot of time. After inspecting the allocated instances we discovered both the CPU and Memory were constantly spiking to maximum capacity. That’s not good.
What made things worse, the generation sometimes took up to 37 minutes to complete! That’s not acceptable if your customers are waiting for the document.

This service was deployed on Windows instances, as it’s the default.
Because Linux is more lightweight compared to Windows, we started doing a test if the document rendering would be faster on Azure Functions hosted on Linux instances. The results were staggering!
On the Linux instances, all of the documents being generated never took more than 3 minutes to complete (99th percentile) and the used resources were less. We were still using a lot of CPU and memory, but acceptable levels. That’s the moment we decided to go forward using Linux hosted Azure Functions for this part of the system.
We just had to find out how to deploy them.

Don’t mix and match

The first thing I discovered is you can’t put Azure Functions hosted on Linux in a resource group where you also have a service plan running Windows. I got these type of messages when trying to deploy the resources

pwsh> az group deployment create --resource-group resource-group-with-windows-service-plan `
>>             --template-file azuredeploy-linux.json `
>>             --parameters azuredeploy-linux.parameters.json

Azure Error: InvalidTemplateDeployment
Message: The template deployment 'azuredeploy-linux' is not valid according to the va
Exception Details:
        Error Code: ValidationForResourceFailed
        Message: Validation failed for a resource. Check 'Error.Details[0]' for more

In the end, I did manage to create a Service Plan running on Linux in the same resource group as my Windows plans, but the Functions didn’t work. This is a current limitation of the Linux Service Plans and it’s being worked on to get it fixed. It’s also noted in the documentation.

I’ve tried doing this 7 months ago, in November/December 2019, so it might be fixed by now.

Using Linux containers

By deploying the Linux Service Plan & Azure Functions via the portal I quickly noticed everything works with containers. When spinning up a new App Service, the latest version of the Azure Functions runtime image is being downloaded and made available as a container.

At the time, only Azure Functions running on .NET Core 2.x were supported.
Our software already ran on .NET Core 3.0 and .NET Core 3.1, so we required container images with the latest runtimes.

What I discovered, you have to specify which image you want to use in the siteConfig.linuxFxVersion property of your ARM template. This brings some opportunities. I quickly navigated to the Docker Hub where the (official) Azure Functions Dotnet image is available.

Over there you can see how to pull the image.
Thing is, you don’t want to pull this image (version 2.0 at the time of writing). You need a more recent tag of this image. The Full Tag Listing can also be found on this page. When clicking on it you’ll see an enormous list of tags that you can use.

Because we required an image with .NET Core 3.0 or .NET Core 3.1, I’ve opted for the tag 3.0.13130-appservice, so our linuxFxVersion looks like this: DOCKER|mcr.microsoft.com/azure-functions/dotnet:3.0.13130-appservice.

Using this image you can deploy and run your Azure Functions on .NET Core 3.x on a Linux plan.

The ARM template

While the ARM template documentation is quite good, it has still taken me a lot of time to create the proper template for Linux hosted Azure Functions. That’s the main reason I’m sharing it over here because I want to save you this trouble. I’m using excerpts of the actual template, but I’m using pretty straightforward parameter and variables names. If you’re struggling to understand which values to put where let me know and I’ll adjust the samples.

Service Plan

First of all, you need to create the Service Plan.

{
    "apiVersion": "2018-11-01",
    "name": "[parameters('servicePlanInstanceName')]",
    "type": "Microsoft.Web/serverfarms",
    "location": "[resourceGroup().location]",
    "kind": "linux",
    "tags": "[parameters('tags')]",
    "properties": {
        "name": "[parameters('servicePlanInstanceName')]",
        "workerSize": 0,
        "workerSizeId": 0,
        "numberOfWorkers": 1,
        "hostingEnvironment": "",
        "reserved": true
    },
    "sku": {
        "tier": "Standard",
        "name": "S1"
    }
}

The only ‘special’ thing you have to specify over here is kind should be linux and to put reserved to true.

Function App

Now for the harder part, the actual Function App. The following piece of JSON is what I came up with.

{
    "type": "Microsoft.Web/sites",
    "kind": "functionapp,linux,container",
    "identity": {
        "type": "SystemAssigned"
    },
    "name": "[parameters('functionAppInstanceName')]",
    "apiVersion": "2015-08-01",
    "location": "[resourceGroup().location]",
    "tags": "[union(parameters('tags'), variables('tags'))]",
    "properties": {
        "serverFarmId": "[resourceId(variables('servicePlanResourceGroup'), 'Microsoft.Web/serverfarms', parameters('servicePlanInstanceName'))]",
        "siteConfig": {
            "appSettings": [
            {
                "name": "FUNCTIONS_EXTENSION_VERSION",
                "value": "~3"
            },
            {
                "name": "FUNCTIONS_WORKER_RUNTIME",
                "value": "dotnet"
            },
            {
                "name": "ASPNETCORE_ENVIRONMENT",
                "value": "[variables('environmentName')]"
            },
            {
                "name": "APPINSIGHTS_INSTRUMENTATIONKEY",
                "value": "[reference(variables('deployments').applicationInsights.name).outputs.instrumentationKey.value]"
            },
            {
                "name": "WEBSITE_RUN_FROM_PACKAGE",
                "value": "1"
            },
            {
                "name": "AzureWebJobsStorage",
                "value": "[concat('DefaultEndpointsProtocol=https;AccountName=', variables('deployments').azureWebJobsStorageAccount.instanceName, ';AccountKey=', listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('deployments').azureWebJobsStorageAccount.instanceName), providers('Microsoft.Storage', 'storageAccounts').apiVersions[0]).keys[0].value)]"
            }],
            "linuxFxVersion": "DOCKER|mcr.microsoft.com/azure-functions/dotnet:3.0.13130-appservice",
            "alwaysOn": true,
            "http20Enabled": true,
            "minTlsVersion": "1.2",
            "ftpsState": "Disabled"
        }
    }
}

Most of it is rather straightforward, but a couple of things are ‘special’ over here.
First of all kind not only states it’s a Function App, but also Linux and a Container functionapp,linux,container.
Second, the FUNCTIONS_EXTENSION_VERSION has to be put on ~3, but that’s something you also do on Windows hosted Function Apps. And last, as mentioned, you need to specify the linuxFxVersion over here. I’m using DOCKER|mcr.microsoft.com/azure-functions/dotnet:3.0.13130-appservice. You’re free to try out other images. I know this one works as our service has been running for over 6 months now.

To conclude

Figuring out all of this stuff has taken me quite a bit of time. I wanted to post this way earlier but didn’t get around to it.
I hope I’ll save you some time if you’re struggling with the same thing. I’m pretty sure this stuff will be documented better in the future and made easier for us. It might even be updated already, check out the docs!

If you’re still stuck deploying your Azure Functions on Linux in an App Service, let me know and I’ll see if I can help.


Share

comments powered by Disqus