Serverless computing promises to make it "easier" to just write some code, sling it at the cloud and voila, you're done. No more worrying about how your code is hosted, configuring application servers or writing hundreds of lines of "boilerplate" to get something running. The reality, however, is a little different, as we found out when trying to write an Azure function that targets the .NET 5 runtime.
The reason it failed to live up to this promise is that at this point in time the default Azure functions host supports .NET Core 3. So in order to target .NET 5 we have to get our hands dirty and write some boilerplate. Unfortunately, the documentation on how to do this and the project templates for .NET 5 Azure functions don't seem to cover all of the "quirks" that are necessary to make it work.
In this post I'm going to walk through the steps required to get a .NET 5 Azure function running both locally and in Azure and highlight the gotchas that tripped us up when trying to do this. The function itself will be written in F#, but all of the quirks are related to using a .NET 5 runtime and so would equally apply to a C# project. In fact, switching between the two languages is an extremely minimal code change.
The final version of the code shown in this blog post can be found on GitHub.
"In-process" vs "Out-of-process" hosting
First off, what's a host? In Azure functions the host is the term used to refer to the process that your function will be executed in. It includes a runtime, e.g. dotnet or Python, and all of the external libraries, e.g. DLLs or Python modules, that your code needs to be able to run.
By default Azure functions uses what it calls an "in-process" hosting model. In practice this means that when you deploy your code as a library, or a script, it runs it in a process with a pre-configured host.
The upside to this model is that you don't have to worry about infrastructure concerns. You just write your core logic and ship it. This supposedly frees developers from the burden of configuring a host in the entry point to their application, saving them time.
The downside is that if you don't like what's included in the box then your options for altering that are limited to whatever is exposed via the switches and dials they provide. Unfortunately thereβs no .NET 5 switch available. This is where the "out-of-process" host comes in.
With out-of-process hosting you get explicit control over how the host is created allowing you to choose a dotnet runtime. When you ship an Azure function using this hosting model you're responsible for configuring the host and making sure it boots correctly. With that background out the way, let's get stuck into the code.
Create a new project
We're going to need an F# project to work with. It will be edited quite heavily, so the easiest way to create one is to just create a console app by running the following command from the root of your project directory.
dotnet new console -lang f# -n Function -o src/Function
Note, we've put the project under a src
directory as is quite typical for dotnet projects, you don't have to follow this convention, but the rest of the steps will assume this project layout.
Configure the .fsproj
file
The first step is to ensure the project file (.fsproj
) is configured correctly. A minimal example looks like this.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AzureFunctionsVersion>v3</AzureFunctionsVersion>
<_FunctionsSkipCleanOutput>True</_FunctionsSkipCleanOutput>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="1.4.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Timer" Version="4.1.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="1.0.4" />
</ItemGroup>
<ItemGroup>
<Compile Include="Execute.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
<None Include="host.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="local.settings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</None>
</ItemGroup>
</Project>
You can just go ahead and replace the contents of the generated Function.fsproj
file with the above.
There are a few things to point out that are required to make this work which weren't documented or included in the official code templates.
- The
_FunctionsSkipCleanOutput
must be set toTrue
. - The directives for the
host.json
andlocal.settings.json
must use<None Include=>
. The functions templates generate these with<None Update=>
which doesn't work. -
The templates also don't include the correct package references. At a minimum we need the ones listed above, at their latest versions, in order to run on .NET 5.
Note this example is using
Microsoft.Azure.Functions.Worker.Extensions.Timer
because this basic example just uses a simple timer trigger, you might need a different extension package if you're using a different trigger, e.g. Blob triggers requireMicrosoft.Azure.Functions.Worker.Extensions.Storage
.
Configure host.json
We need to add a host.json
file to the project with the following contents.
{
"version": "2.0",
"logging": {
"applicationInsights": {
"samplingSettings": {
"isEnabled": true,
"samplingExcludedTypes": "Request"
}
}
}
}
There aren't any quirks here, so just add that and move on.
Add a local.settings.json
We also need a local.settings.json
file in the project which should look like this.
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated"
}
}
The crucial bit here is that we've set FUNCTIONS_WORKER_RUNTIME
to "dotnet-isolated"
. This is how we specify that we want to use an out-of-process host.
Configure the host in Program.fs
Because we've opted to go out-of-process we need to write the host bootstrapping code in Program.fs
.
open Microsoft.Extensions.Hosting
HostBuilder()
.ConfigureFunctionsWorkerDefaults()
.Build()
.Run()
As you can see, the boilerplate is actually very minimal for this simple function. Also, the nice thing about using the dotnet-isolated
runtime and explicitly defining how the host is bootstrapped is that if the application grows and requires interaction with more azure services, such as blob storage or message queues, then we get complete control over how those services are configured. We're also able to specify exactly which NuGet packages, at which versions, are included in the runtime too. In my experience this leads to much fewer surprises and less time spent opaquely debugging in Azure, as compared to using an in-process host in which issues such as assembly conflicts occur at runtime. Give me that sweet, sweet, boilerplate every time.
Add your functions
We can now go ahead and write our functions. Our project is configured to expect a file called Execute.fs
, which is where we're going to place them. There's nothing special about that file name, so feel free to call it whatever you want, or arrange your functions in multiple files if that suits your needs better.
Here's what our simple example timer function looks like in F#.
namespace My.Function
open Microsoft.Azure.Functions.Worker
open Microsoft.Extensions.Logging
type Execute(logger: ILogger<Execute>) =
[<Function("Execute")>]
member _.Run([<TimerTrigger("0 */5 * * * *")>] timer: TimerInfo) =
logger.LogInformation($"Hello at {System.DateTime.UtcNow} from an Azure function using F# on .NET 5.")
Somewhat annoyingly, we have to add the TimerTrigger
attribute to a method parameter, rather than being able to specify it at the method level. So even if we don't want to use the TimerInfo
, we need to accept it as an argument.
Also, ironically, we have to create a class to write a "function" π
Building the code
This one should be fairly straight forward, but there's a gotcha here too. In order to build an Azure function running on .NET 5 you'll need the .NET Core 3.1 SDK installed. There's more information in this GitHub issue. Once you've got that installed you can just run dotnet build
like usual.
Run it locally
At this point we can run the function locally using the Azure Function Tools and the Azurite storage emulator.
With these two tools installed you can run the following commands (in separate terminals or as background processes) to start the function.
azurite --location ~/.azurite --debug ~/.azurite/debug.log
func start
The output from func start
should look something like this.
Deploy to Azure
In order to deploy to Azure we need to create some Azure resources. At a minimum we need a resource group containing a storage account, app service plan on the consumption billing tier and a function app. We can configure all of this from an ARM template. Whilst straight forward if you know how to use ARM templates, it's quite verbose, so instead of inlining it here, you can see it on GitHub instead.
Assuming you've got an Azure subscription and the Az CLI installed then we can deploy it with these commands.
# Create a resource group, only needs to be done the first time
az group create -n <resource-group-name> -l <location>
# Build the code, this will publish all the necessary files to the location specified by the -o argument
dotnet build src/Function -c Release -o .publish/func
# The template outputs the function app's name so we can use it in the next step to deploy the code
FUNCTION_NAME=$(az deployment group create \
--no-prompt \
--output tsv \
--query properties.outputs.functionName.value \
--resource-group <resource-group-name> \
--template-file ./azuredeploy.json)
cd .publish/func
# Zip up the build output for deployment to the app
zip -r ./funtionapp.zip .
az functionapp deployment source config-zip \
--resource-group <resource-group-name> \
--name ${FUNCTION_NAME} \
--src ./functionapp.zip
One quirk here is that you might have been expecting to use the dotnet publish
command in order to create the deployable artefact, as is typical when developing an ASP.NET app. That's not the case here, for Azure functions the publishing happens automatically when running dotnet build
.
Bonus: Build and Deploy from GitHub
Now that we've automated the build and deployment using CLI tools, it's actually very easy to turn this into a GitHub action. Especially if we create a script called deploy.sh
that takes care of all of the Azure related steps from the previous section.
name: Build & Deploy
on:
push:
branches:
- master
pull_request:
env:
FUNCTION_PACKAGE_PATH: .publish/function
RESOURCE_GROUP: az-function-fsharp-net5
jobs:
build-and-deploy:
runs-on: ubuntu-18.04
steps:
- name: Checkout code
uses: actions/checkout@master
with:
fetch-depth: 0
- name: Setup dotnet SDK 3.1 (https://github.com/Azure/azure-functions-dotnet-worker/issues/480)
uses: actions/setup-dotnet@v1
with:
dotnet-version: "3.1.409"
- name: Setup dotnet SDK
uses: actions/setup-dotnet@v1
with:
dotnet-version: "5.0.300"
- name: Publish Function
run: dotnet build src/Function -c Release -o ${{ env.FUNCTION_PACKAGE_PATH }}
- name: Login to Azure
uses: azure/login@v1
with:
creds: ${{ secrets.AZURE_RBAC_CREDENTIALS }}
- name: Deploy to Azure
uses: azure/CLI@v1
with:
inlineScript: ./deploy.sh ${{ env.FUNCTION_PACKAGE_PATH }} -g ${{ env.RESOURCE_GROUP }}
Note that we have to install both the .NET Core 3.1 and .NET 5 SDKs in order to build the code.
In order for this to work you'll need to set a GitHub secret called AZURE_RBAC_CREDENTIALS
in your repo. More details about how to generate and set these credentials can be found on the Azure Login Action's page.
See the full example on GitHub
If you want to see the final example all in one place then you can check it out on GitHub. Feel free to clone it or fork it and use it as a starting template.
Choc13 / az-function-fsharp-net5
A minimal example of creating an Azure function using F# on .NET 5. with bonus GitHub actions deployment
Example Azure Function using F# on .NET 5
This repo shows a minimal example of how to write an Azure function using F# and run it on .NET 5 It also includes an example of deploying to Azure from your local machine and using GitHub actions.
Gotchas
There were several gotchas that were discovered when trying to get this to work which were often tricky to find in the existing documentation. In fact, all of these gotchas are related to using .NET 5 and apply equally to a C# project.
-
Isolated .NET Host
Running the function on .NET 5 requires an isolated .NET host Specifically, we have to set the environment variable
FUNCTIONS_WORKER_RUNTIME
to the value"dotnet-isolated"
This is because the default host in the functions runtime is still using .NET Core 3.1. We have to set this in both thelocal.settings.json
file for running locally and the β¦
The state of Azure severless in 2021
Given the variety of triggers available and the consumption based billing model Azure functions are well suited for running reactive asynchronous background tasks. This is an important piece in the architecture of any reasonably sized distributed system hosted in the cloud. It also provides a complimentary role to that of the backend API, such as a REST API, which expects to make quick decisions and not spend its time chugging away at long running async computations.
It's a shame then that Azure functions seem to have conflated this feature set with the one of reducing-all-the-boilerplate. It's understandable that there is a use case that exists in which many people will just want to write some code and get it running somewhere with minimal fuss. Unfortunately, I think that's a completely different set of people to the ones who want to use Azure functions as part of the architecture of a larger distributed system.
As someone in the latter camp I care much more about being able to have explicit control over the environment in which my code runs than I do about eliminating a dozen lines of host configuration boilerplate. Spending time eliminating this boilerplate is a false economy because I probably spend less than 0.001% of my time when building such a system on those dozen lines of config code. The problem with trying to eliminate all the boilerplate is that if you don't nail the abstractions they end up creating more friction than the original boilerplate did, by forcing developers to figure out how to work around them.
Conclusion
Creating an Azure function targeting the latest .NET runtime is quite fiddly and insufficiently documented at present. Fortunately, with a bit of tinkering it is possible to make it work. Choosing between F# and C# is also just a matter of which language suits your project better as neither requires any extra Azure functions magic than the other.
In the future it would be great the see Microsoft focus more on building a solid pay-per-use background processing solution without all the magic, basically good old WebJobs on a consumption plan. The "in-process" use case should then be simple to build on top of this foundation for those that don't need such control and don't want to have to figure out how to configure a host. Better to try and walk before you can run.
Top comments (2)
Great article :) Another option is to use Web Jobs. They have pretty much the same semantics as Azure Functions but are deployed as part of a normal web app. That way you can configure and scale your host using app service etc quite easily. I have a blog about it here compositional-it.com/news-blog/rea...
Nice! π
Funny you should say that, on my previous project earlier in the year I did opt for WebJobs to handle some background processing triggered by a Storage Queue. I nearly blogged about that that experience then, as I'd tried to use both Functions and WebJobs and found the latter more straight forward to get working because there was less magic.
I think if it wasn't for the fact that the consumption billing model was only available for functions then I would exclusively use WebJobs, especially given that Azure Functions are just a thin layer on top of WebJobs anyway and even more so when using the
dotnet-isolated
runtime.