0
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?

OpenTelemetryのトレースを理解したい

0
Posted at

はじめに

バックエンドで複数のAPIの呼び出しを行っていると、障害発生時など、どこのAPIでエラーになっているのかなどの切り分けが難しくなることがあると思います。

今回は[Next.js → PHP(BFF) → 複数APIの呼び出し]という構成でトレースできる環境を調べながら動かした記録をまとめています。

作ったもの

architecture.png

:point_down:について確かめていきたいと思います。

  • フロントエンドからAPIにtraceparentヘッダーでトレースを繋ぐ
  • ブラウザ → BFF → APIが1つのTrace IDでJaegerに表示される

OpenTelemetryとは

OpenTelemetry (OTel)はオープンソースのオブザーバビリティのフレームワークです。
ベンダーに依存しない形でテレメトリデータを収集・送信できます。

テレメトリシグナル

OTelは以下のシグナルを定義しています。今回はTracesに絞って扱います。

シグナル 記録する内容
Traces リクエストの流れ BFF → API-Aの呼び出し経路
Metrics 数値の集計 リクエスト数/秒、レスポンスタイム
Logs 個別イベント 「ユーザー123が注文を作成」

用語の整理

用語 説明
Span 1つの処理単位。「APIを呼んだ」「DBに問い合わせた」などが1Spanになる
Span ID 各Span固有の識別子。Spanが作られるたびに新規生成される
Parent ID このSpanの親はどれかを示す値。Span生成時にアクティブだったSpanのIDが入る
Trace ID リクエスト全体の識別子。生成されたものを全APIで共有する
Context Propagation サービス間でTrace IDを引き継ぐ仕組み
Collector テレメトリを受信・加工・転送する中継サーバー。PHPプロセスの外で独立して動く

traceparentヘッダー

サービス間でTrace IDを途切れさせずに引き継ぐ仕組みがContext Propagationです。その具体的な伝搬方法として、W3C Trace Contextで標準化されたtraceparentヘッダーを使います。

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             │  │                                │                │
             │  trace-id (32桁)                  parent-id (16桁)  trace-flags
             version                             ※送信時にアクティブなSpanのspan-id
  • parent-id: リクエスト送信時にアクティブだったSpanのSpan IDが入ります。受信側はこの値を親として自身のSpanを生成します

このヘッダーはフロントエンド → BFF間、BFF → API間のHTTPリクエストで使います。

使用パッケージと役割

今回は、以下のパッケージの組み合わせでアプリのコードの変更を少なく実現しています。

フロントエンド (Next.js)

パッケージ 役割
@opentelemetry/sdk-trace-web Span生成の本体(WebTracerProvider)
@opentelemetry/instrumentation-fetch fetch()をフックしてSpan生成 + traceparent付与
@opentelemetry/exporter-trace-otlp-http SpanをCollectorに送信
@opentelemetry/context-zone 非同期処理をまたいだコンテキスト追跡

バックエンド (CakePHP)

パッケージ 役割
kaz29/cakephp-otel-plugin ControllerのSpan自動生成
opentelemetry-auto-psr15 受信リクエストからtraceparentを抽出(extract)
opentelemetry-auto-guzzle 送信リクエストにtraceparentを注入(inject)
ext-opentelemetry 上記パッケージがPHPメソッドをフックするために必要なPECL拡張

トレースの仕組み

Step 1: ブラウザ → BFF

ブラウザのOTel JS SDKがSpanを生成し、fetch()traceparentヘッダーを自動付与します。

POST /api/dashboard.json HTTP/1.1
Host: localhost:8080
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-aabb001122334455-01
フロントエンド の Span:
  traceId:  4bf92f3577b34da6a3ce929d0e0e4736  (新規生成)
  spanId:   aabb001122334455                   (新規生成)
  parentId: なし (起点)
  name:     HTTP POST

Step 2: BFFでトレースを引き継ぐ

BFFに到達すると、opentelemetry-auto-psr15traceparentヘッダーからContextを自動抽出し、サーバースパンを生成します。フロントエンドと同じTrace IDが引き継がれます。

続いてcakephp-otel-pluginがControllerのSpanを自動生成します。

