しばやん雑記

Azure とメイドさんが大好きなフリーランスのプログラマーのブログ

Azure Functions (.NET Isolated Worker) に追加された ASP.NET Core Integration を一通り試した

今年の 11 月にリリース予定の .NET 8 と同時に .NET 向け Azure Functions は、これまでの In-Process モデルから Isolated Worker Process というモデルに統一されるのですが、正直なところ完成度が低いのと In-Process からの移行を全く考慮していない SDK などの要因で .NET 6 の LTS が切れるまでは In-Process でギリギリまで粘るつもりでした。

流石に Azure Functions の開発チームも In-Process からの移行が全然進んでいないことを気にしているのか、In-Process で対応していた機能を Isolated 側でも実装するようになってきました。このあたりについては以下の Issue にまとまっています。

正直なところ HttpTrigger での ASP.NET Core 関連クラスのサポートがなくなり、完成度の低い HTTP 抽象化モデルになってしまったのが最悪でしたが、ASP.NET Core Integration という形で現在プレビュー段階ですが利用できるようになりました。

利用方法は公式ドキュメントにまとまっていますので、テンプレートで生成されたコードを少し変更するだけで有効化出来ます。使い勝手としては In-Process とほぼ同じです。

これまでは全ての Function 実行が gRPC 経由で行われていましたが、今回 ASP.NET Core Integration のために HTTP の場合は直接バックエンドの Worker にプロキシする機能が追加されたようです。アーキテクチャ図が以下の Issue に載っているので分かりやすいです。

HTTP に対しても gRPC に一度変換していたことで、余計なオーバーヘッドや Stream が使えないといった問題が発生していたのが、ASP.NET Core Integration と同時に追加された仕組みで綺麗に解消しそうです。

軽く実装を見た感じではリバースプロキシには YARP が使われているようなので信頼性は非常に高そうですし、パフォーマンス面でも問題は発生しないでしょう。仕組みとしては .NET 専用というわけではないので、将来的には他の言語向けにも追加される可能性が高そうです。*1

ASP.NET Core Integration を有効化する

ここまでは .NET Isolated Worker の問題点と ASP.NET Core Integration での変更について簡単に書いてきましたので、ここからは実際に ASP.NET Core Integration を触っていくことにします。

Azure Functions のプロジェクトは当然ながら .NET Isolated で作成する必要がありますが、.NET のバージョンは 6 か 7 の好きな方を選べばよいです。現時点では特に .NET のバージョン違いで差はないようですが、今から .NET 7 を選ぶ理由はほぼないので .NET 6 を選んでいます。

プロジェクトを作成したら NuGet から Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore をインストールします。まだプレビューなので可能な限り最新版を使うようにします。

パッケージをインストールすれば Program.cs を以下のように ConfigureFunctionsWebApplication を呼び出すように変更します。これだけで HttpTrigger 内で ASP.NET Core のクラスが使えるようになります。

using Microsoft.Extensions.Hosting;

var host = new HostBuilder()
    .ConfigureFunctionsWebApplication()
    .Build();

host.Run();

後はテンプレートで生成された HttpTrigger の実装を In-Process のように変更するだけです。一般的には HttpRequest を受け取って IActionResult を返すようにするのが分かりやすいと思います。Function の実装が In-Process と全く同じになったので、ようやくスムーズに移行が出来そうな気がしてきましたね。

public class Function1
{
    private readonly ILogger _logger;

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

    [Function("Function1")]
    public IActionResult Run([HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequest req)
    {
        _logger.LogInformation("C# HTTP trigger function processed a request.");

        return new OkObjectResult("Welcome to Azure Functions!");
    }
}

これでコード側の修正は完了ですが、動作させるためには追加の設定が必要になります。

ASP.NET Core Integration は新しいアーキテクチャ上に構築されているので、現状は local.settings.jsonAzureWebJobsFeatureFlagsEnableHttpProxying を設定する必要がありますが、将来的に Azure Functions Host が 4.24.3 以上に更新されると必要なくなります。

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

ちなみに Azure 上にデプロイされているのは 4.24.3 なので設定は必要ありませんが、Core Tools の方は古いバージョンなので挙動が異なっています。こういうバージョンのミスマッチは困ったものです。

これで全ての設定が完了したので Visual Studio からデバッグ実行すると、HttpTrigger の動作を確認できるはずです。シンプルな HttpTrigger だとあまり差を感じませんが、実装は HTTP 向けに効率化されています。

Azure Functions Host は YARP を使ったリバースプロキシ、.NET Isolated 側は ASP.NET Core の Endpoint Routing を利用した Function 呼び出しというように、In-Process の時よりも圧倒的にシンプルになったので、謎の挙動に悩まされることが減る可能性が高いです。

例を挙げるとストリーミングを利用した際の挙動が大幅に改善されています。ASP.NET Core Integration を使わずにファイルのダウンロードを Stream で実装すると以下のようなコードになりますが、実行してみるとストリーミングで処理されません。

public class Function1
{
    private readonly ILogger _logger;
    private static readonly HttpClient _httpClient = new();

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

