.Net Core

Migrer vos Azure Functions .NET 6 vers .NET 8 sur le modèle isolated-worker

Avr 12, 2024

Romain LAPREE-KAMINSKI

Les Azure Functions sont des services serverless proposés par Azure qui permettent d’exécuter du code en réponse à des événements déclenchés par requêtes HTTP, des minuteries ou d’autres ressources Azure (CosmosDB, Service Bus…). Ces fonctions ont l’avantage de permettre aux développeurs de se concentrer sur l’écriture de code métier plutôt que sur la gestion de l’infrastructure sous-jacente.

Deux modèles de déploiement sont disponibles lorsque ces fonctions sont écrites en .NET : le modèle intégré (in-process) et le modèle isolé (isolated worker).

  1. Modèle in-process – Dans ce modèle, le code de l’Azure Function s’exécute dans le même processus que le runtime Azure Function. Cela signifie que la fonction partage le même espace mémoire que le runtime, ce qui peut entraîner certaines limitations en termes de performance, de sécurité et de compatibilité des versions des dépendances.
  2. Modèle isolated worker – Ce modèle est une nouvelle approche dans laquelle chaque fonction s’exécute dans son propre processus, isolé du runtime Azure Function. Cela offre plusieurs avantages, notamment une meilleure isolation, une évolutivité accrue, une compatibilité avec les dernières versions de .NET, une meilleure prise en charge des dépendances et des performances améliorées.

En résumé, le modèle isolated-worker représente l’avenir des Azure Functions, offrant de nombreux avantages aux développeurs. Dans cet article, nous explorerons, au travers d’un exemple de projet, les étapes nécessaires pour migrer une Azure Function .NET 6 du modèle in-process vers le modèle isolé en .NET 8.

Projet initial

Le projet support de ce guide de migration contient deux fonctions purement fictives permettant de mettre en évidence la majorité des changements entre les deux modèles de déploiements. La première fonction basée sur un Timer trigger a une liaison d’entrée et deux liaisons de sortie sur un compte de stockage de type Blob. La seconde fonction utilise un Http trigger. Ce projet dispose d’un système d’injection de dépendances configuré dans le fichier Startup.cs.

using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;

namespace FunctionApp6IP
{
    public static class Function1
    {
        [FunctionName("FunctionTriggerAndBindings")]
        public static void RunFunctionTriggerAndBindings([TimerTrigger("0 */5 * * * *")] TimerInfo myTimer,
                [Blob("images-sm/{name}", FileAccess.Write, Connection = "MyStorageConnection")] Stream imageSmall,
                [Blob("images-md/{name}", FileAccess.Write, Connection = "MyStorageConnection")] Stream imageMedium,
                [Blob("images-src/{name}", FileAccess.Read, Connection = "MyStorageConnection")] Stream imageOrigin,
                ILogger log)
        {
            log.LogInformation($"C# Timer trigger function executed at: {DateTime.Now}");
            //Code to generate images from source image
        }

