1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Azure Functions】 Queue トリガーと HTTP トリガー、どっちを使う

1
Posted at

対象読者

  • Azure Functions の HTTP トリガーは書いたことがあるが、Queue トリガーはまだ触ったことがない方
  • 「非同期処理は Queue を使うと良い」とはよく聞くが、具体的に何が嬉しいのか実感が湧かない方
  • Queue トリガーの自動リトライ・poison queue の仕組みを、実際の挙動として知りたい方

説明すること・しないこと

説明すること

  • 「在庫を補充する」処理を HTTP トリガーと Queue トリガーの両方で実装し、比較する
  • Queue トリガーの自動リトライ・poison queue の挙動を、実際に不正な値を送って検証した結果
  • Functions ホストを実際に落として、HTTP と Queue でリクエストの結果がどう変わるかを検証した結果

説明しないこと

  • Service Bus トリガーなど、Queue Storage 以外のメッセージング機能

この記事のゴール

「その場で結果が欲しいなら HTTP、可用性やリトライをランタイムに任せたいなら Queue」という判断基準を、実際に手を動かして検証した結果とともに説明できるようになること。

経緯

「HTTP トリガーとQueue トリガー、どっちを使えばいいか」は、Azure Functionsを触り始めると必ず出てくる疑問です。
ただ、機能一覧を見比べただけでは実感が湧きませんでした。
そこで、SKUごとに在庫数を持つだけのシンプルな「在庫補充」機能を用意し、それを HTTP と Queue の両方の入り口から叩けるようにしました。
実際にホストを落として、不正な値を送って、両者がどう振る舞うかを検証してみました。

検証に使う「在庫補充」機能について

検証にはSKU(商品コード)ごとに在庫数(int)を1つだけ持つ、シンプルなデータベースサービスを使います。
この在庫数を安全に読み書きする、小さなクラス ProductRepository を用意しました(今回は Azure Table Storage を使っていますが、DBの種類自体はこの記事の本題ではありません)。

internal sealed class Product
{
    // 商品が登録済み
    public required bool IsRegistered { get; init; }

    // 在庫量
    public required int StockQuantity { get; init; }
}
public sealed class ProductRepository
{
    private const string PartitionKey = "sku";

    private readonly TableClient _tableClient;

    public ProductRepository(TableServiceClient tableServiceClient)
    {
        _tableClient = tableServiceClient.GetTableClient("InventoryStock");
        _tableClient.CreateIfNotExists();
    }

    public async Task RestockAsync(string sku, int quantity)
    {
        var succeeded = false;
        // 永久ループになる可能性があるが、検証コードなので省略        
        do
        {
            try
            {
                var record = await _tableClient.GetEntityIfExistsAsync<TableEntity>(PartitionKey, sku);
                var product = record.HasValue
                    ? new Product { IsRegistered = true, StockQuantity = (int)record.Value!["StockQuantity"] }
                    : new Product { IsRegistered = false, StockQuantity = 0 };

                if (!product.IsRegistered)
                {
                    var entity = new TableEntity(PartitionKey, sku) { ["StockQuantity"] = quantity };
                    await _tableClient.AddEntityAsync(entity);
                }
                else
                {
                    var current = record.Value!;
                    current["StockQuantity"] = product.StockQuantity + quantity;
                    await _tableClient.UpdateEntityAsync(current, current.ETag, TableUpdateMode.Replace);
                }

                succeeded = true;
            }
            catch (RequestFailedException ex) when (ex.Status == 409 || ex.Status == 412)
            {
                // 別のリクエストと競合したので、最新の値を読み直してから再計算・再試行する
            }
        } while (!succeeded);
    }

    public async Task<int?> TryGetAvailableAsync(string sku)
    {
        var record = await _tableClient.GetEntityIfExistsAsync<TableEntity>(PartitionKey, sku);
        var product = record.HasValue
            ? new Product { IsRegistered = true, StockQuantity = (int)record.Value!["StockQuantity"] }
            : new Product { IsRegistered = false, StockQuantity = 0 };

        return product.IsRegistered ? product.StockQuantity : null;
    }
}

ProductRepository は、SKUごとの在庫数を安全に読み書きするだけの小さなクラスです。
中身の細かい実装はこの記事の本題ではないので、以降は「SKUごとの在庫数を安全に加算・参照できる小さな仕組み」とだけ理解していれば読み進められます。

検証コード

この「在庫補充」処理を、HTTP トリガー版( RequestRestock )と Queue トリガー版( EnqueueRestock で Queue に投入し、 RestockFromQueue が消費する)の2つの入り口で用意しました。
どちらも ProductRepository を呼ぶだけの実装です。

