PLAY DEVELOPERS BLOG

HuluやTVerなどの日本最大級の動画配信を支える株式会社PLAYが運営するテックブログです。

HuluやTVerなどの日本最大級の動画配信を支える株式会社PLAYが運営するテックブログです。

New RelicでDynamoDB Streamsをまたいだ分散トレーシングを実現した

こんにちは、PLAY CLOUD本部 技術推進室の市川です。

PLAY CLOUDでは現在、システム運用の質を高めるためにオブザーバビリティ(可観測性)の強化に注力し、New Relicの導入を進めています。

現代のシステムは、マイクロサービスやイベント駆動アーキテクチャの普及により、その構造が複雑化しています。システム全体を流れる一つのリクエストを起点に、処理がどのサービスを通過し、どこでボトルネックやエラーが発生しているのかを追跡する分散トレーシングは、この複雑化した環境において欠かせない要素です。

New Relicの導入を進める中で、特にAWS DynamoDB Streamsを介して駆動する非同期処理において、分散トレーシングを実現したいとなりました。 システム全体のパフォーマンスや障害点を把握する上でDynamoDB Streams起点で動作するシステムをトレースできることは重要であるにも関わらず、その効果的なトレース方法に関する公式ドキュメントや具体的な知見が見当たりません。 そこで本記事では、このDynamoDB Streamsが絡む非同期処理をトレースしシステム全体を繋げるようにした方法を解説したいと思います。

New Relicにおける分散トレーシングの仕組み

New Relicの分散トレーシングの仕組みは以下に詳しく記載されています。 docs.newrelic.com

New Relicは、シンプルな同期 HTTP 通信であれば、自動計装(エージェントのインストール)だけでトレースがつながります。例えば、ブラウザ監視用のエージェント(Browser Monitoring)とAPIサーバー監視用のエージェント(Application Performance Monitoring: APM)を導入すれば、ブラウザとAPIサーバー間のリクエストを自動でトレースできます。また、APIサーバー同士などAPMエージェントが導入されたシステム間のHTTP通信も自動でトレースすることが可能です。

その他、AWSのSQS(Amazon Simple Queue Service)などの非同期サービスも、多少の計装(Instrumentation)は必要ですが、ライブラリとしてサポートされています。以下の記事が参考になります。 qiita.com 現在は、ライブラリのアップデートにより送信側の計装が不要になるなど、機能強化も進んでいます。 qiita.com

しかし、このようにNew Relicで標準サポートが進む中でも、DynamoDB Streamsは標準機能ではサポートされていません。(技術的な仕組み上、自動でのトレースが難しいという側面があります。) では、このイベントソースを起点とする非同期処理を、どのようにして既存のトレースに繋げることができるのか、次から具体的に説明していきます。

W3C Trace Context

DynamoDB Streamsのような非同期で、標準サポートされていないイベントソースをトレースに組み込む鍵となるのが、W3C Trace Contextという標準規格です。 W3C Trace Contextは、マイクロサービスなどの分散システムにおいて、リクエストが複数のサービスを流れる際に、その処理の全体像を一貫して追跡(トレース)するための標準規格です。 核となるのは「traceparent」というヘッダーで、これはリクエストごとに一意の「トレースID」と、サービス間の「親子関係」を示す情報を含んでいます。このtraceparentヘッダーをシステム内のすべてのサービス間で適切に生成・更新・伝播することで、異なる監視ツールやプロトコルをまたいでも、エンドツーエンドの追跡を可能にする仕組みです。

New RelicではこのW3C Trace Contextをサポートしています。 そのため、このtraceparentヘッダーをDynamoDB Streamsを介したイベント処理を含むシステム間で適切に伝搬させることができれば、トレースが可能となるはずです。 W3C Trace Contextには、その他にもtracestateヘッダーというベンダー固有の情報を設定するオプショナルなヘッダーが存在します。仕様上は任意ですが、New Relicのドキュメント上ではこのtracestateも追跡に必須とされているため、このヘッダーも合わせて適切に伝搬させることで、理論上トレースを実現できます。

DynamoDB Streamsをトレースする