    [Function("Function1")]
    public async Task<HttpResponseData> Run([HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req)
    {
        _logger.LogInformation("C# HTTP trigger function processed a request.");

        var stream = await _httpClient.GetStreamAsync("https://github.com/Azure/azure-functions-core-tools/releases/download/4.0.5198/func-cli-4.0.5198-x64.msi");

        var response = req.CreateResponse(HttpStatusCode.OK);
        response.Headers.Add("Content-Type", "application/octet-stream");

        response.Body = stream;

        return response;
    }
}

メモリの使用量を見ると分かるように、GetStreamAsync で返ってきたデータを全てメモリ上に展開してから処理するので、ダウンロード開始までに時間がかかりますし、そもそもリソースの使用効率が悪いです。

この挙動は応答を gRPC として渡していたので Stream として扱えないことが根本原因です。

新しい ASP.NET Core Integration を利用すると FileStreamResult を使って簡単にファイルダウンロードを実装出来ます。HTTP はプロキシ経由でクライアントに渡されるので Stream のまま扱うことが出来ます。

public class Function1
{
    private readonly ILogger _logger;
    private static readonly HttpClient _httpClient = new();

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

    [Function("Function1")]
    public async Task<IActionResult> Run(
        [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequest req)
    {
        _logger.LogInformation("C# HTTP trigger function processed a request.");

        var stream = await _httpClient.GetStreamAsync("https://github.com/Azure/azure-functions-core-tools/releases/download/4.0.5198/func-cli-4.0.5198-x64.msi");

        return new FileStreamResult(stream, "application/octet-stream");
    }
}

同じように実行してみると、ASP.NET Core Integration の場合はメモリ使用量が増えていないことが確認できます。正しくストリーミングでダウンロードが行われているので効率的です。

ここまで ASP.NET Core Integration のメリットについても書いてきましたが、実際には HttpTrigger を扱う新しいアーキテクチャのメリットという感じです。非常にシンプルで信頼性の高いコンポーネントで構築されており、コントロール可能な部分が増えたので歓迎したいですね。

ここからは実際に ASP.NET Core Integration を試していた時に気になった点を書いています。

ASP.NET Core の HttpContext を取得する

古い HttpTrigger は gRPC ベースだったので HttpContext 自体が存在していませんでしたが、ASP.NET Core Integration を使うと HttpContext が内部的に存在しています。

しかし直接 HttpContext が DI やバインディングで渡されるわけではなく、FunctionContext 経由で取得する必要がある点には注意が必要です。FunctionContext はメソッドへのバインディングで取得出来ます。

以下のサンプルコードのようにバインディングで取得した FunctionContext に対して、GetHttpContext 拡張メソッドを使うと HttpContext を取り出せます。

public class Function1
{
    private readonly ILogger _logger;

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

    [Function("Function1")]
    public IActionResult Run(
        [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequest req, 
        FunctionContext functionContext)
    {
        _logger.LogInformation("C# HTTP trigger function processed a request.");

        var httpContext = functionContext.GetHttpContext();

        return new OkObjectResult("Welcome to Azure Functions!");
    }
}

デバッグ実行してみると HttpContext が正しく取得出来ていることが確認できます。

In-Process では IHttpContextAccessor を DI 経由で受け取ることも出来ましたが、Isolated ではエラーになるのでそのままでは使うことが出来ません。利用するためには以下のように DI に登録して、Function Middleware で現在の HttpContext を設定してあげる必要があります。

using Microsoft.AspNetCore.Http;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

var host = new HostBuilder()
    .ConfigureFunctionsWebApplication(workerApplication =>
    {
        workerApplication.UseMiddleware(async (context, next) =>
        {
            var httpContextAccessor = context.InstanceServices.GetRequiredService<IHttpContextAccessor>();

            httpContextAccessor.HttpContext = context.GetHttpContext();

            await next();
        });
    })
    .ConfigureServices(services =>
    {
        services.AddHttpContextAccessor();
    })
    .Build();

host.Run();

これで DI 経由で IHttpContextAccessor を取得できるようになったので、以下のように Function の実装を行えば HttpContext を取得出来ます。IHttpContextAccessor は意外と必要になるケースが多いので、対応方法としては覚えておいて損はないと思います。

public class Function1
{
    private readonly ILogger _logger;

    public Function1(IHttpContextAccessor httpContextAccessor, ILogger<Function1> logger)
    {
        _httpContextAccessor = httpContextAccessor;
        _logger = logger;
    }

    private readonly IHttpContextAccessor _httpContextAccessor;