// HTTP版: リクエストを受けたその場で在庫を補充する
[Function(nameof(RequestRestock))]
public static async Task<IActionResult> RunAsync(
    [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "stocks/{sku}/restock/{quantity:int}")] HttpRequest req,
    string sku,
    int quantity,
    FunctionContext executionContext)
{
    var repository = executionContext.InstanceServices.GetRequiredService<ProductRepository>();
    await repository.RestockAsync(sku, quantity);

    return new OkResult();
}
// 送信側: HTTPで受けてQueueに投入するだけ
[Function(nameof(EnqueueRestock))]
public static async Task<IActionResult> RunAsync(
    [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "queue/restock/{sku}/{quantity:int}")] HttpRequest req,
    string sku,
    int quantity,
    FunctionContext executionContext)
{
    var queueServiceClient = executionContext.InstanceServices.GetRequiredService<QueueServiceClient>();
    var queueClient = queueServiceClient.GetQueueClient("restock-requests");
    // 検証用: 本来はデプロイ時にQueueを作成しておき、リクエストのたびには呼ばない
    await queueClient.CreateIfNotExistsAsync();

    var message = JsonSerializer.Serialize(new RestockMessage(sku, quantity));
    await queueClient.SendMessageAsync(message);

    return new AcceptedResult();
}
// 受信側: Queueにメッセージが届くたびに自動起動
[Function(nameof(RestockFromQueue))]
public static async Task RunAsync(
    [QueueTrigger("restock-requests")] string messageText,
    FunctionContext executionContext)
{
    var logger = executionContext.GetLogger(nameof(RestockFromQueue));
    var message = JsonSerializer.Deserialize<RestockMessage>(messageText)!;

    if (message.Quantity < 0)
    {
        logger.LogWarning("Invalid quantity {Quantity} for sku {Sku}. Throwing so the queue trigger retries this message.", message.Quantity, message.Sku);
        throw new InvalidOperationException($"quantity must be >= 0 (sku={message.Sku}, quantity={message.Quantity})");
    }

    var repository = executionContext.InstanceServices.GetRequiredService<ProductRepository>();
    await repository.RestockAsync(message.Sku, message.Quantity);

    logger.LogInformation("Restocked {Sku} by {Quantity} via queue message.", message.Sku, message.Quantity);
}

数量がマイナスなら例外を投げるバリデーションを実装しています。
これは自動リトライの挙動の検証にも使います。

検証①:正常系

(Invoke-WebRequest -Method Post -Uri "http://localhost:7071/api/queue/restock/gadget-777/20").StatusCode

↓ Response

200
(Invoke-WebRequest -Uri "http://localhost:7071/api/stocks/gadget-777").Content

↓ Response

{"sku":"gadget-777","available":20}

送信側はQueueに送った時点で即 200 を返し、実際の在庫更新は非同期に(約1秒後)完了しています。

検証②:異常値 → リトライ → poison queue

数量に -5 (不正値)を渡し、受信側で例外を投げさせました。
maxDequeueCount は既定値5ですが、検証を早く終えるため host.json で3に短縮しています。

(Invoke-WebRequest -Method Post -Uri "http://localhost:7071/api/queue/restock/broken-sku/-5").StatusCode

↓ Response

200
[01:39:31.235] Executing 'Functions.RestockFromQueue'  DequeueCount: 1
[01:39:31.242] Invalid quantity -5 for sku broken-sku. Throwing so the queue trigger retries this message.
[01:39:37.293] Executing 'Functions.RestockFromQueue'  DequeueCount: 2   ← 約6秒後(visibilityTimeout)に再試行
[01:39:37.294] Invalid quantity -5 for sku broken-sku. ...
[01:39:42.965] Executing 'Functions.RestockFromQueue'  DequeueCount: 3
[01:39:42.967] Invalid quantity -5 for sku broken-sku. ...
[01:39:42.970] Message has reached MaxDequeueCount of 3. Moving message to queue 'restock-requests-poison'.
try { Invoke-WebRequest -Uri "http://localhost:7071/api/stocks/broken-sku" } catch { $_.Exception.Response.StatusCode.value__ }
404   ← 一度も成功していないので、対象のレコード自体が作られていない

DequeueCount が 1→2→3 と自動で増えながら、約5〜6秒間隔( visibilityTimeout どおり)で計3回試行され(1回目の失敗後、2回リトライされ)、最後に自動で restock-requests-poison Queue に退避されました!
この間、呼び出し元は一切何もしていません。

