LabHub

ブログ

OpenTelemetryでマイクロサービスのブラックボックスを解き明かす5つの決定的戦略

한국어English日本語


1. はじめに: なぜ我々の分散システムは今なお迷宮の中なのか?

1.1 マイクロサービスのパラドックス: 分割したのに複雑さは増した

マイクロサービスアーキテクチャ(MSA)は、独立デプロイ、技術の多様性、チームの自律性という魅力的な約束を掲げてモノリスを置き換えた。しかし、一つのユーザーリクエストがAPI Gatewayから始まり、認証サービス、商品カタログ、在庫管理、決済、通知まで 10~30個以上のサービスを連鎖的に貫通する という現実において、問題が起きたときに根本原因を追跡することは、まるで迷宮をさまようようなものだ。

ユーザーリクエストの旅路 (一般的なeコマース)
============================================

[Client] ──▶ [API Gateway] ──▶ [Auth Service]
                  ├──▶ [Product Service] ──▶ [Search Engine]
                  │         │
                  │         └──▶ [Recommendation Service] ──▶ [ML Model]
                  ├──▶ [Cart Service] ──▶ [Redis Cache]
                  ├──▶ [Order Service] ──▶ [Inventory Service] ──▶ [Warehouse DB]
                  │         │
                  │         └──▶ [Payment Service] ──▶ [External PG API]
                  └──▶ [Notification Service] ──▶ [Email/SMS/Push]

一つの「注文完了」に最低12個のサービスが関与する。
p99レイテンシの原因を探すには? どこから見ればよいのか?

従来のAPM(Application Performance Monitoring)ツールは、この問題を部分的に解決してきた。しかし、大半の商用APMは 独自エージェントと専用プロトコル に依存する。Datadog AgentはDatadogにしかデータを送らず、New Relic AgentはNew Relicにしかデータを送らない。これがまさに Vendor Lock-in である。

1.2 Vendor Lock-inの実質的なコスト

Vendor Lock-inは単なる技術的な不便を超えて、実質的なビジネスコスト へとつながる。

影響領域問題点実際のコスト
ライセンス費用データ量ベースの課金、年々上昇する単価年間で数十億ウォン規模 (大規模サービスの場合)
移行コスト専用エージェントの再導入、ダッシュボード再構築、通知ルール再設定6~12か月のエンジニアリング投入
技術的負債ベンダー専用SDKに結合したコード、ベンダー専用クエリ言語コードベース全体にわたる侵襲的な変更が必要
戦略的な柔軟性の喪失より良いツールが出ても移行不可、価格交渉力の低下長期的な競争力の低下

1.3 OpenTelemetry: "Instrument Once, Export Anywhere"

OpenTelemetry(OTel)はCNCF(Cloud Native Computing Foundation)で 2番目に活発なプロジェクト(Kubernetesに次ぐ)であり、2019年にOpenTracingとOpenCensusの統合によって誕生した。OTelの中核となる哲学は、単純でありながら革命的だ。

「計装(Instrumentation)は一度だけ行い、望む場所どこへでもエクスポートする」

OTelが提供するものは大きく三つある。

  1. 標準ワイヤプロトコル (OTLP): テレメトリーデータの転送規格。gRPCとHTTPの両方をサポートする。
  2. SDKとAPI: すべての主要言語(Java, Python, Go, .NET, Node.js, Rust, C++, PHP, Ruby など)で利用できる計装ライブラリ。
  3. OpenTelemetry Collector: テレメトリーデータを受信、加工、ルーティングするベンダー中立なパイプライン。
OpenTelemetryのアーキテクチャ概要
============================================

  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐
Service A  │  │  Service B  │  │  Service C    (Java SDK) (Python SDK)  (Go SDK)  └──────┬──────┘  └──────┬──────┘  └──────┬──────┘
OTLPOTLPOTLP
         ▼                ▼                ▼
  ┌─────────────────────────────────────────────────┐
OpenTelemetry Collector  │  ┌──────────┐  ┌───────────┐  ┌──────────────┐ │
  │  │Receivers │→ │Processors │→ │  Exporters   │ │
  │  └──────────┘  └───────────┘  └──────────────┘ │
  └────────┬──────────────┬──────────────┬──────────┘
           │              │              │
           ▼              ▼              ▼
    ┌───────────┐  ┌───────────┐  ┌───────────┐
Jaeger   │  │   Tempo   │  │  Datadog     (Traces) (Traces) (All-in-1)    └───────────┘  └───────────┘  └───────────┘

OTelを導入すれば、ベンダーの乗り換えは Collectorのexporter設定を変えるだけ で完了する。コードを一行も修正することなく、JaegerからTempoへ、あるいはDatadogからGrafana Cloudへ移行できる。

本記事では、OpenTelemetryを実戦で効果的に活用するための 5つの決定的戦略 をアーキテクトの視点から深く分析する。単なる「Getting Started」レベルではなく、プロダクションレベルの意思決定に必要な深さを提供する。


2. [Takeaway 1] W3C Baggage: ビジネスコンテキストを伝播させる秘密兵器

2.1 Trace ContextとBaggageの区別

分散トレーシングにおける コンテキスト伝播(Context Propagation)は、根本的に二つに分かれる。

区分W3C Trace ContextW3C Baggage
W3C標準W3C Trace ContextW3C Baggage
HTTPヘッダーtraceparent, tracestatebaggage
目的Trace ID、Span ID、サンプリングフラグの伝播任意のビジネスkey-valueの伝播
必須かどうかトレーシングのために必須任意 (ビジネス要件に応じて)
データ例00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01tenantId=acme,userTier=premium,featureFlag=newCheckout

重要なポイント: BaggageはTrace Contextと 独立して動作する。トレーシングが無効になっていてもBaggageは伝播でき、逆にトレーシングが有効であってもBaggageを使わないでいられる。この独立性がBaggageをより柔軟なツールにしている。

2.2 ビジネスユースケース: なぜBaggageが必要なのか?

Baggageの真の価値は、インフラレイヤーを超えて、ビジネスコンテキストをサービス境界をまたいで伝播できるという点にある。具体的な活用事例を見てみよう。

マルチテナント分離 (Tenant ID Propagation)

SaaS環境で、一つのリクエストがどのテナントに属するのかをダウンストリームサービスまで伝播すれば、各サービスが独立してテナント別のrate limiting、データ分離、リソース割り当てを実行できる。

Feature Flagの伝播

フロントエンドで決定されたfeature flagの状態を、バックエンドサービスチェーン全体にわたって一貫して適用できる。A/Bテストにおいて、ユーザーが実験群なのか対照群なのかをすべてのサービスが認識できる。

ユーザー等級ベースのQoS (Quality of Service)

プレミアムユーザーのリクエストにより高い優先度を与えたり、より余裕のあるタイムアウトを設定したり、より精密なサンプリング比率を適用したりできる。

コスト帰属 (Cost Attribution)

リクエストにコストセンタータグを付与することで、どの部署、どのプロジェクトがインフラコストをどれだけ消費しているかを追跡できる。

2.3 多言語での実装例

Java (Spring Boot + OTel SDK)

import io.opentelemetry.api.baggage.Baggage;
import io.opentelemetry.api.baggage.BaggageEntryMetadata;
import io.opentelemetry.context.Scope;
import io.opentelemetry.api.trace.Span;

// ---- API GatewayでBaggageを設定 (リクエストのエントリポイント) ----
@RestController
public class GatewayController {

    @PostMapping("/api/orders")
    public ResponseEntity<?> createOrder(
            @RequestHeader("X-Tenant-Id") String tenantId,
            @RequestHeader("X-User-Tier") String userTier,
            HttpServletRequest request) {

        // W3C Baggageの設定 - ダウンストリームサービス全体に伝播される
        Baggage baggage = Baggage.builder()
            .put("tenantId", tenantId,
                 BaggageEntryMetadata.create("tenant context"))
            .put("userTier", userTier,
                 BaggageEntryMetadata.create("qos context"))
            .put("entryPoint", "order-api",
                 BaggageEntryMetadata.create("routing context"))
            .put("requestRegion", determineRegion(request),
                 BaggageEntryMetadata.create("geo context"))
            .build();

        // Baggageを現在のContextにattach
        try (Scope scope = baggage.makeCurrent()) {
            // このscope内で呼び出されるすべてのダウンストリームサービスは
            // 自動的にbaggageをHTTPヘッダーとして受け取る
            return orderService.processOrder(request.getBody());
        }
    }
}

// ---- ダウンストリームのOrder ServiceでBaggageを読む ----
@Service
public class OrderService {

