 
AWS Kinesis 上で OpenTelemetry トレースヘッダを伝搬する方法: パート2
PartitionKeyの課題を理解する
2024年6月27日
翻訳: 竹洞 陽一郎
この記事は米Catchpoint Systems社のブログ記事「How to propagate OpenTelemetry trace headers over AWS Kinesis: Part 2」の翻訳です。
Spelldataは、Catchpointの日本代理店です。
この記事は、Catchpoint Systemsの許可を得て、翻訳しています。
シリーズの最初の記事では、トレースヘッダの重要性とその伝播に伴う複雑さについて探りました。
理論から実践へと移行します。
この第二弾では、AWS KinesisでPartitionKeyパラメータを使用してOpenTelemetryトレースコンテキストを伝播するための実践的なベースラインシナリオと初期戦略について説明します。
ベースラインの探求
まず、OpenTelemetry SDKが提供する自動計測とトレースコンテキスト伝播について、そのデフォルト機能に焦点を当てて調査します。
私たちのテストでは、AWS Kinesis内の2つの相互接続されたサービス、イベントを生成して送信するプロデューサーと、それを受信して処理するコンシューマーを使用します。
この初期シナリオでは、システムに変更を加えることなく、サービス間でのトレース伝播の自然な挙動を観察することができます。
テストシナリオと設定をシンプルに保ち、主要な問題に集中しました。
- AWS Kinesisストリームには2つのシャード(※)があります。
- event-producerというアプリがあります。
- このアプリはイベントを生成し、AWS SDKを介してAWS Kinesisストリームに送信します。
- イベントはイベントグループIDによってシャードに分割され、同じグループのイベントは同じシャードに配置され、順次処理されます。
- event-producerアプリはOTEL Javaエージェント(バージョン1.30.1)によって自動計測およびトレースされます。
- event-consumerというアプリがあります。
- このアプリはAWS Lambda関数(otel-lambda-playground-kinesis-handler)であり、AWS Kinesisストリームからトリガーされます。
- トリガーのバッチサイズは1に設定されており、複数の上流トレースリンクケースを防ぐためです。
 (イベントは同じ呼び出しでバッチ処理されますが、各イベントはイベントプロデューサーアプリケーション内の異なる下流トレースに配置されます)
- event-consumerのAWS Lambda関数ハンドラはOTEL Java AWS Lambdaパッケージ(- opentelemetry-aws-lambda-core-1.0および- opentelemetry-aws-lambda-events-2.2)によって自動的にトレースされます。
※訳注: シャードとは、巨大なデータセットを複数の小さな部分に分割し、それぞれを異なるサーバーやノードに保存する方法です。
データのスケーラビリティとパフォーマンスを向上させるために使用されます。
Kinesisでは、シャードはデータストリームを構成する基本単位であり、各シャードは独立してデータの読み書きを処理します。
テストシナリオを実行すると、event-producerとevent-consumerの両方のアプリケーションがOTEL SDKによって自動的にトレースされますが、同じフローに属しているにもかかわらず、互いに独立した2つの異なるトレースが作成されます。
 
event-producerアプリケーションのトレース 
event-consumerアプリケーションのトレース最初の試み - PartitionKeyを通じたトレースコンテキストの伝播
AWS KinesisのPut Recordリクエストモデルを確認すると、以下の6つのパラメータがあることが分かります。
- Data
- ExplicitHashKey
- PartitionKey
- SequenceNumberForOrdering
- StreamARN
- StreamName
しかし、traceparentヘッダを伝播するために、これらの多くのパラメータを使用したり変更したりすることはできません。
- Dataは変更すべきではありません。
 以前のセクションで、このリクエストボディを変更した場合に発生する可能性のある問題について説明しました。
- StreamNameと- StreamARNも変更できません。
 これらのパラメータは、イベントを送信するAWS Kinesisストリームを指定します。
- SequenceNumberForOrderingパラメータも変更できません。
 同じクライアントから同じシャードに送信するイベントの順序が変わり、ここで単調増加するシーケンス番号を付与できない場合、いくつかのレコードが処理されずに無視される可能性があります。
 この状況がどのように発生するかについては、以下のAWS re:Postの質問で簡単な例とともに説明されています。
 Understanding Kinesis sequence numbers