    [Function("Function1")]
    public IActionResult Run(
        [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequest req)
    {
        _logger.LogInformation("C# HTTP trigger function processed a request.");

        var httpContext = _httpContextAccessor.HttpContext;

        return new OkObjectResult("Welcome to Azure Functions!");
    }
}

こちらもデバッグ実行してみると、正しく HttpContext が取得出来ていることが確認できます。

将来的には FunctionContextIHttpContextAccessor のように DI 経由で取得できるようになるかもしれませんが、まだ設計段階のようなので時間がかかりそうな気配です。

この辺りは自作ライブラリで利用するために念入りに確認した経緯があります。In-Process から Isolated へスムーズに移行するために、ライブラリで違いを吸収するように作ってあります。

ASP.NET Core Integration によって HttpTrigger の移行はスムーズに行えそうな手ごたえがあります。

Kestrel のオプションを変更する

Isolated の ASP.NET Core Integration は Kestrel と Endpoint Routing 上に実装されているので、In-Process とは異なり Kestrel の設定がカスタマイズ可能です。

試している範囲だとファイルアップロードが例によって 30MB ぐらいで失敗してしまったので、リクエストボディの上限値を変更する必要がありました。詳細は以下のドキュメントを参照してください。

ドキュメントでは ConfigureKestrel を使って設定をしていますが、Isolated では Configure<T> を使って KestrelServerOptions を直接設定するように書けば反映されます。以下のサンプルでは MaxRequestBodySize の設定を変えて、大きなファイルでもアップロード可能にしています。

var host = new HostBuilder()
    .ConfigureFunctionsWebApplication()
    .ConfigureServices(services =>
    {
        services.Configure<KestrelServerOptions>(options =>
        {
            options.Limits.MaxRequestBodySize = 300_000_000;
        });
    })
    .Build();

host.Run();

このサンプルでは一括で制限値を変更しましたが、シナリオによっては特定の Function のみ制限を緩くしたいといったケースもあります。そういった場合には Middleware を利用することで対応可能です。Function の Middleware については以下のドキュメントを参照してください。

ASP.NET Core にはリクエスト単位でボディのサイズを制限する IHttpMaxRequestBodySizeFeature が用意されていますので、これを利用して Function 単位で設定を変更することが出来ます。

以下のようなコードを書くと Function1 だけボディのサイズ制限をカスタマイズすることが可能です。

var host = new HostBuilder()
    .ConfigureFunctionsWebApplication(workerApplication =>
    {
        workerApplication.UseWhen(context => context.FunctionDefinition.Name == "Function1", async (context, next) =>
        {
            var httpContext = context.GetHttpContext();
            var httpMaxRequestBodySizeFeature = httpContext.Features.Get<IHttpMaxRequestBodySizeFeature>();

            if (httpMaxRequestBodySizeFeature is not null)
            {
                httpMaxRequestBodySizeFeature.MaxRequestBodySize = 300_000_000;
            }

            await next();
        });
    })
    .Build();

host.Run();

ASP.NET Core では Features が良く出てきますが、.NET Isolated にも別途 Features が用意されているので混同しないように注意が必要です。具体的には FunctionContext にも Features が存在しますが ASP.NET Core の Features とは全く別物です。

ルーティングの挙動に In-Process と同様の問題あり

Azure Functions の In-Process の時からルーティングに癖があったのですが、何故か Isolated でも同じ癖を受け継いでいるようで困りました。具体的には以下のようなルーティングを書いた時に発生します。

public class Products
{
    [Function(nameof(Details))]
    public IActionResult Details(
        [HttpTrigger(AuthorizationLevel.Function, "get", Route = "Products/{id}")] HttpRequest req,
        string id)
    {
        return new OkObjectResult("Invoke Details function");
    }

    [Function(nameof(List))]
    public IActionResult List(
        [HttpTrigger(AuthorizationLevel.Function, "get", Route = "Products/List")] HttpRequest req)
    {
        return new OkObjectResult("Invoke List function");
    }
}

本来であれば Products/List の方が Products/{id} よりも具体的な定義なので、Products/List にアクセスされた場合は List の呼び出しが優先されるはずですが、Azure Functions のルーティング実装では何故かアルファベット順になっているようです。

以下は実際にアクセスした例ですが、意図した List への呼び出しではなく Details が呼び出されていることが分かります。ちなみに Details の Function 名を X などに変えると意図した通りの挙動になります。

この挙動は ASP.NET Core のルーティングとはドキュメントにもあるように当然ながら異なっていますし、利用する側としても自然なものではないため非常に扱いにくいです。

一応 Issue は作成していますが、重要性を理解してもらえない可能性が高いのであまり期待していません。何とか DI でカスタマイズして挙動を改善できないか検討している段階です。

折角 YARP と Endpoint Routing を使っているのに、最後の最後でいまいちな実装が残っていて残念です。

*1:特に Python は既に WSGI 対応が追加されているので対応するメリットが大きそう