次の方法で共有


Azure API Management、Event Hubs、Moesif を使用した API の監視

適用対象: すべての API Management レベル

API Management サービス は、HTTP API に送信された HTTP 要求の処理を強化する多くの機能を提供します。 しかし、要求と応答の存在は一時的なものです。 要求が行われ、API Management サービスを介してバックエンド API に送信されます。 API によって要求が処理されると、応答が API コンシューマーに返されます。 API Management サービスでは Azure Portal ダッシュボードへの表示用に API に関するいくつかの重要な統計情報が保持されますが、それ以上の詳細は失われます。

API Management サービスで log-to-eventhub ポリシーを使用することにより、要求および応答の任意の詳細を Azure Event Hubs に送信できます。 API に送信される HTTP メッセージからイベントを生成する理由はいくつかあります。 たとえば、更新プログラム、利用状況分析、例外のアラート、サード パーティの統合の監査証跡が該当します。

この記事では、HTTP 要求と応答メッセージ全体をキャプチャし、それをイベント ハブに送信し、そのメッセージを HTTP ログと監視サービスを提供するサード パーティのサービスに中継する方法について説明します。

API Management サービスから送信する理由

HTTP API フレームワークにプラグインして HTTP 要求と応答をキャプチャし、ログ記録および監視システムにフィードできる HTTP ミドルウェアを記述できます。 このアプローチの欠点は、HTTP ミドルウェアをバックエンド API に統合する必要があり、API プラットフォームと一致する必要がある点です。 API が複数ある場合は、それぞれがミドルウェアをデプロイする必要があります。 多くの場合、バックエンド API を更新できない理由が存在します。

Azure API Management サービスを使用してログ記録インフラストラクチャを統合すると、プラットフォームに依存しない一元的なソリューションを実現できます。 また、Azure API Management の geo レプリケーション 機能によってもスケーラブルです。

イベント ハブに送信する理由

Azure Event Hubs に固有のポリシーを作成する理由を尋ねるのが妥当です。 要求をログに記録する場所は多数あります。 なぜ最終的な宛先に直接、要求を送信しないのでしょうか。 これはオプションです。 ただし、API Management サービスからログ記録要求を行う場合は、メッセージのログ記録が API のパフォーマンスに与える影響を考慮する必要があります。 負荷が段階的に増加する場合は、システム コンポーネントの使用可能なインスタンスを増やすか、geo レプリケーションを活用することで対処できます。 しかし、トラフィックが短期間で急増した場合、負荷がかかることでログ記録インフラストラクチャへの要求の処理速度が低下し始めると、要求が遅延する可能性があります。

Azure Event Hubs は、大量のデータを受信するように設計されており、ほとんどの API プロセスの HTTP 要求数よりもはるかに多くのイベントを処理できます。 イベント ハブは、API Management サービスと、メッセージを格納して処理するインフラストラクチャとの間で高度なバッファーの一種として機能します。 これにより、API のパフォーマンスはログインフラストラクチャの影響を受けることはありません。

イベント ハブに渡されたデータは保持され、イベント ハブ コンシューマーによって処理されるまで待機します。 イベント ハブでは、それがどのように処理されるかは考慮されません。考慮されるのは、メッセージが正常に配信されるようにすることだけです。

Event Hubs には、複数のコンシューマー グループにイベントをストリーム配信する機能があります。 これにより、イベントを異なるシステムで処理できます。 これは、1 つのイベントのみを生成する必要があるため、API Management サービス内の API 要求の処理に遅延を生じさせることなく、多くの統合シナリオをサポートします。

アプリケーション/HTTP メッセージを送信するポリシー

イベント ハブでは、イベント データを単純な文字列として受け取ります。 その文字列の内容はユーザーが決めることができます。 HTTP 要求をパッケージ化して Azure Event Hubs に送信できるようにするには、要求または応答情報を使用して文字列を書式設定する必要があります。 このような状況では、再利用できる既存の形式がある場合は、独自の解析コードを記述する必要がない可能性があります。 最初に、HTTP 要求と応答を送信するために HAR を使用することを検討できます。 しかし、この形式は、JSON ベースの形式で一連の HTTP 要求を格納するために最適化されています。 この形式には必須の要素が多数含まれていたため、ネットワーク経由で HTTP メッセージを渡すシナリオでは不必要に複雑さが増しました。