    public void processOrder(OrderRequest order) {
        // 伝播されたBaggageからビジネスコンテキストを抽出
        String tenantId = Baggage.current().getEntryValue("tenantId");
        String userTier = Baggage.current().getEntryValue("userTier");

        // 現在のSpanにビジネス属性として追加 (検索/フィルタリング用途)
        Span currentSpan = Span.current();
        currentSpan.setAttribute("business.tenant_id", tenantId);
        currentSpan.setAttribute("business.user_tier", userTier);

        // ユーザー等級に応じたQoS分岐
        if ("premium".equals(userTier)) {
            processWithPriority(order);
        } else {
            processNormally(order);
        }
    }
}

Python (FastAPI + OTel SDK)

from opentelemetry import baggage, trace, context
from opentelemetry.baggage.propagation import W3CBaggagePropagator
from opentelemetry.context.context import Context
from fastapi import FastAPI, Request, Header
from typing import Optional

app = FastAPI()
tracer = trace.get_tracer("order-service")

# ---- API GatewayでBaggageを設定 ----
@app.post("/api/orders")
async def create_order(
    request: Request,
    x_tenant_id: Optional[str] = Header(None),
    x_user_tier: Optional[str] = Header(None, alias="X-User-Tier"),
):
    # Baggageの設定
    ctx = baggage.set_baggage("tenantId", x_tenant_id or "unknown")
    ctx = baggage.set_baggage("userTier", x_user_tier or "standard", context=ctx)
    ctx = baggage.set_baggage("featureFlag", "new-checkout-v2", context=ctx)

    # Contextを有効化してダウンストリーム呼び出しに自動伝播
    token = context.attach(ctx)
    try:
        with tracer.start_as_current_span("process-order") as span:
            # Baggageの値をSpan属性としても記録
            tenant = baggage.get_baggage("tenantId")
            tier = baggage.get_baggage("userTier")
            span.set_attribute("business.tenant_id", tenant)
            span.set_attribute("business.user_tier", tier)

            result = await order_processor.process(request)
            return {"status": "created", "order_id": result.id}
    finally:
        context.detach(token)


# ---- ダウンストリームのInventory ServiceでBaggageを読む ----
@app.get("/api/inventory/{product_id}")
async def check_inventory(product_id: str, request: Request):
    # OTel SDKが自動的にHTTPヘッダーからBaggageを抽出
    tenant_id = baggage.get_baggage("tenantId")
    user_tier = baggage.get_baggage("userTier")

    with tracer.start_as_current_span("check-inventory") as span:
        span.set_attribute("business.tenant_id", tenant_id)

        # テナント別に分離されたデータソースへアクセス
        inventory = await get_tenant_inventory(tenant_id, product_id)
        return {"available": inventory.quantity > 0}

Go (Gin + OTel SDK)

package main

import (
    "context"
    "net/http"

    "github.com/gin-gonic/gin"
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/baggage"
    "go.opentelemetry.io/otel/attribute"
)

var tracer = otel.Tracer("order-service")

// API GatewayでBaggageを設定
func CreateOrderHandler(c *gin.Context) {
    tenantID := c.GetHeader("X-Tenant-Id")
    userTier := c.GetHeader("X-User-Tier")

    // W3C Baggageメンバーを生成
    tenantMember, _ := baggage.NewMember("tenantId", tenantID)
    tierMember, _ := baggage.NewMember("userTier", userTier)
    flagMember, _ := baggage.NewMember("featureFlag", "new-checkout-v2")

    bag, _ := baggage.New(tenantMember, tierMember, flagMember)

    // ContextにBaggageを付与
    ctx := baggage.ContextWithBaggage(c.Request.Context(), bag)

    // ダウンストリーム呼び出し時に自動伝播
    ctx, span := tracer.Start(ctx, "process-order")
    defer span.End()

    span.SetAttributes(
        attribute.String("business.tenant_id", tenantID),
        attribute.String("business.user_tier", userTier),
    )

    result, err := processOrder(ctx, c.Request.Body)
    if err != nil {
        span.RecordError(err)
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }
    c.JSON(http.StatusCreated, result)
}

// ダウンストリームでBaggageを読む
func processOrder(ctx context.Context, body io.ReadCloser) (*OrderResult, error) {
    bag := baggage.FromContext(ctx)
    tenantID := bag.Member("tenantId").Value()
    userTier := bag.Member("userTier").Value()

    ctx, span := tracer.Start(ctx, "validate-order")
    defer span.End()

    span.SetAttributes(
        attribute.String("business.tenant_id", tenantID),
    )

    // ビジネスロジックの処理...
    return &OrderResult{ID: "ord-12345"}, nil
}

2.4 Baggageのセキュリティと性能に関する考慮事項

Baggageは強力だが、セキュリティと性能の面で慎重なアプローチが必要だ。

セキュリティリスク

Baggageの伝播経路とセキュリティリスク
============================================

[内部サービス] ──HTTPヘッダ──▶ [内部サービス] ──HTTPヘッダ──▶ [外部 API]
     │                              │                             │
     │  baggage: tenantId=acme,     │  baggage: tenantId=acme,    │  ⚠️ 外部へ
     │  userId=12345,               │  userId=12345,Baggageが
     │  email=user@acme.com         │  email=user@acme.com        │  流出する!
     │                              │                             │
     └──────────────────────────────┘                             │
           Trust Boundary 内部             Trust Boundary 外部 ───┘

⚠️ Baggageは平文のHTTPヘッダーで送信される!
⚠️ PII(個人識別情報)は絶対に入れるな!

必須のセキュリティ対策:

  1. PII禁止: メールアドレス、電話番号、住民番号などの個人識別情報は絶対にBaggageに入れない。
  2. Trust Boundary Sanitization: 外部サービス呼び出し時にBaggageを除去またはフィルタリングする。
  3. 許可リスト(Allowlist)ベースの伝播: 許可されたキーだけを伝播するよう、CollectorやSDKでフィルタを設定する。
  4. 値のサイズ制限: W3C Baggageの仕様では合計8,192バイトを推奨しているが、実務ではできる限り小さく保つ。
# OTel CollectorでのBaggageフィルタリング設定例
processors:
  # 特定のbaggageキーだけを許可するattributesプロセッサ
  attributes/baggage-filter:
    actions:
      # 許可されたビジネスキーのみ維持
      - key: baggage.tenantId
        action: upsert
      - key: baggage.userTier
        action: upsert
      # 機微なキーは削除
      - key: baggage.email
        action: delete
      - key: baggage.userId
        action: delete

性能オーバーヘッド

Baggageは サービス間のすべてのHTTPリクエストのヘッダーに含まれるため、ネットワークオーバーヘッドが発生する。

Baggageのサイズ毎秒10,000リクエスト時の追加帯域影響レベル
100 bytes~1 MB/s無視できる
500 bytes~5 MB/s軽微
2 KB~20 MB/s注意が必要
8 KB (仕様上の最大値)~80 MB/s深刻なオーバーヘッド

Best Practice: Baggageには短い識別子(ID)だけを入れ、実際のデータはサービスがそのIDで照会する設計にする。たとえば、ユーザープロフィール全体の代わりに tenantId=acme だけを伝播し、各サービスが必要なときにテナント設定をキャッシュから照会する。


3. [Takeaway 2] 自動計装の限界とハイブリッド戦略の必要性

3.1 自動計装(Zero-Code Instrumentation)がカバーする領域

OpenTelemetryの自動計装は、コードを一行も修正せずに アプリケーションへオブザーバビリティを追加する機能だ。言語ごとにメカニズムは異なるが、中核となる原理は同じで、フレームワークとライブラリの入口/出口をintercept(横取り)して自動的にSpanを生成する

自動計装がキャプチャするもの:

これだけでも インフラレイヤーの80~90%をカバーできる。大半のレイテンシ問題、エラー率の急増、サービス間の依存関係の把握が可能になる。

3.2 言語別の自動計装サポート水準

言語サポート水準メカニズム非侵襲性性能オーバーヘッド主な特徴
Java非常に高いBytecode Manipulation (Java Agent)-javaagent JVMオプションを足すだけ3~7% CPU200+ ライブラリを自動サポート
Python高いMonkey Patchingopentelemetry-instrument CLIラッパー5~10% CPUDjango、Flask、FastAPIなどをサポート
.NET高いCLR Runtime Hooks環境変数の設定だけで有効化3~5% CPUASP.NET Core、EF Coreをサポート
Node.js高いModule Loading Hooks (require/import)--require フラグを追加5~8% CPUExpress、Fastify、NestJSをサポート
Go中程度eBPFベースまたはコンパイル時ラッピングeBPFは非侵襲的、ラッピングはコード修正が必要1~3% (eBPF)eBPF方式はカーネル4.x+が必要
Rust低い手動計装が必須 (tracing crateと連携)コード修正が必須最小tracing-opentelemetryクレートを使用
C++低い手動計装が必須コード修正が必須最小OTel C++ SDKを直接使用

