しばやん雑記

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

Cosmos DB SDK v3 に追加された ReadMany API を使って効率的に複数項目の取得を行う

去年リリースされた Cosmos DB SDK v3.18.0 から Point Read のような API で複数項目を 1 回のメソッド呼び出しで行える ReadMany API が追加されています。

Point Read のような API デザインなので Id と対応した PartitionKey 両方の指定が必要です。

API としては以下の PR にもあるように、Tuple として IdPartitionKey のコレクションを渡すと対応する項目をコレクションで返してくれるという、非常に分かりやすいものになります。

パッと見は Point Read を単純に並列で実行してくれているような API になっていますが、ReadMany API は効率的に取得が行えることがアピールされているので、どのくらい効率的なのか気になったので試しました。

元データとしては以下のようなコードを書いて、10000 件 Cosmos DB に投入しています。パーティションキーの範囲は 0-49 の 50 個になるように調整しています。

var container = cosmosClient.GetContainer("Sample", "TodoItems");

var tasks = new Task[10000];

for (int i = 0; i < 10000; i++)
{
    var todoItem = new TodoItem
    {
        Id = $"todoitem-{i}",
        Title = $"Sample Title {i}",
        Body = $"Sample Body {i}",
        User = new User
        {
            Id = $"user-{i % 50}",
            Name = $"User Name {i % 50}"
        }
    };

    tasks[i] = container.CreateItemAsync(todoItem, new PartitionKey(todoItem.User.Id));
}

await Task.WhenAll(tasks);

public class TodoItem
{
    public string Id { get; set; }
    public string Title { get; set; }
    public string Body { get; set; }
    public User User { get; set; }
}

public class User
{
    public string Id { get; set; }
    public string Name { get; set; }
}

こういった大量データの追加は Bulk を有効化して Task ベースで非同期にするだけで、簡単に Cosmos DB のスループットを最大限に使い切ってくれるので高速です。

素直に Point Read を並列化する

単純に考えると Cosmos DB は Point Read を使うと一番 RU 少なく取得が出来るので、これを並列化すれば複数項目を効率よく取得できそうな気がしますね。

以下のようなコードを書くと、Point Read を並列実行させて複数項目を簡単に取得できます。

var container = cosmosClient.GetContainer("Sample", "TodoItems");

var tasks = list.Select(x => container.ReadItemAsync<TodoItem>($"todoitem-{x}", new PartitionKey($"user-{x % 50}")));

var result = await Task.WhenAll(tasks);

これで並列実行は出来ますが、1 項目を取得するのに 1 つのリクエストが投げられるため、ネットワークレイテンシの影響をかなり受けてしまうので、効率はあまり良くないです。

ここでいうところの list に含まれる数が非常に多くなると、マシンのリソースを大量に消費してしまいますが、.NET 6 から追加された Chunk を使うとバッチ的に処理出来ます。

var container = cosmosClient.GetContainer("Sample", "TodoItems");

var result = new List<ItemResponse<TodoItem>>();

foreach (var chunk in list.Chunk(100))
{
    var tasks = chunk.Select(x => container.ReadItemAsync<TodoItem>($"todoitem-{x}", new PartitionKey($"user-{x % 50}")));

    result.AddRange(await Task.WhenAll(tasks));
}

当然ながら一部直列処理になるので全体としての処理時間は伸びますが、マシンリソースを食い尽くしてしまって、逆に遅くなるという事態を避けることが出来ます。

バッチでまとまった単位で処理したとしても、ネットワークレイテンシの影響を減らすことは出来ません。ただし消費される RU は一番少ないという期待が持てます。

SQL を使って論理パーティション単位で取得する

Cosmos DB では Point Read だけではなく SQL を使ったクエリを実行することが出来るので、クエリを使うことで 1 回のリクエストで複数項目を取得できます。

ここで重要になるのはクロスパーティションクエリを回避するように、クエリを実行する必要があるという点です。従ってパーティションキー単位でグループ化してから実行する方法を選びます。

var container = cosmosClient.GetContainer("Sample", "TodoItems");

var items = list.Select(x => ($"todoitem-{x}", new PartitionKey($"user-{x % 50}"))).ToArray();

var tasks = new List<Task<(IReadOnlyList<TodoItem>, double)>>();

foreach (var group in items.GroupBy(x => x.Item2))
{
    tasks.Add(LinqQueryableByPartitionKeyAsync(container, group.Select(x => x.Item1), group.Key));
}

var result = await Task.WhenAll(tasks);

async Task<(IReadOnlyList<TodoItem>, double)> LinqQueryableByPartitionKeyAsync(Container container, IEnumerable<string> idList, PartitionKey partitionKey)
{
    var iterator = container.GetItemLinqQueryable<TodoItem>(requestOptions: new QueryRequestOptions { PartitionKey = partitionKey, MaxItemCount = 1000 })
                            .Where(x => idList.Contains(x.Id))
                            .ToFeedIterator();

    var result = new List<TodoItem>();
    var requestCharge = 0.0;

    do
    {
        var response = await iterator.ReadNextAsync();

        requestCharge += response.RequestCharge;
        result.AddRange(response);

    } while (iterator.HasMoreResults);

    return (result, requestCharge);
}

この例では LINQ を使って Contains を条件式に指定していますが、SQL の IN に自動で変換されるようになっています。さらにパーティションキー単位で並列実行することで、処理時間の短縮を狙います。

明示的にパーティションキーを指定しているので、クロスパーティションクエリとして実行されません。

ReadMany API を使って取得する

最後は今回取り上げる ReadMany API を使って取得する例です。API としては Point Read とほぼ同じで、 IdPartitionKeyTuple をコレクションとして渡しているだけです。

var container = cosmosClient.GetContainer("Sample", "TodoItems");

var items = list.Select(x => ($"todoitem-{x}", new PartitionKey($"user-{x % 50}"))).ToArray();

var result = await container.ReadManyItemsAsync<TodoItem>(items);

前にも書いたように、API 的には Point Read を並列実行してくれるように見えます。本当に効率的に取得できるのか若干怪しい気もしますが、ここまで紹介した 3 つの方法を実際に動かして試します。

実行結果と考察

単純に Id が 100 から 299 までの 200 件分の項目を取得した結果が以下になります。処理時間は複数回実行していないので、参考程度に捉えておいてください。

f:id:shiba-yan:20220112004627p:plain

一番 RU の消費が少ないと思われた Point Read の並列化が一番多い結果となりましたが、Point Read では 1 項目読み取る際に 1 RU 以上が絶対に消費されるので、200 件分取得したので 200 RU 消費されているという単純な結果です。これが 1KB 以上のデータではもちろん変わってきます。

SQL を使って論理パーティション単位での取得した場合は Point Read の並列化よりは少なくなっていますが、この例では ReadMany API を使った方法が圧倒的に効率が良いです。

正直なところ、Cosmos DB から任意の項目を取得する方法は Point Read か SQL しかないので、SQL と ReadMany API で大きな差が付くのは考えにくいのですが、その答えは以下の実装にありました。

結論から言うと ReadMany API は SQL を使って複数項目を取得していますが、クエリの実行が論理パーティション単位ではなく物理パーティション単位で行われています。

実際に発行されるクエリはパーティションキーが /id かそれ以外で変わりますが、概ね以下のようなクエリが生成されて Cosmos DB で実行されています。

-- パーティションキーが /id の場合
SELECT * FROM c WHERE c.id IN (...)

-- パーティションキーがそれ以外(この例では /pk)の場合
SELECT * FROM c WHERE (c.id = '...' AND c.pk = '...') OR (c.id = '...' AND c.pk = '...') OR ...

パッと見はクロスパーティションクエリになっていて、パフォーマンスに影響が出そうですね。

ついでに RequestHandler を使って SQL を使って論理パーティション単位で実行した時と、ReadMany API を使って実行した時のリクエストヘッダーをキャプチャしたのが以下の画像です。

f:id:shiba-yan:20220113023826p:plain

f:id:shiba-yan:20220113023819p:plain

上の論理パーティション単位で実行した場合は、リクエストヘッダーでパーティションキーが設定されていますが、下の ReadMany API を使って実行した場合にはクロスパーティションクエリが有効化されています。

クロスパーティションクエリを使っている ReadMany API の方が消費 RU が少ないのは疑問に感じると思いますが、Cosmos DB の同一物理パーティション内で完結するクロスパーティションクエリの場合は低コストになります。この辺りは以下のドキュメントに記載されています。

じゃあクロスパーティションクエリを避ける必要はないと考える人もいるかもしれませんが、利用者側では特定のパーティションキーがどの物理パーティションに入っているか知る方法が存在しないので、これまで通りパーティションキーを明示的に指定するのは重要です。

ReadMany API はパーティションキーがどの物理パーティションに入っているか知っているので、効率の良い物理パーティション単位での実行が出来るという仕組みです。ちょっとズルいですね。

ちなみに ReadMany API が全てのユースケースで最適かと言われるとそうではありません。取得したい項目のパーティションキーの偏りがほぼ存在しない場合は、素直に SQL を書いた方が消費 RU は少なくなります。