別のオプションとして、HTTP 仕様 application/http で説明されているように、 メディアの種類を使用することもできます。 このメディアの種類は、実際にネットワーク経由で HTTP メッセージを送信するために使用されるのとまったく同じ形式を使用しますが、メッセージ全体を別の HTTP 要求の本文に入れることができます。 ここでは、本文を、Event Hubs に送信するメッセージとして使用するだけです。 Microsoft ASP.NET Web API 2.2 クライアント ライブラリには、この形式を解析してネイティブ HttpRequestMessage オブジェクトと HttpResponseMessage オブジェクトに変換できる便利なパーサーが含まれています。

このメッセージを作成できるようにするには、Azure API Management の C# ベースの ポリシー式 を使用する必要があります。 AZURE Event Hubs に HTTP 要求メッセージを送信するポリシーを次に示します。

<log-to-eventhub logger-id="myapilogger" partition-id="0">
@{
   var requestLine = string.Format("{0} {1} HTTP/1.1\r\n",
                                               context.Request.Method,
                                               context.Request.Url.Path + context.Request.Url.QueryString);

   var body = context.Request.Body?.As<string>(true);
   if (body != null && body.Length > 1024)
   {
       body = body.Substring(0, 1024);
   }

   var headers = context.Request.Headers
                          .Where(h => h.Key != "Authorization" && h.Key != "Ocp-Apim-Subscription-Key")
                          .Select(h => string.Format("{0}: {1}", h.Key, String.Join(", ", h.Value)))
                          .ToArray<string>();

   var headerString = (headers.Any()) ? string.Join("\r\n", headers) + "\r\n" : string.Empty;

   return "request:"   + context.Variables["message-id"] + "\n"
                       + requestLine + headerString + "\r\n" + body;
}
</log-to-eventhub>

ポリシーの宣言

このポリシー式に関して触れておく必要があることがいくつかあります。 log-to-eventhub ポリシーには logger-id という属性があり、API Management サービス内で作成されたロガーの名前を参照します。 イベント ハブ ロガーの設定方法の詳細については、API Management サービスの「 Azure API Management で Azure Event Hubs にイベントをログに記録する方法」を参照してください。 2 番目の属性は、メッセージを格納するパーティションをイベント ハブに指示する省略可能なパラメーターです。 Event Hubs では、パーティションを使用してスケーラビリティを実現するため、最低 2 つ必要になります。 メッセージの順次配信は、パーティション内でのみ保証されます。 どのパーティションにメッセージを配置するかを Azure Event Hubs に指示しなかった場合は、ラウンドロビン アルゴリズムを使用して負荷が分散されます。 ただし、一部のメッセージが順番に処理されなくなることがあります。

メジャー グループ

メッセージがコンシューマーに順番に配信されるようにし、パーティションの負荷分散機能を利用するために、HTTP 要求メッセージを 1 つのパーティションに送信し、HTTP 応答メッセージを 2 つ目のパーティションに送信できます。 これにより、均等な負荷分散が保証され、すべての要求とすべての応答が順番に使用されることが保証されます。 対応する要求の前に応答が使用される可能性がありますが、これは問題ではありません。これは、要求を応答に関連付ける別のメカニズムがあり、要求が常に応答の前に来ることがわかっているためです。

HTTP ペイロード

requestLineをビルドした後、要求本文を切り捨てる必要があるかどうかを確認します。 要求本文は 1,024 に切り詰められます。 これは増やすことができます。ただし、個々のイベント ハブ メッセージは 256 KB に制限されているため、一部の HTTP メッセージ本文が 1 つのメッセージに収まらない可能性があります。 ログ記録と分析を行うときは、HTTP 要求行とヘッダーから大量の情報を取得できます。 また、多くの API 要求では小さな本文のみが返されるため、本文の内容をすべて保持するための転送、処理、ストレージコストの削減に比べて、大きな本文を切り捨てることによる情報値の損失はかなり最小限です。

本文の処理に関する最後の注意事項の 1 つは、本文の内容を読み取るが、バックエンド API が本文を読み取ることができるようにするため、 trueAs<string>() メソッドに渡す必要があるということです。 このメソッドに true を渡すことで、本文をもう一度読み取ることができるように本文はバッファーに格納されます。 これは、大きなファイルをアップロードしたり、長いポーリングを使用したりする API がある場合に重要です。 このような場合は、本文をまったく読まないようにすることをお勧めします。

