blog-api: OTel によるボトルネック観測基盤 (Lambda〜TiDB 経路)

  • 起票日: 2026-07-03

  • ステータス: 実装済み・デプロイ待ち(手動作業は本書の「手動作業」節)

  • 関連 PR: #523

  • 関連実装:

    • apps/blog-api/shared/src/telemetry.rs(OTel SDK 初期化)

    • apps/blog-api/api/src/observability.rs(リクエスト計装)

    • apps/blog-api/adapter/src/observability.rs(DB 計装)

    • apps/blog-api/infrastructure/src/observability.rs(外部 API 計装)

    • apps/tidb-proxy/cmd/forwarder/otel.go(forwarder 計装)

    • iac/aws/ecspresso/tidb-proxy/otel-config.yaml(ADOT Collector 設定)

    • iac/aws/lib/api/observability-construct.ts(ダッシュボード + プローブ)

目的

Lambda から自宅 TiDB までのリクエスト経路のうち、レイテンシがどこで発生しているかを切り分けられるようにする。

  • Lambda アプリ処理

  • DB 接続確立

  • Tailscale / ネットワーク経路

  • ECS forwarder

  • TiDB SQL 実行(TiKV / PD 含む)

アプリ / proxy 側は OpenTelemetry で計測し、TiDB 内部は既存の TiDB Dashboard / Statement Summary / Slow Query を使う。両者を同一時間軸で突き合わせて判定する。

アーキテクチャ

Client / API Gateway
  └─ Lambda (blog-api, Rust + LWA)
      │  DATABASE_URL = mysql://tidb-proxy.internal:13306/...
      │  OTEL_EXPORTER_OTLP_ENDPOINT = http://tidb-proxy.internal:4318
      └─ ECS Fargate task: tidb-proxy
          ├─ tidb-forwarder (Go, tsnet) ──→ tidb.<tailnet>:4000 (TiDB)
          │     └─ OTLP/gRPC → localhost:4317
          ├─ squid (3128)
          └─ otel-collector (ADOT, essential: false)
                ├─ traces  → X-Ray (awsxray exporter)
                └─ metrics → CloudWatch OTel Metrics
                             (otlphttp + sigv4auth →
                              monitoring.ap-northeast-1.amazonaws.com/v1/metrics)
  • Lambda はコンテナイメージ Lambda のため ADOT Lambda layer は使えない。collector を Lambda イメージに焼き込むとコールドスタート(= 計測対象)を汚すため、常駐している tidb-proxy task に collector を同居させ、Lambda は VPC 越しに OTLP/HTTP で送る

  • collector は essential: false。死んでもテレメトリが途切れるだけで、本番経路 (MySQL forward) には影響しない

  • アプリ側 SDK はすべて upstream OTel。OTEL_EXPORTER_OTLP_ENDPOINT 未設定なら完全に無効化されるため、ローカル開発に影響なし

計装内容

Span

Span

場所

主な属性

lambda.handler

Lambda (route_layer)

app.route, cold_start, http.response.status_code

db.connect

Lambda (pool 生成)

db.system, db.name, net.peer.name

db.query

Lambda (全リポジトリの SQL)

db.query_type, db.operation, db.statement_hash, db.rows_returned

db.healthcheck

Lambda (SELECT 1)

db.query_type=select_1

external.request

Lambda (GitHub API)

peer.service, external.operation, url.template

proxy.forward

forwarder (TCP 接続の一生)

proxy.close.reason, proxy.bytes.in/out, net.peer.ip

proxy.accept

forwarder

accept 直後のマーカー

proxy.upstream.connect

forwarder (ts.Dial)

proxy.upstream.name, error.type

生 SQL・バインド値・トークンは span に入れない。SQL は正規化後ハッシュ (db.statement_hash) と論理名 (db.query_type) で識別し、TiDB の statement digest と突き合わせる。

Metrics

メトリクス

意味

app.request.duration

histogram

Lambda リクエスト処理時間

lambda.cold_start.count

counter

cold start 回数

db.connection.duration

histogram

DB 接続確立時間

db.query.duration

histogram

クライアント視点のクエリレイテンシ

db.healthcheck.duration

histogram

SELECT 1 ベースライン

db.query.error.count

counter

DB クエリエラー

proxy.connection.active

gauge

アクティブ接続数