        [FunctionName("FunctionJson")]
        public static async Task<IActionResult> RunFunctionJson(
            [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
            ILogger log)
        {
            log.LogInformation("C# HTTP trigger function processed a request.");

            string name = req.Query["name"];
            
            if (string.IsNullOrEmpty(name))
            {
                string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
                dynamic data = JsonConvert.DeserializeObject(requestBody);
                name = name ?? data?.name;
            }
            string responseMessage = string.IsNullOrEmpty(name)
                ? "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."
                : $"Hello, {name}. This HTTP triggered function executed successfully.";

            return new OkObjectResult(responseMessage);
        }
    }
}
[assembly: FunctionsStartup(typeof(FunctionApp6IP.Startup))]
namespace FunctionApp6IP
{
    public class Startup : FunctionsStartup
    {
        public override void Configure(IFunctionsHostBuilder builder)
        {
            var configuration = new ConfigurationBuilder()
                .SetBasePath(Environment.CurrentDirectory)
                .AddJsonFile("local.settings.json", optional: true, reloadOnChange: true)
                .AddEnvironmentVariables()
                .Build();

            var serviceBusSettings = configuration.GetSection("ServiceBusSettings").Get<ServiceBusSettings>();

            builder.Services.AddAzureClients(azureClientsBuilder =>
                azureClientsBuilder.AddBlobServiceClient(serviceBusSettings.ConnectionString)
                );
        }
    }
}
public class ServiceBusSettings
{
    public string ConnectionString { get; internal set; }
}

Utilisation de l’assistant de migration .NET

L’assistant de migration .NET est un outil fourni par Microsoft pour aider les développeurs à migrer leurs projets vers les dernières versions de .NET. Cet outil est particulièrement utile car il peut automatiser de nombreuses tâches courantes liées à la mise à jour du code, des dépendances et des configurations.

Dans le cadre d’une utilisation sur une Azure Function .NET 6, l’assistant va non seulement effectuer la migration vers .NET 8, mais il va aussi basculer vers le modèle isolated-worker. En effet, au moment où ces lignes sont écrites, l’utilisation du modèle in-process n’est pas disponible en .NET 8.

Même si cet assistant permet de faire gagner du temps, il ne réalise pas l’ensemble de la migration dans la majorité des cas. Il est donc important d’identifier ce qui est fait par l’assistant pour vous permettre de compléter et d’ajuster si besoin.

Pour installer l’assistant, je vous invite à suivre le guide de Microsoft.

On lance donc l’assistant de migration (clic droit sur le projet -> Upgrade), on sélectionne .NET 8 et surtout, on suit dans la fenêtre de sortie l’ensemble du travail réalisé.

Mise à jour du fichier csproj

L’assistant est normalement en capacité de s’occuper de l’ensemble des modifications à apporter au fichier csproj, à savoir :

  • Passage de propriété targetFramework de net6 à net8.0
  • Ajout de la propriété outputType pour pointer vers la génération d’un exécutable
  • Ajout du contexte d’exécution qui pourra ensuite être utilisé par vos fonctions
  • Suppression des packages liés au in-process à savoir :
    • Microsoft.NET.Sdk.Functions
    • Microsoft.Azure.WebJobs.Extensions.*
    • Microsoft.Azure.Functions.Extensions
  • Ajout des packages liés au modèle isolated worker à savoir :
    • Microsoft.Azure.Functions.Worker
    • Microsoft.Azure.Functions.Worker.Sdk
    • Microsoft.Azure.Functions.Worker.Extensions.*

En sortie de migration, votre projet devrait ressembler à cela :

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <AzureFunctionsVersion>v4</AzureFunctionsVersion>
    <OutputType>Exe</OutputType>
    <ImplicitUsings>enabled</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.Azure.Functions.Worker" Version="1.21.0" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="1.17.2" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Timer" Version="4.3.0" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.1.0" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Storage.Blobs" Version="6.3.0" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="1.2.1" />
  </ItemGroup>
  <ItemGroup>
    <None Update="host.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="local.settings.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
      <CopyToPublishDirectory>Never</CopyToPublishDirectory>
    </None>
  </ItemGroup>
  <ItemGroup>
    <Using Include="System.Threading.ExecutionContext" Alias="ExecutionContext" />
  </ItemGroup>
</Project>

Création du fichier Program.cs

L’assistant crée ensuite le fichier Program.cs permettant notamment la création, la configuration et lancement de l’hôte.

C’est ici qu’on basculera l’injection de dépendances plus tard dans notre migration.

Mise à jour du code des fonctions

La dernière étape du processus de migration par l’assistant .NET consiste à mettre à jour le code de nos fonctions et plus particulièrement les attributs, les déclencheurs et les liaisons :