HTTP ヘッダー

HTTP ヘッダーは、単純なキーと値のペアの形式のメッセージ形式に変換できます。 資格情報情報が不必要に漏えいしないように、特定のセキュリティの機密性の高いフィールドを削除することを選択しました。 API キーとその他の資格情報が分析目的で使用される可能性はほとんどありません。 ユーザーと使用している特定の製品を分析する場合は、 context オブジェクトからそれを取得し、メッセージに追加できます。

メッセージのメタデータ

イベント ハブに送信する完全なメッセージを作成する際、最初の行は実際には application/http メッセージの一部ではありません。 最初の行は追加のメタデータで、メッセージが要求メッセージと応答メッセージのどちらであるかを示す値と、要求を応答に関連付けるためのメッセージ ID で構成されています。 メッセージ ID は、次のような別のポリシーを使用して作成されます。

<set-variable name="message-id" value="@(Guid.NewGuid())" />

要求メッセージを作成し、応答が返されるまで変数に格納し、要求と応答を 1 つのメッセージとして送信できます。 ただし、要求と応答を個別に送信し、 message-id を使用して 2 つを関連付けることで、メッセージ サイズの柔軟性が少し向上し、メッセージの順序を維持しながら複数のパーティションを利用でき、ログ ダッシュボードに要求が到着する時間が短縮されます。 また、有効な応答がイベント ハブに送信されないシナリオもあります (API Management サービスの致命的な要求エラーが原因である可能性があります)。ただし、要求の記録は残っています。

応答 HTTP メッセージを送信するポリシーは要求に似ています。完全なポリシーの構成の例を次に示します。

<policies>
  <inbound>
      <set-variable name="message-id" value="@(Guid.NewGuid())" />
      <log-to-eventhub logger-id="myapilogger" partition-id="0">
      @{
          var requestLine = string.Format("{0} {1} HTTP/1.1\r\n",
                                                      context.Request.Method,
                                                      context.Request.Url.Path + context.Request.Url.QueryString);

          var body = context.Request.Body?.As<string>(true);
          if (body != null && body.Length > 1024)
          {
              body = body.Substring(0, 1024);
          }

          var headers = context.Request.Headers
                               .Where(h => h.Key != "Authorization" && h.Key != "Ocp-Apim-Subscription-Key")
                               .Select(h => string.Format("{0}: {1}", h.Key, String.Join(", ", h.Value)))
                               .ToArray<string>();

          var headerString = (headers.Any()) ? string.Join("\r\n", headers) + "\r\n" : string.Empty;

          return "request:"   + context.Variables["message-id"] + "\n"
                              + requestLine + headerString + "\r\n" + body;
      }
  </log-to-eventhub>
  </inbound>
  <backend>
      <forward-request follow-redirects="true" />
  </backend>
  <outbound>
      <log-to-eventhub logger-id="myapilogger" partition-id="1">
      @{
          var statusLine = string.Format("HTTP/1.1 {0} {1}\r\n",
                                              context.Response.StatusCode,
                                              context.Response.StatusReason);

          var body = context.Response.Body?.As<string>(true);
          if (body != null && body.Length > 1024)
          {
              body = body.Substring(0, 1024);
          }

          var headers = context.Response.Headers
                                          .Select(h => string.Format("{0}: {1}", h.Key, String.Join(", ", h.Value)))
                                          .ToArray<string>();

          var headerString = (headers.Any()) ? string.Join("\r\n", headers) + "\r\n" : string.Empty;

          return "response:"  + context.Variables["message-id"] + "\n"
                              + statusLine + headerString + "\r\n" + body;
     }
  </log-to-eventhub>
  </outbound>
</policies>

set-variable ポリシーは、log-to-eventhub セクションと <inbound> セクションの<outbound> ポリシーの両方からアクセスできる値を作成します。

Event Hubs からのイベントの受信

