しばやん雑記

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

Dev Container / GitHub Codespaces を利用した Azure Functions 開発環境のベストプラクティス

昔にも Dev Container を利用して Azure Functions の開発環境を構築する方法を書いたのですが、その後 Dev Container の機能強化と Azure Functions のアップデートによってベストプラクティスが変わってきたので、現時点でのベストプラクティスを確認しておきました。

Windows 環境であれば Visual Studio 2022 を利用しておけば Azure Functions + C# の開発環境は一発で構築できますが、それ以外の言語で特に Python の場合は Dev Container を利用した方が良いケースが多いです。最近では Visual Studio Code を使う人も増えていますし、Dev Container を用意しておくと最悪でも GitHub Codespaces 上でブラウザベースの開発が出来るので便利になります。

Azure Functions 向けの Dev Container は以前はテンプレートを利用して作る流れでしたが、現在公開されている Dev Container 向けのイメージは 1 年以上更新がされていないため、もはや使い物になりません。Dev Container のページでも以下のように表示されています。

古いイメージを使ってしまうと Python V2 / Node.js V4 といった新しいプログラミングモデルが利用できないので、Azure Functions Core Tools のバージョンは常に新しいことが重要になります。

Visual Studio Code を使った Azure Functions の開発には Azure Functions Core Tools がインストールされていれば問題ないので、公式で用意されている各言語向けイメージに Core Tools を自動でインストールするのがシンプルです。作成した Dev Container のテンプレートは以下に公開しておきました。

ここから先はテンプレートの内容を簡単に説明しているだけなので、Dev Container を今すぐ使いたい人はあまり読む必要はないです。C# 向けの Dev Container を利用した例となります。

Dev Container のベースとなるイメージを選択する

Visual Studio Code を使うとドロップダウンでベースとなるイメージを選べますが、以下の公式ページでも一覧は確認出来るので、こちらの方が目当てのイメージを探すのが楽です。

今回は C# を使うので .NET のイメージを選択します。imageVariant は適当に選択しておきます。

重要になるのは devcontainer.jsonimage に書かれた値だけなので、この devcontainer.json をコピペして作っても、新しく作成して image を指定する方法でもどちらでも良いです。

Azure Functions Core Tools を自動インストールする

ベースとなるイメージの選択はシンプルですが、Azure Functions Core Tools のインストールは公式ドキュメントの通りにすると Dockerfile を用意してインストールすることになるので手間です。

最近の Dev Container では Features という形で Dev Container に対して Azure CLI などのツールをインストール出来るようになっているので、これを利用して Dockerfile を用意することなく Azure Functions Core Tools をインストールします。

非常に有難いことに、既に Azure Functions Core Tools をインストールする Feature が公開されているので、これを使うだけで任意の言語 + Azure Functions Core Tools の環境が出来上がります。

使い方も devcontainer.json ファイルの featuresghcr.io/jlaundry/devcontainer-features/azure-functions-core-tools:1 を追加するだけなのでシンプルです。これだけで最新の Core Tools v4 がインストール出来ます。v3 が必要な場合は version を指定します。

現時点の問題としてベースイメージに Debian 12 (bookworm) を指定すると Feature のインストールに失敗します。これは以下の Issue で指摘されている通り、まだ Azure Functions Core Tools が Debian 12 向けに公開されていないことあります。

対応されるまでは Debian 11 (bullseye) のベースイメージを使う必要があります。意外にはまる部分なのでバージョン指定は気を付けたいですね。

拡張機能を自動インストールする

Visual Studio Code で Azure Functions 開発を行うには専用の拡張機能が必要になるので、Dev Container が立ち上がったタイミングで自動的にインストールされるように設定します。

ローカルで拡張機能がインストール済みであれば、歯車ボタンを押して表示されるメニューから Add to devcontainer.json を選びます。これで devcontainer.json に設定が追加されます。

後は言語向けの拡張機能をインストールするようにしておきます。C# の場合は GitHub Codespaces にライセンスが含まれている C# Dev Kit をインストールするようにしておけば、一通りの機能が揃うので簡単です。

これで Azure Functions のプロジェクト作成と、その対応した言語でコードを書く準備が出来ます。後は Azure Functions の実行に必要なストレージアカウントを用意するだけです。

Azurite をバックグラウンドで自動起動する

Visual Studio 2022 には Azurite が組み込まれているので、Azure Functions のプロジェクトを作成すれば自動的に Azurite が起動するので、特に設定の必要なしで Azure Functions アプリケーションの開発が行えていましたが、VS Code ではそこまで高度な統合は用意されていないので、自前で何とかする必要があります。

一応 VS Code 向けの Azurite 拡張は用意されていますが、コマンドパレットで起動や停止を行う必要があるのでシームレスではありません。Azurite は Dev Container の起動と同時に立ち上がっていて欲しいので、今回は Docker Compose を利用して同時に Azurite のコンテナーを立ち上げるようにします。

Dev Container では Docker Compose 定義を使うことも出来るので、今回のように同時にデータストア系のコンテナーが必要な場合にも応用出来ます。

特に Dev Container だから書き方が変わるということはないですが、Dev Container 用のコンテナーがすぐに終わらないように command で無限にスリープするように指定しておくぐらいです。

version: "3"

services:
  app:
    image: mcr.microsoft.com/devcontainers/dotnet:0-6.0-bullseye
    volumes:
      - ..:/workspace:cached
    command: sleep infinity
    network_mode: service:azurite

  azurite:
    image: mcr.microsoft.com/azure-storage/azurite
    restart: unless-stopped

Azurite 用のコンテナーはデータを揮発させるつもりなので、特にボリュームの指定をしていません。永続化が必要な場合はマウントを追加して、Codespaces などの揮発しないストレージに保存させればよいです。

この docker-compose.yml を利用すると Dev Container と Azurite の両方が同時に起動します。

完成した devcontainer.json の例

最後に作成した devcontainer.json を載せておきます。dockerComposeFileservice の指定は Dev Container として Docker Compose を使う際に必要なものです。forwardPorts に Azurite のポートを追加しているので、ローカルの Azure Storage Explorer からアクセス可能です。

{
  "name": "Azure Functions (.NET)",
  "dockerComposeFile": "docker-compose.yml",
  "service": "app",
  "workspaceFolder": "/workspace",
  "forwardPorts": [
    7071,
    10000,
    10001,
    10002
  ],
  "otherPortsAttributes": {
    "onAutoForward": "ignore"
  },
  "features": {
    "ghcr.io/devcontainers/features/azure-cli:1": {},
    "ghcr.io/jlaundry/devcontainer-features/azure-functions-core-tools:1": {}
  },
  "customizations": {
    "vscode": {
      "extensions": [
        "ms-azuretools.vscode-azurefunctions",
        "ms-dotnettools.csdevkit"
      ]
    }
  }
}

この定義を使って Dev Container を起動すると、以下の通り Azurite が利用しているポートが稼働していることが分かります。GitHub Codespaces を使ってブラウザで実行している場合には外部からアクセスできませんが、ローカルの VS Code で開くと localhost にフォワーディングされるので、これまで通り Azure Storage Explorer からアクセス可能となります。

