Rewriting Azure Functions for .NET 10
It seemed like a strange request, when a colleague asked me to look into upgrading our Function Apps to use .NET 10, ‘isolated worker’. Since when was it possible to change their runtime version ourselves? And what on God’s Green Earth was an ‘isolated worker’? It was time to investigate.
There are a couple of important things to remember about Function Apps, as a starting point. Firstly, my colleagues and I had developed Function Apps exclusively as custom Logic App components, to handle more cumbersome data transforms. They were sort of ‘hacks’, created in the Azure Portal editor. I actually don’t think there’s anything inherently wrong with doing that, if the functions are only a few hundred lines of C#.
Secondly, a Function App sends and receives data as HTTP payloads, and it could be thought of as a single-purpose .NET API. If it could be run locally, it could be tested in isolation, using Postman or Bruno.
So, what’s changed? Until recently, Function Apps were based on an ‘in-process’ model, with the functions and the function host both running as a single process. The entire Function App, therefore, was importing whichever runtime and assembly versions Microsoft compiled the function host with, and they’d likely be different to the versions developers were building with in Visual Studio. As far as I know, the actual problems with that are hypothetical.
From November, all Azure Function Apps must be developed for the ‘isolated worker’ model, in which the function and function host run as separate processes - func.exe and dotnet.exe. The first is the Azure Functions Host, and the second would be the compiled Function App. Both have separate CLRs and dependency loading. They exchange their system calls using HTTP, so we shouldn’t need to care about how the function host handles the data.
What this means in practice is we need to rewrite, test and redeploy our Function Apps, as Visual Studio projects. It probably means we’ll need to worry about technical debt again, which negates the whole premise of using ’low code’ in the first place.
Project Structure
The Function App project structure is that of a typical .NET project.
- C# project file (.csproj), for obvious reasons
- Program.cs
- Other .cs files for the Azure functions themselves
- host.json file that defines shared configurations for the Azure functions
- local.settings.json, for environment variables when running the functions locally
And the Program.cs file will look something like this:
var builder = FunctionsApplication.CreateBuilder(args);
builder.ConfigureFunctionsWebApplication();
if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING")))
{
builder.Services.AddOpenTelemetry()
.UseFunctionsWorkerDefaults();
//.UseAzureMonitorExporter();
}
// Added this to get around the Kestrel request size limit
builder.Services.Configure<KestrelServerOptions>(options =>
{
options.Limits.MaxRequestBodySize = int.MaxValue;
});
builder.Build().Run();
Unfortunately we do need builder.Services.AddOpenTelemetry, but commenting out ‘.useAzureMonitorExporter’ often fixes issues with it running locally.
The local.settings.json file sould already have the following present:
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated"
}
}
Basic Function App Project
The Function App project will have a Program.cs file, and at least one other .cs file for the functions themselves, instead of everything being in just a single run.csx.
When opening or creating a new project in Visual Studio, we want the Azure Functions template. This should be based on the ‘.NET 10.0 Isolated (Long Term Support)’ framework.
As a quick test, we can run the project locally, and use Bruno or Postman to send a request to
http://localhost:7021/api/Function. The function should send ‘Welcome to Azure Functions!’ as the response.
The code in one of our older Function Apps looked something like this:
public static async Task<IActionResult> Run(HttpRequest req, ILogger log)
{
[...]
return new OkObjectResult(new FileContentResult(fileBytes, "application/pdf"))
{
StatusCode = (int)HttpStatusCode.OK
};
}
Implemented in the new Function App project, it looks something like this:
[Function("MyFunction")]
public async Task<IActionResult> Run(
[HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequest req)
{
[...]
return new OkObjectResult(new FileContentResult(fileBytes, "application/pdf"))
{
StatusCode = (int)HttpStatusCode.OK
};
}
Logging comes from instantiating ILogger, as with most .NET projects these days.
private readonly ILogger<MyFunction> _logger;
public MyFunction(ILogger<MyFunction> logger)
{
_logger = logger;
}
The method attribute ([Function("MyFunction")]) is essentially the endpoint the Logic App calls the Function App with. An HTTP request for testing this locally would look something like:
POST http://localhost:7021/api/MyFunction
Since this started looking more like a conventional .NET API project, I felt the obligation to do a considerable amount of refactoring. I spent time extracting a lot of the original code to data model and helper classes, implementing SRP the best I could, and using ReSharper for the less obvious things. I wasn’t too bothered with dependency injection and interface here, because the Function App isn’t touching a database and it’s only ever getting used in one context.
When the Function App runs locally, we should see this console message:
Azure Functions Core Tools
[...]
[...] Azure Functions .NET Worker (PID: 5300) initialized in debug mode. Waiting for debugger to attach...
[...] Worker process started and initialized.
Functions:
MyFunction: [GET,POST] http://localhost:7021/api/MyFunction
MyOtherFunction: [GET,POST] http://localhost:7021/api/MyOtherFunction
To test this, I pulled the JSON from the Azure Logic App’s run history, and used Bruno to send the payloads to the endpoints.