Azure Event Hubs からのイベントは、AMQP プロトコルを使用して受信します。 Microsoft Service Bus チームは、クライアント ライブラリでコンシューマー側のイベントを簡単に作成できるようにしました。 サポートされている方法は 2 つあり、1 つはダイレクト コンシューマー、もう 1 つは EventProcessorHost クラスの使用です。 これら 2 つの方法の例は、 Event Hubs サンプル リポジトリにあります。 違いの短いバージョン: Direct Consumer は完全な制御を提供し、 EventProcessorHost は一部の配管作業を行いますが、それらのイベントを処理する方法について特定の前提を持っています。

EventProcessorHost

このサンプルでは、わかりやすくするために EventProcessorHost を使用しますが、この特定のシナリオには最適な選択肢ではない可能性があります。 EventProcessorHost では、特定のイベント プロセッサ クラス内でスレッドの問題について心配する必要がないようにする困難な処理が行われます。 ただし、このシナリオでは、非同期メソッドを使用してメッセージを別の形式に変換し、別のサービスに渡します。 共有状態を更新する必要がないため、スレッドの問題のリスクはありません。 ほとんどのシナリオでは、EventProcessorHost がおそらく最善の選択肢であり、より簡単な方法であることは確実です。

IEventProcessor

EventProcessorHost を使用する場合は、IEventProcessor メソッドを含む ProcessEventAsync インターフェイスの実装を作成することが中心的な考え方になります。 そのメソッドの本質を次に示します。

async Task IEventProcessor.ProcessEventsAsync(PartitionContext context, IEnumerable<EventData> messages)
{

    foreach (EventData eventData in messages)
    {
        _Logger.LogInfo(string.Format("Event received from partition: {0} - {1}", context.Lease.PartitionId,eventData.PartitionKey));

        try
        {
            var httpMessage = HttpMessage.Parse(eventData.GetBodyStream());
            await _MessageContentProcessor.ProcessHttpMessage(httpMessage);
        }
        catch (Exception ex)
        {
            _Logger.LogError(ex.Message);
        }
    }
    ... checkpointing code snipped ...
}

EventData オブジェクトのリストがメソッドに渡されます。ここでは、そのリストに対して反復処理を行います。 各メソッドのバイトが HttpMessage オブジェクトに解析され、そのオブジェクトが IHttpMessageProcessor のインスタンスに渡されます。

HttpMessage

HttpMessage インスタンスには、3 つのデータが格納されます。

public class HttpMessage
{
    public Guid MessageId { get; set; }
    public bool IsRequest { get; set; }
    public HttpRequestMessage HttpRequestMessage { get; set; }
    public HttpResponseMessage HttpResponseMessage { get; set; }

... parsing code snipped ...

}

HttpMessage インスタンスには、HTTP 要求を対応する HTTP 応答に関連付けるための MessageId GUID と、オブジェクトに HttpRequestMessage と HttpResponseMessage のインスタンスが含まれるかどうかを示すブール値が格納されます。 System.Net.Http の組み込みの HTTP クラスを使用することで、application/http に含まれている System.Net.Http.Formatting 解析コードを使用することができました。

IHttpMessageProcessor

次に、HttpMessage インスタンスは、IHttpMessageProcessor の実装に転送されます。これは、Azure Event Hubs からのイベントの受信および解釈と実際のイベントの処理を分離するために作成したインターフェイスです。

HTTP メッセージの転送

このサンプルでは、HTTP 要求を Moesif API Analytics にプッシュすることにしました。 Moesif は、HTTP 分析とデバッグに特化したクラウドベースのサービスです。 無料レベルがあるため、簡単に試すことができます。 Moesif を使用すると、API Management サービスを介してリアルタイムで HTTP 要求を確認できます。

IHttpMessageProcessor の実装は次のようになります。

public class MoesifHttpMessageProcessor : IHttpMessageProcessor
{
    private readonly string RequestTimeName = "MoRequestTime";
    private MoesifApiClient _MoesifClient;
    private ILogger _Logger;
    private string _SessionTokenKey;
    private string _ApiVersion;
    public MoesifHttpMessageProcessor(ILogger logger)
    {
        var appId = Environment.GetEnvironmentVariable("APIMEVENTS-MOESIF-APP-ID", EnvironmentVariableTarget.Process);
        _MoesifClient = new MoesifApiClient(appId);
        _SessionTokenKey = Environment.GetEnvironmentVariable("APIMEVENTS-MOESIF-SESSION-TOKEN", EnvironmentVariableTarget.Process);
        _ApiVersion = Environment.GetEnvironmentVariable("APIMEVENTS-MOESIF-API-VERSION", EnvironmentVariableTarget.Process);
        _Logger = logger;
    }

