ECS(Fargate) から X-Ray にトレースを送る3経路(ADOT サイドカー / OTLP 直送 / Application Signals)を実際にデプロイして比較してみた

ECS(Fargate) から X-Ray にトレースを送る3経路(ADOT サイドカー / OTLP 直送 / Application Signals)を実際にデプロイして比較してみた

ECS上のNode.jsアプリに分散トレーシングを導入するなら、OpenTelemetryが必須です。X-Ray SDK のメンテナンスモード化に伴い、ADOT を使った3つの送信経路を実際にデプロイして徹底比較しました。ESM+esbuild構成での落とし穴から、各案の手間・効果まで、実測データに基づいてまとめます。
2026.07.04

はじめに

LINE/アプリ DevOps チームの及川です。

ECS(Fargate)上で動く Node.js アプリに、あとから分散トレーシングを入れたい、というケースは多いのではないでしょうか。リクエストがどのサービスを経由し、どこで時間を使っているのかを、サービスマップとトレースで見えるようにしたい、というものです。

AWS でトレースといえば X-Ray ですが、2026 年 2 月 25 日に X-Ray の SDK / Daemon がメンテナンスモードに入り、以降は OpenTelemetry(OTel)が推奨、という方針が公式に示されています。

https://docs.aws.amazon.com/xray/latest/devguide/xray-sdk-migration.html

X-Ray SDK/Daemon Maintenance Notice – On February 25th, 2026, the AWS X-Ray SDKs/Daemon will enter maintenance mode, where AWS will limit X-Ray SDK and Daemon releases to address security issues only.

It is recommended to adopt OpenTelemetry as the observability solution for instrumenting your application.

そこで、OpenTelemetry の AWS 版である ADOT(AWS Distro for OpenTelemetry)で ECS から X-Ray にトレースを送ることになりますが、送信経路は 1 つではありません。大きく次の 3 つです。

  • 案①: アプリと同じタスク内に ADOT Collector(テレメトリを受け取り X-Ray へ中継するプロセス)をサイドカーとして置き、アプリはそこへ OTLP を投げるだけにする
  • 案②: サイドカーを置かず、アプリから X-Ray の OTLP エンドポイントへ直送する
  • 案③: CloudWatch Application Signals(トレースに加えて主要メトリクスまで自動で集める APM 機能)を使う

さらに、TypeScript を ESM + esbuild でバンドルしている構成では、OpenTelemetry の自動計装(コードにトレース取得の仕込みを自動で入れる仕組み)に固有の落とし穴があり、素直に入れると「受信スパンが出ずサービスマップにアプリが載らない」状態になります。

どれを選ぶべきか、そして ESM 構成で何に注意すべきかを判断するため、3 案すべてを実際に AWS 環境へデプロイして挙動と数値を確かめました。

この記事で紹介すること

  • ECS から X-Ray へトレースを送る 3 経路(①ADOT サイドカー ②OTLP 直送 ③Application Signals)を実デプロイして比較した結果
  • ESM + esbuild バンドル構成で自動計装が「受信スパンを出さない」落とし穴と、その回避方法
  • 各案の「アプリ改修量」「アカウント設定変更の要否」などの違い
  • どの案がどんなワークロードに向くか(私見)

基本用語のうち、本記事で効いてくるものだけ公式定義とあわせて押さえておきます。OpenTelemetry では、トレースは「アプリを通るリクエストの経路」、スパンはその中の 1 つの作業単位です。スパンには種類(SpanKind)が Server / Client / Internal / Producer / Consumer の 5 つあり、本記事で関係するのは受信側の Server と送信側の Client の 2 つです。

https://opentelemetry.io/docs/concepts/signals/traces/

Server: A server span represents a synchronous incoming remote call such as an incoming HTTP request or remote procedure call.

Client: A client span represents a synchronous outgoing remote call such as an outgoing HTTP request or database call.

X-Ray 側は、サービスからのデータを「セグメント」として受け取り、それを束ねてサービスマップ(サービスグラフ)を描きます。受信(Server)スパンがアプリ自身のノードに、送信(Client)スパンが下流呼び出しになるため、アプリのノードがマップに載るには受信スパンが必要、という点が本記事のポイントです(X-Ray の概念はこちら)。