f:id:shiba-yan:20220113022226p:plain

このように 1 つの論理パーティションに対してのクエリは SQL の IN を使う方法が最適なことが分かりますね。ReadMany API はクロスパーティションクエリかつ、若干複雑なクエリを使っているのでその分のコストが増えているようです。

Cosmos DB の論理パーティション・物理パーティション・Change Feed はかなり面白く、内部構造を知っているとパフォーマンスの更なる追求とスケーリングが実現できるので、どこかでまとめたいと思います。

Cosmos DB の直接モード利用時に Application Insights で詳細なテレメトリを収集できるようにする

最近は色々なところで Application Insights をインストールして、アプリケーションのテレメトリを継続的にちゃんと取得しましょう的なことを言い続けているのですが、Cosmos DB に関してはデフォルトでは依存関係テレメトリが送信されません。

ドキュメントにも以下のように記載されています。要するに直接モード (TCP / Direct mode) を使っていると、組み込みの依存関係トラッキングの対象外となります。

Azure Cosmos DB
Only tracked automatically if HTTP/HTTPS is used. TCP mode won't be captured by Application Insights.

Dependency tracking in Application Insights - Azure Monitor | Microsoft Learn

Application Insights の依存関係トラッキングが主に HttpClientDiagnosticSource ベースなので、HTTP 以外は個別対応が必要になるという話です。

単純に Cosmos DB の接続モードを Gateway に変更すれば解決しますが、以下のドキュメントにもあるようにパフォーマンス上のデメリットや、Azure Functions の Consumption Plan では接続数の問題も出てくるので、基本は Direct を使いたいところです。

Cosmos DB SDK v3 ではリクエスト送信前後に独自の処理を追加できる仕組みが用意されているので、それを利用することで Direct でも Application Insights にテレメトリを送信できるようになります。

この辺りは Cosmos DB SDK が公式で Application Insights への統合を用意してくれそうなのですが、時間がかかりそうなのでひとまず独自に実装します。

実装方針含め完全に不明ですが Cosmos DB SDK が DiagnosticSource に対応すると、ユーザーコードを変更することなく対応できるので期待したいところです。

独自のテレメトリを送信する方法はドキュメントに用意されているので、この通り実装すれば簡単です。今回は依存関係を送信したいので DependencyTelemetry を使えばよいです。

以前に Cosmos DB の Request Unit 消費を Application Insights に送信する方法を書きましたが、これは古い v2 SDK を使っていた時のコードなので、今の v3 SDK ではもっと洗練したコードを実現できます。

Cosmos DB SDK v2 は廃止が近いので、まだ使っている場合は早めに移行しておきましょう。

RequestHandler を利用して送信する

Cosmos DB SDK v3 には HttpClientHttpMessageHandler のような機能として RequestHandler が導入されています。用途はほぼ同じで、メソッドも 1 つしかないので簡単です。

内部で使われるメタデータ系リクエスト以外で呼び出されるようになっているので、これを使って Application Insights にテレメトリを送信する仕組みを用意できます。

実際に作成した RequestHandler の実装は以下の通りになります。これは最低限の情報のみを送信しているので、追加したい情報があれば Properties に入れると良いです。

Application Insights へのテレメトリ送信に必要な TelemetryClient は DI 経由で貰うので、コンストラクタで受け取るようにしておきます。ドキュメントによっては TelemetryConfiguration を使う例があるかも知れませんが、今は TelemetryClient を直接受け取るのが推奨されています。

public class AppInsightsRequestHandler : RequestHandler
{
    public AppInsightsRequestHandler(TelemetryClient telemetryClient)
    {
        _telemetryClient = telemetryClient;
    }

    private readonly TelemetryClient _telemetryClient;

    public override async Task<ResponseMessage> SendAsync(RequestMessage request, CancellationToken cancellationToken)
    {
        // using var で定義しておくと自動で StopOperation が呼び出される
        using var dependency = _telemetryClient.StartOperation<DependencyTelemetry>($"{request.Method} {request.RequestUri.OriginalString}");

        var response = await base.SendAsync(request, cancellationToken);

        var telemetry = dependency.Telemetry;

        // Application Insights 上で Cosmos DB として表示するために必要
        telemetry.Type = "Azure DocumentDB";
        telemetry.Data = request.RequestUri.OriginalString;

        telemetry.ResultCode = ((int)response.StatusCode).ToString();
        telemetry.Success = response.IsSuccessStatusCode;

        // メトリックも同時に送信できるので便利
        telemetry.Metrics["RequestCharge"] = response.Headers.RequestCharge;

        return response;
    }
}

Cosmos DB のプロトコルは Direct でも中身は HTTP ベースなので、特に難しいことはないと思います。Request Charge を送信しておかないと、RU を大量に消費したリクエストを特定出来なくなるので、メトリックとして同時に送信するようにしています。

ペイロードを解析すれば実行されたクエリを同時に送ることも出来ますが、Cosmos DB から Log Analytics に送信する機能を使った方が効率的だと思うので実装していません。

作成した RequestHandler を使うためには、DI を組み合わせて以下のように初期化します。

builder.Services.AddSingleton<AppInsightsRequestHandler>();

builder.Services.AddSingleton(provider =>
{
    var requestHandler = provider.GetRequiredService<AppInsightsRequestHandler>();

    var connectionString = builder.Configuration.GetConnectionString("CosmosConnection");

    return new CosmosClient(connectionString, new CosmosClientOptions
    {
        CustomHandlers = { requestHandler }
    });
});

当然ながら TelemetryClient が DI に登録されている必要があるので、Application Insights SDK のインストールが必要です。Azure Functions の場合は最初から組み込まれているので必要ありません。

組み込んだアプリケーションを実行して、Cosmos DB へリクエストを投げる処理を実行すると、Application Insights の Dependencies にテレメトリが表示されるようになるはずです。

テレメトリを選択すると詳細が表示されるので、同時に送信した Request Charge が確認できます。応答時間や成功したかどうかも E2E タイムライン上で確認できるので、追跡が格段に行いやすくなります。

同時に送信した Request Charge は Application Insights の Log-based Metrics から確認できるので、グラフ表示も自由に行えます。もちろんアラート作成やダッシュボードに表示することも出来ます。

これで直接モードを使っていた場合にブラックボックスになってしまっていた Cosmos DB のリクエストを、Application Insights 上で他のテレメトリと同様に可視化出来るようになりました。

送信するテレメトリをさらに詳細化する

Cosmos DB の接続モードを Gateway で利用した場合に送信されるテレメトリは、行われた処理名が具体的に表示されるようになっていてわかりやすいですが、今回作成した送信処理では HTTP ベースのメソッドと URL が表示されるだけでした。

特にクエリ系は POST として表示されず分かりにくいので、Application Insights で使われている処理を利用してテレメトリの詳細化を行います。実装は以下に存在しています。

やっていることはメソッドと URL から具体的な処理を特定して、その名前をテレメトリとして送信しているだけですが、URL の正規化やマッピングが必要になるので少し手間です。

Application Insights SDK は MIT ライセンスで公開されているので、既存の実装をそのまま利用してしまうのが簡単です。以下のコードでは変更した部分のみ載せています。

public class AppInsightsRequestHandler : RequestHandler
{
    public AppInsightsRequestHandler(TelemetryClient telemetryClient)
    {
        _telemetryClient = telemetryClient;
    }

    private readonly TelemetryClient _telemetryClient;

    public override async Task<ResponseMessage> SendAsync(RequestMessage request, CancellationToken cancellationToken)
    {
        using var dependency = _telemetryClient.StartOperation<DependencyTelemetry>("Cosmos");

        var response = await base.SendAsync(request, cancellationToken);

        var telemetry = dependency.Telemetry;

        var resourcePath = HttpParsingHelper.ParseResourcePath(request.RequestUri.OriginalString);

        // populate properties
        foreach (var (key, value) in resourcePath)
        {
            if (value is not null)
            {
                var propertyName = GetPropertyNameForResource(key);
                if (propertyName is not null)
                {
                    telemetry.Properties[propertyName] = value;
                }
            }
        }

        var operation = HttpParsingHelper.BuildOperationMoniker(request.Method.ToString(), resourcePath);
        var operationName = GetOperationName(operation);

        telemetry.Type = "Azure DocumentDB";
        telemetry.Name = operationName;
        telemetry.Data = request.RequestUri.OriginalString;

        telemetry.ResultCode = ((int)response.StatusCode).ToString();
        telemetry.Success = response.IsSuccessStatusCode;

        telemetry.Metrics["RequestCharge"] = response.Headers.RequestCharge;

        return response;
    }