上記の通り、New Relicにトレースを繋げるためには、DynamoDBの更新を起点として生成されるイベントに、traceparentヘッダーとtracestateヘッダーの情報をペイロードとして埋め込み、後続のシステムへ伝搬させる必要があります。 この実現のために、以下の2つの箇所に計装が必要です。

  1. DynamoDBを更新する側(送信側):現在のトレースコンテキストを抽出し、後段のシステムに伝えられるようにする。

  2. DynamoDB Streamsを受ける側(受信側):抽出されたコンテキストを受け入れ、新しいスパンの親として紐づける。

送信側の計装

送信側では、現在の実行コンテキスト(スパン)からトレースヘッダー情報を抽出し、DynamoDBに保存するデータの一部として埋め込みます。このアプローチでは、DynamoDBのデータモデルにトレース情報用の専用カラムを追加する必要があります。

以下にTypeScriptでの実装例を示します。

import newrelic from 'newrelic';

/**
 * 現在のNew Relicのトレースコンテキストを抽出し、DynamoDBアイテムに追加する
 * @param item DynamoDBに保存する元のオブジェクト
 * @returns トレースコンテキストが付加されたオブジェクト
 */
injectNewRelicContext(item: Record<string, any>): Record<string, any> {
    const traceContext: Record<string, string | number | string[] | undefined> =
      {};

    // 1. 現在のトランザクションから分散トレーシングのヘッダー情報を抽出(注入)
    const transaction = newrelic.getTransaction();
    transaction.insertDistributedTraceHeaders(traceContext);
   
    // 2. DynamoDBアイテムに「traceContext」キーとしてトレース情報を結合
    return {...item,  traceContext };
  }

この処理により、traceContextというオブジェクト内に、New Relicが必要とする以下のようなヘッダー情報が生成されます。

{
    "traceparent": "00-a1afc2a3ac4fcbbaca56ccf8d0395dxx-04dca62b1tcf544f-00",
    "tracestate": "1234567@nr=0-0-1234567-8765433210-012ab34c56def78g-65022fe64833f01b-0-0.476179-1762427314327",
    "newrelic": "base64 new relic header...."
}

つまり、DynamoDBに永続化されるデータに「トレースコンテキスト・ペイロード」を追加し、そのデータがDynamoDB Streamsを通じて次の処理へ引き継がれるようにするわけです。

受信側の計装

DynamoDB Streamsによって駆動されるAWS Lambdaなどのコンシューマ側では、イベントペイロードから保存されたトレース情報を抽出し、それを新しいスパンの親コンテキストとしてNew Relicに認識させる必要があります。 以下のサンプルは、Lambdaハンドラ内でトレースコンテキストを受け入れる処理を示しています。

import newrelic from 'newrelic';
import type { DynamoDBStreamEvent, Context } from "aws-lambda";
import { unmarshall } from "@aws-sdk/util-dynamodb";

export async function handler(event: DynamoDBStreamEvent, context: Context) {
    for (const record of event.Records) {
        // StreamViewTypeがNEW_IMAGEなどが設定されている場合に、更新後の新しい値を取得
        if (record.dynamodb?.NewImage) {

            // DynamoDBのAttributeValue形式から通常のJavaScriptオブジェクトへ変換
            const newImage = unmarshall(
                record.dynamodb.NewImage as Record<string, AttributeValue>,
            );

            // DynamoDBに保存したトレースコンテキストを抽出
            const traceContext = newImage["traceContext"];

            if (traceContext && typeof traceContext === "object") {
                // 3. New Relicのトランザクションにトレースヘッダー情報を受け入れさせる
                const transaction = newrelic.getTransaction();
                // "Other"はトレースのタイプを指定します
                transaction.acceptDistributedTraceHeaders("Other", traceContext);
            }
            // ... ここでStreamsのビジネスロジックを実行 ...
        }
    }
}
             ︙

重要なポイント:

  1. DynamoDB Streamsのデータ処理: Lambdaが受け取るイベントの event.Records から、NewImage に格納されている更新後のレコード(traceContextを含む)を抽出します。unmarshall処理は、DynamoDBの特殊なデータ形式を一般的なJSON形式に戻すために必要です。

  2. コンテキストの受け入れ: 抽出したtraceContexttransaction.acceptDistributedTraceHeaders() に渡すことで、DynamoDBを更新した送信側のトレースと、このLambda処理のスパンが正しく繋がります。

