Lessons learned after migrating Azure Functions to Isolated Functions on .NET 8

The In-process model of running Azure Functions is being retired in favor of the Isolated model in two years. A lot of components on elmah.io are running on Azure Functions. To ensure we are running on the most modern and supported platform (also in two years), we have spent quite some time migrating from In-process to Isolated functions. In this post, I'll share both a checklist to help you do the same as well as some of the lessons learned we had during the migration.

Lessons learned after migrating Azure Functions to Isolated Functions on .NET 8

Let me start by putting a few words on the Isolated model if you haven't heard about it already. Azure Functions currently support two different models: In-process and Isolated. The Isolated model is also sometimes referenced as the Out-of-process model. To better understand the main difference it's important to know that there's something called the Function Host.

The host is the process responsible for executing functions. It invokes functions based on messages on a queue, timed functions, and other ways of triggering functions. The In-process model executed functions in the same process as the host itself. The Isolated model splits things up. It still have a host, but functions are executed in processes different from the process running the host.

There are many advantages of the Isolated model over the In-process:

  • The functions can target a .NET framework different from the host.
  • Each function app runs in its own process which isolates it from problems in other function apps.
  • Initialization and configuration are similar to ASP.NET Core and other modern frameworks.
  • Isolated functions can use middleware as known from web frameworks.

Migration steps

To better explain the steps needed to migrate, let's take a look at a simple function app using the In-process model. I'm using the Microsoft.Azure.Functions.Extensions package which offered an improved way of configuring functions compared to what was baked in from the beginning. The first file is the Startup.cs file:

[assembly: FunctionsStartup(typeof(MyFunctionApp.Startup))]

namespace MyFunctionApp
{
    public class Startup : FunctionsStartup
    {
        public override void Configure(IFunctionsHostBuilder builder)
        {
            var dependency = new MyDependency();
            builder.Services.AddSingleton<IMyDependency>(dependeny);

            builder.Services.AddLogging(logging =>
            {
                logging.AddConsole();
            });

#pragma warning disable CS0618 // Type or member is obsolete
            builder.Services.AddSingleton<IFunctionFilter, MyExceptionFilter>();
#pragma warning restore CS0618 // Type or member is obsolete
        }
    }
}

This file set up a singleton as well as add logging using Microsoft.Extensions.Logging. In the last line of the method, it set up a IFunctionFilter to catch and handle exceptions. Filters are similar to middleware but in many ways match Action Filters from ASP.NET MVC more. The #pragma declaration is there to ignore the warning about using obsolete code by using IFunctionFilter. The decision to declaring IFunctionFilter has also felt weird since there has been no alternative.

Next is the function itself. In this example a timed function:

namespace MyFunctionApp
{
    public class MyFunction
    {
        private readonly IMyDependency myDependency;

        public UptimeChecker(IMyDependency myDependency)
        {
            this.myDependency = myDependency;
        }

        [FunctionName("MyFunction")]
        public async Task Run([TimerTrigger("0 */5 * * * *")]TimerInfo myTimer, ILogger log)
        {
            // Execute
        }
    }
}

The configuration is in the local.settings.json file:

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet",
  }
}

Finally, there's the csproj file:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <AzureFunctionsVersion>v4</AzureFunctionsVersion>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Sdk.Functions" Version="4.2.0" />
    <PackageReference Include="Microsoft.Azure.Functions.Extensions" Version="1.1.0" />
  </ItemGroup>
  <ItemGroup>
    <None Update="host.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="local.settings.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
      <CopyToPublishDirectory>Never</CopyToPublishDirectory>
    </None>
  </ItemGroup>
</Project>

The app is targeting .NET 6 but the code will look similar to previous versions of .NET.

To migrate this function app to the Isolated model, here's a list of steps that I have been following when migrating our apps. Isolated functions run on .NET 6, but I have upgraded to .NET in the process too.

Update target framework

Change the TargetFramework element in the csproj file to target .NET 8:

<TargetFramework>net8.0</TargetFramework>

This step is optional unless your In-process app is target .NET lower than .NET 5. In case you have a test project, remember to update the target framework in that project too.

Set output type

Add the following element below the TargetFramework element:

<OutputType>Exe</OutputType>

Since the functions are executed in a new process, the project needs to output an executable rather than a library in the In-process model.

As part of the migration to .NET 8, I have also added the following properties but these are optional:

<LangVersion>12.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

These properties will make it possible to use the newest language features in C# and enable both implicit usings and improved nullable handling.

Replace NuGet packages

The Isolated model has a whole new set of NuGet packages. This means that the Microsoft.NET.Sdk.Functions and Microsoft.Azure.Functions.Extensions packages in the csproj file needs to be replaced with these:

<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="1.10.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Timer" Version="4.0.1" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="1.7.0" />