検証③:ホストがダウンした時の耐性

Queue と HTTP の一番の違いを確かめるため、Functions ホストを完全に止めた状態で、両方に「依頼」を送ってみました。
まず、ホストを止めた状態で HTTP に直接リクエストします。

Invoke-WebRequest -Method Post -Uri "http://localhost:7071/api/stocks/downtime-sku/50" -TimeoutSec 5
Invoke-WebRequest: Unable to connect to the remote server 'http://localhost:7071/api/stocks/downtime-sku/50'.

接続すら確立できず、リクエストは即座に失われました!!!
サーバー側には何の記録も残らず、呼び出し元が自分でリトライしない限り、この補充依頼は永久に失われます。
次に、ホストを止めたまま、Queue には(Function App を経由しない、外部の送信側を想定して)直接メッセージを送ってみます。

Sent while consumer (func host) is down: {"Sku":"downtime-sku","Quantity":50}

ホストが落ちていても、Queue への送信自体は成功しました!
そして、ホストを再起動すると

[01:41:02.584] Executing 'Functions.RestockFromQueue' (Reason='New queue message detected on 'restock-requests'.', ...)
[01:41:02.705] Restocked downtime-sku by 50 via queue message.
(Invoke-WebRequest -Uri "http://localhost:7071/api/stocks/downtime-sku").Content

↓ Response

{"sku":"downtime-sku","available":50}

ホストが復帰した瞬間、放置されていたメッセージが自動的に処理されました!!!
呼び出し元は何もしていません。

HTTP(downtime-sku宛の直接POST) Queue(downtime-sku宛のメッセージ)
ホスト停止中の送信結果 失敗・記録なし 成功・Storageに保存
ホスト復帰後 何も起きない(送信されていないので) 自動的に処理される
リトライ責任 呼び出し元が自分で実装する必要がある Storage Queue + Functions ランタイムが肩代わりする

検証④:大量メッセージをまとめて投げても並列で捌かれる

Queue トリガーには、大量のメッセージが同時に届いても、まとめて(バッチで)取得して並列に処理してくれる仕組みが標準で備わっています。
実際に確かめてみます。
restock/1 のリクエストを、Queue経由で 20並列 で同時に投げます。

1..20 | ForEach-Object -Parallel {
      Invoke-WebRequest -Method Post -Uri "http://localhost:7071/api/queue/restock/burst-test/1" -UseBasicParsing | Out-Null
    } -ThrottleLimit 20

Functionsホストのログを見ると、こうなっていました(抜粋・実際のログ)。

[01:41:22.478] Executing 'Functions.RestockFromQueue' (Id=46dd92f1-...)
[01:41:22.478] Executing 'Functions.RestockFromQueue' (Id=85c0768a-...)
[01:41:22.479] Executing 'Functions.RestockFromQueue' (Id=2a66bfd6-...)
... (1バッチ目16件が 22.478〜22.482 の4ミリ秒以内にほぼ同時に実行開始)
[01:41:22.552] Executing 'Functions.RestockFromQueue' (Id=389cfda3-...)
[01:41:22.553] Executing 'Functions.RestockFromQueue' (Id=a46bed79-...)
[01:41:22.553] Executing 'Functions.RestockFromQueue' (Id=33d50b81-...)
[01:41:22.553] Executing 'Functions.RestockFromQueue' (Id=5a168b20-...)
... (1バッチ目の処理が半分ほど終わったところで、2バッチ目の残り4件が追加で取得される)
[01:41:22.610] Executed 'Functions.RestockFromQueue' (Succeeded, Id=a46bed79-..., Duration=57ms)
(Invoke-WebRequest -Uri "http://localhost:7071/api/stocks/burst-test").Content
{"sku":"burst-test","available":20}

20件のメッセージが「16件+4件」というちょうど2つのバッチに分かれて取得され、それぞれのバッチ内はほぼ同時に実行が始まっていました!
最初の実行開始(22.478)から最後の完了(22.610)まで、20件全体でもわずか約130ms程度です。
20件のメッセージが「16件+4件」というちょうど2つのバッチに分かれて取得され、それぞれのバッチ内はほぼ同時に実行が始まっていました!
最初の実行開始(22.478)から最後の完了(22.610)まで、20件全体でもわずか約130ms程度です。
個々の処理には12〜127msかかっているので、1件ずつ順番に処理していたら、最短でも12ms×20件=240ms程度はかかるはずです。実際には全体で約130msだったので、明らかに並列に処理されていることが分かります。
「16件」というバッチの区切りも、後述する公式ドキュメントの既定バッチサイズ(16)とぴったり一致しています。
最終的に在庫は取りこぼしなくきっちり 20 になりました。
これは公式ドキュメントにも明記されている挙動です。