3.3 自動計装が取りこぼすもの: ビジネスロジックのブラックボックス

自動計装の根本的な限界は、ビジネスロジックの内部をのぞき込めないという点だ。次のようなシナリオを考えてみよう。

自動計装だけで見えるもの vs 実際に知る必要があるもの
============================================

自動計装がキャプチャするSpan:
  [POST /api/orders] ──▶ [SELECT * FROM products] ──▶ [POST /payments]
       200 OK, 1.2s          50ms                       800ms

見える情報: 「注文API1.2秒かかり、DBは50ms、決済は800ms」
分からない情報: 残りの350msはどこで消費されたのか?

実際にその350msの中で起きていること:
  ├── 在庫可用性の検証ロジック (50ms)
  ├── 割引クーポン適用ルールエンジン (120ms)  ← これがボトルネック!
  ├── 配送料の計算ロジック (30ms)
  ├── 不正検知(Fraud Detection)スコアの算出 (80ms)
  └── 注文イベントのシリアライズ (70ms)

自動計装はこの350msを「ビジネスロジック」という
一つの不透明な塊としてしか見せてくれない。

3.4 ハイブリッド戦略: 自動計装と手動計装の組み合わせ

最適な戦略は、自動計装でインフラレイヤーをカバーし、手動計装でビジネスクリティカルなロジックを精密に計装することだ。

Java: 自動計装の設定 + 手動Spanの追加

# 1段階: 自動計装 (JVMエージェント方式)
# OTel Java Agentのダウンロード
curl -L -o opentelemetry-javaagent.jar \
  https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/latest/download/opentelemetry-javaagent.jar

# JVM起動時にエージェントをアタッチ
java -javaagent:opentelemetry-javaagent.jar \
  -Dotel.service.name=order-service \
  -Dotel.exporter.otlp.endpoint=http://otel-collector:4317 \
  -Dotel.exporter.otlp.protocol=grpc \
  -Dotel.resource.attributes=service.namespace=ecommerce,deployment.environment=production \
  -jar order-service.jar
// 2段階: ビジネスクリティカルなロジックに手動Spanを追加
import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.StatusCode;
import io.opentelemetry.api.common.Attributes;

@Service
public class OrderProcessingService {

    // 自動計装エージェントが初期化したグローバルTracerを使用
    private static final Tracer tracer =
        GlobalOpenTelemetry.getTracer("order-processing", "1.0.0");

    public OrderResult processOrder(OrderRequest request) {
        // 自動計装: HTTPの入口Spanはすでに生成されている
        // 手動計装: ビジネスロジックの詳細な段階を明示的に計装

        // 割引適用ロジックの計装
        Span discountSpan = tracer.spanBuilder("apply-discount-rules")
            .setAttribute("business.coupon_code", request.getCouponCode())
            .setAttribute("business.original_amount", request.getTotalAmount())
            .startSpan();

        try (var scope = discountSpan.makeCurrent()) {
            DiscountResult discount = discountEngine.calculate(request);
            discountSpan.setAttribute("business.discount_amount", discount.getAmount());
            discountSpan.setAttribute("business.discount_type", discount.getType());
            discountSpan.setAttribute("business.rules_evaluated", discount.getRulesCount());
        } catch (Exception e) {
            discountSpan.setStatus(StatusCode.ERROR, e.getMessage());
            discountSpan.recordException(e);
            throw e;
        } finally {
            discountSpan.end();
        }

        // 不正検知の計装
        Span fraudSpan = tracer.spanBuilder("fraud-detection")
            .setAttribute("business.user_id", request.getUserId())
            .setAttribute("business.order_amount", request.getTotalAmount())
            .startSpan();

        try (var scope = fraudSpan.makeCurrent()) {
            FraudScore score = fraudDetector.evaluate(request);
            fraudSpan.setAttribute("business.fraud_score", score.getValue());
            fraudSpan.setAttribute("business.fraud_decision", score.getDecision());

            if (score.getValue() > 0.8) {
                fraudSpan.addEvent("high-fraud-risk-detected",
                    Attributes.of(
                        AttributeKey.doubleKey("score"), score.getValue(),
                        AttributeKey.stringKey("reason"), score.getReason()
                    ));
            }
        } finally {
            fraudSpan.end();
        }

        // ...残りのビジネスロジック
    }
}

Python: 自動計装の設定 + 手動Spanの追加

# 1段階: 自動計装パッケージのインストール
pip install opentelemetry-distro opentelemetry-exporter-otlp
opentelemetry-bootstrap -a install  # 検出されたライブラリ用の計装パッケージを自動インストール

# 2段階: 自動計装でアプリケーションを起動
OTEL_SERVICE_NAME=order-service \
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 \
OTEL_EXPORTER_OTLP_PROTOCOL=grpc \
OTEL_RESOURCE_ATTRIBUTES=service.namespace=ecommerce,deployment.environment=production \
opentelemetry-instrument python -m uvicorn main:app --host 0.0.0.0 --port 8000
# 2段階: ビジネスロジックに手動Spanを追加
from opentelemetry import trace
from opentelemetry.trace import StatusCode

tracer = trace.get_tracer("order-processing", "1.0.0")

class OrderProcessor:
    async def process(self, order: OrderRequest) -> OrderResult:
        # 割引ロジックの手動計装
        with tracer.start_as_current_span(
            "apply-discount-rules",
            attributes={
                "business.coupon_code": order.coupon_code,
                "business.original_amount": order.total_amount,
            }
        ) as discount_span:
            try:
                discount = await self.discount_engine.calculate(order)
                discount_span.set_attribute("business.discount_amount", discount.amount)
                discount_span.set_attribute("business.rules_evaluated", discount.rules_count)
            except DiscountError as e:
                discount_span.set_status(StatusCode.ERROR, str(e))
                discount_span.record_exception(e)
                raise

        # 不正検知の手動計装
        with tracer.start_as_current_span("fraud-detection") as fraud_span:
            score = await self.fraud_detector.evaluate(order)
            fraud_span.set_attribute("business.fraud_score", score.value)
            fraud_span.set_attribute("business.fraud_decision", score.decision)

            if score.value > 0.8:
                fraud_span.add_event("high-fraud-risk", attributes={
                    "score": score.value,
                    "reason": score.reason,
                })

        return await self._finalize_order(order, discount)

3.5 ハイブリッド戦略の適用ガイドライン

手動計装を あらゆる場所 に追加すると、かえってノイズになる。どこに手動Spanを追加するかを決める基準が重要だ。

手動計装を追加する基準優先度
コストの大きいビジネスロジック価格計算、割引エンジン、税額計算
外部依存とのやり取り独自HTTPクライアントのラッパー、レガシーAPI呼び出し
条件分岐が多いロジック決済手段ごとの分岐、配送方法の決定
バッチ/バルク処理大量データの加工、ETLパイプラインの各段階
単純なCRUD操作基本的なDBの読み書き低 (自動計装で十分)
ユーティリティ関数文字列変換、日付フォーマット不要

4. [Takeaway 3] テールベースサンプリング: データの洪水から本当の問題だけを選び出す知的フィルタ

4.1 なぜサンプリングが必要なのか?

プロダクションのマイクロサービス環境で、すべてのリクエストのすべてのSpanを100%収集すると、どうなるだろうか?

テレメトリー量の試算例
============================================

サービス数: 30サービスあたり平均Span数: 5/リクエスト
秒間リクエスト数(RPS): 10,000
Spanあたり平均サイズ: 1 KB

秒間Span数:    30 x 5 x 10,000 = 1,500,000 spans/sec
秒間データ量:  1,500,000 x 1 KB = 1.5 GB/sec
日次データ量:  1.5 GB x 86,400 = ~130 TB/day

年間ストレージ費用 (S3の場合): ~$35,000/month = ~$420,000/year
年間Datadog費用 (ingestion基準): 数十倍以上

100%の収集は非現実的だ。サンプリングは必須である。問題はどのようにサンプリングするかだ。

4.2 Head-based vs Tail-basedサンプリングの比較

特性Head-based SamplingTail-based Sampling
決定タイミングTrace開始時 (最初のSpan生成時)Trace完了後 (すべてのSpan収集後)
決定基準確率的 (例: 10%をランダムに)内容ベース (エラー、レイテンシ、属性値など)
メモリ要求量ほぼなし高い (Trace完了を待つバッファが必要)
実装場所SDK (アプリケーション内部)Collector (外部パイプライン)
エラーTraceの保証不可 (エラー発生前にドロップされうる)可能 (エラーTraceを100%維持できる)
ネットワークコスト低い (ドロップしたSpanは送信しない)高い (すべてのSpanをCollectorまで送信)
Collector依存性なし高い (専用Collectorインフラが必要)
複雑度低い高い