proxy.connection.accept.count

counter

accept 数

proxy.upstream.connect.duration

histogram

forwarder→TiDB 接続時間

proxy.connection.duration

histogram

接続の生存時間

proxy.bytes.in / proxy.bytes.out

counter

転送バイト数

proxy.error.count / proxy.timeout.count

counter

エラー / タイムアウト

メトリクス基盤: CloudWatch OTel Metrics (OTLP ネイティブ)

当初は ADOT の awsemf exporter で Classic メトリクス (EMF) に送る構成だったが、2026-06-16 GA の CloudWatch OTel Metrics に移行した。

観点

Classic (EMF)

OTel Metrics (採用)

課金

ユニークメトリクス数 × $0.30/月 (~$10/月)

取り込み GB ベース (このワークロードでは $1 未満/月)

クエリ

Metric Math

PromQL (histogram_quantile 等)

dimension 制御

metric_declarations で厳密に管理

不要 (150 ラベルまで)

PromQL 上の見え方(dev 環境の実データで確認済み):

  • ドット入りメトリクス名は引用構文で参照する: {"db.query.duration", "@resource.service.name"="blog-api-prd"}

  • OTLP histogram は _bucket 系列に分解されず native histogram としてそのままの名前で格納される。sum by (le)_bucket サフィックスは不要

  • resource 属性は @resource.service.name 等のラベルになる。__type__ / __unit__ / __temporality__ などのメタラベルも付く

  • counter を rate()/increase() すると「name does not end in _total/...」という informational warning が付くが動作に問題はない

ダッシュボードの p95 クエリ例:

histogram_quantile(0.95, sum(rate({"db.query.duration", "@resource.service.name"="blog-api-prd"}[15m])))

ダッシュボードの chart ウィジェットの注意点(2026-07 時点の実測):

  • plotOptions はドキュメント上省略可能だが、無いとコンソールが markOptions 参照の TypeError で全ウィジェット "Something went wrong" になるplotOptions.style.lineOptions / markOptions を明示的に入れる(observability-construct.ts で対応済み)

  • chart ウィジェットは x / width 等のレイアウトプロパティが無視される(put-dashboard 時に warning。自動レイアウトになるだけで実害なし)

設計判断・注意点

  • temporality は cumulative(PromQL の rate() / histogram_quantile() 前提)。並行する Lambda サンドボックスやローリング中の ECS task が同一ラベルで cumulative 値を吐くと混線するため、service.instance.id をリソース属性に付与してプロセス単位で系列を分離している

  • Lambda freeze 対策: batch export だけに頼らず、レスポンス返却後に別スレッドで force_flush する。freeze で送り損ねた分は次 invoke で送信される

  • 1 サンプルしか持たない系列(1 回 invoke されて消えたサンドボックス等)は rate() に反映されない。リクエスト単位の悉皆データは X-Ray トレース側で見る

  • SELECT 1 ベースラインは EventBridge API Destination が 5 分毎に /health/db を叩いて供給する。毎分にすると Lambda が常時 warm になり cold start が観測できなくなるため 5 分にしている

  • Lambda の NO_PROXYtidb-proxy.internal を追加している。OTLP 送信が squid (HTTP_PROXY) を経由しないようにするため

  • task メモリは 512 MB のまま (squid + forwarder + collector の 3 コンテナ同居)。collector が OOM でメモリ不足になる場合は task メモリ増量を検討する

  • CloudWatch OTLP metrics エンドポイントは 1 リクエスト 1,000 datapoint / 1MB 制限があるため、collector の metrics パイプラインは batch/metrics (send_batch_max_size: 800) を使う

手動作業(デプロイ手順)

1. CloudWatch の OTLP ingestion 有効化(アカウント初回のみ)

CloudWatch コンソール (ap-northeast-1) → Settings で OpenTelemetry (OTLP) metrics ingestion が有効かを確認し、無効なら有効化する。

未有効のまま送信すると collector ログ (/ecs/tidb-proxyotel-collector prefix) に 4xx が出るので、疎通確認はそこで行う。

2. deploy role の更新(ローカルから、GitHub Actions デプロイより先)

本タスクで deploy role に cloudwatch:*(ダッシュボード)/ events:*(ヘルスチェックプローブ)を追加した。GitHub Actions はこのロールを assume してデプロイするため、先にローカルの管理者権限で deploy role スタックを反映する。