Azure Functions を VS Code から作成すると、デフォルトで用意された local.settings.json 内の AzureWebJobsStorage が空になっていますが、コマンドパレットから設定コマンドを実行するとエミュレーターを選択できます。意外に忘れがちなので注意します。

これで Azure Functions の開発環境が Dev Container / GitHub Codespaces 向けに構築出来ました。新しく Azure Functions のプロジェクトを作成して F5 を押せばスムーズにデバッグ実行まで行えることが確認出来ます。後はこの定義をリポジトリに含めておけば完成です。

今回は .NET 向けでしたが、最初に紹介したリポジトリには各言語向けのサンプルも用意しているので、そちらも参照してください。とはいえ考え方は .NET の時とほとんど同じで、重要なのは Azurite になります。

カスタム Docker イメージを利用する場合

.NET や Node.js ではカスタム Docker イメージを使う機会はほぼ無いのですが、Python の場合はビルドが必要なパッケージがあり、コンテナー側にもインストールが必要なケースも多いのでカスタム Docker イメージを使うことがあります。構成についてはドキュメントを確認してください。

Dev Container や GitHub Codespaces のようなコンテナーベースの環境では、Feature として ghcr.io/devcontainers/features/docker-in-docker:2 を追加するとコンテナーの中でも Docker CLI が使えるようになるので、Azure Functions のカスタム Docker イメージを使った開発が行えるようになります。

公開したリポジトリでは Python 向けの Dev Container 定義のみ対応しています。

Azure Functions の .NET 8 向けアップデートが発表された

.NET 8 の GA が 2 カ月後に迫ってきたこのタイミングで、Azure Functions での .NET 8 向けアップデートが本格的に発表され始めました。そろそろ .NET 6/7 で Isolated を利用しているケースではアップデートを検討しても良いでしょう。.NET 6 の In-Process を利用しているケースは後述しますが急ぐ必要はありません。

そして .NET 8 とは直接関係ないですが、.NET 8 から主流になるはずだった Isolated Worker モデルの改善が同時に公開されました。ようやく In-Process に近い開発体験が得られるようになってきましたが、正直パフォーマンスと信頼性の面ではまだまだという印象です。

しかしアップデート内容としては興味深いものがあるので .NET 10 での Isolated への移行を見越して確認しておきました。.NET 10 がリリースされる頃には十分成熟したものになる予感です。

.NET 8 Isolated への対応が Preview

今回のアップデートのメインはこれです。Azure Functions v4 で .NET 8 Isolated への対応が Preview として公開されました。Azure Functions Core Tools のバージョン 4.0.5312 以降がインストールされていれば、プロジェクト作成時に net8.0 が選べるようになっています。

Visual Studio Code では最新の Azure Functions 拡張と .NET 8 SDK がインストールされていれば、コマンドパレットから新規プロジェクト作成時に .NET 8 Isolated Preview が選べるようになります。

Visual Studio 2022 ではバージョン 17.8 Preview と .NET 8 SDK がインストールされていて、最新の Azure Functions のツールセットとテンプレートに更新済みであれば、プロジェクト作成時に .NET 8.0 Isolated が選べるようになります。17.7 でも .NET 8 SDK が入っていれば選べるかもしれませんが未確認です。

Visual Studio 2022 の場合は WinGet などでインストールした Azure Functions Core Tools が使われず、Visual Studio が管理しているバージョンが使われるようになっているので、選択できない場合にはオプションから Azure Functions のツールセットとテンプレートを更新しておきます。

意外に知られていないので、ワークショップなどで Visual Studio 2022 を使って Azure Functions 開発を行う際には、事前にチェックしておいた方が良いですね。意外にダウンロードに時間かかることがあります。

Azure Functions チームのツイートでは Linux で .NET 8 Preview のサポートが追加されたとありますが、既に Early Access で Windows でも利用可能になっています。

これまでの Early Access と同様にコールドスタートのパフォーマンスはあまりよくないですが、GA すると自動的に最適化されるようになっています。

.NET 8 での In-Process サポートが追加予定

以前のロードマップでは .NET 8 からはこれまで主流だった In-Process のサポートがなくなり、既存の Azure Functions は全て Isolated Worker への移行が必要となるはずでしたが、最新のロードマップでは .NET 8 でも In-Process サポートが追加されることになりました。

但し .NET 8 が GA されてすぐに対応するのは Isolated Worker だけで、In-Process のサポートは少し間が空くようです。しかし .NET 6 のサポートは来年まで続くので多少の遅れは問題になりません。

Semantic Kernel が最新バージョンでは Isolated を選ばないと動作しないことは以前お話ししましたが、アセンブリのバージョン問題が解消されるため .NET 8 の In-Process がリリースされると問題なく利用できるようになります。.NET 8 が最新のうちは安定して動作するはずです。

ただし .NET 9 がリリースされると .NET 5 がリリースされた時と同様に、またバージョン問題が発生するので流石に .NET 10 では Isolated への移行を真剣に検討したいと思います。

少なくとも Isolated Worker のパフォーマンスと信頼性が高まり、本番での運用が可能なレベルに達するまでの時間は十分あるはずです。それを待ってからでも遅くないでしょう。

ASP.NET Core Integration が GA

Isolated でも In-Process と同じように ASP.NET Core 関連クラスを使い、簡単に HttpTrigger を実装できる ASP.NET Core Integration が GA しました。

パッケージのバージョンは 1.0.0 になっていますが中身は 1.0.0-preview4 と同じです。

利用方法については以下のエントリで書いたので、気になる方はこちらを参照してください。

ASP.NET Core Integration と同時に Azure Functions Host 側にも新しい HTTP パイプラインが実装されたので、まだ安定性の面では不安が残る部分がありますが、ストリーミング周りが正しく動作するようになったので、パフォーマンス面では有利になってきます。

コールドスタートの最適化が Preview

Azure Functions の永遠の課題でもあるコールドスタートの高速化ですが、構造上 In-Process よりも不利な Isolated Worker について複数の最適化が追加されました。ドキュメントに利用方法がまとまっています。

今回追加されたのは Placeholders と Optimized executor の 2 つになります。ReadyToRun は以前から利用可能で、何なら In-Process でも有効化出来るものです。

  • Placeholders (Preview)
  • Optimized executor (Preview)
  • ReadyToRun

ReadyToRun は既に何回も触れているので、詳細は以下のエントリを参照してください。

ここからは Placeholders と Optimized executor の 2 つについて確認した範囲で書いていきます。現時点でも効果の測定中ですが、今のところそんなに大きな違いはなさそうです。

Placeholders について

Placeholders は Azure Functions の Consumption Plan に用意されている機能で、予め Azure Functions の Runtime をユーザーコードが入っていない状態でプロビジョニングしておいて、コールドスタートにかかる時間を短縮するものです。

仕組みについて公式な説明はないのですが、Azure Functions チームの牛尾さんが Linux については仕組みを簡単に書いているので、気になる方はこちらを参照してください。Windows も同じような仕組みです。

既存の In-Process の Azure Functions ではデフォルトで有効化されているので、Consumption Plan を使っている場合には意識することなく恩恵に預かっています。ちなみに設定でオフにすることも可能です。

