Bye bye Azure Functions, Hello Azure Container Apps: Migrating from Azure Functions to ASP.NET Core
As I discussed in part 1 of this series, we've decided to take an internal application whose APIs are currently running as Azure Functions and move them to be hosted in Azure Container Apps. Part 1 explains the reasons for this, which are mainly to do with cold start issues and the cost of addressing those on the existing platform.
As a first step we decided to migrate the Azure Functions apps to run as ASP.NET Core applications. This wasn't strictly necessary; we would have been able to containerise them in their existing form. However, our longer term plans involve adding Dapr features to the application and we thought it would be simpler to do this with a vanilla ASP.NET Core app as support for using Dapr in Azure Functions isn't quite as well evolved so far.
Step 1: Basic endpoint migration
Since our Functions apps were hosting HTTP APIs, the vast majority of the Functions we had represent HTTP endpoints. Under normal circumstances, each of these would be migrated to a controller action in a standard ASP.NET Controller. In our case, however, we had a secret weapon which meant that the transition was far easier: our Menes framework.
Menes is an OpenAPI contract-first framework for building web APIs, and supports hosting those APIs in both Azure Functions and ASP.NET core. Conceptually, it's quite simple; you define your API using an OpenAPI contract (normally written as a YAML file that looks something like this). Then you implement the operations in service classes, which look like this. These service classes and the methods within them are analagous to ASP.NET Controllers and their methods.
Once you've done that, Menes handles incoming requests and uses your OpenAPI contract to validate them, ensuring that headers, parameters and request bodies adhere to the schema you've specified. It does the same for responses, to ensure you're not trying to return something that doesn't match up with the contract. Consumers can use the contract to generate client libraries using tools like AutoRest.
Menes hooks into the host in different ways depending on what you're doing - for our existing Azure Functions hosting, that's via an HTTP triggered Function with a catch-all route, which looks like this. For ASP.NET Core, Menes plugs in via middleware in much the same way as ASP.NET MVC does.
So for us, the switchover from a Functions host to an ASP.NET Core one didn't mean we had to re-write our Functions as controller actions; it was simply a case of changing the project type and wiring Menes up appropriately. However, there were some other changes we had to make.
(If you'd like to learn more about Menes, please leave a comment - if there's enough interest, I'll look at writing some more posts on the subject.)
Step 2: Changes to startup code
Startup and configuration are similar in the Azure Functions and ASP.NET Core world, but there are a few differences.
In the Functions world, we had a class which inherits from FunctionsStartup
and which was wired up via the WebJobsStartup
attribute:
[assembly: Microsoft.Azure.WebJobs.Hosting.WebJobsStartup(typeof(MyNamepace.Startup))]
namespace MyNamespace
{
// ... various using statements
public class Startup : FunctionsStartup
{
public override void Configure(IFunctionsHostBuilder builder)
{
// ... startup code, such as adding our services to the IServiceCollection, goes in here.
}
public override void ConfigureAppConfiguration(IFunctionsConfigurationBuilder builder)
{
// Access the `IConfigurationBuilder` via `builder.ConfigurationBuilder` to add anything
// needed. Basic configuration will already have been added by the host.
}
}
}
Various bits of config are added by default before ConfigureAppConfiguration
is invoked; I covered some of this in my post "Configuration in Azure Functions - What's in the box?". This is done because (when using in-process mode) you share the resulting configuration with the host process. In our case, we were using this method to add the configuration provider for Azure App Configuration.
In the ASP.Net Core world, this is achieved slightly differently.
The entry point for your application will likely be the standard Main
method, which will configure and run your host (a host is an object which encapsulates all of an application's resources). When you create a new ASP.NET Core application in Visual Studio you get code that looks like this:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();
WebApplication.CreateBuilder
is a streamlined way to set up a web application host, but doesn't support use of a separate Startup
class. Since we already had our Startup
class - albeit currently written for the Functions world, the simplest solution seemed to be to continue using that class. This requires slightly different code in the Main
method:
IHostBuilder builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder(args)
.ConfigureAppConfiguration(ConfigureAppConfiguration)
.ConfigureWebHostDefaults(x => x.UseStartup<Startup>());
Documentation on the generic host builder can be found here. In short, using CreateDefaultBuilder
gives you a host builder that's setup with standard configuration and logging providers. Then using ConfigureWebHostDefaults
layers in the standard components you need to host a web app - documentation on this can be found here.
Crucially, this also allows us to use our Startup
class, after a few tweaks have been applied.
Our Startup
class no longer inherits FunctionsStartup
- in fact, it doesn't need to inherit anything. Our service provider setup code (which is the bulk of what was in our startup class) is now in a method that looks like this...
public void ConfigureServices(IServiceCollection services)
{
// ... add to IServiceCollection in here.
}
... and we have a new method that looks like this...
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILogger<Startup> logger)
{
// ... add middleware and do any other configuration here.
}
... in which we add our Menes middleware.
Our ConfigureAppConfiguration
method which was previously part of the Startup
class is now invoked as part of the HostBuilder
setup, so has moved to live alongside that code in the Program
class.
With those changes in place, our ASP.NET Core app was able to start up with minimal issues.
Step 3: Testing and missing services
By this point, the APIs were seemingly running correctly, but we quickly found that we were missing something - specifically, App Insights integration.
With Azure Functions, App Insights is configured by the host - and there are various pieces of information that the host logs for you as standard. All you have to do is make sure you provide a configuration value called APPINSIGHTS_INSTRUMENTATIONKEY
. This is normally in local.settings.json
when running locally, and provided via an environment variable when running in Azure.
This means that if you need to do anything additional, you can simply take a dependency on TelemetryClient
. This isn't the case in ASP.NET Core. Fortunately, it's trivially simple to add into ConfigureServices
:
services.AddApplicationInsightsTelemetry()
This expects to find the App Insights connection string in your config, specifically in a section that looks like this:
{
"ApplicationInsights": {
"ConnectionString" : "Copy connection string from Application Insights Resource Overview"
}
}
However, our configuration wasn't structured that way, and since we already had Bicep templates to deploy our config in App Configuration, we decided it would be easier to keep using what we had. Fortunately there's an overload to the AddApplicationInsightsTelemetry
method for this scenario:
services.AddApplicationInsightsTelemetry(configuration["APPINSIGHTS_INSTRUMENTATIONKEY"]);
Step 4: What to do with our one TimerTrigger
Function?
The last issue we had to deal with was the one Azure Function we had which wasn't using an HTTP trigger. We had one Function using a TimerTrigger that pushes usage data into Stripe as part of our billing mechanism. There's no natural home in ASP.NET Core web applications for this kind of code, and it's worth saying that this is precisely the kind of thing that Azure Functions is really good for.
We identified the following options for this code:
- Leave it in a standalone Azure Function.
- Pull it into the new ASP.NET Core app anyway, and turn it into a hosted service.
- Use Dapr's CRON binding.
One of the big risks in a migration project such as this is that the scope has the potential to expand at each stage as you learn and discover things you weren't expecting. We were keen to avoid that, and as a result we chose what we agreed was probably the worst option on that list: the hosted service.
The first option would be good - likely the best, but it would be a bigger change than it seems as we'd have needed to break it out into its own project and add the necessary infrastructure as part of our Bicep templates to deploy it.
The third option would also be an interesting one, and definitely better than option 2. However, we would then be expanding our scope by bringing in Dapr.
However, and as I mentioned in the first post in this series, we do plan to use this application as a platform for learning more about Dapr. So we made the decision to go with option 2 on the basis that this required the least work and that we would then look at option 3 once we'd completed the migration.
So, we implemented a class inheriting from BackgroundService
which determines how long it needs to wait before it's due to run the task, waits for that amount of time and then executes the code that was originally in the Function. I'm not going to share that class here because it's purely a temporary solution and not something we'd recommend anyone actually use in practice.
Since we're only using the application internally at the moment, integration with the billing platform isn't critical. This means the impact of the code failing is very low. Hopefully I'll be able to return to this in a future blog post to describe how we use this with the Dapr CRON binding - or possibly my colleague James Dawson will get there first, in his "Adventures in Dapr" series.
Learnings and next steps
This part of our migration seemed to go really smoothly - moving from Azure Functions-based hosting to ASP.NET Core hosting for all three of our APIs was done in a matter of hours, although the fact that our Menes framework meant we didn't need to rewrite our services made a big difference there. Whilst we've used it in the past to host APIs in both Functions and ASP.NET Core, this was the first time we've migrated from one to the other and it was extremely satisfying to find the process so straightforward.
It's also a good reminder to avoid putting your application code anywhere that's host-specific. For example, when using ASP.NET MVC, good practice is to avoid putting your application specific code in your controllers. Instead, the controllers are there to handle the incoming request, invoke code elsewhere to process it, and then to return the response. This isn't always something that's reflected in online samples, tutorials and so on, so it's worth restating.
With our APIs migrated to ASP.NET Core, the next step was to get them containerised and deployed into Azure Container Apps, and we'll cover this in Part 3. And we'll be returning to the code in Part 4 to deal with some issues we ran into around CORS and authentication.