核心的なTrade-off: Head-basedは軽いが盲目的であり、Tail-basedは知的だが重い。

4.3 Tail-basedサンプリングの中核となる前提条件: Trace-IDベースのロードバランシング

Tail-basedサンプリングが正しく動作するには、一つのTraceを構成するすべてのSpanが同一のCollectorインスタンスに到達する必要がある。そうして初めて、CollectorがTrace全体を見てサンプリングの判断を下せる。

そのために 2-tier Collectorアーキテクチャ が必要になる。

Tail-based Samplingのための2-Tier Collectorアーキテクチャ
============================================

  [Service A]   [Service B]   [Service C]   [Service D]
       │              │              │              │
OTLPOTLPOTLPOTLP
       ▼              ▼              ▼              ▼
  ┌──────────────────────────────────────────────────────┐
Tier 1: Agent Collectors              (DaemonSet, 各ノードに1)  │                                                      │
  │  ┌──────────┐  ┌──────────┐  ┌──────────┐           │
  │  │ Agent 1  │  │ Agent 2  │  │ Agent 3  │           │
 (Node 1) (Node 2) (Node 3) │           │
  │  └────┬─────┘  └────┬─────┘  └────┬─────┘           │
  │       │              │              │                 │
  │       │   Load Balancing Exporter   │                 │
   (Trace IDハッシュ基準)       │                 │
  └───────┼──────────────┼──────────────┼─────────────────┘
          │              │              │
          ▼              ▼              ▼
  ┌──────────────────────────────────────────────────────┐
Tier 2: Gateway Collectors           (Deployment/StatefulSet, 水平拡張)  │                                                      │
  │  ┌──────────────┐  ┌──────────────┐                  │
  │  │  Gateway 1   │  │  Gateway 2   │                  │
  │  │              │  │              │                  │
  │  │ Trace ID     │  │ Trace ID     │                  │
  │  │ a1xx → ここ  │  │ b2xx → ここ  │                  │
  │  │              │  │              │                  │
  │  │ tail_sampling │  │ tail_sampling │                  │
  │  │ processor    │  │ processor    │                  │
  │  └──────┬───────┘  └──────┬───────┘                  │
  └─────────┼──────────────────┼──────────────────────────┘
            │                  │
            ▼                  ▼
      ┌───────────┐     ┌───────────┐
Backend  │     │  Backend       (Tempo) (Jaeger)      └───────────┘     └───────────┘

4.4 OTel Collectorの設定: Tier 1 (Agent)

# otel-collector-agent.yaml
# Tier 1: 各ノードにDaemonSetとしてデプロイされるAgent Collector

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  # メモリ保護のためのリミッター (必須!)
  memory_limiter:
    check_interval: 1s
    limit_mib: 512
    spike_limit_mib: 128

  # 基本属性の追加 (ノード情報など)
  resource:
    attributes:
      - key: k8s.node.name
        value: '${K8S_NODE_NAME}'
        action: upsert
      - key: deployment.environment
        value: 'production'
        action: upsert

  # バッチ処理 (ネットワーク効率)
  batch:
    send_batch_size: 1024
    send_batch_max_size: 2048
    timeout: 5s

exporters:
  # Trace IDベースのロードバランシング → Tier 2 Gatewayへ送信
  loadbalancing:
    protocol:
      otlp:
        tls:
          insecure: true
    resolver:
      dns:
        hostname: otel-gateway-headless.observability.svc.cluster.local
        port: 4317

  # MetricsとLogsは直接バックエンドへ送信 (サンプリング不要)
  otlp/metrics:
    endpoint: mimir.observability.svc.cluster.local:4317
    tls:
      insecure: true

  otlp/logs:
    endpoint: loki.observability.svc.cluster.local:4317
    tls:
      insecure: true

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, resource, batch]
      exporters: [loadbalancing] # TraceはGatewayへルーティング

    metrics:
      receivers: [otlp]
      processors: [memory_limiter, resource, batch]
      exporters: [otlp/metrics] # Metricsは直接送信

    logs:
      receivers: [otlp]
      processors: [memory_limiter, resource, batch]
      exporters: [otlp/logs] # Logsは直接送信

4.5 OTel Collectorの設定: Tier 2 (Gateway with Tail Sampling)

# otel-collector-gateway.yaml
# Tier 2: Tail-based Samplingを実行するGateway Collector

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317

processors:
  memory_limiter:
    check_interval: 1s
    limit_mib: 4096 # Gatewayはより多くのメモリが必要
    spike_limit_mib: 1024

  # ⚠️ 重要: tail_samplingの前にbatchを使ってはならない!
  # batchが同じTraceのSpanを分離してしまうことがある。

  # Tail-basedサンプリングのポリシー
  tail_sampling:
    # Trace完了を待つ時間
    # システムの最大想定Trace継続時間 + ネットワークマージン
    decision_wait: 30s
    # 決定後に追加Spanを待つ猶予時間
    num_traces: 100000 # 同時に追跡する最大Trace数
    expected_new_traces_per_sec: 1000

    policies:
      # ポリシー1: エラーが発生したTraceは100%維持 (最優先)
      - name: errors-always-keep
        type: status_code
        status_code:
          status_codes:
            - ERROR

      # ポリシー2: 高レイテンシのTraceを維持 (p99以上)
      - name: high-latency
        type: latency
        latency:
          threshold_ms: 5000 # 5秒以上かかったTrace

      # ポリシー3: 特定サービスのTraceは常に維持 (クリティカルサービス)
      - name: critical-services
        type: string_attribute
        string_attribute:
          key: service.name
          values:
            - payment-service
            - order-service
          enabled_regex_matching: false

      # ポリシー4: プレミアムユーザーのTraceをより高い比率で維持
      - name: premium-users
        type: and
        and:
          and_sub_policy:
            - name: is-premium
              type: string_attribute
              string_attribute:
                key: business.user_tier
                values: ['premium', 'enterprise']
            - name: premium-rate
              type: probabilistic
              probabilistic:
                sampling_percentage: 50 # 50%を維持

      # ポリシー5: 残りの正常Traceは5%だけ維持 (コスト最適化)
      - name: baseline-probabilistic
        type: probabilistic
        probabilistic:
          sampling_percentage: 5

  # tail_samplingの後にbatchを適用
  batch:
    send_batch_size: 2048
    timeout: 10s

exporters:
  otlp/tempo:
    endpoint: tempo.observability.svc.cluster.local:4317
    tls:
      insecure: true

  otlp/jaeger:
    endpoint: jaeger-collector.observability.svc.cluster.local:4317
    tls:
      insecure: true

service:
  telemetry:
    metrics:
      address: 0.0.0.0:8888 # Collector自体のメトリクス監視
    logs:
      level: info

  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, tail_sampling, batch]
      exporters: [otlp/tempo]

4.6 Tail Samplingのメモリサイジング計算式

Tail samplingは決定を下すまですべてのSpanをメモリに保持しなければならないため、適切なメモリ見積もりが重要だ。

メモリ要求量の計算式
============================================

Required Memory (GB) =
  traces_per_second
  x decision_wait_seconds
  x avg_spans_per_trace
  x bytes_per_span
  / 1,000,000,000
  x safety_factor

計算例:
  traces_per_second     = 1,000
  decision_wait_seconds = 30
  avg_spans_per_trace   = 15
  bytes_per_span        = 1,000 (1 KB)
  safety_factor         = 2.0

  = 1,000 x 30 x 15 x 1,000 / 1,000,000,000 x 2.0
  = 450,000,000 / 1,000,000,000 x 2.0
  = 0.45 x 2.0
  = 0.9 GB

→ 最小1 GB、推奨2 GBのメモリを割り当て

4.7 サンプリング戦略の選択ガイド

シナリオ推奨戦略根拠
導入初期、トラフィックが低い (< 1K RPS)Head-based 100% (サンプリングなし)量が小さくコスト負担がない
成長期、中程度のトラフィック (1K~10K RPS)Head-based 10~50%単純で効果的
大規模トラフィック、エラー追跡が重要Tail-based (エラー100% + 正常5%)コスト削減とエラー可視性を同時に達成
複数チーム、サービスごとに別ポリシーが必要Tail-basedの複合ポリシーサービス別、ユーザー等級別の差をつけたサンプリング
規制要件 (すべての取引の記録が必須)100%収集 + 別途アーカイブパイプライン規制遵守のための全数記録

5. [Takeaway 4] Semantic Conventions: データの標準化とコラボレーションの価値

5.1 標準のない世界の混乱