dev:

cd iac/aws
bunx dotenv -- cdk deploy \
  -c stageName=dev \
  d-st-deploy-role \
  --require-approval never

prd:

cd iac/aws
bunx dotenv -- cdk deploy \
  -c stageName=prd \
  p-st-deploy-role \
  --require-approval never

3. tidb-proxy スタックのデプロイ(IAM 変更)

task role への X-Ray 書き込み + cloudwatch:PutMetricData 付与。

GitHub Actions (推奨): Deploy workflow を workflow_dispatch で実行し、stack=st-tidb-proxy を選択する(stageName はどちらでもよい。proxy スタックは stage 共用)。

ローカルから実行する場合:

cd iac/aws
export STAGE_NAME=prd  # proxy スタックは stage 共用だが getConfig() の実行に必要
bunx dotenv -- cdk deploy \
  -c stageName=${STAGE_NAME} \
  st-tidb-proxy \
  --require-approval never

4. tidb-proxy イメージ更新 + ecspresso deploy(collector sidecar 追加)

forwarder の OTel 計装入りイメージを push し、otel-collector sidecar 入りの task def を反映する。

scripts/deploy-tidb-proxy.sh

5. main スタックのデプロイ(dev / prd)

Lambda 環境変数 (OTEL_*, NO_PROXY) / SG 4318 / ダッシュボード / ヘルスチェックプローブ。

GitHub Actions (推奨): Deploy workflow を workflow_dispatch で stack=main を選択し、stageName=devstageName=prd でそれぞれ実行する(stack=all なら 3 と 5 をまとめて実行できる)。

ローカルから実行する場合:

dev:

cd iac/aws
bunx dotenv -- cdk deploy \
  -c stageName=dev \
  d-st-main \
  --require-approval never

prd:

cd iac/aws
bunx dotenv -- cdk deploy \
  -c stageName=prd \
  p-st-main \
  --require-approval never

6. デプロイ後の確認

  1. collector 起動確認: /ecs/tidb-proxy ロググループの otel-collector prefix にエラーが無いこと

  2. トレース確認: X-Ray コンソールで lambda.handler 配下に db.query / db.healthcheck がぶら下がる 1 リクエスト 1 トレースが見えること

  3. メトリクス名の実地確認: CloudWatch Query Studio で以下を実行し、系列が返ることを確認する(dev では確認済み。histogram は _bucket 分解されず元の名前のまま)

    {"db.healthcheck.duration"}
    
  4. ダッシュボード確認: d-st-observability / p-st-observability で p50/p95/p99 が描画されること(プローブが 5 分毎なので初回データまで最大 ~15 分待つ)

  5. TiDB Dashboard の statement duration と CloudWatch を同一時間窓で並べ、突き合わせができること

7. 運用上の手動作業(継続)

  • 新規追加の恒常的な手動運用は無し。Tailscale auth key の 90 日ローテーション(既存運用)のみ

  • コスト観点で外したくなったら: collector sidecar を task def から外せばメトリクス課金は止まる(アプリ側計装は endpoint 未設定なら no-op なので残してよい)

ボトルネック分析の読み方

db.healthcheck.duration (SELECT 1 = 経路コストのみ) と db.query.duration (経路 + SQL 実行 + 結果転送) と TiDB statement duration の 3 つを比較する。

観測結果

ボトルネック

SELECT 1 も実クエリも遅い

Tailscale / ネットワーク / forwarder / 接続経路

SELECT 1 は速く実クエリだけ遅い

SQL / インデックス / TiDB 実行 / TiKV / 結果サイズ

クライアント計測は高いが TiDB statement duration は低い

ネットワーク / forwarder / 結果転送

クライアント計測も TiDB も高い

SQL / TiDB / TiKV 側

db.connection.duration だけ高い

接続確立 / pool / Tailscale 経路 / TLS

forwarder の proxy.upstream.connect.duration が高い

forwarder → TiDB 経路

forwarder に reset / timeout が多い

ネットワーク不安定 / idle timeout / Lambda freeze との相互作用

コスト

項目

月額 (USD)

CloudWatch OTel Metrics (取り込み GB)

< $1

X-Ray (無料枠 10 万トレース/月)

$0

EventBridge プローブ + Lambda/API GW

~$0.1

合計

~$1/月