Placeholders が有効化されていると、環境変数などで特殊なパスに展開されているのが確認できます。Placeholders で起動されて、CPU bitness まで専用に確保されているのが分かりますね。

このような仕組みなので Isolated でも Placeholders を有効化することで、コールドスタートにかかる時間の短縮が期待できるわけです。ただし新しくプロセスを立ち上げる必要はあるので、その部分で時間がかかってしまうとあまり効果は出ないです。

Optimized executor について

今回のアップデートのメインは Optimized executor だと考えています。理由としては Isolated じゃないと不可能な最適化なのと、将来的には Native AOT によって劇的なコールドスタートの改善が期待できるからです。

具体的には C# の Source Generator を利用して、リフレクションを使って実行時に Function を初期化して呼び出すのではなく、ビルド時に Function の初期化と呼び出すコードを生成してしまう仕組みです。そのためリフレクションのコストを無くせるので効率的になります。

Optimized executor の設定を有効化してビルドしたアセンブリを dotPeek などで確認すると、DirectFunctionExecutor というクラスが含まれていることが分かります。

実装を見るとリフレクションを使って Function の初期化と呼び出しを行うのではなく、特定の Function 名の場合は実装されたクラスをインスタンス化して、メソッドを直接呼び出すというコードが生成されています。

外部から文字列で与えられた値で、任意のメソッドを呼び出すのは結構な高コストなので、最近はこの分野では Source Generator を使うケースが増えていますね。

面白い点としては IFunctionExecutor を実装して DI に登録すれば、自由に Function 実行部分をカスタマイズできるようなので、独自に最適化するという方法も取れそうです。

当然ながら開発チームとしても Native AOT も視野に入れているようなので、以下のような Issue で Native AOT に必要なタスクがまとめられています。

Native AOT が有効化されるとコールドスタートは劇的に改善するはずなので、出来れば .NET 10 ぐらいまでには実装されていて欲しいですね。

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 対応が追加されているので対応するメリットが大きそう

Application Insights の Code Optimizations 機能を ASP.NET Core アプリケーションで試してみた

少し前から Application Insights の Performance を開くと、上部に Code Optimizations というボタンが表示されるようになっています。隣にある Profiler は以前からある機能ですが、Code Optimizations はひっそりと追加された新しい機能となります。

あまり注目されている気配を感じないのですが、上手く有効化出来ると継続的にアプリケーションのパフォーマンスを解析し、改善するというループを回しやすくなるはずです。ドキュメントがあるので、こちらも目を通しておくのが良いです。ちなみに現在サポートされているのは .NET のみです。

後で触れますが、Code Optimizations を利用するには Profiler を有効化する必要があります。この有効化方法が少し悩ましいので後半でまとめておきました。

Code Optimizations と Profiler で同じような機能を提供していますが、Profiler はトレースを収集するのがメインで、その結果は自分で解析・調査する必要があるのに対して、Code Optimizations は Profiler のトレースを AI ベースで自動解析してくれるので、パフォーマンス解析の知識が少なくても扱えるのが特徴です。

上手く有効化出来ていると Code Optimizations を開くと一覧が表示されるようになります。

自動的にグルーピングしてくれるので問題点を把握しやすいです。回数や影響度も同時に確認できるので、影響度の高いものから優先して対応するといったことも容易です。

一覧から項目を選ぶと更にドリルダウンして詳細を確認できます。スタックトレース込みで問題点についての詳細が AI ベースで生成されているので、アプリケーションのパフォーマンス改善に役立ちます。今回はサンプルなので ASP.NET Core 側の問題点が出てきましたが、通常のアプリケーションならユーザーコード側の問題点が検出されることが多いはずです。

影響度についてもグラフで表示してくれるので、検出された問題点にどのくらいのインパクトがあるのかをすぐに把握できます。閾値は AI によって自動的に判断されているようです。

Code Optimizations は Application Insights の機能であり、App Service の機能ではないため Profiler を有効化出来れば何処でも利用可能です。今回は App Service で試していますが、以下のドキュメントにもある通り Profiler 自体は様々な環境で有効化出来ます。

軽く触っただけですが、単なる APM ではなく AI ベースで問題の検出と解決策についても提案してくれる Code Optimizations は試してみる価値があると言えます。課題としては Application Insights Profiler の有効化が少し難しいというぐらいです。

ここから先は Code Optimizations というよりも Application Insights Profiler を有効化する方法をメモしておきます。特に App Service で利用する場合に悩むので、最適な設定を探る意味もあります。

ASP.NET Core 向けに Application Insights を有効化する方法

今回は ASP.NET Core 向けの話になりますが、App Service との組み合わせでは App Service に組み込まれた自動インストルメンテーションを使う方法と SDK をアプリケーションに組み込む方法があります。

詳細は以下のドキュメントに任せますが、それぞれの方法には特徴があるので最適なものを選んでいただければと思います。個人的には SDK をインストールする方法をフィルタリングなど高度なカスタマイズが可能なのでお勧めしています。

重要なのは Application Insights Profiler を有効化する方法は Application Insights の有効化方法に依存するという点です。混在はサポートされていないようなので注意が必要です。

App Service 組み込みの App Insights Profiler を有効化する

まずは非常にシンプルな App Service 組み込みの Application Insights Profiler を有効化する方法ですが、これはほぼ説明が要らない気がしますのでドキュメントを紹介するぐらいで行きます。

Application Insights 自体を App Service で有効化していれば、Application Insights Profiler も Azure Portal からポチっと有効化するだけで終わります。非常にシンプルですがカスタマイズは出来ません。

有効化後に Profiler の実行ログを確認して、正しくトレースが送信されているかを確認すれば終わりです。しばらく待てば Code Optimizations の方にも何かしらの検出結果が表示されるはずです。

もし正しく動作していないように見える場合は、以下のドキュメントにある通り診断ツールを使って、正しく Application Insights の Agent が読み込まれているか確認してください。

原因としてはアプリケーション側に Application Insights SDK が組み込み済みというのが多いですが、その辺りについても診断ツールは検出してくれます。

SDK をインストールして App Insights Profiler を有効化する

ASP.NET Core アプリケーションに Application Insights SDK をインストールするのはよく行われていると思いますが、この場合 App Service に組み込みの Application Insights Profiler は使えないので、NuGet で公開されている Profiler をインストールします。

この方法は Linux や Container 環境向けで紹介されていますが、Windows + SDK の組み合わせでも問題なく動作します。ドキュメントがあるのでこちらも参照してください。

設定方法は NuGet から Microsoft.ApplicationInsights.Profiler.AspNetCore をインストールすることから始めます。このパッケージに Profiler の実装が含まれているのでサイズは少し大きくなります。

パッケージをインストールした後は以下のように Startup.cs などで DI にサービスを追加するだけで終わります。Application Insights SDK がインストール済みの場合は AddServiceProfiler だけを追加します。

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddControllersWithViews();

// この 2 行を追加する
builder.Services.AddApplicationInsightsTelemetry();
builder.Services.AddServiceProfiler();

var app = builder.Build();