    private static readonly Dictionary<string, string> OperationNames = new()
    {
        // Database operations
        ["POST /dbs"] = "Create database",
        ["GET /dbs"] = "List databases",
        ["GET /dbs/*"] = "Get database",
        ["DELETE /dbs/*"] = "Delete database",

        // Collection operations
        ["POST /dbs/*/colls"] = "Create collection",
        ["GET /dbs/*/colls"] = "List collections",
        ["POST /dbs/*/colls/*"] = "Query documents",
        ["GET /dbs/*/colls/*"] = "Get collection",
        ["DELETE /dbs/*/colls/*"] = "Delete collection",
        ["PUT /dbs/*/colls/*"] = "Replace collection",

        // Document operations
        ["POST /dbs/*/colls/*/docs"] = "Create document",
        ["GET /dbs/*/colls/*/docs"] = "List documents",
        ["GET /dbs/*/colls/*/docs/*"] = "Get document",
        ["PUT /dbs/*/colls/*/docs/*"] = "Replace document",
        ["DELETE /dbs/*/colls/*/docs/*"] = "Delete document"
    };

    private static string? GetPropertyNameForResource(string resourceType)
    {
        // ignore high cardinality resources (documents, attachments, etc.)
        return resourceType switch
        {
            "dbs" => "Database",
            "colls" => "Collection",
            _ => null
        };
    }

    private static string GetOperationName(string operation)
    {
        return OperationNames.TryGetValue(operation, out var operationName) ? operationName : operation;
    }
}

長いコードですが、変わっていることは operationName の組み立てだけで、それ以外は前のコードと同じです。クエリに関しては Gateway と Direct で API が変わった感があるので、マッピングは修正しています。

この実装を使って Application Insights にテレメトリを送信すると、Application Insights 組み込みの実装と同じように実際の処理名が表示されるようになります。

同時に Properties に Database 名と Container 名を含めるようになっているので、カスタムプロパティを使ってグルーピングやフィルタリングが簡単に行えるようになっています。

やはり公式で Direct 対応の実装が入って、何もしなくても有効になる世界になって欲しいと思いますが、現実問題として Cosmos DB の監視は非常に重要になるので独自の実装でも入れておくのが良いです。

同時に Cosmos DB から Log Analytics への転送も行っておくと、パフォーマンスの問題に対応しやすいです。

Azure Storage の Blob Inventory をイベントベースで Azure Functions から利用する

Azure Storage の Blob は大量のデータをスケーラブルかつ安く保存することに特化されているのと、実際には Data Lake Storage Gen 2 以外は名前空間を持っていないので、一般的なファイルシステムのように特定ディレクトリ以下のファイル数やサイズなどの情報は簡単には取れないのですが、Blob Inventory を使うと日次や週次で Blob のデータを CSV や Parquet で作成してくれます。

公式ドキュメントにもあるように一般的には Synapse Analytics と組み合わせて SQL ベースでクエリを書いてしまうのが簡単です。ここでも Synapse Analytics Serverless が使えるので低コストで実現できます。

Synapse Analytics を使うとアドホックにクエリを実行して、その時に必要なデータのみ簡単に抽出できるので便利ですが、実際にはある程度自動化する必要があるケースが多いと思います。

自動化のためには Blob Inventory が完了したタイミングで処理を行う仕組みを用意する必要があります。幸運にも Event Grid 経由で完了を知ることができるので、これを使って Azure Functons での自動化を試します。

Blob Inventory ルールを作成する

まずはターゲットとなる Storage Account に Blob Inventory ルールを作成します。複数ルールの作成ができるので、必要に応じて条件をカスタマイズしたルールを追加することができます。

f:id:shiba-yan:20211217014633p:plain

Blob Inventory ルールは細かく設定が可能ですが、重要なのは Blob Inventory に含めるメタデータと出力のファイルフォーマットになります。全てのメタデータを出力してしまうと、ファイルサイズがかなり大きくなってしまうので必要最小限のものを選択します。

f:id:shiba-yan:20211217014644p:plain

Inventory freqency で日次・週次かの設定が出来ます。実際に処理が動く時間は特に決まっていないようなので、Blob Inventory が完了したタイミングを Event Grid で通知してもらうことが重要になってくるわけです。

(オプション)最終アクセス日時を保存する

これは Blob Inventory とは直接は関係ないのですが、Azure Portal の Blob Inventory 設定から最終アクセス日時の保存を有効化出来るようになっています。

f:id:shiba-yan:20211218203255p:plain

本来なら Lifecycle Managementと組み合わせたいことの方が多い気がしますが、Blob Inventory の結果に最終アクセス日時を含めることが出来るので、必要に応じて有効化しておきます。

ドキュメントにもありますが、メタデータの書き込みが発生するので課金対象になります。書き込み単価は安いですが、Blob へのアクセスが非常に多い場合には高額課金になる可能性もあるので注意しましょう。

Blob Inventory 完了後に Azure Functions で処理を行う

ここまでに何回か言及しましたが、Blob Inventory の処理は日次・週次で実行されますが実行開始時間は定められていないので、基本的には Event Grid 経由で完了したことを通知してもらわないと使いにくいです。

Azure Storage が対応しているイベントを確認すると Blob Inventory Completed が追加されているので、このイベントを購読すると Blob Inventory が完了したタイミングで処理を行うことが出来ます。

f:id:shiba-yan:20211217014745p:plain

完了時に送信されてくるイベントは以下のようなフォーマットになっています。

イベントに含まれている data の中に Blob Inventory の情報と処理結果のマニフェストファイルのパスが含まれているので、これを読み取ればルールに依存しない処理を作れます。