The Worker and Worker.Sdk packages are required. Depending on your function trigger, you need to include one or more packages containing the trigger. In this example, I'm using the timed trigger which requires the Microsoft.Azure.Functions.Worker.Extensions.Timer package. There are other packages like Microsoft.Azure.Functions.Worker.Extensions.Http for HTTP-based triggers and Microsoft.Azure.Functions.Worker.Extensions.ServiceBus for Service Bus-based triggers.

Create the C# configuration

Create a new file named Program.cs and include the following skeleton:

var host = new HostBuilder()
    .ConfigureFunctionsWorkerDefaults((context, app) =>
    {
    })
    .ConfigureServices(services =>
    {
    })
    .ConfigureLogging(logging =>
    {
    })
    .Build();

host.Run();

I'm using top-level statements but you can declare the full namespace and class if you prefer that syntax.

The lines from the Startup.cs file can be copy pasted into Program.cs:

var host = new HostBuilder()
    .ConfigureFunctionsWorkerDefaults((context, app) =>
    {
        app.UseMiddleware<MyExceptionMiddleware>();
    })
    .ConfigureServices(services =>
    {
        var dependency = new MyDependency();
        services.AddSingleton<IMyDependency>(dependency);
    })
    .ConfigureLogging(logging =>
    {
        logging.AddConsole();
    })
    .Build();

The MyExceptionFilter class has been migrated to middleware. There are numerous examples of how to create middleware in Azure Functions already, so I won't go into more detail on this here. The important thing to notice is that the class is added using the UseMiddleware method. This should look familiar if you already know ASP.NET Core.

The dependency is injected into the ConfigureServices method and the logging is set up in the ConfigureLogging method.

When all of the code is moved, the Startup.cs file can be deleted.

Update JSON config

The configuration in the local.settings.json file needs an update too:

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
  }
}

The only change I made was changing dotnet to dotnet-isolated. For now, this is all we need to move the function app to run on the Isolated model. Later in this post, I'll put some words on additional configuration and why we can no longer specify that in the local.settings.json file.

Update the function class

The final thing missing is the function class itself. In this example, the My

namespace MyFunctionApp
{
    public class MyFunction(IMyDependency myDependency, ILogger<MyFunction> logger)
    {
        private readonly IMyDependency myDependency = myDependency;
        private readonly ILogger<MyFunction> logger = logger;

        [Function("MyFunction")]
        public async Task Run([TimerTrigger("0 */5 * * * *")]TimerInfo myTimer)
        {
            // Execute
        }
    }
}

I made a few changes here, besides migrating the code to use a primary constructor. I've changed the injected ILogger in the Run method to a constructor-injected generic logger. Your In-process function may already use this approach so there might not be anything to change there. The second change is to replace the FunctionName attribute with the Function attribute. When doing this you will need to using Microsoft.Azure.WebJobs; and adding using Microsoft.Azure.Functions.Worker; instead.

That's it! Hit F5 and see your "new" Isolated function app in action.

Known issues

While migrating all of elmah.io's function apps to Isolated we have run through several issues and lessons learned. Isolated functions feel stable but have bugs like everything else. In this section of the post, I'll try to list all of the things I can think of.

Random exception sent to ILogger

In HTTP-based functions, there seems to be some kind of problem with simultaneous requests. I haven't experienced this problem causing any issues for the calling code why it must be a log message gone rogue or similar. The issue is explained here: https://learn.microsoft.com/en-us/answers/questions/1609713/azure-function-fails-with-functioninvocationexcept. What we experience is a series of log statements sent to our log looking like this:

Random exceptions shown in elmah.io
Log messages from HTTP functions

Log messages stored twice

This is an old issue but still relevant for Isolated functions. Log messages are in some instances stored twice. The issue is explained here: https://github.com/Azure/azure-functions-host/issues/6564. TBH, it's a bit annoying that issues like these are not prioritized higher by Microsoft.

Error converting 1 input parameters for Function

This is a really strange issue that seems to originate in auto-generated code. The issues is when using the Service Bus trigger alongside a CancellationToken. The issue is explained here: https://stackoverflow.com/questions/77898023/error-converting-1-input-parameters-for-function-cannot-convert-input-parameter. I haven't experienced this in a while so there might be a fix made already.

I'm sure there are a couple more. I'll update this post when my memory clears 😅

elmah.io: Error logging and Uptime Monitoring for your web apps

This blog post is brought to you by elmah.io. elmah.io is error logging, uptime monitoring, deployment tracking, and service heartbeats for your .NET and JavaScript applications. Stop relying on your users to notify you when something is wrong or dig through hundreds of megabytes of log files spread across servers. With elmah.io, we store all of your log messages, notify you through popular channels like email, Slack, and Microsoft Teams, and help you fix errors fast.

See how we can help you monitor your website for crashes Monitor your website