これでデフォルトの設定で Profiler が有効化されて、ランダムで Profiling が実行されるようになります。

細かいカスタマイズ項目については GitHub でドキュメントが公開されているので、必要に応じて設定を追加してもらえれば問題ありません。この辺りも SDK を使う上でのメリットですね。

App Service にデプロイする際には Application Insights の接続文字列が設定されていることを確認します。App Service で Application Insights を有効化すると App Settings に大量のキーが追加されてしまいますが、SDK の場合は以下のようにシンプルな設定で済みます。

こちらも Azure Portal から Profiler の実行ログを確認すると、正しく動作しているのか分かります。デフォルトではランダムで動くようになっているので、そこそこログは表示されるはずです。

これで SDK を利用している場合でも Application Insights Profiler を有効化出来たので、同じように Code Optimizations を利用できるようになります。どちらの方法も簡単ですが、カスタマイズ性とアプリケーションに組み込み可能かの観点で選ぶのが良いです。

App Service と SDK で Profiler を有効化した際の違い

ここまで紹介した 2 つの方法はどちらを使っても Profiler と Code Optimizations は利用可能になりますが、実際には細かい違いがあります。特に分かりやすい点だと生成されるトレースのフォーマットが .diagsession.netperf で異なります。

App Service の方は Visual Studio で標準的に使われる .diagsession が出力され、SDK の方は dotnet-trace で使われる .netperf が出力されます。

VS Code などで開発している場合には .netperf ファイルの方がビジュアライズの方法が柔軟なので、こちらの方が良いかもしれません。VS を持っている場合は一応どちらも読めそうです。

後は地味に気になる Profiler の負荷ですが、雑に App Service を 2 つ作成して確認したところ、App Service で有効化するよりも SDK で有効化した方が CPU 負荷は低いようでした。

この差が付いているのは Profiler の実装方法の差によると考えていて、App Service で有効化すると Profiler のプロセスが常に立ち上がっているのと、Profiling 方式の違いで高めになっている可能性があります。

本番環境では CPU 負荷を出来るだけ下げたいので、そういう意味でも SDK で有効化した方が良さそうです。

App Service Authentication と Entra ID で保護された Web API にアクセス可能な Access Token を取得する

App Service Authentication (Easy Auth) は非常に便利な機能なのですが、Web API をホストしている場合には他のアプリケーションから Service Principal を利用してアクセスしたいことがあります。

直近では自分が開発している Key Vault Acmebot というアプリで Web API を公開していますが、Easy Auth を有効化したまま Web API を呼び出したいという要望が多くて、とりあえずサンプル用意するかと思ったら地味にやり方を忘れていたのでブログに書いています。

ドキュメントに書かれていない気もしますが Easy Auth はリクエストに Bearer Token を付けて投げると、正しく検証してクレームをデコードしてくれるようになっています。これを使うと色々楽になります。

アプリケーション側の実装は Easy Auth を使っているので手を入れる必要がないですが、Entra ID 側のアプリケーション設定をほどほどに弄る必要があるので理解していないとはまります。

追記 : Entra ID に非常に詳しい @watahani さんからフィードバックを貰ったので、Client Credentials Flow で必要のない部分について修正を行っています。ブログも書いてくださったので是非読んでください。

ちなみに Key Vault Acmebot というアプリでは App Role を検証する機能が付いているので、有効化すると誰でもアクセス出来る状態ではなくなります。

App Service Authentication を有効化

まずは App Service Authentication を有効化して Microsoft Entra ID でのログインを行えるようにします。設定方法はドキュメントもありますし、Azure Portal からであれば簡単に行えるので特に深く説明はしません。

現在の Azure Portal を利用すると Entra ID の v2.0 エンドポイントが使われるので問題ありませんが、手動で設定する際には openIdIssuer が v2.0 エンドポイントになっているか確認しておきます。

自動生成された Application 設定を更新

Easy Auth の設定時に Entra ID 側に新しい Application が追加されますが、デフォルトの設定のままでは必要な Access Token が取得できないので、いくつかの設定を弄る必要があります。具体的には API スコープの公開と Access Token のバージョン変更が必要です。

API スコープの公開は Expose an API から Application ID URI などを設定するだけなので分かりやすいです。作成直後では空っぽですが Edit を選ぶと推奨される値が自動設定されるので保存して終わりです。

Application ID URI を設定するとスコープの値にも正しく反映されます。デフォルトでスコープは 1 つ設定されているので、後でこのスコープに対する許可を Service Principal に対して追加します。

追記 : Client Credentials Flow の場合はスコープの許可を追加する必要はありません。

最後に Application のマニフェストを編集して accessTokenAcceptedVersion の値を 2 に設定します。この設定で発行される Access Token のバージョンが 2 に更新されてフォーマットが大きく変わるため、Easy Auth でも正しく検証できるようになります。

バージョンを未設定の場合には Easy Auth では検証できないフォーマットとなるので認証が通りません。地味にはまりやすいポイントなのでバージョンについては気を付けたいところです。

Service Principal を作成し権限を追加(追記あり)

これで Web API 側の Application 設定は全て完了したので、ここからは Service Principal 周りの設定を行って必要な権限を持った Access Token を取得出来るようにします。

追記 : Client Credentials Flow を利用する場合には Service Principal に対してスコープを追加する必要はなく、同一テナントに存在するアプリケーションであれば全てにアクセス可能になります。

Service Principal の作成方法は書かないので、以下のドキュメントを参照して作成しておいてください。

作成した Service Principal で必要な作業は API Permissions で Web API 側の Application のスコープを追加することです。Add a permission から Web API の該当スコープを探して追加すれば完了です。

後は Client Credentials Flow で利用するための Client Secret を生成しておくぐらいで完了です。

MSAL を利用して Access Token を取得

最後は作成した Service Principal と MSAL を利用して Web API にアクセス可能な Access Token を取得します。今回は Client Secret を利用して Access Token を取得するので Client Credentials Flow となります。

例によって MSAL の使い方は公式ドキュメントに任せるので、経験が無い方はこちらを参照してください。

コードとしては ConfidentialClientApplicationBuilder を使う方法なので比較的簡単ですが、スコープの扱いだけ注意が必要です。Access Token を取得して Web API を呼び出すサンプルは以下のようになります。

using System.Net.Http.Headers;

using Microsoft.Identity.Client;

var app = ConfidentialClientApplicationBuilder.Create("<client id>")
    .WithClientSecret("<client secret>")
    .WithTenantId("<tenant id>")
    .Build();

var token = await app.AcquireTokenForClient(new[] { "<application uri>/.default" }).ExecuteAsync();

Console.WriteLine(token.AccessToken);

var httpClient = new HttpClient();

httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken);

var response = await httpClient.GetStringAsync("https://***.azurewebsites.net/api/certificates");

Console.WriteLine(response);

AcquireTokenForClient に Web API のスコープを渡していますが、今回のようなアプリケーション向けにトークンを取得する場合には .default で終わるスコープを指定する必要があります。

詳細は以下のドキュメントに記載されていますので、こちらを参照してください。

このコードを実行すると Access Token を取得して、Easy Auth で保護されている Web API が呼び出されます。以下は実行した例ですが API の呼び出しまで成功しています。