20チームがそれぞれのマイクロサービスを運用し、各チームが自由にテレメトリー属性名を決めたら、どうなるだろうか?

標準なしに各チームが自由に命名した属性
============================================

チームA (注文サービス): user_id="12345", status_code=200, method="POST"
チームB (決済サービス): userId="12345",  httpStatus=200,  httpMethod="POST"
チームC (在庫サービス): uid="12345",     response_code=200, req_method="POST"
チームD (通知サービス): customer_id="12345", http_code=200, verb="POST"
チームE (検索サービス): user="12345",    code=200,        http.method="POST"

同じユーザーの同じリクエストなのに、5通りの異なる属性名が使われている。
サービス間の相関分析(Correlation)が不可能!
→ 統合ダッシュボードの構築が不可能!
→ 自動アラートルールの作成が不可能!

この問題を解決するのが OpenTelemetry Semantic Conventions である。

5.2 Semantic Conventionsの構造

OpenTelemetry Semantic Conventions(現在v1.40.0)は、テレメトリーデータの 標準的な属性名と意味 を定義する。主要な領域ごとの標準属性を見ていこう。

リソース属性 (Resource Attributes)

サービス自体を識別するメタデータだ。

属性名説明
service.namestringサービスの論理的な名前 (必須)order-service
service.versionstringサービスのバージョン2.1.0
service.namespacestringサービスグループ/ネームスペースecommerce
deployment.environment.namestringデプロイ環境production
host.idstringホストの一意な識別子i-0a1b2c3d4e5f6
host.namestringホスト名ip-10-0-1-42
k8s.pod.namestringKubernetes Pod名order-service-7d4f5b-x9z2k
k8s.namespace.namestringKubernetesネームスペースproduction
k8s.deployment.namestringKubernetes Deployment名order-service

HTTP属性 (Span Attributes)

HTTPリクエスト/レスポンスに対する標準属性だ。

属性名説明
http.request.methodstringHTTPメソッドPOST
url.fullstringURL全体https://api.example.com/orders
url.pathstringURLパス/api/orders
http.response.status_codeintHTTPレスポンスコード201
server.addressstringサーバーアドレスapi.example.com
server.portintサーバーポート443
network.protocol.versionstringプロトコルバージョン2.0
user_agent.originalstringUser-Agentヘッダー原文Mozilla/5.0...

データベース属性

属性名説明
db.systemstringデータベースシステムpostgresql
db.namespacestringデータベース名orders_db
db.operation.namestringDB操作名SELECT
db.query.textstringクエリテキスト (sanitized)SELECT * FROM orders WHERE id = ?
db.collection.namestringテーブル/コレクション名orders

5.3 標準化の実戦的な効果: Before vs After

Before (標準なし):
  チームごとに異なる属性名 → 統合クエリが不可能
  ─────────────────────────────────────
  Grafanaで「すべてのサービスの5xxエラー率」ダッシュボードを作るには?

  Panel 1 (注文): rate({status_code=~"5.."})
  Panel 2 (決済): rate({httpStatus=~"5.."})
  Panel 3 (在庫): rate({response_code=~"5.."})
  → サービスごとに別のクエリが必要。新サービス追加時はダッシュボードの修正が必須。


After (Semantic Conventions適用):
  すべてのチームが同じ属性名を使用 → 単一クエリで全体を照会
  ─────────────────────────────────────
  Grafanaで「すべてのサービスの5xxエラー率」ダッシュボード:

  単一クエリ: rate({http.response.status_code=~"5.."}) by (service.name)
  → すべてのサービスが自動的に含まれる。新サービス追加時も変更なし。

5.4 Cross-Signal Correlation: Logs、Metrics、Tracesの統合

Semantic Conventionsのもう一つの強力な利点が シグナル間の相関分析(Cross-Signal Correlation)だ。同じ属性名を使えば、Traceで見つけた異常をMetricで確認し、関連するLogをすぐに照会できる。

Cross-Signal Correlationのワークフロー
============================================

1. Alertの発生:
   metric: http_server_request_duration_seconds{service.name="order-service"} > 5s

2. Traceで原因を追跡:
   trace: service.name="order-service"
          AND http.response.status_code >= 500
Spanでdb.operation.name="SELECT",
            db.collection.name="orders" を確認

3. 関連するLogを照会:
   log: service.name="order-service"
        AND trace_id="abc123"
"Connection pool exhausted" のエラーログを発見

すべてのシグナルでservice.name、trace_idなど同じ属性名を使うため
このワークフローが自然につながる。

5.5 Semantic Conventions適用のためのCollector設定

チームごとにすでに異なる属性名を使っているなら、Collectorの attributes プロセッサで 中央で正規化 できる。

# Collectorで属性名をSemantic Conventionsに合わせて正規化
processors:
  # レガシーな属性名を標準の属性名へ変換
  attributes/normalize:
    actions:
      # HTTP属性の正規化
      - key: http.method
        action: upsert
        from_attribute: httpMethod # チームBの属性名
      - key: http.method
        action: upsert
        from_attribute: req_method # チームCの属性名
      - key: http.method
        action: upsert
        from_attribute: verb # チームDの属性名
      # レガシーキーの削除
      - key: httpMethod
        action: delete
      - key: req_method
        action: delete
      - key: verb
        action: delete

      # ユーザーIDの正規化
      - key: enduser.id
        action: upsert
        from_attribute: user_id
      - key: enduser.id
        action: upsert
        from_attribute: userId
      - key: enduser.id
        action: upsert
        from_attribute: uid
      - key: enduser.id
        action: upsert
        from_attribute: customer_id
      # 레거시 키 삭제
      - key: user_id
        action: delete
      - key: userId
        action: delete
      - key: uid
        action: delete
      - key: customer_id
        action: delete

      # HTTP 상태 코드 정규화
      - key: http.response.status_code
        action: upsert
        from_attribute: status_code
      - key: http.response.status_code
        action: upsert
        from_attribute: httpStatus
      - key: http.response.status_code
        action: upsert
        from_attribute: response_code
      - key: http.response.status_code
        action: upsert
        from_attribute: http_code

  # 필수 리소스 속성이 누락된 경우 기본값 추가
  resource:
    attributes:
      - key: service.namespace
        value: 'default'
        action: insert # 이미 존재하면 덮어쓰지 않음
      - key: deployment.environment.name
        value: 'production'
        action: insert

5.6 커스텀 비즈니스 속성 네이밍 가이드

Semantic Conventions에 정의되지 않은 비즈니스 속성을 추가할 때는 일관된 네이밍 규칙을 따라야 한다.

규칙좋은 예나쁜 예
네임스페이스를 접두사로 사용business.order_idorderId
snake_case 사용business.payment_methodbusiness.paymentMethod
단위를 속성명에 포함business.order_total_usdbusiness.order_total
불리언은 is_ 접두사business.is_first_orderbusiness.first_order
열거형은 소문자business.user_tier="premium"business.user_tier="PREMIUM"

6. [Takeaway 5] OTLP 전송 방식의 선택: gRPC vs HTTP

6.1 OTLP(OpenTelemetry Protocol)란?

OTLP는 OpenTelemetry가 정의한 텔레메트리 데이터 전송을 위한 표준 프로토콜이다. 현재 OTLP 스펙 1.9.0 기준으로, Traces, Metrics, Logs(그리고 최근 추가된 Profiles)를 단일 프로토콜로 전송할 수 있다. OTLP는 세 가지 전송 방식을 지원한다.

6.2 성능 비교

항목OTLP/gRPCOTLP/HTTP (Protobuf)OTLP/HTTP (JSON)
기본 포트431743184318
직렬화 형식Protobuf (바이너리)Protobuf (바이너리)JSON (텍스트)
전송 프로토콜HTTP/2 (양방향 스트리밍)HTTP/1.1 또는 HTTP/2HTTP/1.1 또는 HTTP/2
측정 처리량~10,000-50,000 spans/sec~5,000-30,000 spans/sec~3,000-15,000 spans/sec
상대 CPU 사용1.0x (기준)1.2x2.5x
페이로드 크기가장 작음 (1.0x)작음 (1.0x, 동일 Protobuf)큼 (3~5x)
연결 관리Connection MultiplexingConnection per requestConnection per request
헤더 압축HPACK (자동)없음없음
압축 지원gzip, zstdgzip, zstdgzip, zstd

6.3 gRPC의 장점과 적합한 시나리오

gRPC가 유리한 경우:

