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 (route_layer) |
|
|
Lambda (pool 生成) |
|
|
Lambda (全リポジトリの SQL) |
|
|
Lambda ( |
|
|
Lambda (GitHub API) |
|
|
forwarder (TCP 接続の一生) |
|
|
forwarder |
accept 直後のマーカー |
|
forwarder ( |
|
生 SQL・バインド値・トークンは span に入れない。SQL は正規化後ハッシュ (db.statement_hash) と論理名 (db.query_type) で識別し、TiDB の statement digest と突き合わせる。
Metrics
メトリクス |
型 |
意味 |
|---|---|---|
|
histogram |
Lambda リクエスト処理時間 |
|
counter |
cold start 回数 |
|
histogram |
DB 接続確立時間 |
|
histogram |
クライアント視点のクエリレイテンシ |
|
histogram |
|
|
counter |
DB クエリエラー |
|
gauge |
アクティブ接続数 |
|
counter |
accept 数 |
|
histogram |
forwarder→TiDB 接続時間 |
|
histogram |
接続の生存時間 |
|
counter |
転送バイト数 |
|
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 ( |
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_PROXYにtidb-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-proxy の otel-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=dev と stageName=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. デプロイ後の確認
collector 起動確認:
/ecs/tidb-proxyロググループのotel-collectorprefix にエラーが無いことトレース確認: X-Ray コンソールで
lambda.handler配下にdb.query/db.healthcheckがぶら下がる 1 リクエスト 1 トレースが見えることメトリクス名の実地確認: CloudWatch Query Studio で以下を実行し、系列が返ることを確認する(dev では確認済み。histogram は
_bucket分解されず元の名前のまま){"db.healthcheck.duration"}
ダッシュボード確認:
d-st-observability/p-st-observabilityで p50/p95/p99 が描画されること(プローブが 5 分毎なので初回データまで最大 ~15 分待つ)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 側 |
|
接続確立 / pool / Tailscale 経路 / TLS |
forwarder の |
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/月 |