これで Service Principal を使って Access Token を取得し、Easy Auth で保護されている Web API を正しく呼び出せていることが確認出来ました。割と応用が利くので覚えておいて損はないはずです。

補足 : MSAL を利用せずに Access Token を取得する

今回は Access Token の取得に MSAL を使いましたが、Client Credentials Flow は token エンドポイントに POST リクエストを投げるだけなので、MSAL をわざわざ使う必要はありません。以下のドキュメントにもあるように、シンプルなリクエストを投げれば取得可能です。

重要なのは Web API 側でスコープを公開すること、Service Principal でスコープへの権限を追加すること、そして Access Token の取得時にスコープを指定することになります。

補足 : Easy Auth を使って Access Token を取得する

MSAL や HTTP リクエストを自分で叩いて Access Token を取得する以外にも、Easy Auth を使っている Web アプリケーションの場合は Azure Resource Explorer などでスコープを追加すればログイン時に必要な Access Token を同時に取得できます。

ちなみに Easy Auth で保護された Web App から同じく Easy Auth で保護された Web API を呼び出すサンプルは、珍しく公式ドキュメントにまとめられていますのでこちらを参考にするとよいです。

とはいえ考え方は Service Principal を利用する場合とほぼ同じで、ログイン時にアクセスしたい Web API のスコープを指定して Access Token を要求するだけです。スコープの指定方法だけが少し面倒ですが、コードを書く必要なく設定だけで済むためとても簡単です。

追記 : Easy Auth を使って Access Token を取得する場合にはユーザーがログインする側のアプリケーションに対して Web API のスコープを許可する必要があります。

Terraform と Azure DNS を使って Azure Front Door への Apex ドメイン割り当てを自動化する

Azure Front Door は Azure DNS のような Alias record set に対応したサービスと組み合わせると、Apex ドメインを割り当てて利用することが可能です。もちろん Managed Certificate として必要な証明書も自動で発行されるので HTTPS を無料で有効化できます。

手順は全て以下のドキュメントにまとまっているので、特に難しい作業なく有効化できます。

Azure DNS の Alias record set を利用することで以前問題となった Subdomain Takeover を防ぐことが出来るのでお勧めなのですが、Front Door の場合は Managed Certificate の自動更新が Alias record set を利用していると行われないという制約があります。

具体的には以下のように CNAME で Front Door のエンドポイントを指している場合のみ、Managed Certificate の自動更新が行われるとドキュメントにも記載されています。

Apex domains don't have a CNAME record pointing to an Azure Front Door endpoint, so the auto-rotation for managed certificate fails until the domain ownership is revalidated.

Apex domains in Azure Front Door | Microsoft Learn

流石にドキュメントが古いだけで、今は改善されているだろうと思って調べたのですが、以下の Issue にもあるように現時点でも挙動は改善されておらず、実際に半年前に割り当てた Apex ドメインの証明書が切れるという事象も確認されています。

自動更新が出来ない場合には、証明書の期限が切れる 45 日前から再検証が行えるようになるらしいです。もちろん Azure Portal からの手動での作業が必要となるので、証明書の期限切れの危険性が高まりますので、何らかの手を打つ必要があります。

同じように Alias record set を利用することで Apex ドメインと証明書の発行まで出来る Static Web Apps ではこのような問題は発生しないので、Front Door 側でも改善できるだろうと思っていますが、暫くはこのままだと思うので Terraform でのある程度の自動化を検証しました。

Terraform を利用して Front Door に Apex ドメインを追加

まずは最低限必要となる Terraform を利用した Front Door 関連リソースの作成を行っておきます。少し Front Door 周りの定義は癖がありますが、用語を理解しておけば何とかなるはずです。個人的にはカスタムドメインの割り当て周りで少しはまりましたが、最終的には以下のような定義に落ち着きました。

ちなみに Azure DNS 周りも Terraform で管理するようにしていますが、実際は DNS Zone を含めるのは少し難しいと思うので、DNS レコードのみ Terraform で管理するのが良さそうです。

provider "azurerm" {
  features {}
}

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.0"
    }
  }
}

resource "azurerm_resource_group" "example" {
  name     = "rg-apex-demo"
  location = "West Europe"
}

resource "azurerm_cdn_frontdoor_profile" "example" {
  name                = "afd-apex-demo"
  resource_group_name = azurerm_resource_group.example.name
  sku_name            = "Standard_AzureFrontDoor"
}

resource "azurerm_cdn_frontdoor_endpoint" "example" {
  name                     = "fde-apex-demo"
  cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.example.id
}

resource "azurerm_cdn_frontdoor_origin_group" "example" {
  name                     = "apex-demo-origin-group"
  cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.example.id

  load_balancing {}
}

resource "azurerm_cdn_frontdoor_origin" "example" {
  name                          = "apex-demo-origin"
  cdn_frontdoor_origin_group_id = azurerm_cdn_frontdoor_origin_group.example.id
  enabled                       = true

  certificate_name_check_enabled = true

  host_name          = "shibayan.jp"
  http_port          = 80
  https_port         = 443
  origin_host_header = "shibayan.jp"
  priority           = 1
  weight             = 1
}

resource "azurerm_cdn_frontdoor_route" "example" {
  name                          = "apex-demo-route"
  cdn_frontdoor_endpoint_id     = azurerm_cdn_frontdoor_endpoint.example.id
  cdn_frontdoor_origin_group_id = azurerm_cdn_frontdoor_origin_group.example.id
  cdn_frontdoor_origin_ids      = [azurerm_cdn_frontdoor_origin.example.id]
  enabled                       = true

  forwarding_protocol    = "HttpsOnly"
  https_redirect_enabled = true
  patterns_to_match      = ["/*"]
  supported_protocols    = ["Http", "Https"]

  cdn_frontdoor_custom_domain_ids = [azurerm_cdn_frontdoor_custom_domain.example.id]
  link_to_default_domain          = false
}

resource "azurerm_cdn_frontdoor_custom_domain" "example" {
  name                     = "apex-demo-custom-domain"
  cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.example.id
  dns_zone_id              = azurerm_dns_zone.example.id
  host_name                = "e5c4p3.cloud"

  tls {
    certificate_type    = "ManagedCertificate"
    minimum_tls_version = "TLS12"
  }
}

resource "azurerm_dns_zone" "example" {
  name                = "e5c4p3.cloud"
  resource_group_name = azurerm_resource_group.example.name
}

resource "azurerm_dns_a_record" "example" {
  name                = "@"
  zone_name           = azurerm_dns_zone.example.name
  resource_group_name = azurerm_resource_group.example.name
  ttl                 = 3600
  target_resource_id  = azurerm_cdn_frontdoor_endpoint.example.id
}

resource "azurerm_dns_txt_record" "example" {
  name                = "_dnsauth"
  zone_name           = azurerm_dns_zone.example.name
  resource_group_name = azurerm_resource_group.example.name
  ttl                 = 3600

  record {
    value = azurerm_cdn_frontdoor_custom_domain.example.validation_token
  }
}