When there are multiple queue messages waiting, the queue trigger retrieves a batch of messages and invokes function instances concurrently to process them. By default, the batch size is 16. ... the maximum number of concurrent messages being processed per function on one virtual machine (VM) is 24.
(日本語訳)複数の Queue メッセージが待機している場合、Queue トリガーはメッセージをバッチで取得し、複数の関数インスタンスを同時に呼び出して処理します。既定のバッチサイズは16です。……1台の仮想マシン(VM)上で1つの関数が同時に処理するメッセージの最大数は24です。

Azure Queue storage trigger for Azure Functions

さらに公式ドキュメントには、Queue の滞留量に応じてワーカーインスタンス数自体も自動でスケールアウトする(ターゲットベースのスケーリング)とも書かれています。
こちらはローカルの単一インスタンス環境では再現できないため実測はしていませんが、公式ドキュメントに明記されている仕様として紹介しておきます。
一方 HTTP トリガーには、「まとめて取得して並列実行する」というこの仕組み自体がそもそも存在しません。
大量のリクエストが同時に届いた場合の捌き方は ASP.NET Core のスレッド・接続管理に委ねられ、Queue トリガーのように host.json ( batchSize など)で処理の並列度を明示的にコントロールする手段もありません。
「大量の依頼をまとめて受け止め、処理側の都合に合わせて捌く」という緩衝材としての役割は、Queueならではの強みです!!!

逆引き:どっちを使う

シナリオ 向き 理由
呼び出し元がその場で結果(成功/失敗、値)を必要とする HTTP Queueは結果を呼び出し元に返せない(非同期・一方向)
処理側が一時的に落ちていても、依頼を取りこぼしたくない Queue メッセージはStorageに永続化され、受信側復帰後に自動処理される(検証③)
失敗時に自動でリトライしてほしい Queue maxDequeueCount までランタイムが自動的に再試行し、poison queueに退避してくれる(検証②)
大量のリクエストを受けて、処理速度に波があってもよい Queue Queue が緩衝材になり、受信側のスケールに合わせてバッチで並列処理される(検証④)
外部サービス(Webhook等)からのコールバックを受ける HTTP 外部サービスは基本的にHTTPでしか呼べない

見分け方としては、**「呼び出し元が結果を待ちたいか」で第一に分岐し、「処理側の可用性・リトライをランタイムに任せたいか」で Queue の採用を後押しする、**という順番で考えると判断しやすいです。
この判断軸はAzure Functionsのトリガーに限った話ではありません。
「呼び出し元がその場で結果を待ちたいか」「処理側の可用性・リトライ・負荷の吸収をミドルウェア側に任せたいか」という考え方は、システム設計全般に、そのまま当てはまる汎用的な判断基準です。
なお、Queue トリガーが保証しているのは「少なくとも1回( at-least-once )」の配信であって、「ちょうど1回( exactly-once )」ではありません。
在庫の加算処理が完了した直後、メッセージが削除される前に Functions ホストが落ちると、同じメッセージがもう一度配信され、同じ加算処理が再実行される可能性があります。
今回のサンプルの RestockAsync は単純な加算なので、このタイミングで再実行されると数量を二重に加算してしまいます。本来は一意識別子などで処理済みかどうかを判定する、冪等性を考慮した設計が必要です。

まとめ

機能一覧を見比べるだけでは分からなかった HTTP トリガーと Queue トリガーの違いが、実際にホストを落として・壊して・大量に投げてみることで、はっきり見えてきました!

  • HTTP は「呼び出し元が結果をその場で受け取れる」代わりに、ホストが落ちている間のリクエストは跡形もなく消える
  • Queue は「結果をその場では受け取れない」代わりに、ホストが落ちていてもメッセージは残り、自動でリトライ・poison化までしてくれる
  • Queue はメッセージをまとめて取得し並列に処理してくれるため、大量のリクエストが同時に届いても緩衝材として機能する

「非同期処理には Queue を使うとよい」という言葉の意味を、DequeueCount が 1→2→3 と増えていくログ、ホスト復帰後に自動で処理が走る様子、そして20並列のメッセージがバッチで一気に処理される様子で、実感を持って理解できました。

最後まで読んでいただきありがとうございました。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?