# gRPC Exporter 설정 예시 (SDK)
# 환경변수 방식
OTEL_EXPORTER_OTLP_PROTOCOL=grpc
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
OTEL_EXPORTER_OTLP_COMPRESSION=gzip
OTEL_EXPORTER_OTLP_TIMEOUT=10000
# Collector Receiver 설정 (gRPC)
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
        max_recv_msg_size_mib: 16 # 최대 수신 메시지 크기
        max_concurrent_streams: 100 # 동시 스트림 수
        keepalive:
          server_parameters:
            max_connection_idle: 60s
            max_connection_age: 300s
            time: 30s
            timeout: 10s
        tls:
          cert_file: /certs/server.crt
          key_file: /certs/server.key
          client_ca_file: /certs/ca.crt # mTLS

6.4 HTTP의 장점과 적합한 시나리오

HTTP가 유리한 경우:

# HTTP Exporter 설정 예시 (SDK)
# 환경변수 방식 (Protobuf)
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
OTEL_EXPORTER_OTLP_COMPRESSION=gzip
OTEL_EXPORTER_OTLP_TIMEOUT=10000

# HTTP JSON (디버깅 용도)
OTEL_EXPORTER_OTLP_PROTOCOL=http/json
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
# Collector Receiver 설정 (HTTP)
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318
        cors:
          allowed_origins:
            - 'https://*.example.com' # 브라우저 CORS 허용
          allowed_headers:
            - 'Content-Type'
            - 'X-Custom-Header'
          max_age: 7200
        tls:
          cert_file: /certs/server.crt
          key_file: /certs/server.key

6.5 전송 방식 선택 의사결정 트리

OTLP 전송 방식 선택 의사결정 트리
============================================

시작: 텔레메트리 전송 방식을 선택해야 한다

Q1. 브라우저 또는 서버리스 환경인가?
  ├── YESHTTP/Protobuf 사용
           (gRPC는 브라우저/Lambda에서 지원 불가)
  └── NO ──▶ Q2. 방화벽이 HTTP/2 또는 gRPC를 차단하는가?
                ├── YESHTTP/Protobuf 사용
                         (HTTP/1.1로 폴백 가능)
                └── NO ──▶ Q3. 초당 10,000 spans 이상인가?
                              ├── YES → gRPC 사용
                                       (HTTP/2 멀티플렉싱, 최고 성능)
                              └── NO ──▶ Q4. 디버깅이 주 목적인가?
                                            ├── YESHTTP/JSON 사용
                                                     (사람이 읽을 수 있음)
                                            └── NOHTTP/Protobuf 사용
                                                      (가장 범용적, 무난한 선택)

6.6 하이브리드 구성: gRPC + HTTP 동시 수신

실무에서는 Collector가 gRPC와 HTTP를 동시에 수신하도록 구성하여, 다양한 클라이언트를 모두 수용하는 전략이 일반적이다.

# 하이브리드 Receiver 설정 (gRPC + HTTP 동시 수신)
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
        max_recv_msg_size_mib: 16
        keepalive:
          server_parameters:
            max_connection_idle: 60s
            time: 30s
            timeout: 10s
      http:
        endpoint: 0.0.0.0:4318
        cors:
          allowed_origins: ['*']
          allowed_headers: ['*']
# 서비스 유형별 권장 구성
# ┌─────────────────────────┬──────────────────────────┐
# │ 서비스 유형              │ 권장 전송 방식             │
# ├─────────────────────────┼──────────────────────────┤
# │ 백엔드 (Java, Go)       │ gRPC (포트 4317)          │
# │ 프론트엔드 (Browser JS) │ HTTP/Protobuf (포트 4318) │
# │ 서버리스 (Lambda)       │ HTTP/Protobuf (포트 4318) │
# │ 디버깅/테스트           │ HTTP/JSON (포트 4318)     │
# │ IoT/Edge                │ HTTP/Protobuf (포트 4318) │
# └─────────────────────────┴──────────────────────────┘

7. OTel Collector 배포 토폴로지: Agent vs Gateway

7.1 배포 패턴 개요

OpenTelemetry Collector는 세 가지 핵심 배포 패턴을 지원하며, 각각의 장단점과 적합한 시나리오가 다르다.

OTel Collector 배포 패턴 비교
============================================

Pattern 1: Agent (DaemonSet)
  ┌─────────────────────────────────┐
Kubernetes Node  │  ┌─────────┐  ┌─────────┐      │
  │  │Service A│  │Service B│      │
  │  └────┬────┘  └────┬────┘      │
  │       │ localhost   │          │
  │       ▼             ▼          │
  │  ┌──────────────────────┐      │
  │  │   OTel Collector     │      │
   (DaemonSet Pod)    │      │
  │  └──────────┬───────────┘      │
  └─────────────┼──────────────────┘
          ┌──────────┐
Backend          └──────────┘

Pattern 2: Sidecar
  ┌──────────────────────────────┐
Application Pod  │  ┌──────────┐ ┌───────────┐  │
  │  │App       │ │OTel       │  │
  │  │Container │→│Collector  │  │
  │  │          │ (Sidecar)  │  │
  │  └──────────┘ └─────┬─────┘  │
  └─────────────────────┼────────┘
                  ┌──────────┐
Backend                  └──────────┘

Pattern 3: Gateway (Deployment)
  [Service A]  [Service B]  [Service C]
       │            │            │
       └────────────┼────────────┘
          ┌──────────────────┐
OTel Collector            (Deployment,          │   replicas: 3+)+ HPA          └────────┬─────────┘
             ┌──────────┐
Backend             └──────────┘

7.2 배포 패턴별 상세 비교

특성Agent (DaemonSet)SidecarGateway (Deployment)
배포 단위노드당 1개Pod당 1개클러스터당 N개 (수평 확장)
리소스 격리노드의 모든 Pod 공유Pod 전용 리소스중앙 집중
장애 영향 범위해당 노드의 모든 서비스해당 Pod만모든 서비스 (단일 장애점)
설정 관리노드 공통 설정Pod별 맞춤 설정 가능중앙 집중 설정
네트워크 지연최소 (localhost)최소 (localhost)네트워크 홉 존재
리소스 효율높음낮음 (Pod마다 중복)매우 높음
적합한 시나리오범용, 가장 일반적멀티테넌트, 보안 격리 필요중앙 처리, 샘플링, 라우팅

7.3 Kubernetes DaemonSet 배포 예시

# otel-collector-daemonset.yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: otel-collector-agent
  namespace: observability
  labels:
    app: otel-collector
    component: agent
spec:
  selector:
    matchLabels:
      app: otel-collector
      component: agent
  template:
    metadata:
      labels:
        app: otel-collector
        component: agent
    spec:
      serviceAccountName: otel-collector
      containers:
        - name: otel-collector
          image: otel/opentelemetry-collector-contrib:0.120.0
          args:
            - '--config=/conf/otel-collector-config.yaml'
          ports:
            - containerPort: 4317 # gRPC
              hostPort: 4317
              protocol: TCP
            - containerPort: 4318 # HTTP
              hostPort: 4318
              protocol: TCP
            - containerPort: 8888 # Prometheus metrics
              protocol: TCP
          env:
            - name: K8S_NODE_NAME
              valueFrom:
                fieldRef:
                  fieldPath: spec.nodeName
            - name: K8S_POD_IP
              valueFrom:
                fieldRef:
                  fieldPath: status.podIP
            - name: GOMEMLIMIT
              value: '460MiB' # Go runtime 메모리 제한
          resources:
            requests:
              cpu: 200m
              memory: 256Mi
            limits:
              cpu: 1000m
              memory: 512Mi
          volumeMounts:
            - name: config
              mountPath: /conf
          livenessProbe:
            httpGet:
              path: /
              port: 13133 # health_check extension
            initialDelaySeconds: 15
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /
              port: 13133
            initialDelaySeconds: 5
            periodSeconds: 5
      volumes:
        - name: config
          configMap:
            name: otel-agent-config

---
apiVersion: v1
kind: ConfigMap
metadata:
  name: otel-agent-config
  namespace: observability