{
  "topic": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/***/providers/Microsoft.Storage/storageAccounts/***",
  "subject": "BlobDataManagement/BlobInventory",
  "eventType": "Microsoft.Storage.BlobInventoryPolicyCompleted",
  "id": "0b0c92c0-6bfe-4a5b-bf1a-bff8cd89144a",
  "data": {
    "scheduleDateTime": "2021-12-16T06:19:57Z",
    "accountName": "***",
    "ruleName": "default",
    "policyRunStatus": "Succeeded",
    "policyRunStatusMessage": "Inventory run succeeded, refer manifest file for inventory details.",
    "policyRunId": "4acf8dfe-82d0-4868-bb52-262318454482",
    "manifestBlobUrl": "https://***.blob.core.windows.net/inventory/2021/12/16/06-19-57/default/default-manifest.json"
  },
  "dataVersion": "1.0",
  "metadataVersion": "1",
  "eventTime": "2021-12-16T06:31:06Z"
}

実際に Blob Inventory によって作成されたファイル情報はマニフェストファイルの方に含まれているので、Event Grid から処理が発火されたらまずはこのファイルを読みに行くようにします。

イベントとマニフェストファイルの JSON スキーマから C# のクラスに変換したのが以下の定義です。

public class BlobInventoryEvent
{
    public DateTimeOffset ScheduleDateTime { get; set; }
    public string AccountName { get; set; }
    public string RuleName { get; set; }
    public string PolicyRunStatus { get; set; }
    public string PolicyRunStatusMessage { get; set; }
    public string PolicyRunId { get; set; }
    public Uri ManifestBlobUrl { get; set; }
}

public class Manifest
{
    public string DestinationContainer { get; set; }
    public Uri Endpoint { get; set; }
    public File[] Files { get; set; }
    public DateTimeOffset InventoryCompletionTime { get; set; }
    public DateTimeOffset InventoryStartTime { get; set; }
    public string RuleName { get; set; }
    public string Status { get; set; }
    public Summary Summary { get; set; }
    public string Version { get; set; }
}

public class Summary
{
    public int ObjectCount { get; set; }
    public int TotalObjectSize { get; set; }
}

public class File
{
    public string Blob { get; set; }
    public int Size { get; set; }
}

多少簡略化していますが、基本的にはこの定義があれば十分処理が行えます。

マニフェストファイルを読み込んでしまえば、後は Blob Inventory 結果のファイルを読み込んで、好きなように処理してしまえば終わりです。ファイルフォーマットには前述したように CSV と Parquet が選べますが、今回は簡単にするために CSV を選び、更に読み込みには以下のライブラリに含まれている DataFrame を使うことにしました。実体としては ML.NET に含まれているライブラリです。

名前の通り pandas の DataFrame によく似たインターフェースを持った C# 実装です。単純に C# で CSV を読むだけなら CsvHelper で十分ですが、読み込んだ後にフィルタなどを行う場合はこちらのが便利です。

今回書いた Azure Functions のコードを以下に載せておきます。マニフェストファイルなどは読み込むために認証が必要なので、Blob SDK 経由で扱う必要があることに注意してください。

public class Function1
{
    [FunctionName("Function1")]
    public async Task Run([EventGridTrigger] EventGridEvent eventGridEvent, ILogger log)
    {
        // Event Grid のデータを Blob Inventory Event としてデシリアライズ
        var blobInventoryEvent = eventGridEvent.Data.ToObjectFromJson<BlobInventoryEvent>(new JsonSerializerOptions { PropertyNameCaseInsensitive = true });

        var credential = new StorageSharedKeyCredential(Environment.GetEnvironmentVariable("AccountName"), Environment.GetEnvironmentVariable("AccountKey"));

        // イベントで渡された Manifest ファイルをダウンロード
        var manifestBlob = new BlobClient(blobInventoryEvent.ManifestBlobUrl, credential);

        var content = await manifestBlob.DownloadContentAsync();

        var manifest = content.Value.Content.ToObjectFromJson<Manifest>(new JsonSerializerOptions { PropertyNameCaseInsensitive = true });

        // Manifest に記載された情報から今回生成された CSV ファイルを読み取る
        var blobServiceClient = new BlobServiceClient(manifest.Endpoint, credential);

        var containerClient = blobServiceClient.GetBlobContainerClient(manifest.DestinationContainer);
        var blobClient = containerClient.GetBlobClient(manifest.Files[0].Blob);

        // 生成された CSV をダウンロードして DataFrame として読み込む
        var inventory = await blobClient.DownloadContentAsync();

        var dataFrame = DataFrame.LoadCsv(inventory.Value.Content.ToStream());

        var filtered = dataFrame[dataFrame["Content-Length"].ElementwiseGreaterThan(16 * 1024)]["Name"];

        log.LogInformation($"Count = {filtered.Rows.Count}, Values = {string.Join("\n", filtered["Name"].Cast<string>())}");
    }
}

処理内容としてはファイルサイズが 16KB 以上の Blob の件数を数えて、ファイル名を出力するといった簡単なものです。今回使った Microsoft.Data.Analysis については別で詳しく書こうかと思っています。

この Function をデプロイして、Event Grid のエンドポイントとして指定すれば完成です。

f:id:shiba-yan:20211218203350p:plain

後はしばらく放置して、Blob Inventory が完了するのを待ちましょう。実行されると Application Insights には以下のようなログが出力されるので、正しく動作していることが確認できます。

f:id:shiba-yan:20211226004845p:plain

今回はファイル数が少なかったので CSV を使いましたが、基本的には CSV はファイルサイズが大きくなってしまうので、ファイル数が多い場合は Parquet を使った方がコンパクトかつ高速です。

古い Blob Inventory ファイルを自動で削除する

最後はおまけ的な話になりますが、Blob Inventory で生成されたファイルは放置すれば無駄に溜まり続けていくので、Blob Lifecycle Management を設定して 90 日以上前のファイルは削除するようにしました。

{
  "rules": [
    {
      "enabled": true,
      "name": "inventory",
      "type": "Lifecycle",
      "definition": {
        "actions": {
          "baseBlob": {
            "delete": {
              "daysAfterModificationGreaterThan": 90
            }
          }
        },
        "filters": {
          "blobTypes": [
            "blockBlob"
          ],
          "prefixMatch": [
            "inventory"
          ]
        }
      }
    }
  ]
}

既に Blob の Lifecycle Management は利用している人が多いと思うので、ルールを見ていただければすぐに理解できるかと思います。やっていることは特定のコンテナー以下の最終書き込み日時を調べて、それが 90 日以上前のファイルを消すようにしているだけです。

Azure Functions v4 で廃止された Azure Functions Proxies の代替ソリューションを実装した

先月に .NET 6 と同時にリリースされた Azure Functions v4 では、ひっそりと Azure Functions Proxies が廃止されました。公式には API Management を使うように推奨されていますが、明らかに一部のユースケースしか見えておらず、廃止の理由を聞いても何も教えてくれませんでした。

Azure Functions Proxies を利用していた場合は、何とかしないと v4 への移行は行えないという状態です。

正直なところ Azure Functions Proxies は結構便利に使っていたので非常に困ります。だからと言って API Management や Application Gateway で代替できるかと言ったらそういうわけでもないので、失われた機能を補うためのライブラリを 2 つ作成しました。

Static Website として利用していた場合

今では Static Web Apps があるので Azure Functions 上でわざわざ静的コンテンツをホストする必要はないのですが、Static Web Apps が出る前までは Azure Storage の Static Website を有効にして、フロントに CDN やリバースプロキシを置いて HTTPS やカスタムドメインの対応をしていたと思います。

Azure Functions Proxies を利用すると Consumption Plan を使いつつカスタムドメインや認証を手軽に追加できるのと、フロントエンドとバックエンドの実装を 1 つの zip にまとめてアトミックにデプロイできるので結構便利に使っていました。

Azure Functions v4 ではこのあたりのユースケースが失われてしまったので、以前実装した Azure Functions の HttpTrigger をより簡単に書くためのライブラリに必要な機能を実装しました。

このライブラリを使うと Azure Functions Proxies よりも柔軟で、静的コンテンツのホスティングに特化した機能を実現できます。同時に HttpTrigger のルート選択処理がイマイチなのを修正します。

例えば Azure Storage の Static Website にホストされた静的コンテンツにルートからアクセスする場合は以下のような HttpTrigger な Function を追加します。

public class StaticWebsite : HttpFunctionBase
{
    public StaticWebsite(IHttpContextAccessor httpContextAccessor)
        : base(httpContextAccessor)
    {
    }

    [FunctionName(nameof(Serve))]
    public IActionResult Serve(
        [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = "{*path}"})] HttpRequest req,
        ILogger log)
    {
        // Azure Storage の Static Website へのプロキシとして動作する
        return RemoteStaticApp("https://ststaticwebsiteproxy.z11.web.core.windows.net", fallbackExclude: $"^/_nuxt/.*");
    }
}

Azure Storage 側には create-nuxt-app で作成したサンプルサイトをコピーしてあります。404 時に Nuxt.js 側で処理を継続するための Fallback 処理も組み込んでいます。

この状態で Azure Functions を実行してルートにアクセスすると、以下のようにサイトが表示されます。

f:id:shiba-yan:20211201182553p:plain

あまり知られていないと思いますが、Azure Functions の HttpTrigger のルートは Function 名のアルファベット順で決定されるので、Catch All 定義がある場合には意図した動作にならないことが多いです。このライブラリではそのイマイチなルート決定処理を置き換えているので、ASP.NET Core と同様の処理になります。

Azure Functions Proxies ではローカルファイルを配信することは出来ませんでしたが、このライブラリでは外部ストレージだけではなく、Azure Functions と一緒にデプロイされたファイルの配信にも対応しています。

実際に Key Vault Acmebot では利用しているので興味があれば参考にしてください。

サンプルコードもリポジトリに含まれているので、こちらも一緒に参照してもらえればすぐに使い方は分かると思います。単一パッケージに含めてデプロイできるのは結構便利です。

軽量リバースプロキシとして利用していた場合

Azure Functions は Web Apps と同様に VNET Integration を使って、閉域環境や ExpressRoute 経由でのオンプレミス環境へのアクセスが可能で、更に App Service Authentication を使った認証も簡単に用意できるので、簡易ゲートウェイとして利用していたケースもあると思います。

このユースケースでは Function は全くデプロイせずに proxies.json だけ用意するだけで済むので、案外便利に使えていたのではないかと思います。そのようなユースケース向けには App Service Proxy という Site Extension を実装しました。

中身は .NET 6 と YARP で作られたシンプルなリバースプロキシですが、特徴としては Azure Functions Proxies で使われていた proxies.json との互換性があることです。シンプルに wwwroot 以下に proxies.json を置くと自動的に再読み込みされて利用可能になります。

{
    "$schema": "http://json.schemastore.org/proxies",
    "proxies": {
        "proxy1": {
            "matchCondition": {
                "methods": [ "GET" ],
                "route": "/{*path}"
            },
            "backendUri": "https://shibayan.jp/{path}"
        }
    }
}

現在はレスポンスを独自に返す系の処理には対応していません。実装できるか不明なので、今後もどうなるのかはわからないですが上手くいきそうなら検討します。

ファイルを配置した後に Web App にアクセスすると、定義通りに動作していることが分かります。

f:id:shiba-yan:20211130202858p:plain

この Web App には App Service Authentication を使った Azure AD 認証を組み込んでいるので、アクセスすると以下のように自動的に Azure AD のログイン画面にリダイレクトして、アクセスしているユーザーに権限がある場合のみ見られるようになっています。

f:id:shiba-yan:20211130203039p:plain

Application Gateway を使っても実現できますが、デプロイに時間がかかり認証や無料証明書は用意されていないですし、オートスケールも App Service のように素早くはありません。

もっとカジュアルに利用できる L7 のリバースプロキシが欲しい場面は結構あるので、そういった場面でピンポイントに利用できる Site Extension に出来たかと思います。

Azure Functions v4 における Dynamic PGO と ReadyToRun の使い分け

.NET 6 ではパフォーマンス向上のために Dynamic PGO という機能が JIT に追加されています。

名前の通り PGO を実行時に行ってパフォーマンス向上に役立てるという機能で、Azure AD の Gateway では .NET 6 と Dynamic PGO を組み合わせることで CPU 使用率を 33% 下げて、これまでより 50% もアプリケーションの実行効率を向上出来たらしいです。

.NET Core 3.0 で Tiered Compilation が追加されて Tier 0 コードから Tier 1 に差し替え出来るようになりましたが、Dynamic PGO は Tier 0 から Tier 1 のコード生成時に、実行時に収集されたプロファイルデータに従って最適なコードを生成できるようになりました。

パフォーマンス向上の機能としては同じく .NET Core 3.0 で Tier 0 相当のコードを事前生成する ReadyToRun がありますが、Dynamic PGO は ReadyToRun と同時利用できないので、アプリケーションでどちらを利用するか判断する必要があります。

ReadyToRun に関しては .NET Core 3.0 リリース時に書いたエントリがあるので参照してください。

実行時にプロファイルデータを収集して最適なコードを生成する仕組み上、ASP.NET Core アプリケーションのように長時間の稼働が予想される場合は、Tier 0 のコードを事前に生成するだけの ReadyToRun よりも高いパフォーマンスが期待できます。是非 .NET 6 への移行時には有効化を検討しましょう。

では Azure Functions のような FaaS ではどうするべきか、というのが今回の話です。

Azure Functions の特性から考える

既に広く知られていると思いますが Azure Functions は FaaS なので、アプリケーションは必要な時に都度起動されて、実行が終わったらすぐに捨てられるというライフサイクルを辿ります。つまり実行時よりもスタートアップのパフォーマンスの方が優先されます。

とは言いつつも Azure Functions の実行基盤は以下の 3 種類が用意されているので、アプリケーションの要件によって適切なものを使っていると思います。

  • Consumption Plan
  • Functions Premium
  • App Service Plan

中でも Consumption Plan は完全従量課金で、純粋な FaaS となるのでコールドスタートの方が重要になるので、ReadyToRun を利用するのが適切と言えるでしょう。Dynamic PGO では折角最適化したコードを生成しても、すぐに捨てられてしまいます。

Functions Premium と App Service Plan に関しては少し毛色が違っていて、常時稼働するインスタンス上でホストされているので、コールドスタートによるオーバーヘッドはほぼ無視できます。この 2 つの使う場合には実行時のパフォーマンスを優先して Dynamic PGO が適切と考えられます。

ここから先は実際に Azure Functions v4 上に Dynamic PGO と ReadyToRun のそれぞれを有効化した Function App をデプロイして試した結果を含みます。

Dynamic PGO の有効化

パッと見は公式ドキュメントには Dynamic PGO 周りの設定は載っておらず、今は以下の Gist が実質的なドキュメントとして存在している状況のようです。設定はとても簡単です。

Dynamic PGO in .NET 6.0.md · GitHub

Dynamic PGO を有効化するには環境変数に以下の 3 つの設定を追加します。ReadyToRun とは異なりコンパイル時の設定は必要ありません。

  • DOTNET_TieredPGO = 1
  • DOTNET_TC_QuickJitForLoops = 1
  • DOTNET_ReadyToRun = 0

Azure Functions の場合は App Settings に追加すると、自動的に同じ名前で環境変数に設定されるので有効化出来ます。ARM Template や Terraform を使っていても有効化するのは簡単です。

f:id:shiba-yan:20211130163234p:plain

これで Dynamic PGO が有効になりました。前述の通り Functions Premium や App Service Plan で常時稼働するインスタンスが存在する場合には有利でしょう。

ReadyToRun の有効化

.NET 6 では ReadyToRun も若干機能が増えているようですが、基本は自己完結型でデプロイした場合に限られるものなので、Azure Functions で利用する際には関係ないです。

Azure Functions での ReadyToRun の有効化方法は既に Azure Functions v3 がリリースされたタイミングで書いているので、こちらを参照してください。.NET 6 になっても方法は同じです。

コンパイル後は Tier 0 のコードを含むのでアセンブリサイズが大きくなります。今回は Consumption Plan でのコールドスタート計測はしていないですが、良くなることはあっても悪くなることはないでしょう。

Dynamic PGO と ReadyToRun の比較

実際のところ本当に効果があるのかは謎だったので、loader.io を使って単純な HttpTrigger について雑にパフォーマンス測定をしました。中身はテンプレートで生成されるような HttpTrigger なので、実際のアプリケーションでは結果が変わるはずですが、Azure Functions Runtime 部分の参考までに。

測定に使用した条件は以下になります。5 分間で 250 RPS の負荷を掛けたので合計 75000 リクエストです。

  • Azure Functions v4 (v4.0.1)
  • Windows - P1v2 (Premium V2)
  • 64bit 有効
  • 250 RPS, 5 分間

loader.io のグラフ表示機能は結構弱いので、久し振りに Excel で雑グラフを作りました。

本当は Azure Functions v3 と v4 の比較のために今月頭から定期的に回していたのですが、最後に追加した ReadyToRun が 10 日前だったのでデータ数は少し少ないです。

f:id:shiba-yan:20211130182745p:plain

予想通り HttpTrigger のテンプレート実装しかないので大きな差は出ていないです。Dynamic PGO と ReadyToRun 共に未設定の場合よりも 3~9% 程向上していますが、今回のケースでは ReadyToRun の方が良い結果が出ていました。

Azure Functions Runtime 部分から Dynamic PGO が実行されると、もう少し差が出るかなと思いましたが、若干予想が外れました。設定を入れる前には必ず検証するようにしましょう。

Azure Functions v4 が正式リリースされたので既存の環境とアプリケーションをアップグレードする

Visual Studio 2022 のローンチイベントで .NET 6 の GA と同時に Azure Functions v4 の GA が発表されました。これまでは .NET のリリースから遅れて対応するバージョンの Azure Functions がリリースされていましたが、今回は Day 0 サポートがアピールポイントです。

.NET 5 は LTS ではなかったので In-Process 版のサポートではなく Out-of-Process でのサポートになりましたが、.NET 6 は LTS なので In-Process と Out-of-Process の両方がサポートされています。

これまで Azure Functions v3 を使って開発していた場合は、比較的スムーズに v4 へのアップグレードが行えます。Breaking changes をいくつか存在するので、そのあたりに注意すれば問題ないはずです。今回、開発環境を含め一通り Azure Functions v4 向けに更新したので、メモを兼ねて残します。

Visual Studio を使っている場合

最新の Visual Studio 2022 をインストール

Visual Studio 2022 のインストール時に Azure の開発ワークロードを選択すると、自動的に Azure Functions v4 の開発に必要なパッケージがインストールされます。

Azure Functions プロジェクトを作成すると .NET 6 と .NET 6 Isolated が選択できるようになっています。

f:id:shiba-yan:20211101233802p:plain

まだ .NET 6 に LTS 表示はついていないですが、今後 LTS の表示が追加されるのだと思います。

稀に表示されない場合がありますが、一旦は放置してプロジェクトを作り直すと出てくるようになります。バックグラウンドで Azure Functions Core Tools のインストールを行っているようなので、若干新バージョンがリリースされた時はラグが発生します。

Visual Studio Code を使っている場合

Azure Functions Core Tools のアップグレード

Windows 以外の環境では CLI や Visual Studio Code を使って Azure Functions の開発を行っていると思いますが、この場合は Core Tools が自動的に v4 に更新されないので、以下のドキュメントを参考に v4 をインストールする必要があります。

最近では Azure Functions Core Tools のバージョンマネージャーがリリースされたので、これを使うと簡単に Azure Functions v3 と v4 が共存する環境を用意できます。

最近だとプロジェクト単位でバージョンを指定出来るのと、インストールもメジャーバージョンを指定すれば最新が入るので結構便利です。npm を使ってインストールしていた環境では検討しても良いと思います。

.NET 6 SDK と最新の Azure Functions 拡張をインストール

Azure Functions Core Tools の実行には .NET 6 SDK は必要ないですが、C# の Function App を使う際には必要となるのでインストールしておきましょう。同時に Azure Functions の Visual Studio Code 拡張も最新バージョンが入っているか確認します。

古いバージョンの拡張では .NET 6 対応されていないので注意が必要です。

.NET 6 SDK と v4 系の Azure Functions Core Tools が入っている環境では、Visual Studio Code を使った Azure Function プロジェクトの新規作成時に .NET 6 の選択肢が表示されるようになります。

f:id:shiba-yan:20211101233830p:plain

これは funcvm を使っていても正しく反映されるので、v3 系に切り替えてから新規作成すると .NET Core 3.1 の選択肢が出てきます。基本は v4 に移行するのが良いのですが、移行中には両方使いたいことがあると思うので便利に使えます。

Dev Container 向けの Core Tools イメージはまだ v4 向けが提供されていないので利用できません。Dockerfile やビルドパイプラインは用意されているので、近日中にリリースはされると思いますが注意しましょう。

Azure Functions v4 へのアップグレード

環境の準備ができた後に、既存の Azure Function プロジェクトを Azure Functions v4 対応にアップグレードしていくわけですが、マイグレーションのドキュメントが用意されているので Breaking changes を主に確認しておくことをお勧めします。

こまめに Extension のバージョンを上げていれば、ほぼ csproj の修正ぐらいで終わる内容です。v4 は .NET 6 対応とレガシー機能の削除が行われているぐらいで、内部的に大きな違いはないので当たり前ではあります。

実際の手順としては大体以下のようになります。ドキュメントには書かれていないですが .NET 5 の時に大勢を悩ませた Microsoft.* パッケージのバージョンミスマッチ問題も一気に解消できるので、一緒に対応しておくのがお勧めです。ようやく EF Core 5.0 から追加された機能が安心して利用できます。

上のリストには書いていないですが Azure Functions Proxies は v4 では完全に削除されていて利用できなくなっています。ドキュメントには API Management に移行しろとありますが、全くユースケースを理解しているとは思えない提案で、コスト的にも圧倒的不利なケースが多いので代替ソリューションを用意しました。

別エントリで Azure Functions Proxies の移行については紹介する予定です。

Azure Functions v4 対応の Function App を作成・更新

Azure Functions v4 に対応した Function App の新規作成は Azure Portal では .NET 6 を選べば済みますが、既存の Function App の場合は Runtime version を 4 もしくは FUNCTIONS_EXTENSION_VERSION~4 に変更しつつ、同時に .NET 6 を有効化する必要があります。

f:id:shiba-yan:20211110143536p:plain

既存のインスタンスを .NET 6 対応にするには ARM Explorer などを使って netFrameworkVersionv6.0 に変更するだけで完了します。App Service にプリインストールされるまでは JIT インストールになるので、インスタンスの新規起動時には多少のオーバーヘッドが発生しますが、無視できる程度でしょう。

暫くすると自動的に JIT インストールからプリインストールに変更され、オーバーヘッドは消滅します。

Terraform でも ARM Explorer を使う場合と同じように dotnet_framework_versionv6.0 に変更すれば良いです。AzureRM Provider のバージョンにだけ注意が必要です。

ちなみに Visual Studio からデプロイする際は自動的に設定変更してくれるので、開発中やテスト目的で使っている Function App の場合は Visual Studio に従えばいい感じに設定してくれます。

Azure Container Apps の特徴と Azure Web Apps / Azure Functions との違い

Ignite 2021 で発表された Azure Container Apps について、実際に触って調べたのでいろいろと所感を書きます。特に Web Apps / Azure Functions との違い・使い分けについて重視しました。

名前から分かるようにコンテナーの実行に特化したサービスです。既報の通り Kubernetes 上で動作していますが Kubernetes の知識が無くても簡単に扱えるようになっています。

最新の Serverless サービスなだけあって、全体的に設計が洗練されている印象を持っています。最初からログ周りは Log Analytics ベースになっているのも好印象です。

提供される機能と App Service との違い

まだプレビューなので機能は少なめですが、現時点で提供されている Container Apps の機能は以下のような感じです。カスタム VNET 機能がないと色々始まらない気はしますが、まだ提供されていないようです。

  • Container App
    • 1 つ以上のコンテナーを実行可能、ライフサイクルが同じなので実質 Sidecar 向けか
    • KEDA で管理されたスケーリング、Dapr でイベント駆動と Microservices 向けの機能を提供
    • エンドポイントを Internal / External それぞれに公開可能
    • 同じ Container App Environment 内にある Container App 間は通信可能
    • GitHub Actions を使った Continuous deployment
    • Web App / Azure Function に相当する
  • Container App Environment
    • ネットワークが分離された環境を表すリソース
    • 中身は App Service Kubernetes Environment な模様*1
    • カスタム VNET サポートは Container App Environment 単位になると思われる
    • App Service Plan / App Service Environment に相当する

上手く Kubernetes 部分が隠されているように感じます。開発者がアプリケーションの実装に集中できるというのは、Azure の PaaS / Serverless において不変の思想ですね。

個人的には Azure Spring Cloud から Spring 成分を抜き取ったものが Azure Container Apps だと感じました。AKS と違ってリソースも非常にシンプルです。

f:id:shiba-yan:20211104041136p:plain

Container Apps が公開されて多くの人が思ったのは「今後 Azure Functions と Web Apps はどうなるのか」という点だと思います。そのあたりは自分の中で結論は出ているので書きます。

これまでは Azure Functions や Web Apps の制限に引っかかった場合*2は、次の選択肢が実質 AKS になるという中々ハードな道でした。これは流石に厳しすぎますね。

雑にそれぞれのサービスの特徴をまとめると以下のようになると思います。Azure Functions と Web Apps が Container Apps に取って代わられるというわけではなく、App Service と AKS 間の差が大きかった部分を上手く埋めてきたという印象です。

  • Azure Functions
    • イベント駆動アプリケーション向け
    • 完全に自動化された HTTP / イベントベースのオートスケーリング
    • Azure Functions SDK と Binding / Trigger でサービス依存のコードを書く必要なし
  • Web Apps
    • Web アプリケーション / API 向け
    • Azure Monitor メトリックベースのオートスケーリング
    • カスタムドメイン / TLS 証明書 / 認証・認可 / デプロイといった Web アプリケーションに必要な機能が組み込まれている
    • Windows / Linux に対応、Custom Container にも対応
  • Container Apps
    • Web アプリケーション / イベント駆動アプリケーション向け
    • Managed KEDA / Dapr / AKS という感じ、Kubernetes を意識せずに使えるのは大きなメリット
    • バックグラウンドで常時稼働するアプリケーションを実装可能
    • Microservices を採用したアプリケーションを作る際には今後一択になりそう
  • AKS
    • Container App ではリソースが足りない場合に検討したい*3
    • 全部自分で管理したい場合には視野に入る、その分運用が大変

Azure Functions と Web Apps で多くのサービスを持つ Microservices アプリケーションを開発するのは難しいですが、逆に Container Apps でシングルコンテナーのアプリケーションを 1 つだけ動かすのも、あまりにもオーバースペックだと感じます。

どちらかに統一する必要はなく、両方を要件に合わせて適切に使っていけば良いです。個人的には AKS の出番ほぼなくなったかなと思っています。

HTTP/2 や gRPC への対応

App Service では IIS が gRPC に必要な HTTP/2 機能への対応が遅かったので、現時点でも gRPC は使うことが出来ないですが Container App では問題なく利用できます。気になったので中の人に確認を取りました。

適当に ASP.NET Core で gRPC サービスを作成して Container App にデプロイして、コンソールアプリケーションから利用できるか試しましたがすんなり動きました。

Container App の設定で Transport を Http2 に設定して試しましたが、デフォルトでは Auto なので実は変更しなくても問題なく通る疑惑もあります。

f:id:shiba-yan:20211103192300p:plain

若干はまった点としては ASP.NET Core で gRPC サービスと Dockerfile を作成すると、デフォルトで 80 番ポートが EXPOSE されていたので、Container App にデプロイした際に 80 番が優先して使われてしまい、gRPC が通らなくなっていました。

443 番だけ EXPOSE してデプロイしなおせば動くようになりました。エラーメッセージが Envoy のものだったので若干わかりにくかったです。

アプリケーションのデプロイ

既に Azure Portal には Continuous deployment の設定が用意されていますが、中身は App Service と同様に GitHub Actions の Workflow を自動的に作成する機能のようです。

Azure CLI を使うので Service Principal が必要なのはわかりますが、少し面倒ですね。

f:id:shiba-yan:20211104161536p:plain

Docker Image をビルドして、ACR にプッシュ出来れば問題ないので、Azure Pipelines を使った CI/CD も当然ながら問題ないです。現在は Azure CLI が使われていますが、将来的には Container App へのデプロイ用 Action や Task も用意されるでしょう。

価格について

課金体系は Azure Container Instances と Azure Functions の Consumption Plan を混ぜたようなものになっていて、正直なところ計算がとても面倒に感じました。GiB/sec はあまり好きではないです。

1 vCPU 当たりの金額を考えると、ACI の 1 vCPU と比べると 2.6 倍高いことになります。メモリは課金方法が違うように思うので比較は難しいですが、ACI よりも安くなることはありえないでしょう。機能が多いので価格が ACI よりも高くなるのは当然ですね。

アイドル時間は割引価格になるのはとても良いですね。App Service はアプリケーションがアイドルになっていても App Service Plan があれば通常課金されるので、真似してもらいたさがあります。

しかし常時コンテナーが起動しっぱなしで、アイドル時間がほぼ無い場合は割高になるはずなので、どれほどアイドル状態に落とせるかが重要になるはずです。ユースケースによっては App Service の Premium V2 よりも高くなることもあるでしょうし、ゲームのように常にリクエストが大量に発生するサービスでは、Dedicated なインスタンスの方が適しているので、将来的にはオプションで用意されるかもしれません。

リクエスト数課金は gRPC や WebSocket のようなコネクション張りっぱなしの場合にどうなるのか少し気になります。その場合のスケーリングも同様ですが、KEDA を調べればわかりそうです。

まとめ

App Service と同じチームによって開発された Container Apps は、Azure において Serverless なコンピューティングの重要な選択肢になることは間違いありません。以下に自分なりのまとめを雑に書きました。

  • イベント駆動アプリケーションを作りたい
    • Azure Functions
  • C# や Node.js で動く Web アプリケーションを作りたい
    • Web Apps
  • SPA / SSG を使った Web アプリケーションを作りたい
    • Static Web Apps
  • 利用したい言語が Azure Functions でサポートされていない
    • Container Apps
  • バックグラウンドで常時稼働するサービスを作りたい
    • Container Apps
  • 大規模に Microservices を採用したアプリケーションを作りたい
    • Container Apps
  • GPU インスタンスなど特殊なインスタンスが必要な場合
    • AKS

まだネットワーク周りの機能がサポートされていないので Container Apps が使える場面は少ないのですが、App Service のように機能が拡充されると AKS を使う必要がほぼ無いという状況になる気がします。

とはいえ、自分の中では Azure Functions と Web Apps が最初の選択肢になることは変わらないです。まずは Azure Functions / Web Apps で実現できるか検討し、その後に Container Apps を検討することになります。

*1:アイコンも App Service Environment 系と同じデザインになっている

*2:特に gRPC とか

*3:GPU インスタンスや特定の CPU、大量のメモリが必要なケースなど

Ignite 2021 で発表された Azure App Service のアップデート

App Service は Build や Ignite に合わせてアップデートを発表することは少なく、むしろずれて発表することの方が多い傾向にあるのですが、今回はアップデートのまとめブログが公開されていました。

先日も Availability Zones 対応が発表されましたが、イベントとは全く関係ないタイミングだったので Ignite で少し触れるかなと思いましたが、そんなことはなかったです。

とはいえ Ignite 前に発表されたものも多いので、今回のメインは ASEv3 での Windows Containers サポートと Service Connector だったのかなと思っています。

例によって自分が気になったアップデートのみ深堀したので、メモを兼ねて残しておきます。

Azure Portal 上での UX 改善

最近は Authentication や Networking の設定画面が大きく改善されて使いやすくなりましたが、その流れはまだ続いているようで TLS/SSL settings が今回プレビューで新しくなっています。

まだ途中のようで、証明書をドメインにバインドする機能は新しい方には実装されていないので、現行の画面を使う必要があります。今は完全に証明書管理機能だけが提供されています。

いつ追加されたかは分からないですが、ついに Azure Portal の App Service Authentication 設定から OpenID Connect Provider を追加できるようになりました。これまでは ARM API を使う必要がありました。

地味に設定項目は多いので、検証時には Azure Portal からサクッと追加できた方が便利です。

新しい言語バージョンのサポート

最新の言語バージョンに対応する際に一般的になってきた Early Access ですが、以下の言語バージョンのサポートが行われるようです。

.NET 6 は来週の .NET Conf で GA するので、間もなく GA バージョンが利用可能になるはずです。

  • .NET 6 RC 2
  • Node.js 16 LTS
  • Python 3.10
  • Java 17

Node.js 16 LTS が利用可能になったと書いていましたが、Azure Portal からは選択できませんでした。ARM レベルでは linuxFxVersionNODE|16-lts を指定すれば 16.6.1 が利用可能になりましたが、v16 が LTS になったのは 16.13.0 からのはずなので、まだテスト中の気配があります。

f:id:shiba-yan:20211103232038p:plain

.NET 6 のように Early Access 中にバージョンが上がるのは良くあることなので、Node.js 16 LTS も Early Access が終わる前には正しい LTS バージョンに更新されると思います。

そろそろ Java に関しては Microsoft がビルドしたバージョンが提供されてもおかしくないと思っています。

VNET Integration / Managed Identity で ACR にアクセス

以前から Web App for Container では Private Endpoint を経由した Azure Container Registry のアクセスは出来ていたようですが、Managed Identity との組み合わせがサポートされたようです。

App Service Team ブログの該当記事も大幅に変更が加えられています。

毎回 ACR を作成する度に Admin user を有効にするのが面倒ですし、セキュリティ的にもあれなので Managed Identity と RBAC で解決できるのなら有効化しておきたい部分です。

Service Connector の追加

個人的には地味に便利だと思ったのが Service Connector です。現在は限定されたリージョンでのみ利用可能ですが、Azure Portal や Azure CLI を使って App Service や Azure Spring Cloud に必要なサービスの接続情報を管理できます。

様々なサービスと連携可能な Azure Functions では、App Settings に様々な接続文字列を登録する必要がありますが、その登録は非常に面倒な作業です。特に最近では Identity-based connections のサポートで、Managed Identity と RBAC の設定も必要になっていて複雑さを増しています。

Service Connector はその当たりの設定を自動化しつつ、必要なサービスを管理できるようになるので、特に Azure Portal を使ってリソースを作成する際に便利に使えます。

実際に Managed Identity を使って Azure Storage への Connection を作成してみました。ターゲットリソースと接続名を指定すれば大体 OK です。

f:id:shiba-yan:20211103203052p:plain

Client Type は今のところ .NET だけのようですが、接続情報が App Settings に設定されるだけなので言語やフレームワークに依存する部分ではありません。

次は認証の種類を選択します。Azure Storage のような Managed Identity に対応しているサービスでは、デフォルトで System Assigned Managed Identity が推奨されるようになっています。

f:id:shiba-yan:20211103203119p:plain

Managed Identity を選ぶ時は予め App Service 側で有効にしておかないとエラーになりました。

新しい Connection を作成すると、ターゲットリソースの RBAC に自動的に App Service が追加されます。最低限必要なロールが設定されるようなので、必要に応じて変更すればよいと思います。

f:id:shiba-yan:20211103203126p:plain

機能としてはシンプルなんですが、初めて Azure Functions を利用する際の障壁となりがちなのが接続情報の設定周りなので、Service Connector でその当たりを簡単に出来ると捗りそうです。

Service Connector の仕組みが知りたい方はドキュメントが用意されているので、目を通してくと良いです。そんなに大したことをやっているわけではないのですが、リソースと紐づけて App Settings が管理可能になったというのは重要ですね。

個人的には Azure Functions の Binding / Trigger で直接 Connection 名を設定できるようになってほしいと思いました。Twitter で呟いたら PM からメールが来たので、その旨をフィードバックしておきました。

Azure Static Web Apps に追加されたカスタムロールの割り当て機能を試した

Static Web Apps には全てのプランで使える組み込み認証 + ロール管理機能と、Standard 以上で使えるカスタム認証機能がありますが、Azure AD B2C や OpenID Connect Provider を使ったカスタム認証の場合は、ロールは認証済みしか使えなかったので高度なアクセス制限は実装出来ませんでした。

これまでロールベースで管理していたアプリケーションは SWA の利用が難しかったのですが、プレビューでカスタム認証でのロール割り当てが自由に行える機能が公開されました。

仕組みとしては簡単で、ログインの度に呼び出される API を用意して、その API がロール情報を返せば反映されるというものになっています。

ドキュメントは既に十分なものが用意されているので、これを読めば簡単に実装出来ます。

API には ID トークンに含まれる情報 + アクセストークンが含まれているという感じです。アクセストークンが含まれているので、Azure AD の場合は scope の設定を行っておけば、アクセストークンを使って別の API を実行してロールを返すこともできるわけです。

実際にチュートリアルでは渡されるアクセストークンを使って、Graph API を呼び出してユーザーの所属するグループを取得してロールとして返しています。

しかしユーザーの所属するグループはわざわざ Graph API を叩かなくても、Azure AD アプリケーションの設定で ID トークンに含めることができるので、こちらのアプローチでカスタムロールを試しました。

適当に Azure AD でセキュリティグループを作成して、ユーザーに割り当てます。ID トークンには Object ID が含まれるので、面倒ですが API でマッピングを持つか Graph API で解決するかのどちらかが必要です。

Graph API で解決する場合は Managed Identity を有効化して、そちらに Graph API の実行権限を付けるほうがスマートです。実現方法が気になる方は以下のエントリを参照してください。

ユーザーをグループに追加すれば、後は Azure AD アプリケーションの設定から ID トークンにセキュリティグループの情報を含めるように設定します。

さらに詳しく ID トークンにグループ情報を含める方法を知りたい方は、以下のドキュメントを参照してください。細かい設定が多いので一度目を通しておくとよいかと思います。

ここまでの設定でログインした時の ID トークンにグループ情報が含まれるようになったので、後はロール情報を返す Azure Function を作成して、それの API を Static Web App 側に設定する流れになります。

ロール情報を返す Function は C# を使って以下のように実装しました。マッピングはチュートリアルと同様にコード側に持つようにして簡略化していますが、この Function から SQL Database や Cosmos DB にアクセスしてロール情報を取得しても良いので、ロールに関しては完全に自由に制御できるようになっています。

public static class GetRoles
{
    private static readonly Dictionary<string, string> _roleGroupMappings = new Dictionary<string, string>
    {
        { "496e06f7-7493-4ab3-8218-821fd6e1f2bb", "Admin" }
    };

    [FunctionName("GetRoles")]
    public static async Task<IActionResult> Run(
        [HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequest req,
        ILogger log)
    {
        var body = await new StreamReader(req.Body).ReadToEndAsync();

        var request = JsonConvert.DeserializeObject<RolesSourceRequest>(body);
        
        var roles = new List<string>();

        foreach (var claim in request.Claims.Where(x => x.Typ == "groups"))
        {
            if (_roleGroupMappings.TryGetValue(claim.Val, out var roleName))
            {
                roles.Add(roleName);
            }
        }

        return new OkObjectResult(new RolesSourceResponse { Roles = roles });
    }
}

public class RolesSourceRequest
{
    public string IdentityProvider { get; set; }
    public string UserId { get; set; }
    public string UserDetails { get; set; }
    public IReadOnlyList<Claim> Claims { get; set; }
    public string AccessToken { get; set; }
}

public class RolesSourceResponse
{
    public IReadOnlyList<string> Roles { get; set; }
}

public class Claim
{
    public string Typ { get; set; }
    public string Val { get; set; }
}

注意点としては claims は単純な Key-Value で入ってくるので、groups のような複数値があるものは同じキー名で渡されます。LINQ でフィルタするのが簡単ですね。リクエストのデシリアライズが冗長になっていますが、何故かバインディングだと正しく動作しなかったので、このように書いています。

最後に staticwebapp.config.jsonrolesSource を追加してデプロイすると完了です。この API は / から始まる必要があるので、外部ホストの API は指定できませんが BYOF でも問題なく動きました。

{
  "$schema": "https://json.schemastore.org/staticwebapp.config.json",
  "auth": {
    "rolesSource": "/api/GetRoles",
    "identityProviders": {
      "azureActiveDirectory": {
        "enabled": true,
        "registration": {
          "openIdIssuer": "https://login.microsoftonline.com/<TENANT_ID>/v2.0",
          "clientIdSettingName": "AAD_CLIENT_ID",
          "clientSecretSettingName": "AAD_CLIENT_SECRET"
        }
      }
    }
  }
}

デプロイ後に Azure AD でのログインを行い、お馴染みの /.auth/me にアクセスしてみると、セキュリティグループと同名のロールが割り当てられていることが確認できます。

Application Insights を確認すると rolesSource に指定した API がログインの度に実行されているのが分かります。この API はログインの度に呼び出されるため応答が遅いとユーザー体験が悪くなってしまうので、実装には注意したいところです。

API が失敗してもログインが失敗することはないですが、当然ながらロールは割り当てられません。

このロール情報を利用するには、これまでの組み込みロールと同様に staticwebapp.config.jsonallowedRoles で指定すれば問題ないです。以下のような定義を用意すれば /admin ページには Admin ロールを持ったユーザーのみアクセス出来るようになります。

{
  "routes": [
    {
      "route": "/admin",
      "allowedRoles": ["Admin"]
    }
  ]
}

API の実装は必要になりますが、完全にログインユーザーのロールをカスタマイズできるようになったので、複雑なロールを持つアプリケーションにも十分対応できるようになりました。

注意点としてはログイン時のみ呼び出されるので、途中でロール情報を変更しても反映されるのは明示的なログアウトか、ログインセッションが切れてからになることぐらいでしょうか。反映されるまでの時間を短くするためには、セッションの有効期限を変更する必要がありますが、SWA では未対応です。

Hack Azure! #7 - 次世代 Serverless アプリケーションアーキテクチャ!フォローアップ

App Service / Azure Functions の Availability Zones 対応が GA となり、今後のアーキテクチャ設計への大きな変化となることは確信していたので、この辺り話したいな思っていたら話すことになったのが今回です。

要するに PaaS / Serverless で Availability Zones をフル活用したアーキテクチャについて話す会です。

基本は配信のアーカイブを見てもらえれば良いのですが、配信だとドキュメントや補足が難しいことも多いので出来る限りこのようなフォローアップを書くようにしています。

相変わらず長くなったので、以下から興味のある部分をピックアップしてもらえれば良いです。

Azure の Availability Zones は設定から有効化すれば、後はプラットフォーム側にお任せというパターンが多いのでゾーン冗長にするだけなら簡単ではありますが、意図せず SPOF を作らないためにはサービスの選択には注意が必要な印象です。特にストレージ周りは注意したいです。

Twitter まとめと YouTube アーカイブ

例によって Twitter のまとめと YouTube でのアーカイブがすでに公開されています。

特に今回は Twitter のコメントが盛り上がって自分としても凄く勉強になったので、一通りアーカイブを合わせて参照してもらうと良いかと思います。

AZ に対応した App Service Plan が Azure Portal で簡単に作れるようになるまでは、基本的に ARM Template か Terraform を使うことになると思います。

既存の App Service Plan を AZ 対応に変更する機能は永久に来ないと思うので、作成前に設定しましょう。

Azure Availability Zones について

これまで App Service や Azure Functions といった PaaS / Serverless なコンピューティングサービスを使っていると、ASE を除いては全く意識することがなかったのが Availability Zones です。

詳細は公式ドキュメントに任せますが、App Service / Azure Functions では完全に Availability Zones の存在を隠して利用できるようになっているので、これまでと使い方は全く変わらないのが特徴です。

AZ に対応したサービスの一覧を確認すると、素の VM 以外はほとんど対応している状況です。

個人的には App Service / Azure Functions は最後の 1 ピースだと思っていたので、今回のアップデートで盤石になったなという印象です。APIM は例外感ありますが、今後対応するでしょう。

App Service / Azure Functions の AZ 対応

既に App Service のゾーン冗長に関しては以下のエントリを書いているので、そちらを参照してください。Premium V2 / V3 と Functions Premium を選んでいる場合に利用可能です。

配信でも話しましたが、最低 3 インスタンスが必要なのでコスト面は考慮が必要です。

Functions Premium に関しては少し遅れて対応が行われたので、少し情報が不足気味ですが公式ドキュメントに必要最低限の設定についてまとまっています。

Consumption Plan に関しては元々 Stamp を超えてスケールアウトする謎設定が用意されていたので、実質的にはゾーン冗長になっていそうな気配もしています。

AZ 対応によって App Service を使った高可用性設計が大きく変わったので、以下のエントリはどこかでアップデート版を書かないといけないなと思っています。

今後のイベントで App Service / Azure Functions の AZ 対応については触れられそうな気がするので、その情報をキャッチアップしながら暇を見つけて対応しようかと思います。

Storage / Cosmos DB / SQL Database の AZ 対応

LRS と ZRS は同期レプリケーションが行われますが、GRS は非同期レプリケーションなので、LRS と ZRS 以外は Azure Functions 向けに使えないことは認識しておきましょう。あと GPv2 必須です。

ストレージをゾーン冗長にするには作成時に ZRS に設定するだけなので簡単です。ただし既存の LRS / GRS を ZRS に変更するのは簡単には行えないので注意が必要です

一応サポートにライブマイグレーションのリクエストを投げれば行ってもらえるようですが、完了までに時間がかかることもあるようなので、高可用性が必要な場合は最初から ZRS で設計した方が良いですね。

Azure におけるメインストレージとなる Cosmos DB と SQL Database は Serverless を選んでもゾーン冗長を有効化できるので、個人的にはかなり熱いと思いました。

SQL Database は一部の Tier はプレビュー扱いなので注意が必要ですが、アーキテクチャを見る限りは ZRS を利用したシンプルなものなので、そう遠くない未来に GA するのではと思います。

この辺りは GA したタイミングでムッシュが詳しくブログで説明してくれるはずです。

SQL Database Serverless のアップデートと同時に AZ 対応の GA も期待したいところです。

Key Vault Reference と Identity-based connection

Twitter でいくつか話題になった Key Vault Reference と Identity-based connection ですが、前者に関しては当たり前のように使っている方は多いのではないかと思います。

User Assigned Managed Identity も使えるようになったので、さらに管理しやすくなっています。

最近は安全に Azure リソースにアクセスするために、Managed Identity と RBAC を使ってシークレットレスを実現する例も増えてきていますが、少し前に Azure Functions のバックエンドで使われる Azure Storage 自体に対して、Managed Identity でアクセスできる機能がプレビューで公開されています。

Azure Functions の Identity-based connection は全体的にプレビューとなりますが、ARM Template や Terraform との相性が良いので GA のタイミングで全体的な移行を検討したいところです。

Stamp 単位でのアップデートと regression の回避

個人的には App Service の AZ 対応によって、マルチリージョンを選択する機会はほぼ無くなったかと思っていますが、河野さんから以下のツイートで 1 点ツッコミが入りました。

確かに Stamp 自体が AZ に跨ってデプロイされているので、アップグレードは AZ を考慮せずに行われます。当然ながらリグレッションがあれば AZ 関係なく影響を受けるでしょう。

そのあたりを考慮したとしても、自分の意見としては以下のようになります。

App Service のアップグレードは当然ながらテストしながら段階的に行われるのでほとんどのケースで安全ですが、特殊な使い方をしている場合には影響を受ける可能性も十分にあるので注意しましょう。

高可用性が必要な場合は Run From Package か Docker Image を利用して出来るだけ環境依存部分を減らしつつ、イミュータブルなデプロイを行うのが鉄則かと考えています。

Static Web Apps を活用した開発について

配信の最後の方で告知した Static Web Apps に関するくらでべの動画が先日公開されたので紹介します。

当初は 20 分ぐらいの予定でしたが、あまりにも喋りすぎたので倍近くになっています。とても長い。

Hack Azure #6 の内容と若干被ってはいますが、最新の情報でアップデートしているので是非ご覧ください。

それではまた次回の Hack Azure でお会いしましょう。