  • l’attribut FunctionName devient Function
  • les liaisons en tant que paramètres de fonction deviennent des attributs sur lesquels sont ajoutés les suffixes Input ou Output
  • les using sont mises à jour avec les nouvelles références

L’assistant reste un assistant…

L’assistant fait une bonne partie du travail mais certaines actions ne peuvent se faire que manuellement. A la fin de l’exécution de l’assistant, plusieurs problèmes subsistent et notre projet ne compile pas


Les actions manuelles de migration

Mise à jour des dépendances

Dans un premier temps, il nous faut ajouter la dépendance que l’assistant de migration n’a pas réussi à aller chercher à savoir Microsoft.Azure.Functions.Worker.Extensions.Storage.Blobs. Pourquoi n’a-t-il pas réussi ? Probablement parce que sur le modèle in-process il n’y avait qu’un package Microsoft.Azure.WebJobs.Extensions.Storage et que dans le modèle isolated-worker chaque module (Blob, Queue, Table) a son propre package.

Ensuite, même si ce n’est pas obligatoire, je vous conseille vivement d’en profiter pour mettre à jour l’ensemble de vos packages vers leur version stable la plus récente. Attention cependant aux mises à jour majeures qui peuvent inclure des changements importants dans votre code source.

Liaisons de sortie

L’assistant de migration a mis à jour les liaisons d’entrée/sortie, mais dans notre projet ce n’est pas suffisant.

Dans le cas d’une liaison de sortie unitaire, il suffit de changer le paramètre de sortie de la fonction. Par exemple pour une sortie vers un Azure Service Bus, on pourrait retourner une chaîne de caractères qui serait un message publié dans la file.

Pour une liaison de sortie multiple, c’est à dire lorsqu’on souhaite transmettre plusieurs informations à un ou plusieurs services, il faut créer et retourner un objet de type DTO contenant l’ensemble de nos liaisons de sortie. C’est le cas de notre projet avec la publication de deux images dans le conteneur d’un compte de stockage.

[Function("FunctionTriggerAndBindings")]
public static MultiOutputBinding RunFunctionTriggerAndBindings([TimerTrigger("0 */5 * * * *")] TimerInfo myTimer,
        [BlobInput("images-src/{name}", Connection = "MyStorageConnection")] Stream imageOrigin,
        ILogger log)
{
    log.LogInformation($"C# Timer trigger function executed at: {DateTime.Now}");

    Stream smallImageStream = null;
    Stream mediumImageStream = null;
    //Code to generate images from source image

    return new MultiOutputBinding
    {
        SmallImage = smallImageStream,
        MediumImage = mediumImageStream
    };
}
public class MultiOutputBinding
{