data:
  otel-collector-config.yaml: |
    extensions:
      health_check:
        endpoint: 0.0.0.0:13133

    receivers:
      otlp:
        protocols:
          grpc:
            endpoint: 0.0.0.0:4317
          http:
            endpoint: 0.0.0.0:4318

      # 노드 레벨 메트릭 수집 (호스트 메트릭)
      hostmetrics:
        collection_interval: 30s
        scrapers:
          cpu: {}
          memory: {}
          disk: {}
          network: {}

      # Kubelet 메트릭 수집
      kubeletstats:
        collection_interval: 30s
        auth_type: "serviceAccount"
        endpoint: "https://${K8S_NODE_NAME}:10250"
        insecure_skip_verify: true

    processors:
      memory_limiter:
        check_interval: 1s
        limit_mib: 400
        spike_limit_mib: 100

      batch:
        send_batch_size: 1024
        timeout: 5s

      resource:
        attributes:
          - key: k8s.node.name
            value: "${K8S_NODE_NAME}"
            action: upsert

      # Kubernetes 메타데이터 자동 추가
      k8sattributes:
        auth_type: "serviceAccount"
        extract:
          metadata:
            - k8s.pod.name
            - k8s.pod.uid
            - k8s.namespace.name
            - k8s.deployment.name
            - k8s.node.name
          labels:
            - tag_name: app.label.team
              key: team
              from: pod
          annotations:
            - tag_name: app.annotation.version
              key: app-version
              from: pod

    exporters:
      # Traces → Gateway (Tail Sampling을 위해)
      loadbalancing:
        protocol:
          otlp:
            tls:
              insecure: true
        resolver:
          dns:
            hostname: otel-gateway-headless.observability.svc.cluster.local
            port: 4317

      # Metrics → Prometheus/Mimir 직접 전송
      prometheusremotewrite:
        endpoint: http://mimir.observability.svc.cluster.local:9009/api/v1/push

      # Logs → Loki 직접 전송
      otlp/logs:
        endpoint: loki.observability.svc.cluster.local:4317
        tls:
          insecure: true

    service:
      extensions: [health_check]
      pipelines:
        traces:
          receivers: [otlp]
          processors: [memory_limiter, k8sattributes, resource, batch]
          exporters: [loadbalancing]
        metrics:
          receivers: [otlp, hostmetrics, kubeletstats]
          processors: [memory_limiter, k8sattributes, resource, batch]
          exporters: [prometheusremotewrite]
        logs:
          receivers: [otlp]
          processors: [memory_limiter, k8sattributes, resource, batch]
          exporters: [otlp/logs]

7.4 Gateway Deployment 예시

# otel-collector-gateway.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: otel-collector-gateway
  namespace: observability
spec:
  replicas: 3
  selector:
    matchLabels:
      app: otel-collector
      component: gateway
  template:
    metadata:
      labels:
        app: otel-collector
        component: gateway
    spec:
      containers:
        - name: otel-collector
          image: otel/opentelemetry-collector-contrib:0.120.0
          args:
            - '--config=/conf/otel-collector-config.yaml'
          ports:
            - containerPort: 4317
              protocol: TCP
          env:
            - name: GOMEMLIMIT
              value: '3600MiB'
          resources:
            requests:
              cpu: 1000m
              memory: 2Gi
            limits:
              cpu: 4000m
              memory: 4Gi
          volumeMounts:
            - name: config
              mountPath: /conf
      volumes:
        - name: config
          configMap:
            name: otel-gateway-config

---
# Headless Service (Load Balancing Exporter의 DNS 리졸버용)
apiVersion: v1
kind: Service
metadata:
  name: otel-gateway-headless
  namespace: observability
spec:
  clusterIP: None
  selector:
    app: otel-collector
    component: gateway
  ports:
    - port: 4317
      targetPort: 4317
      protocol: TCP

---
# HPA (수평 자동 확장)
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: otel-gateway-hpa
  namespace: observability
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: otel-collector-gateway
  minReplicas: 3
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 75

7.5 OpenTelemetry Operator를 활용한 자동화

Kubernetes 환경에서는 OpenTelemetry Operator를 사용하면 Collector의 배포와 자동 계측 주입을 선언적으로 관리할 수 있다.

# OpenTelemetryCollector CRD를 통한 선언적 배포
apiVersion: opentelemetry.io/v1beta1
kind: OpenTelemetryCollector
metadata:
  name: otel-agent
  namespace: observability
spec:
  mode: daemonset # daemonset | deployment | sidecar | statefulset
  image: otel/opentelemetry-collector-contrib:0.120.0
  resources:
    requests:
      cpu: 200m
      memory: 256Mi
    limits:
      cpu: 1000m
      memory: 512Mi
  config:
    receivers:
      otlp:
        protocols:
          grpc:
            endpoint: 0.0.0.0:4317
          http:
            endpoint: 0.0.0.0:4318
    processors:
      memory_limiter:
        check_interval: 1s
        limit_mib: 400
      batch:
        send_batch_size: 1024
        timeout: 5s
    exporters:
      otlp:
        endpoint: otel-gateway.observability.svc.cluster.local:4317
        tls:
          insecure: true
    service:
      pipelines:
        traces:
          receivers: [otlp]
          processors: [memory_limiter, batch]
          exporters: [otlp]

---
# 자동 계측 주입 (Java 애플리케이션에 OTel Agent 자동 설치)
apiVersion: opentelemetry.io/v1alpha1
kind: Instrumentation
metadata:
  name: java-instrumentation
  namespace: production
spec:
  exporter:
    endpoint: http://otel-agent.observability.svc.cluster.local:4317
  propagators:
    - tracecontext
    - baggage
  sampler:
    type: parentbased_traceidratio
    argument: '0.25' # Head-based 25% (Tail sampling이 추가 필터링)
  java:
    image: ghcr.io/open-telemetry/opentelemetry-operator/autoinstrumentation-java:latest
    env:
      - name: OTEL_JAVAAGENT_DEBUG
        value: 'false'

---
# 이 annotation을 Pod/Deployment에 추가하면 자동 계측 활성화
# metadata:
#   annotations:
#     instrumentation.opentelemetry.io/inject-java: "java-instrumentation"

8. 옵저버빌리티 백엔드 비교

올바른 백엔드 선택은 옵저버빌리티 전략의 성패를 좌우한다. OpenTelemetry의 벤더 중립성 덕분에 백엔드를 자유롭게 선택하고 교체할 수 있다.

8.1 오픈소스 vs 상용 백엔드 비교

솔루션유형TracesMetricsLogsOTLP 네이티브비용 모델특징
JaegerOSSOXXO무료CNCF 졸업 프로젝트, 경량 추적 전용
Grafana TempoOSSOXXO무료오브젝트 스토리지 기반, 인덱스 불필요
Grafana MimirOSSXOXO무료Prometheus 호환 장기 저장소
Grafana LokiOSSXXOO무료레이블 기반 로그 집계
SigNozOSSOOOO무료올인원 OSS 옵저버빌리티 (ClickHouse 기반)
Grafana CloudSaaSOOOO사용량 기반 (무료 티어 있음)Tempo+Mimir+Loki 통합
DatadogSaaSOOOO호스트 + 사용량 기반가장 풍부한 기능, 높은 가격
New RelicSaaSOOOO사용량 기반 (무료 100GB/월)넉넉한 무료 티어
Elastic APMOSS/SaaSOOOO노드 + 사용량 기반Elasticsearch 기반, 강력한 검색
HoneycombSaaSOXXO이벤트 기반고카디널리티 분석 특화
AWS X-RaySaaSOXXO (ADOT 경유)사용량 기반AWS 네이티브 통합
DynatraceSaaSOOOO호스트 기반AI 기반 자동 근본 원인 분석

8.2 백엔드 선택 기준

요구사항권장 솔루션근거
비용 최소화, 자체 운영 가능SigNoz 또는 Grafana Stack (Tempo+Mimir+Loki)OSS 무료, 커뮤니티 지원
운영 부담 최소화Grafana Cloud 또는 New Relic관리형 SaaS, 무료 티어 존재
대기업, 풍부한 기능 필요Datadog 또는 Dynatrace가장 성숙한 기능셋, 엔터프라이즈 지원
AWS 올인AWS X-Ray + CloudWatch네이티브 AWS 통합, IAM 연동
고카디널리티 분석HoneycombBubbleUp 등 고유 분석 기능
Elasticsearch 이미 운영 중Elastic APM기존 인프라 활용

9. 프로덕션 도입 로드맵

9.1 단계별 도입 체크리스트

OpenTelemetry 도입은 한 번에 모든 것을 적용하는 것이 아니라, 단계적이고 점진적으로 진행해야 한다.

Phase 1: 기반 구축 (2~4주)

체크리스트
============================================
[ ] OTel Collector를 Kubernetes DaemonSet으로 배포
[ ] OTLP Receiver (gRPC + HTTP) 활성화
[ ] 백엔드 선택 및 Exporter 설정 (Tempo, Jaeger)
[ ] memory_limiter 프로세서 설정
[ ] health_check extension 설정
[ ] Collector 자체 메트릭 모니터링 (Prometheus scrape)
[ ] 파일럿 서비스 1~2개에 자동 계측 적용
[ ] 기본 대시보드 구축 (RED metrics: Rate, Errors, Duration)

Phase 2: 확산 및 표준화 (4~8주)