検証結果はすべて実測に基づきます。掲載するログや値は、読みやすさのため識別子を一般名に置き換えていますが、数値そのものには手を加えていません。特定システムに依存しない一般的な検証として、最小構成のサンプルアプリで確認しています。

結論

先に結論をまとめます。

  • 3 案とも「ECS アプリのトレースを取得し、サービスマップにノードを載せる」目的は達成できました(実アカウントで実証済み)
  • 今回の狙い(分散トレースの取得基盤)に対しては、案①(ADOT Collector サイドカー)が最も素直でした
  • 案②(OTLP 直送)は「サイドカーを無くせる」利点はありますが、そのぶんアカウント単位の設定変更と、SigV4(AWS API を呼ぶときの署名認証)署名の自作という手間がかかります
  • 案③(Application Signals)は RED メトリクス(リクエスト数・エラー・レイテンシといった代表的な指標)まで自動生成される付加価値がありますが、構成要素は 3 案中で最も多くなります

3 案を実測した比較が次の表です(◎優 / ○良 / △留意 / ×負担)。

観点 案① ADOT サイドカー 案② OTLP 直送 案③ Application Signals
目的達成(分散トレース / サービスマップ) ◯ 実証済 ◯ 実証済 ◯ 実証済
アプリ改修の軽さ ◎ localhost へ送るだけ △ SigV4 エクスポータ約 90 行を自作・保守 ◯ ADOT に委譲
ESM + esbuild 相性 ○ ESM 用の初期化を 1 行、自分で追加 ○ ESM 用の初期化を 1 行、自分で追加 ◎ ADOT が ESM 対応を自動でやる
アカウント設定変更 ◎ 不要(単独完結) × 必須(Transaction Search へ移行) × 必須(Transaction Search + Application Signals 有効化)
常駐サイドカー △ ADOT ×1 ◎ なし △ CloudWatch Agent ×1(+起動時 init ×1)
付加価値 トレースのみ トレースのみ RED メトリクス / サービスマップ相関を自動生成
多数サービスへの横展開 ◎ 各タスクにサイドカー追加、ロジックは共通で薄い △ 各サービスで SigV4 保守が増える ○ 各タスクにサイドカー+ init、設定は多いが定型

検証を通じて特に重要だと感じた点は次の 2 つです。

  1. ESM + esbuild バンドルでは、自動計装がそのままでは受信スパンを出しません。node --import で読み込ませただけだと下流(fetch)のスパンは出るのに受信(HTTP サーバ)のスパンが 0 になり、サービスマップにアプリが載りません。ESM 用の初期化(ローダーフックの登録)を 1 行入れれば解消します(詳細は後述)。案③はこの登録を ADOT が自動で行うため不要でした
  2. 案②「OTLP 直送」はサイドカーを無くせますが、アカウント単位で Transaction Search を有効化し、さらに SigV4 署名を自作(約 90 行)する必要があります(詳細は後述)

ここからは私見です。どの案が向くかはワークロード次第ですが、目安として次のように考えています。

  • 「まず分散トレースの取得基盤を、既存環境に影響を与えず、多数のサービスへ薄く横展開したい」なら案①が素直です。アプリは localhost へ OTLP を送るだけで、認証や送信先の知識をアプリに持ち込まずに済みます
  • 「サイドカーをどうしても置けない特殊事情がある単一サービス」なら案②も選択肢ですが、アカウント設定変更と SigV4 の自作保守が全サービスに効いてくるため、規模が大きいほど手間が増えます
  • 「トレースだけでなく、レイテンシ・エラー率などの RED メトリクスや SLO まで CloudWatch に寄せたい」なら案③が候補です。まず案①で基盤を作り、APM が必要になった段階でサイドカーを CloudWatch Agent へ替えて案③へ発展させる、という順序が自然だと考えています(案③もサイドカー方式のため)

検証内容

検証環境