BFF の Span (server span):
  traceId:  4bf92f3577b34da6a3ce929d0e0e4736  ← フロントエンドと同じ (引き継いだ)
  spanId:   00f067aa0ba902b7                   (新規生成)
  parentId: aabb001122334455                   ← フロントエンドのspanId (traceparentから)
  name:     RequestHandlerInterface::handle

  └─ BFF の Span (internal span):
       traceId:  4bf92f3577b34da6a3ce929d0e0e4736
       spanId:   1122334455667788               (新規生成)
       parentId: 00f067aa0ba902b7               ← handleのspanId (アクティブだったspanId)
       name:     DashboardController::index

Controller内で下流のAPIを呼び出します。(本来よくないですが、今回はトレースの流れを追いやすくするためControllerに直接書いています)

public function index(): void
{
    $apiA = new ApiClient(env('API_A_URL', 'http://api-a'));
    $apiB = new ApiClient(env('API_B_URL', 'http://api-b'));

    // API呼び出し
    $promises = [
        'user' => $apiA->getAsync('/users/me.json'),
        'products' => $apiB->getAsync('/products.json'),
    ];
    $results = Utils::unwrap($promises);

    $this->set('dashboard', [
        'user' => json_decode((string)$results['user']->getBody(), true),
        'products' => json_decode((string)$results['products']->getBody(), true),
    ]);
    $this->viewBuilder()->setOption('serialize', ['dashboard']);
}

Step 3: BFF → API-A, API-Bを呼ぶ (inject)

下流APIを呼ぶとき、opentelemetry-auto-guzzleがGuzzleのリクエストをフックしてSpan IDを生成し、そのSpan IDをtraceparentヘッダーとしてHTTPリクエストに書き込みます。

BFF の Span (client span):
  traceId:  4bf92f3577b34da6a3ce929d0e0e4736
  spanId:   aabbccdd11223344                   (新規生成)
  parentId: 1122334455667788                   ← ControllerのspanId 
  name:     GET

このCLIENTスパンのSpan IDがtraceparentのparent-idとしてAPI-Aに送られます。

GET /users/me.json HTTP/1.1
Host: api-a
Accept: application/json
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-aabbccdd11223344-01

API-Bにも同じTrace IDでtraceparentが送られます。

Step 4: API-Aが受信 (extract)

API側でもopentelemetry-auto-psr15がサーバースパンを生成し、traceparentのparent-idを親にします。さらにcakephp-otel-pluginがControllerスパンを生成します。

API-A の Span (server span):
  traceId:  4bf92f3577b34da6a3ce929d0e0e4736  ← BFF と同じ (引き継いだ)
  spanId:   1a2b3c4d5e6f7890                   (新規生成)
  parentId: aabbccdd11223344                   ← BFFのCLIENTスパンのspanId (traceparentから)
  name:     RequestHandlerInterface::handle

  └─ API-A の Span (internal span):
       spanId:   5566778899aabbcc               (新規生成)
       parentId: 1a2b3c4d5e6f7890               ← handleのspanId (アクティブだったため)
       name:     UsersController::me

Step 5: テレメトリがCollector → Jaegerに届く

フロントエンド・各PHPサービスのSDKがSpanデータをCollectorに送ります。Jaegerは同じTrace IDのSpanを集めてparentIdでツリーを組み立てます。

データの流れ

data-flow.png

フロントエンドと各バックエンドのOTel SDKがSpanデータをOTLP (OpenTelemetry Protocol)でCollectorに送信します。

そのため、送り先をJaegerからAzure Monitorなど他のサービスに変える際も、Collectorの設定ファイルを書き換えるだけでアプリのコードは触らなくて済みます

環境構築

Dockerfile
FROM php:8.3-apache