    public async Task ProcessHttpMessage(HttpMessage message)
    {
        if (message.IsRequest)
        {
            message.HttpRequestMessage.Properties.Add(RequestTimeName, DateTime.UtcNow);
            return;
        }

        EventRequestModel moesifRequest = new EventRequestModel()
        {
            Time = (DateTime) message.HttpRequestMessage.Properties[RequestTimeName],
            Uri = message.HttpRequestMessage.RequestUri.OriginalString,
            Verb = message.HttpRequestMessage.Method.ToString(),
            Headers = ToHeaders(message.HttpRequestMessage.Headers),
            ApiVersion = _ApiVersion,
            IpAddress = null,
            Body = message.HttpRequestMessage.Content != null ? System.Convert.ToBase64String(await message.HttpRequestMessage.Content.ReadAsByteArrayAsync()) : null,
            TransferEncoding = "base64"
        };

        EventResponseModel moesifResponse = new EventResponseModel()
        {
            Time = DateTime.UtcNow,
            Status = (int) message.HttpResponseMessage.StatusCode,
            IpAddress = Environment.MachineName,
            Headers = ToHeaders(message.HttpResponseMessage.Headers),
            Body = message.HttpResponseMessage.Content != null ? System.Convert.ToBase64String(await message.HttpResponseMessage.Content.ReadAsByteArrayAsync()) : null,
            TransferEncoding = "base64"
        };

        Dictionary<string, string> metadata = new Dictionary<string, string>();
        metadata.Add("ApimMessageId", message.MessageId.ToString());

        EventModel moesifEvent = new EventModel()
        {
            Request = moesifRequest,
            Response = moesifResponse,
            SessionToken = _SessionTokenKey != null ? message.HttpRequestMessage.Headers.GetValues(_SessionTokenKey).FirstOrDefault() : null,
            Tags = null,
            UserId = null,
            Metadata = metadata
        };

        Dictionary<string, string> response = await _MoesifClient.Api.CreateEventAsync(moesifEvent);

        _Logger.LogDebug("Message forwarded to Moesif");
    }

    private static Dictionary<string, string> ToHeaders(HttpHeaders headers)
    {
        IEnumerable<KeyValuePair<string, IEnumerable<string>>> enumerable = headers.GetEnumerator().ToEnumerable();
        return enumerable.ToDictionary(p => p.Key, p => p.Value.GetEnumerator()
                                                         .ToEnumerable()
                                                         .ToList()
                                                         .Aggregate((i, j) => i + ", " + j));
    }
}

MoesifHttpMessageProcessor では、サービスに HTTP イベント データを簡単にプッシュできる C# Moesif API ライブラリが利用されています。 Moesif Collector API に HTTP データを送信するには、アカウントとアプリケーション ID が必要です。あなたはMoesifのウェブサイトでアカウントを作成して Moesif アプリケーションIDを取得し、右上のメニューに移動し、 アプリのセットアップを選択します。

完全なサンプル

サンプルのソース コードとテストは、GitHub から入手できます。 自身でサンプルを実行するには、API Management サービス接続されたイベント ハブ、およびストレージ アカウントが必要です。

サンプルは、Event Hubからのイベントを待ち受け、それらをMoesif EventRequestModel オブジェクトとEventResponseModel オブジェクトに変換し、それからMoesif Collector APIに転送する単純なコンソールアプリケーションです。

次のアニメーション画像では、開発者ポータルで API に対して要求が行われ、コンソール アプリケーションにメッセージの受信、処理、転送が表示され、その後、要求と応答が eventstream に表示されます。

Runscope に転送される要求のアニメーション画像デモ

まとめ

Azure API Management サービスでは、API を経由して送受信される HTTP トラフィックをキャプチャするための理想的な場所が用意されています。 Azure Event Hubs は、そのトラフィックをキャプチャして、ログ記録、監視、その他の高度な分析用のセカンダリ処理システムに供給するための、非常にスケーラブルで低コストのソリューションです。 数十行のコードを書くだけで、Moesif のようなサード パーティ製のトラフィック監視システムに簡単に接続できます。