この acceptDistributedTraceHeaders()の呼び出しは、処理の初期段階で呼び出さないと適切にトレースが繋がらないケースがあったので、処理の最初に実行することを推奨します。 この計装により、DynamoDB Streams(非同期)を介した一連の流れを、New Relic上で途切れることなくエンドツーエンドで追跡できるようになります。

トレースのつながりを確認してみる

サービスマップでの確認

New RelicではService mapを見ることで、システムを構成するエンティティ間の連携と繋がりを直感的に確認することができます。 以下は、今回の計装後にDynamoDB Streamsを介したシステム間の繋がりが見えるようになった図です。

このマップにおいて、API(console-api)から伸びている右の2つの矢印の先のエンティティは、ともにDynamoDB Streamsを起点に起動しているシステム(Lambdaなどのコンシューマ)です。 Service map上では、DynamoDB自体のエンティティは表示されません。しかし、APIと後続のシステムが直接繋がっているように表示されることで、DynamoDBの前後でシステムが論理的に繋がったことが視覚的に確認できます。これにより、この非同期通信経路も監視対象に含まれたことがわかります。

エンドツーエンドのトレース確認

Service mapでシステムが繋がったことを確認できたので、DynamoDB Streamsを経由する特定の一連の処理のトレースを見てみましょう。

こちらでも、Entity map上ではDynamoDBやSQSなどのエンティティは表示されませんが、処理の繋がりは明確に確認できます。

①. 起点(管理画面 - API): 管理画面を起点としたリクエストを受け付けています。

②. 非同期接続 (APIから伸びる次のエンティティ): DynamoDB Streams経由で起動したエンティティ(Lambdaなど)です。

③. さらに次の非同期接続: その先のエンティティは、処理内でメッセージを送信し、 SQSを経由して 起動しているエンティティ(別のLambda)になります

このトレースの詳細画面により、管理画面を起点としたリクエストが、同期処理のAPIを経由し、DynamoDB Streamsを介したイベント駆動処理、さらにはSQSを介した非同期処理まで、裏側のシステム全体の一連の流れとして途切れずにトレースできていることがわかります。

少し話を発展させて

今回の解説では、DynamoDB Streamsを例に挙げ、イベント駆動で動くシステムにおける分散トレーシングの実現方法を具体的に示しました。しかし、このアプローチはDynamoDB Streamsに限定されるものではなく、他の様々な非同期・イベント駆動のパターンにも応用が可能です。

ここまで読んでいただいたことで、お気づき頂けたのではないでしょうか? トレースを繋げるためには、以下の原則に従えば実現できるということです。

  • W3C Trace Contextの利用: New Relicを含む多くの監視ツールがサポートしているW3C Trace Context(traceparenttracestate)という共通の規格に従うこと。
  • コンテキストの適切な伝搬: 処理を非同期で次に引き渡す際、Trace Contextヘッダーの情報を次のシステムに確実に渡すこと。
  • コンテキストの受け入れと継続: 次のシステムがTrace Contextヘッダーを受け取った際、処理の初期段階でこの情報をNew RelicのAPMエージェントに受け入れさせ、新しいスパンを生成すること。

この原則に従えば、メッセージキュー(例:Redis Streams、Kafka、Pub/Subなど)や、その他のデータベーストリガーなど、標準機能ではトレースが途切れてしまうあらゆる非同期通信において、手動でトレースコンテキストを注入・抽出する計装を施すことができれば、エンドツーエンドの分散トレーシングを実現できるということになります。 今回はDynamoDB Streamsを例にその証明ができたと言えるでしょう。

おわりに

今回は、あまり知見のないDynamoDB Streamsを例として、New Relicでイベント駆動処理を分散トレーシングに繋げる具体的な方法を解説しました。 また、話を広げ、この手法がW3C Trace Contextという標準規格に基づいているため、DynamoDB Streams以外の非同期通信経路にも広く応用可能であることに触れました。 確かに、DynamoDBのデータモデルにトレース情報専用のカラムを追加することには、設計上の抵抗があるかもしれません。しかし、そのコストや違和感を上回る大きなメリットがあります。 システム全体を途切れることなくエンドツーエンドでトレースし、可視化できるようになることで、複雑な分散システムにおける障害箇所の特定やパフォーマンス劣化のボトルネック分析が劇的に迅速化し、オブザーバビリティ(可観測性)が格段に向上します。 この内容が、複雑な分散システムのオブザーバビリティを高めたいと考えているエンジニアの方の参考になればと思います。