Front Door は出てくるリソースが多いので複雑そうに見えますが、ルーティングやオリジンの定義が多いだけで、一つずつ分解してみていくと大したことはしていません。Azure DNS 周りも Alias record set の作成と、ドメイン認証に利用する TXT レコードの作成ぐらいなのでシンプルです。

この Terraform 定義を使って Azure 上にリソースを作成して、DNS のネームサーバーを変更してしばらく待てば Front Door のプロビジョニングが完了して、以下のようにアクセスできるようになります。

証明書も 180 日有効なものが発行されているので、もちろん HTTPS での接続も問題なく行えます。

これで Azure リソースの構築はすべて完了していますが、180 日後には証明書の自動更新が行われず HTTPS で接続が出来なくなるという問題が発生してしまうので、Terraform 内で再検証に必要な処理を実行して、証明書の自動更新が行われるようにします

実現には AzureRM Provider だけでは不可能なので、ARM を直接扱える AzAPI Provider を利用します。

AzAPI Provider を利用して再検証 Action を実行

先に Front Door のカスタムドメイン再検証について簡単に説明しておくと、ARM 上はドメイン検証用トークンを再発行して、再度検証フローを実行する API が用意されています。

この API を実行するとカスタムドメインの紐づいている検証トークンが再発行されるので、後は TXT レコードの値を新しく発行されたトークンで更新してあげるだけですが、AzureRM Provider には ARM でいうところの Action に相当する機能が提供されていません。

Action を実行するために AzAPI Provider に用意された azapi_resource_action を使います。

詳細はドキュメントがあるので深くは説明しませんが、AzAPI Provider は非常に薄い ARM REST API のラッパーという感じなので、この Provider だけで全てのリソースを構築するというのは厳しいです。AzureRM Provider に足りない機能を補うために、ピンポイントで利用するのが適しています。

実際に azapi_resource_action を利用して検証用トークンの再発行を行う定義が以下になります。ぱっと見は Bicep っぽさがありますが、ARM REST API を直接叩いているので仕方ない部分です。

resource "time_rotating" "validation_token" {
  rotation_days = 150
}

resource "time_static" "validation_token" {
  rfc3339 = time_rotating.validation_token.rfc3339
}

resource "azapi_resource_action" "refresh_validation_token" {
  type        = "Microsoft.Cdn/profiles/customDomains@2023-05-01"
  resource_id = azurerm_cdn_frontdoor_custom_domain.example.id
  action      = "refreshValidationToken"
  method      = "POST"

  lifecycle {
    replace_triggered_by = [ time_static.validation_token ]
  }
}

time_rotatingtime_static を組み合わせることで、一定時間の経過後に azapi_resource_action が再実行されるようにしています。すなわち、150 日経過した後に Terraform を実行すると検証用トークンが再発行されるということです。

上で作成した Front Door をプロビジョニングする Terraform 定義にこのリソースを追加すると、最初は azapi_resource_action が存在しないので以下のように検証用トークンを再発行しようとします。

Action のリソースを作成すると検証用トークンを再発行し、検証フローを再度開始するので Azure Portal 上は Validation State が Pending に変わります。

そしてもう一度 Terraform を実行すると azurerm_cdn_frontdoor_custom_domain に紐づいている検証トークンの値が変化していることが Terraform によって報告されます。本来なら Action の実行時の 1 回だけで完結させたいですが、検証トークンの再発行が非同期で行われるため困難です。

そのタイミングで報告された Terraform のプランでは Azure DNS の TXT レコードが更新対象だと報告されます。先の Action 実行によって検証トークンの値が変化しているので、Terraform がそれに自動的に追従しようとしてくれていることが確認できますね。

そのまま Terraform から TXT レコードの値を更新し、暫く待つと Azure Portal 上の Validation State が Pending から Approved に変化して、証明書も発行されていることが確認できます。

Azure Portal で手動実行するよりは明らかに Terraform 上で全てを定義した方が、単純にコマンドを実行するだけで自動的に更新されるので安全ですが、期限切れ前に Terraform を実行しないと意味がないです。

この問題を回避するためには Drift detection と同じような考え方で、定期的に terraform plan を実行する仕組みが必要になりそうです。*1