したがって、最初の試みでは、PartitionKeyパラメータを使用してtraceparentヘッダを伝播します。
このため、次のサンプルコードブロックのように、W3Cコンテキストヘッダ形式でtraceparentヘッダをPartitionKeyパラメータに手動で設定します。
private PutRecordRequest injectTraceHeader(PutRecordRequest request){ 
	if (!TRACE_CONTEXT_PROPAGATION_ENABLED) { 
	    return request; 
	} 
	Span currentSpan = Span.current(); 
	if (currentSpan == null) { 
	    return request; 
	} 
	SpanContext currentSpanContext = currentSpan.getSpanContext(); 
	if (currentSpanContext == null) { 
	    return request; 
	} 
	PutRecordRequest.Builder requestBuilder = request.toBuilder(); 
	String traceParent = String.format("00-%s-%s-%s", 
	        currentSpanContext.getTraceId(), 
	        currentSpanContext.getSpanId(), 
	        currentSpanContext.getTraceFlags().asHex()); 
	requestBuilder.partitionKey(traceParent);  
	return requestBuilder.build(); 
} 
これを行うことで、PartitionKeyパラメータを介してtraceparentヘッダをKinesisイベント内でevent-consumerのAWS Lambda関数に渡すことができます。
しかし、ここで使用する方法は標準的な方法ではないため、event-consumer関数側で、このPartitionKeyパラメータからtraceparentヘッダを手動で抽出し、それをトレースコンテキストに渡す必要があります。
こうすることで、event-consumer側のトレースはここで伝播されたトレースIDを使用でき、結果としてevent-producerおよびevent-consumerの両方のアプリケーションが同じトレース内に表示されるようになります。
... 
public class KinesisHandler extends TracingRequestHandler {
    private static final String TRACE_PARENT = "traceparent"; 
    private static final String TRACE_PARENT_PREFIX = "00-"; 
    
    ... 
    @Override 
    protected Void doHandleRequest(KinesisEvent event, Context context) {
    		... 
        return null; 
    } 
    @Override 
    protected Map extractHttpHeaders(KinesisEvent event) { 
        List records = event.getRecords(); 
        if (!records.isEmpty()) { 
            Map headers = new HashMap<>(); 
            KinesisEvent.KinesisEventRecord record = records.get(0); 
            String partitionKey = record.getKinesis().getPartitionKey(); 
            if (partitionKey != null  
                    && partitionKey.startsWith(TRACE_PARENT_PREFIX)) { 
                headers.put(TRACE_PARENT, partitionKey); 
            } 
            if (!headers.isEmpty()) { 
                return headers; 
            } 
        } 
        return super.extractHttpHeaders(event); 
    } 
} 
これらの変更を行い、再度テストシナリオを実行すると、予想通り、前回のテストで別々のトレースにあったevent-producerおよびevent-consumerアプリケーションが、今度は同じトレースに含まれることがわかります。
これは、PutRecordリクエストのPartitionKeyパラメータを介してトレースIDが伝播されたためです。
 
event-producerとevent-consumerアプリケーションのトレース(ブローカーのパーティショニング)
この方法でトレースIDを伝播することはできましたが、このアプローチには以下の副作用がありました。
PartitionKeyパラメータにトレースペアレントヘッダを設定したため、イベントはイベントグループIDではなくtraceparentヘッダに基づいてシャードにランダムに分配されるようになりました。
これにより、アプリケーションのビジネスロジックが変更されます。
言い換えると、event-consumer側に同じグループに属するイベントを同じシャードに配置し、順次処理する必要があるロジックがある場合、このロジックが機能しなくなります。
 
event-consumer関数の呼び出しログ 
event-consumer関数の呼び出しログ
AWS CloudWatchログからわかるように、traceparentヘッダを注入する際にPartitionKeyパラメータをtraceparentヘッダの値に置き換えたため、同じグループIDを持つイベントが異なるシャードに分割されています。
その結果、イベントは生成されたトレースID(およびスパンID)に基づいてKinesisシャード全体にランダムに分割され、同じグループ内のイベントが並行して処理されます。
それぞれのイベントが異なるシャードに割り当てられ、各シャードは異なるAWS Lambda関数のマイクロVMインスタンスによって並行して処理されるため、この動作はevent-consumer関数のビジネスロジックを破壊します。
私たちのビジネスロジックでは、同じグループ内のイベントは、同じグループIDの以前に処理されたイベントを確認して順次処理されることを期待していますが、同じグループのイベントの並行処理は競合状態を引き起こす可能性があります。
パート2の結論
私たちの探求により、PartitionKeyを使用したトレースコンテキストの伝播の初期戦略は一見有望に見えたものの、アプリケーションのビジネス要件にとって重要なイベントのシーケンスロジックを無意識に乱してしまうことが判明しました。
これは分散トレーシングにおける重要な課題を浮き彫りにしています。
トレーサビリティとシステムの機能的整合性のバランスを取る必要があるということです。
次回のシリーズ最終投稿では、この微妙なバランスを保つために、イベントの順序とトレーシングの明確さの両方を保つための代替ソリューションを探求します。