# システム依存パッケージ
RUN apt-get update && apt-get install -y \
    git \
    unzip \
    libzip-dev \
    libicu-dev \
    libonig-dev \
    && rm -rf /var/lib/apt/lists/*

# PHP 拡張
RUN docker-php-ext-install \
    intl \
    pdo_mysql \
    zip \
    mbstring

# ext-opentelemetry (自動計装に必要なPECL拡張)
RUN pecl install opentelemetry \
    && docker-php-ext-enable opentelemetry

# ext-protobuf (protobuf シリアライズの高速化)
RUN pecl install protobuf \
    && docker-php-ext-enable protobuf
    
# Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

# Apache mod_rewrite (CakePHP のルーティングに必要)
RUN a2enmod rewrite

# Apache の DocumentRoot を CakePHP の webroot に変更
ENV APACHE_DOCUMENT_ROOT=/var/www/html/webroot
RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' \
    /etc/apache2/sites-available/*.conf \
    && sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' \
    /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf

# AllowOverride All (.htaccess を有効化)
RUN sed -ri -e 's/AllowOverride None/AllowOverride All/g' \
    /etc/apache2/apache2.conf

WORKDIR /var/www/html
OTel Collector設定 (otel-collector-config.yml)
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318
        cors:
          allowed_origins:
            - "http://localhost:3000"
          allowed_headers:
            - "*"

processors:
  batch: {}

exporters:
  otlphttp/jaeger:
    endpoint: http://jaeger:4318

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlphttp/jaeger]
docker-compose.yml (抜粋)
services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:latest
    volumes:
      - ./otel-collector-config.yml:/etc/otelcol-contrib/config.yaml
    ports:
      - "4318:4318"

  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686"

  bff:
    build: ./php
    volumes:
      - ./php/bff:/var/www/html
    ports:
      - "8080:80"
    environment:
      OTEL_PHP_AUTOLOAD_ENABLED: "true"
      OTEL_SERVICE_NAME: bff
      OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
      OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf
      OTEL_PROPAGATORS: tracecontext,baggage
      API_A_URL: http://api-a
      API_B_URL: http://api-b
      API_C_URL: http://api-c

  api-a:
    build: ./php
    volumes:
      - ./php/api-a:/var/www/html
    environment:
      OTEL_PHP_AUTOLOAD_ENABLED: "true"
      OTEL_SERVICE_NAME: api-a-user          # ← サービスごとに変える
      OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
      OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf
      OTEL_PROPAGATORS: tracecontext,baggage

  # api-b, api-c も同様(OTEL_SERVICE_NAME だけ変える)

  frontend:
    image: node:20-alpine
    working_dir: /app
    volumes:
      - ./frontend:/app
    ports:
      - "3000:3000"
    command: sh -c "npm install && npm run dev"
    environment:
      NEXT_PUBLIC_BFF_URL: http://localhost:8080
      NEXT_PUBLIC_OTEL_COLLECTOR_URL: http://localhost:4318

API-A/B/Cは同じDockerイメージを使い回してOTEL_SERVICE_NAMEだけ変えています。

フロントエンドのOTel SDK初期化

lib/tracing.ts
const COLLECTOR_URL =
  process.env.NEXT_PUBLIC_OTEL_COLLECTOR_URL ?? "http://localhost:4318";

let initialized = false;

export function initTracing(): void {
  if (initialized || typeof window === "undefined") {
    return;
  }
  initialized = true;

  const resource = new Resource({
    [ATTR_SERVICE_NAME]: "frontend",
  });

  const exporter = new OTLPTraceExporter({
    url: `${COLLECTOR_URL}/v1/traces`,
  });

  const provider = new WebTracerProvider({
    resource,
    spanProcessors: [new BatchSpanProcessor(exporter)],
  });

  provider.register({
    contextManager: new ZoneContextManager(),
  });

  registerInstrumentations({
    instrumentations: [
      new FetchInstrumentation({
        // BFF (localhost:8080) への fetch に traceparent を自動付与
        propagateTraceHeaderCorsUrls: [/localhost:8080/],
        // Collector への送信自体を計装しない(再帰防止)
        ignoreUrls: [/localhost:4318/],
      }),
    ],
  });
}
pages/_app.tsx
import { initTracing } from "@/lib/tracing";

initTracing();

export default function App({ Component, pageProps }: AppProps) {
  // ...
}

バックエンド

初期設定
composer require kaz29/cakephp-otel-plugin
composer require open-telemetry/opentelemetry-auto-psr15
composer require open-telemetry/opentelemetry-auto-guzzle
// src/Application.php の bootstrap()
$this->addPlugin('OtelInstrumentation');

Jaegerの見え方

API呼び出しを行った時の見え方(API-A/Bを並列で呼び出し)

image.png

フロントエンドを起点として、BFF → API-A + API-Bの並列呼び出しがツリー構造で可視化されます。

  • 例外が発生した時の見え方(API-Bで例外が発生)

image.png

おわりに

今回はフロントエンドからバックエンドまでを1つのTrace IDで繋ぐ環境を構築しました。

実際の運用となると、Spanの粒度の設計などまだまだ考慮すべき事項は多くありますが、分散トレースがどのように動くのかのイメージは掴めたかなと思います。

参考にさせていただいた記事

0
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
0
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?