*1:Terraform Cloud で Drift detection を有効化しておくと検出されるかもしれません(未確認

Azure OpenAI Service に追加された Content filters (Preview) の実行結果を取得する

少し前から Azure OpenAI Service に Content filters (Preview) が追加されたからなのか、たまに Chat Completion を使っていると以下のようなメッセージが返ってくることがあります。

メッセージ内容から Content filters に引っかかっていることは一発でわかるのですが、何が原因で引っかかったのかは分からないので Chat Playground 上では対処が結構難しいです。

とはいえ、Content filters で弾かれる内容は以下のドキュメントにもあるように 4 項目に対してなので、そこから想像は付くのですが確証がない状態です。

せめて 4 項目のうちどれに引っかかって、どういった表現に問題があったのかもデバッグ中には教えてもらいたいものですが、残念ながら Chat Playground にはそういう機能はありません。

更に現時点では Content filters のカスタマイズでも、これ以上フィルタを緩くする方向には設定できないので、プロンプト側でフィルタに引っかからないように出力を制御するしかありません。

Content filters の実行結果は Chat Playground では確認出来なくても、REST API を直接叩けば取れるだろと思って Postman を使って適当に叩いてみたところ、以下のような情報量がほぼゼロのレスポンスしか返ってきて来なかったので若干絶望しました。

絶対に取れる方法が用意されていると思っていたので調べたところ、以下のドキュメントが見つかりました。適切に API Version を設定すれば、問題なく Content filters の結果は返ってくるようです。

今回サンプル URL を Chat Playground から取得したので、Content filters に対応していない API Version が指定されていましたが 2023-06-01-preview を指定すると、以下のように詳細な情報が返ってくるようになりました。このスクリーンショットでは応答についての結果が含まれていますが、実際にはプロンプトに対しての結果も返ってきています。

Content filters の結果が返ってくることが分かれば、後は SDK 経由で結果が取れるかの確認ですが、残念ながら C# 向けの SDK では現時点では古い API Version 向けに生成されていたので、詳細な情報を取得することは出来ませんでした。

しかし最近の Azure SDK は全て AutoRest を使った OpenAPI 定義からの自動生成となっているので、暫くすると 2023-06-01-preview に対応したバージョンがリリースされると思います。

他の言語の SDK で対応していることを期待して調べてみると、Azure SDK for Python のリポジトリに Content filters 周りのテストコードが存在していることに気が付きました。

Python SDK ではレスポンスの型を定義しておらず、割と自由に参照できるようになっていたので API Version だけ対応したものを指定してやれば、特に問題なく扱えることが分かりました。

Azure SDK for Python のリポジトリにテストコードはありましたが、SDK の実体は OpenAI が提供しているものなので、利用を開始する際には以下のリポジトリを確認しておきます。

適当に Codespaces を立ち上げて Python 環境を作成し、その上で Content filters の応答を取得するサンプルコードを書いてみました。このサンプルコードの肝は前述した通り openai.api_version2023-06-01-preview を指定している部分です。

import openai

openai.api_type = "azure"
openai.api_key = "..."
openai.api_base = "https://***.openai.azure.com/"
openai.api_version = "2023-06-01-preview"

# create a chat completion
completion = openai.ChatCompletion.create(deployment_id="gpt-4", model="gpt-4", messages=[{"role": "user", "content": "Hello world"}])

# print the completion
print(completion.choices[0].message.content)

# print content filter results
print(completion.choices[0].content_filter_results)
#print(completion.prompt_annotations[0].content_filter_results)

このサンプルコードを実行すると、以下のように応答に対する Content filters の実行結果が出力されます。コメントアウトしている行を戻すと、プロンプトに対する結果も表示されます。

これで Content filters のどの項目に引っかかって、応答がフィルタリングされてしまったのかを把握することが可能となりました。開発中だけは応答のどの部分に問題があったのかも教えてほしくはありますが、最低限の情報は取れているので対応は出来そうです。

ASP.NET (.NET Framework) 向けの各 Session State Provider が大規模アップデート

ASP.NET (.NET Framework) を利用したアプリケーションで必要になることが多い Session State Provider ですが、今年になってから大規模なアップデートが多いので一通り確認しておきました。

現状 ASP.NET 向け Session State Provider は Cosmos DB / SQL Server 向けと Redis 向けで、以下のように別々のリポジトリで開発されています。個人的には AspNetSessionState 側に統一してほしいです。

今回は全ての Session State Provider でアップデートがあったので、InProc と StateServer 以外を使っているアプリケーション全てに関係します。パフォーマンス面に影響のあるアップデートも含まれています。

共通のアップデート

Session State を実装している IIS Module 自体も 2.0.0 にアップデートされて、各 Session State Provider に共通した機能が増えています。ただし Redis に関してはアップデートが未だ行われていません。

アップデート内容を要約すると以下の 2 点になります。両方ともパフォーマンスに影響する更新です。

  • セッションの TTL 更新を行わないオプション skipKeepAliveWhenUnused が追加
    • Session State が必須になっている時だけ TTL を更新する
  • IIS Module の preConditionmanagedHandler が追加
    • これまでは静的ファイルなどでも Session State を通っていた

これまでの ASP.NET の Session State Module 実装では、Session State をページやコントローラー単位で明示的に無効化しても、セッションの TTL 更新のためにリクエストが必ず発行されていたのですが、新しく追加されたオプションではその挙動をオフに出来るようになりました。これにより大半でセッションが不要なアプリケーションで信頼性とパフォーマンスを改善することが可能です。

以前 Azure 上で ASP.NET の Session State を使うベストプラクティスについて書いたエントリで触れた部分が改善されています。設定の有効化前に読んでおいてもらえると参考になるかと思います。

IIS Module については破壊的変更が存在していないので、Redis を使っている場合でも明示的にインストールすると新機能が使えるはずなのですが、手元で試した限りでは確認できませんでした。

SQL Server 向け

主にオンプレ向けで利用されていると思われる SQL Server 向けの Session State Provider は、大幅なアップデートを含む形で 2.0.0 が公開されました。アップデート内容的にも更新は必須です。

アップデート内容は大体以下の通りになります。アップデート内容は多いですが、一番大きな点は使用している SDK が Microsoft.Data.SqlClient に更新されたことです。これにより信頼性とパフォーマンス改善だけではなく、Managed Identity への対応も容易になりました。

注意点としては既に SQL Server を Session State Provider として使っている場合には、互換性を保つために repositoryTypeFrameworkCompat を指定する必要がある点です。デフォルト値が FrameworkCompat にはなっていますが、明示的に指定した方が安全です。

デフォルトで参照されている Microsoft.Data.SqlClient は最新ではないので、必要に応じて明示的にインストールしてバージョンアップしておくと安心です。

Managed Identity を利用する際には接続文字列を変えるだけで問題ないので、以下のエントリを参照してください。古いクライアントと比べると Microsoft.Data.SqlClient では劇的に簡単になっているので、SQL Server での Managed Identity 利用を推し進めていきたいですね。

Microsoft Account では利用できないですが、大体のケースでは組織アカウントを使っているはずなので、ローカル開発時でも問題となることは少ないと思います。

Cosmos DB 向け

Azure 上での利用が多いはずの Cosmos DB を利用した Session State Provider についても、大幅にアップデートされた 2.0.0 が公開されています。こちらも更新は必須と言えます。

今回のアップデートではこれまでの謎設定が綺麗に一掃されて、デフォルトで Cosmos DB のパフォーマンスを最大限利用できるようになりました。特にパーティションキー周りの設定については、これまでの Provider では完全に無駄な実装がありました。

  • パーティションキーが /id 固定に変更
    • partitionKeyPathpartitionNumUsedByProvider の廃止
  • 使用している SDK が Microsoft.Azure.Cosmos に変更
  • プロパティの命名が collectionId => containerId に変更
  • consistencyLevel の追加
  • connectionProtocol の廃止

中でも一番大きいアップデートは SDK が Cosmos DB SDK の v3 ベースになっていることです。これまでの Provider は廃止が予定されている v2 ベースでしたので、来年にはサポートされなくなるところでした。

ただし、こちらも SQL Server の Provider と同様に SDK バージョンが既に古くなっているので、明示的にパッケージをインストールしておくのをお勧めしています。

特に Cosmos DB SDK については推奨される最小バージョンが公開されていて、それが現在 3.33.0 となっているので SDK の明示的なアップデートは必須と言えます。

今回から Session を保持する Container のパーティションキーは /id 固定となっているので、現在使用している Container のパーティションキーをカスタマイズしてしまっている場合には、既存セッションは失われてしまいますが作成しなおしてしまうのがお勧めです。

Redis 向け

Cosmos DB や SQL Server とは開発チーム自体が別のようで、微妙に Session State Module のアップデートには追従できていないようなのですが、今年になってから 5.0.0 が公開されています。

破壊的変更が含まれていますが、アップデート内容としては少なめです。要約すると他の Session State Provider の実装に近付けられたという感じです。

例によって、こちらもベースとなっている StackExchange.Redis のバージョンが古くなっているので、明示的にアップデートしておくのがお勧めです。

Redis 向けについては Azure チームが開発していて、それ以外は ASP.NET チームが開発しているようなのですが、どこかで AspNetSessionState リポジトリ側に開発がマージされて欲しいですね。

Azure AD B2C のサインイン用メールアドレスを変更する

Azure AD B2C を使ってローカルアカウントのサインアップを実装するのが非常に簡単なのですが、用意されているユーザーフローでは登録時に使用したメールアドレスを変更出来ないという問題が出てきます。

プロフィールの編集やパスワードのリセット機能はありますが、メールアドレスを変更することは出来ないためアプリケーション側でどのように対応するかが課題となってきます。とはいえ、完全にメールアドレスを変更できないというわけではないので、メールアドレスを変更する 2 種類の方法を紹介します。

メールアドレスの変更ポリシーを追加

1 つ目の方法は公式サンプルでも提供されているカスタムポリシーを利用した方法です。一般的なメールアドレス変更フローと同じように、まずはメールアドレスが正しいか確認コードを送信後に実際に変更を行うので入力間違いの可能性が低くなります

具体的なメールアドレスの変更フローは以下のようになります。当然ながら最初に既存のアカウントでのログインが必要になりますが、サンプルでは非常にシンプルな画面として実装されています。

  1. 既存のメールアドレスとパスワードでログイン
  2. 新しいメールアドレスを入力
  3. 確認用のコードが新しいメールアドレス宛に送信
  4. 検証が成功するとメールアドレスを書き込み

新しいメールアドレスの入力画面はよく見るシンプルなものです。これはカスタマイズ必須だと思います。

このカスタムポリシーの肝となる部分は AAD-UserWriteEmailUsingObjectId Technical Profile で、実際に新しい検証されたメールアドレスを Azure AD に対して書き込みを行っています。

        <TechnicalProfile Id="AAD-UserWriteEmailUsingObjectId">
          <Metadata>
            <Item Key="Operation">Write</Item>
            <Item Key="RaiseErrorIfClaimsPrincipalAlreadyExists">false</Item>
            <Item Key="RaiseErrorIfClaimsPrincipalDoesNotExist">true</Item>
          </Metadata>
          <IncludeInSso>false</IncludeInSso>
          <InputClaims>
            <InputClaim ClaimTypeReferenceId="objectId" Required="true" />
          </InputClaims>
          <PersistedClaims>
            <PersistedClaim ClaimTypeReferenceId="objectId" />
            <!-- Demo: Persist the email address to the user's profile-->
            <PersistedClaim ClaimTypeReferenceId="email" PartnerClaimType="signInNames.emailAddress" />
          </PersistedClaims>
          <IncludeTechnicalProfile ReferenceId="AAD-Common" />
        </TechnicalProfile>
samples/policies/change-sign-in-name/policy/TrustFrameworkExtensions_ChangeSignInName.xml at master · azure-ad-b2c/samples · GitHub

新しいメールアドレスの書き込み先は signInNames.emailAddress となっていますが、この値についての簡単な説明は以下のドキュメントにあります。他にも signInNames.userName もあるので、ユーザー名を使ったログインの場合はこちらを書き換えれば良さそうな感があります。

多少カスタマイズはしたくなりますが、メールアドレスの変更フローはこのポリシーをデプロイするだけで大体実現できました。肝となる部分を理解しておけば割と安心です。

難点は組み込みのユーザーフローを利用している場合、カスタムポリシーを使う必要があるという点です。カスタムポリシーが少しでも混ざってくると急に複雑さが増してきます。

Graph API を使って書き換え

カスタムポリシーでは Azure AD の signInNames.emailAddress を書き換えているだけなのが発覚しましたので、即ち Graph API を使っても同じことが出来るという話です。既に Graph API を利用した先人が居るので、動作するのは間違いないでしょう。

この Stack Overflow の回答では signInNames.emailAddress ではなく、Identities に対して書き換えを行っていますが、以下のドキュメントにあるように signInNames.emailAddressidentities の実体は同じものです。カスタムポリシーでも外部アカウント連携を行う際には identities が出てきます。

実際に Microsoft Graph SDK を利用して identities に保存されたメールアドレスを書き換えるサンプルは以下のようになります。Issuer の値はテナント名になるので間違えないようにしてください。正直なところ Graph SDK の知識のが重要になります。

var graphServiceClient = new GraphServiceClient(clientSecretCredential, scopes);

var updatedUser = new User
{
    Identities = new List<ObjectIdentity>
    {
        new ObjectIdentity
        {
            SignInType = "emailAddress",
            Issuer = "***.onmicrosoft.com",
            IssuerAssignedId = "<new mail address>"
        }
    }
};

await graphServiceClient.Users[<userid>].PatchAsync(updatedUser);

SignInType は色々な値が存在しているので、上手く書き換えてあげれば Facebook などのソーシャル連携もバックエンドで自動的に行うことが出来そうです。

サンプルでは認証周りのコードを丸ごと省いているので、以下のドキュメントを見て必要な認証プロバイダーを選んでください。アプリケーションに組み込む場合は Client Secret Credential か Managed Identity のどちらかになると思います。

Graph API を利用して書き換える方法では、見てわかるようにメールアドレスが正しいかどうかのチェックは出来ないため、何らかの方法で確認の後に Azure AD 側を書き換えるというステップを踏む必要があります。

パスワード変更と同様に組み込みのユーザーフローとして提供して貰いたさしかないのですが、カスタムポリシーや Graph API を利用すると変更も一応可能なので、必要に応じて組み込んでみてください。

App Service / Static Web Apps / Container Apps の Easy Auth が OpenID Connect の RP-Initiated Logout をサポート

App Service などに実装されている Easy Auth (Authentication) はアプリケーション側の修正をすることなく、Azure AD や任意の OpenID Connect に対応した IdP を使ったログインを追加できる非常に便利な機能です。

各 PaaS / Serverless 系サービスに実装されている Easy Auth の差異は以前にまとめているので、興味がある方はこちらを参照してください。思ったよりも違いがあります。

非常に便利な Easy Auth ですが、実際に使っている際によく問題となるのがログアウト時の挙動です。App Service からはログアウトしても IdP 側のログアウトが自動で行われないので、すぐにログイン状態に戻ってしまうので実質的にログアウトが不可能になります。

この挙動を改善するには IdP 側も同時にログアウトする必要があるので、OpenID Connect を利用している場合には RP-Initiated Logout を組み合わせることで対応可能でした。

Azure AD や Azure AD B2C は RP-Initiated Logout に対応しているので、OpenID Connect の Metadata を確認すると end_session_endpoint が含まれています。

以前に Easy Auth を使って OpenID Connect の RP-Initiated Logout を使う方法を書きましたが、手動でリダイレクト URL を組み立てる必要があり、エスケープ周りもまあまあ面倒でした。

手動での対応方法に興味がある方は以下のエントリを参照してください。

ようやく本題に入るのですが、これまでは IdP の同時ログアウトを諦めるか、ログアウト時の複雑な URL を手動で組み立てる必要がありましたが、少し前の Easy Auth Middleware のアップデートで RP-Initiated Logout に対応したので、独自対応が必要なくなりました。

OpenID Connect の Metadata に end_session_endpoint が定義されていれば、通常の /.auth/logout にアクセスすれば IdP 側にもリダイレクトしてログアウトされます。

そして Easy Auth にはサービス毎に 3 種類存在しているので、それぞれで動作を確認しておきました。結論としては全てのサービスで意図した通りの動作をします。特に説明しないので以下の画像を確認してください。

App Service (Azure Functions 含む)


Static Web Apps


Container Apps

全てのサービスで /.auth/logout にアクセスすると、IdP 側にリダイレクトされた後に元のサービスに戻ってくるという挙動になっています。RP-Initiated Logout では id_token_hint を受け付けることも出来ますが、現時点の実装では特に付けられていません。

Azure AD B2C の設定にはログアウト時に id_token_hint を必須にするという設定があるので、それを有効にすると正常にログアウト出来なくなるので注意が必要です。

確認した限りでは既に全てのリージョンで利用できるようになっているみたいなので、今後は OpenID Connect を利用した際のログアウトの挙動に悩まされることはなさそうです。