AWS CDK で、次の最小構成を組みました。

  • 最小構成の Node.js(ESM)アプリを 1 つ用意。エンドポイントは /health と、外部 HTTP を 1 回だけ呼び出す /work の 2 つです。/work を叩くと「受信 → 下流呼び出し」が 1 トレースに繋がり、サービスマップ表示の確認に使えます
  • ビルドは esbuild でバンドルし、@opentelemetry/* は external にしています。起動は node --import ./dist/otel.mjs dist/index.mjs(ESM のため --require は使えません)
  • Fargate で 1 タスクだけ起動。ALB / NAT Gateway は使わず、動作確認は ECS Exec でコンテナ内から /work を叩きます
  • 送信経路(sidecar / otlp / appsignals)は CDK の context で切り替え、案ごとにデプロイし直す方式にしました

検証方法

各案をデプロイした状態で ECS Exec からコンテナに入り、/work を叩いてトラフィックを生成します。そのうえで、X-Ray 側に出るかを次の API で確認しました。

  • get-trace-summaries: トレースが記録されているか(件数・HTTP ステータス・トレース ID)
  • batch-get-traces: トレースの構造(受信セグメントに下流サブセグメントが連結しているか)
  • get-service-graph: サービスマップのノードとして載っているか

前提の落とし穴: ESM + esbuild で受信スパンが出ない

全案の前提になった落とし穴です。ローカル(AWS 不要・標準出力にスパンを吐く設定)で計装の効きを確認したところ、node --import で読み込ませただけの段階では次のようになりました。

  • 下流呼び出し(fetch)のスパン(Client)は出る
  • 受信(HTTP サーバ)のスパン(Server)が 1 本も出ない(= 0)

原因は、HTTP の自動計装が「モジュールを差し替える(monkey-patch)」方式で、ESM では import を捕捉できないためです。fetch(undici)は diagnostics_channel という別方式なので影響を受けません。

この「ESM では計装を正しく差し込むために loader hook(ローダーフック)が必要」という挙動は、今回の環境固有ではなく OpenTelemetry 公式にも明記されています。

https://github.com/open-telemetry/opentelemetry-js/blob/main/doc/esm-support.md

If your application is written in JavaScript as ESM, or compiled to ESM from TypeScript, then a loader hook is required to properly patch instrumentation.

回避策は、otel.ts の冒頭に次の 2 行を足すだけです。この register(...) が「ESM でも自動計装を差し込めるようにするフック」の登録で、これを入れると受信・送信の両スパンが出て、同一トレース ID で連結されました(受信 GET /work → 下流 fetch が 1 本のトレースに)。トレース ID の先頭はタイムスタンプで、X-Ray 互換になっていることも確認できています。

import { register } from "node:module";
register("@opentelemetry/instrumentation/hook.mjs", import.meta.url);

なお、これは esbuild 固有ではなく、ESM(TypeScript を ESM にコンパイル / バンドルした場合を含む)に共通する前提です。esbuild を使う場合は、加えて @opentelemetry/* を external にしておく必要があります(バンドルに取り込むと計装が壊れます)。この落とし穴は案①・案②に共通で、案③は ADOT ディストリビューションが自動対応するため不要でした。

案①: ADOT Collector サイドカー

アプリと同じタスク内に ADOT Collector をサイドカーとして置き、アプリは localhost:4318 へ平文の OTLP を送るだけにする構成です。SigV4 署名や送信先の知識は Collector 側に集約され、Collector が X-Ray へ送ります。

案①: ADOT Collector サイドカー構成

  • デプロイ(Docker ビルド → ECR → VPC / ECS の作成)はおよそ 117 秒で完了しました
  • タスクロールに X-Ray へ書き込む権限を付与。アカウント単位の設定変更は不要でした
  • Collector の送信は従来の PutTraceSegments API を使うため、案②で出てくる「送信先を CloudWatch Logs に切り替える」設定なしでそのまま動作しました

結果として、トレースが記録され、サービスマップに受信ノードが載り、受信セグメントに下流サブセグメントが連結していることを確認できました。アプリ側は「localhost へ OTLP を送るだけ」で済みます。

ECS で ADOT Collector をサイドカーとして X-Ray へ送る構成は、AWS 公式でも案内されています。

https://docs.aws.amazon.com/AmazonECS/latest/developerguide/trace-data.html

案②: OTLP 直送

サイドカーを置かず、アプリから X-Ray の OTLP エンドポイント(https://xray.<region>.amazonaws.com/v1/traces)へ直接送る構成です。タスク構成は最小になりますが、2 つの前提があります。

1 つ目は、Node.js 標準の OTLP エクスポータが SigV4 署名をしないことです。署名を行う自作エクスポータ(約 90 行)を用意しました。実際に送ると SigV4 署名自体は通り(403 認証エラーではなく 400 が返ったことから、リクエストは X-Ray に到達していると判断できます)、コードは正しく動いていました。

2 つ目は、アカウント単位で Transaction Search(X-Ray のトレースを CloudWatch Logs 側に保存して検索・分析できるようにする仕組み)を有効化する必要があることです。X-Ray の OTLP エンドポイントは送信先が CloudWatch Logs であることを前提としており、既定(XRay)のままだと 400 が返ります。

[exporter] response status=400
X-Ray OTLP 400: The OTLP API is supported with CloudWatch Logs
as a Trace Segment Destination. Please enable the CloudWatch Logs destination
for your traces using the UpdateTraceSegmentDestination API

これは公式にも前提として明記されています。

https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-OTLP-UsingADOT.html

If you are using traces, make sure Transaction Search is enabled to send spans to the X-Ray OTLP endpoint.

有効化は AWS 公式手順に沿って 3 ステップで行いました(対象は検証用アカウントのみ)。

# 1. X-Ray が aws/spans ロググループへ書き込めるよう、リソースポリシーを付与
aws logs put-resource-policy --policy-name <ポリシー> --policy-document file://policy.json

# 2. トレースの送信先を CloudWatch Logs へ変更(PENDING → 数分で ACTIVE)
aws xray update-trace-segment-destination --destination CloudWatchLogs

# 3. インデックス率を設定(検証は 100%、本番は 1〜5% で十分)
aws xray update-indexing-rule --name "Default" --rule '{"Probabilistic": {"DesiredSamplingPercentage": 100}}'

送信先が CloudWatchLogs / ACTIVE に遷移してからトラフィックを生成すると、応答が 400 から 200 に変わり、トレース保存とサービスマップ表示を確認できました。

案②: OTLP 直送構成

有効化手順の詳細は公式ドキュメントにまとまっています。

https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/Enable-TransactionSearch.html

なお、この「送信先を CloudWatch Logs にする」有効化は次の案③の前提でもあり、案②を検証した時点で案③の下地も整いました。

案③: CloudWatch Application Signals

CloudWatch Application Signals を使う構成です。AWS 標準の ECS 向けサイドカー方式に従い、次の要素で組みました。

  • init コンテナ(使い捨て): ADOT の Node.js 自動計装一式を共有ボリュームへコピーして終了
  • app コンテナ: 自前の otel.ts は使わず、NODE_OPTIONS=--require /otel-auto-instrumentation-node/autoinstrumentation.js で ADOT ディストリビューションに計装を任せる
  • ecs-cwagent サイドカー: CloudWatch Agent。設定で Application Signals を有効化し、アプリは localhost:4316 へ OTLP を送る

案③はタスク側の構成に加えて、アカウント側で Application Signals を有効化しておく必要があります(有効化時にサービスにリンクされたロール〈SLR: Service-Linked Role〉が作成されます)。案②の Transaction Search と合わせ、アカウント単位の設定が前提になる点が、設定を触らない案①との大きな違いです。

案③: Application Signals 構成

この構成の ECS(Node.js)向け手順は公式ドキュメントに詳しく、ESM 用の設定や init コンテナの定義もこちらにあります。

https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Application-Signals-ECS-Sidecar.html

実測で確認できたことは次のとおりです。

  • 起動ログに ESM loader hooks installed via module.register() が出ました。案①②で自前登録が必要だった ESM ローダーフックを、ADOT ディストリビューション(今回は v0.12.0)が自動登録しており、受信スパンも取得できています
  • トレースは GET /work が 200 で記録され、セグメントに span.kind: SERVERorigin: AWS::ECS::Fargate が付与されていました
  • Application Signals にサービスとして登録され(InstrumentationType: INSTRUMENTED)、Latency / Error / Fault の RED メトリクスが自動生成されていました。ここが案①②(トレースのみ)に対する付加価値です

Application Signals が自動で集める標準メトリクス(latency / faults / errors)は公式にも定義があり、ApplicationSignals 名前空間に送られます。

https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/AppSignals-MetricsCollected.html

Application Signals collects standard application metrics from the services that it discovers. These metrics relate to the most critical aspects of a service's performance: latency, faults, and errors.

一方で、デプロイ時に 1 つ落とし穴がありました。ADOT の Node.js 自動計装イメージ(public.ecr.aws/aws-observability/adot-autoinstrumentation-node)には latest タグが無く、バージョンタグのみです。:latest を指定すると CannotPullContainerError になり、ECS がタスク配置をリトライし続けて CloudFormation が CREATE_IN_PROGRESS のまま滞留します(ハングのように見えます)。

対処は次の 2 点でした。

  • イメージのタグをバージョン(v0.12.0)で固定する
  • ECS サービスにデプロイのサーキットブレーカー(circuitBreaker: { rollback: true })を付け、タスク起動失敗時に即失敗+自動ロールバックさせる

ECS のデプロイサーキットブレーカーは、失敗したデプロイを直前の成功状態へ自動でロールバックする公式機能です。

https://docs.aws.amazon.com/AmazonECS/latest/developerguide/deployment-circuit-breaker.html

The deployment circuit breaker has an option that will automatically roll back a failed deployment to the deployment that is in the COMPLETED state.

公開イメージを使うときは、タグの存在を確認してバージョン固定し、ECS サービスにはサーキットブレーカーを付けておくと安全です。Application Signals の概要は次のページが入口です。

https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/Getting-Started-App-Signals.html

料金について(課金の軸)

3 案は「何に対して課金されるか」の軸が異なります。① 記録トレース件数 / ② 取り込んだデータ量(GB)とインデックス / ③ ②に加えて signal 量、です。金額は改定もあり、ワークロードで大きく変わるため本記事では扱いません。実額は下記の公式ページでご確認ください。

まとめ

ECS(Fargate)+ ESM(esbuild バンドル)の Node.js アプリに、OpenTelemetry で分散トレーシングを入れる 3 経路を実測比較しました。

  • X-Ray SDK / Daemon はメンテナンスモードに入っており、これからは OpenTelemetry(ADOT)が前提。ECS から X-Ray への送信経路には ①ADOT サイドカー ②OTLP 直送 ③Application Signals がある
  • 3 案とも「分散トレースを取得し、サービスマップにノードを載せる」目的は達成できる
  • ESM + esbuild では、①②は自前で ESM ローダーフックの登録が必要(無いと受信スパンが出ない)。③は ADOT ディストリビューションが自動で対応する
  • 案②はサイドカーを無くせるが、そのぶんアカウント単位の Transaction Search 有効化と、SigV4 署名を自作する手間がかかる
  • 案③は RED メトリクスまで自動生成される付加価値があるが、構成要素は最も多くなる
  • まず基盤を作るなら案①が素直で、APM が必要になったら案③へ地続きに発展できる

送信経路は複数あり、それぞれ「アプリに持ち込む複雑さ」「アカウントに与える影響」が違います。目的が「まずトレース基盤を薄く広げる」のか「APM まで一気に寄せる」のかで、選ぶ案が変わってくる、というのが実測してみての実感です。

この記事が誰かのお役に立てば幸いです。

参考情報

クラスメソッドオペレーションズ株式会社について

クラスメソッドグループのオペレーション企業です。
運用・保守開発・サポート・情シス・バックオフィスの専門チームが、IT・AI をフル活用した「しくみ」を通じて、お客様の業務代行から課題解決や高付加価値サービスまでを提供するエキスパート集団です。
当社は様々な職種でメンバーを募集しています。
「オペレーション・エクセレンス」と「らしく働く、らしく生きる」を共に実現するカルチャー・しくみ・働き方にご興味がある方は、クラスメソッドオペレーションズ株式会社 コーポレートサイト をぜひご覧ください。※2026 年 1 月 アノテーション㈱から社名変更しました

この記事をシェアする

関連記事