체크리스트
============================================
[ ] Semantic Conventions 가이드 작성 및 팀 교육
[ ] 레거시 속성명 정규화를 위한 attributes 프로세서 설정
[ ] 자동 계측을 전체 서비스로 확산
[ ] k8sattributes 프로세서로 Kubernetes 메타데이터 자동 부착
[ ] resource 프로세서로 필수 리소스 속성 보장
[ ] service.name, deployment.environment 등 필수 속성 검증
[ ] 알림 규칙 설정 (에러율, 지연 시간 임계치)

Phase 3: 고도화 (8~12주)

체크리스트
============================================
[ ] 비즈니스 크리티컬 로직에 수동 계측 추가 (하이브리드 전략)
[ ] W3C Baggage를 통한 비즈니스 컨텍스트 전파 도입
[ ] Tail-based sampling을 위한 2-tier Collector 아키텍처 구축
[ ] Load Balancing Exporter 설정 (Trace ID 기반)
[ ] tail_sampling 프로세서 정책 설계 및 튜닝
[ ] Cross-signal correlation 대시보드 구축
[ ] SLO(Service Level Objective) 기반 모니터링

Phase 4: 최적화 및 운영 성숙 (지속적)

체크리스트
============================================
[ ] 샘플링 정책 지속적 튜닝 (비용 vs 가시성 최적화)
[ ] Collector 리소스 사용량 모니터링 및 자동 확장(HPA) 적용
[ ] OTel Operator 도입 (자동 계측 주입 자동화)
[ ] 멀티 클러스터 / 멀티 리전 옵저버빌리티 통합
[ ] Baggage 보안 정책 (Trust Boundary 필터링)
[ ] 팀별 셀프 서비스 대시보드 가이드라인
[ ] 정기적인 옵저버빌리티 성숙도 평가

9.2 완전한 파이프라인 구성 예시

프로덕션 환경에서의 완전한 OTel Collector 파이프라인 구성 예시를 제시한다. 이 설정은 Receiver, Processor, Exporter의 전체 흐름을 보여준다.

# production-otel-collector.yaml
# 프로덕션 레벨의 완전한 Collector 파이프라인

extensions:
  health_check:
    endpoint: 0.0.0.0:13133
  pprof:
    endpoint: 0.0.0.0:1777 # Go pprof 프로파일링
  zpages:
    endpoint: 0.0.0.0:55679 # 디버깅용 zPages

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
        max_recv_msg_size_mib: 16
      http:
        endpoint: 0.0.0.0:4318

  # Prometheus 메트릭 스크래핑 (기존 Prometheus 타겟 호환)
  prometheus:
    config:
      scrape_configs:
        - job_name: 'kubernetes-pods'
          kubernetes_sd_configs:
            - role: pod
          relabel_configs:
            - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
              action: keep
              regex: true

  # 호스트 메트릭
  hostmetrics:
    collection_interval: 30s
    scrapers:
      cpu:
        metrics:
          system.cpu.utilization:
            enabled: true
      memory:
        metrics:
          system.memory.utilization:
            enabled: true
      disk: {}
      network: {}

processors:
  # 1. 메모리 보호 (항상 첫 번째)
  memory_limiter:
    check_interval: 1s
    limit_mib: 1800
    spike_limit_mib: 400

  # 2. Kubernetes 메타데이터 부착
  k8sattributes:
    auth_type: 'serviceAccount'
    passthrough: false
    extract:
      metadata:
        - k8s.pod.name
        - k8s.pod.uid
        - k8s.namespace.name
        - k8s.deployment.name
        - k8s.statefulset.name
        - k8s.daemonset.name
        - k8s.node.name
      labels:
        - tag_name: service.team
          key: team
          from: pod
        - tag_name: service.component
          key: component
          from: pod

  # 3. 리소스 속성 보장
  resource:
    attributes:
      - key: deployment.environment.name
        value: 'production'
        action: insert
      - key: service.namespace
        value: 'default'
        action: insert

  # 4. 속성 정규화 (Semantic Conventions)
  attributes/normalize:
    actions:
      - key: http.request.method
        action: upsert
        from_attribute: http.method
      - key: http.response.status_code
        action: upsert
        from_attribute: http.status_code

  # 5. 민감 정보 제거
  attributes/redact:
    actions:
      - key: db.query.text
        action: hash # 쿼리를 해시로 대체
      - key: http.request.header.authorization
        action: delete
      - key: http.request.header.cookie
        action: delete

  # 6. 불필요한 Span 필터링
  filter/drop-health:
    error_mode: ignore
    traces:
      span:
        - 'attributes["http.target"] == "/health"'
        - 'attributes["http.target"] == "/readyz"'
        - 'attributes["http.target"] == "/livez"'
        - 'attributes["http.route"] == "/metrics"'

  # 7. 배치 처리
  batch:
    send_batch_size: 2048
    send_batch_max_size: 4096
    timeout: 10s

exporters:
  # Traces → Grafana Tempo
  otlp/tempo:
    endpoint: tempo.observability.svc.cluster.local:4317
    tls:
      insecure: true
    retry_on_failure:
      enabled: true
      initial_interval: 5s
      max_interval: 30s
      max_elapsed_time: 300s
    sending_queue:
      enabled: true
      num_consumers: 10
      queue_size: 5000

  # Metrics → Prometheus Remote Write (Mimir)
  prometheusremotewrite:
    endpoint: http://mimir.observability.svc.cluster.local:9009/api/v1/push
    tls:
      insecure: true
    retry_on_failure:
      enabled: true

  # Logs → Grafana Loki
  otlp/loki:
    endpoint: loki.observability.svc.cluster.local:4317
    tls:
      insecure: true

  # 디버깅용 (개발 환경에서만 활성화)
  debug:
    verbosity: basic
    sampling_initial: 5
    sampling_thereafter: 200

service:
  extensions: [health_check, pprof, zpages]

  telemetry:
    metrics:
      address: 0.0.0.0:8888
      level: detailed
    logs:
      level: info
      encoding: json

  pipelines:
    traces:
      receivers: [otlp]
      processors:
        - memory_limiter
        - k8sattributes
        - resource
        - attributes/normalize
        - attributes/redact
        - filter/drop-health
        - batch
      exporters: [otlp/tempo]

    metrics:
      receivers: [otlp, prometheus, hostmetrics]
      processors:
        - memory_limiter
        - k8sattributes
        - resource
        - batch
      exporters: [prometheusremotewrite]

    logs:
      receivers: [otlp]
      processors:
        - memory_limiter
        - k8sattributes
        - resource
        - attributes/redact
        - batch
      exporters: [otlp/loki]

10. 결론: 옵저버빌리티 카르텔로부터의 탈출

10.1 OpenTelemetry가 바꾸는 게임의 규칙

전통적인 APM 시장은 "옵저버빌리티 카르텔"과 같은 구조였다. 한 번 특정 벤더의 에이전트를 설치하면, 데이터 형식, 쿼리 언어, 대시보드, 알림 규칙 모두가 해당 벤더에 종속된다. 벤더 교체 비용이 너무 높아 가격이 올라도 참을 수밖에 없는 구조다.

OpenTelemetry는 이 구조를 근본적으로 변화시킨다.

10.2 전략적 가시성의 실질적 효과

이 글에서 다룬 5가지 전략은 단순한 기술적 호기심이 아니라, 실질적인 비즈니스 가치로 이어진다.

전략비즈니스 가치
W3C Baggage멀티테넌트 격리, 사용자 등급별 차별화된 SLA, 비용 귀속
하이브리드 계측비즈니스 로직 병목 30초 내 식별, MTTR(평균 복구 시간) 60% 이상 단축
Tail-based Sampling텔레메트리 비용 80~95% 절감하면서 에러 Trace 100% 보존
Semantic Conventions팀 간 협업 마찰 제거, 통합 대시보드 구축 시간 90% 단축
OTLP 전송 최적화환경별 최적 성능, 네트워크 비용 최소화

10.3 시작을 위한 다음 단계

  1. 지금 당장: 파일럿 서비스 하나에 OTel 자동 계측을 적용하고, OTel Collector를 통해 오픈소스 백엔드(Jaeger 또는 Tempo)로 데이터를 전송한다.
  2. 2주 이내: Semantic Conventions 가이드를 팀에 공유하고, 표준 속성명 사용을 합의한다.
  3. 4주 이내: 비즈니스 크리티컬 서비스에 수동 계측을 추가하여 하이브리드 전략을 검증한다.
  4. 8주 이내: Tail-based sampling을 도입하고 비용 최적화를 시작한다.
  5. 12주 이내: W3C Baggage로 비즈니스 컨텍스트 전파를 구현하여 완전한 옵저버빌리티를 달성한다.

벤더의 관측이 아닌, 우리의 관측을 되찾을 때다. OpenTelemetry는 그 여정의 가장 확실한 출발점이다.


참고 자료

コメント

まだコメントはありません。

ログインするとコメントできます