    [BlobOutput("images-sm/{name}", Connection = "MyStorageConnection")]
    public Stream SmallImage { get; set; }
    [BlobOutput("images-md/{name}", Connection = "MyStorageConnection")]
    public Stream MediumImage { get; set; }
}

Sérialiseur JSON

A l’inverse du modèle in-process qui utilise la librairie NewtonSoft pour la sérialisation JSON, le modèle isolated-worker utilise System.Text.Json. De ce fait, il faut soit injecter NewtonSoft en tant que dépendance externe, soit migrer notre code pour utiliser le package standard. C’est cette seconde option que j’utilise pour ma migration.

[Function("FunctionJson")]
public static async Task<IActionResult> RunFunctionJson(
    [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
    ILogger log)
{
    log.LogInformation("C# HTTP trigger function processed a request.");

    string name = req.Query["name"];

    if (string.IsNullOrEmpty(name))
    {
        string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
        dynamic data = JsonSerializer.Deserialize<dynamic>(requestBody);
        name = data?.name;
    }

    string responseMessage = string.IsNullOrEmpty(name)
        ? "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."
        : $"Hello, {name}. This HTTP triggered function executed successfully.";

    return new OkObjectResult(responseMessage);
}

Utilisation de l’injection de dépendances standard

Comme dit plus haut, le passage à un modèle isolated worker permet désormais d’utiliser l’injection de dépendances standard. Si vous êtes habitué à développer en .NET, il n’y aura rien de nouveau ici mise à part l’appel à ConfigureFunctionsWebApplication qui permet d’ajouter l’intégration ASP.NET Core.

Dans notre modèle in-process, nous avions utilisé l’injection de dépendances via un fichier Startup.cs pour intégrer notre client Azure permettant la communication avec notre Azure Service Bus. Il suffit donc de le déplacer dans notre nouveau fichier Program.cs. C’est dans ce fichier que vous pourrez aussi intégrer des middlewares, par exemple pour la gestion centralisée des exceptions.

using Microsoft.Extensions.Azure;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using System;


var configuration = new ConfigurationBuilder()
               .SetBasePath(Environment.CurrentDirectory)
               .AddJsonFile("local.settings.json", optional: true, reloadOnChange: true)
               .AddEnvironmentVariables()
               .Build();

var serviceBusSettings = configuration.GetSection("ServiceBusSettings").Get<ServiceBusSettings>();

var host = new HostBuilder()
    .ConfigureFunctionsWebApplication()
    .ConfigureServices(s =>
    {
        s.AddAzureClients(azureClientsBuilder =>
                azureClientsBuilder.AddBlobServiceClient(serviceBusSettings.ConnectionString)
        );
    })
    .Build();

host.Run();

Logging

L’utilisation par défaut du ILogger en tant que paramètre des fonctions en mode in-process est remplacée par l’utilisation de l’injection de dépendances.

Il convient donc d’ajouter un constructeur pour les classes hébergeant vos fonctions est d’inclure le ILogger<T>, ce qui peut également inclure le passage de méthodes statiques à des méthodes de classe.

public class Function1
{
    private readonly ILogger<Function1> log;

    public Function1(ILogger<Function1> logger)
    {
        log = logger;
    }

    [Function("FunctionTriggerAndBindings")]
    public MultiOutputBinding RunFunctionTriggerAndBindings([TimerTrigger("0 */5 * * * *")] TimerInfo myTimer,

            [BlobInput("images-src/{name}", Connection = "MyStorageConnection")] Stream imageOrigin)
    {
        log.LogInformation($"C# Timer trigger function executed at: {DateTime.Now}");

        Stream smallImageStream = null;
        Stream mediumImageStream = null;
        //Code to generate images from source image

        return new MultiOutputBinding
        {
            SmallImage = smallImageStream,
            MediumImage = mediumImageStream
        };
    }
}

Garder à l’esprit également que la configuration du logger dans le fichier host.json n’est désormais valable que pour le runtime de l’Azure Function et plus pour vos fonctions. Si besoin, il faudra ajouter votre personnalisation dans la configuration de l’hôte.

Modification du fichier local.settings.json

Enfin, même si le fichier local.setting.json n’est utilisé que sur votre environnement de développement, il faut penser à changer la propriété FUNCTIONS_WORKER_RUNTIME de « dotnet » à « dotnet-isolated »


Test en local et déploiement dans Azure

Avant de conclure cet article, il convient de tester que tout est opérationnel. Lorsque vous lancerez l’application en local, vous devrez obtenir ceci avec notamment la mention « Worker process started and initialized »

Si je teste la fonction basée sur un déclencheur HTTP, j’obtiens bien le message attendu en sortie.

Côté Azure, le seul changement à faire sera de passer le runtime à dotnet-isolated, de la même manière que la modification faite dans le fichier de settings local.

En conclusion, la migration d’une Azure Function vers le modèle isolé en .NET 8 représente une étape cruciale pour optimiser les performances et la sécurité de vos applications. Avec l’aide de l’assistant de migration .NET et une compréhension claire des différences entre les modèles in-process et isolated-worker, cette transition peut être réalisée avec succès.

N’hésitez pas à partager vos réflexions, vos questions et vos expériences dans les commentaires ci-dessous. Votre feedback est précieux pour la communauté des développeurs et peut aider à éclairer d’autres personnes sur leur propre parcours de migration.

0 commentaires

Soumettre un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Découvrez nos autres articles

git et la face cachée du Rebase

git et la face cachée du Rebase

"Faire une rebase ? *sight* heu... ok..." Jean-Michel Fullstack - Développeur fébrile Jean-Michel est inquiet. En effet, lorsque nous collaborons à plusieurs sur un projet, quelque soit les technologies utilisées, il est important de garder à l'esprit que notre...

lire plus
Aller au contenu principal