運用
blog-api (Lambda) 〜 tidb-proxy (forwarder) 〜 TiDB 経路で「遅い・おかしい」が起きたときに、原因箇所を最短で切り分けるための手順と、MiniPC クラスタ・DB の定常メンテナンス手順。設計・デプロイ手順は OTel ボトルネック観測基盤 を参照。
症状別インデックス
症状 |
見る節 |
|---|---|
API / ブログが遅い |
ボトルネック切り分け |
グラフの意味を知りたい |
ダッシュボード |
ダッシュボードにデータが出ない |
テレメトリが出ないとき |
誰が何を送っているか知りたい |
テレメトリ一覧 |
クエリの書き方を調べたい |
クエリリファレンス |
Lambda がどこへ外部通信したか調べたい |
クエリリファレンス |
proxy のアクセス・エラーログを検索したい |
クエリリファレンス |
検索がヒットしない・表示が変 |
ハマりどころ |
MiniPC を再起動したい |
ノード再起動 |
本番 DB のバックアップ / dev への複製 |
バックアップとリストア |
ボトルネック切り分け
# |
手順 |
使うもの |
|---|---|---|
1 |
p95 が悪化している時間帯を特定する |
ダッシュボード |
2 |
SELECT 1(経路コストのみ)と実クエリの p95 を比較する |
ダッシュボード |
3 |
TiDB 側の実行時間を同一時間窓で並べる |
TiDB Dashboard / Statement Summary / Slow Query |
4 |
判定表で原因を絞る |
下表 |
5 |
該当リクエストを個別に深掘りする |
X-Ray(クエリリファレンス参照) |
判定表:
観測結果 |
ボトルネック |
|---|---|
SELECT 1 も実クエリも遅い |
Tailscale / ネットワーク / forwarder / 接続経路 |
SELECT 1 は速く実クエリだけ遅い |
SQL / インデックス / TiDB / TiKV / 結果サイズ |
クライアント計測は高いが TiDB statement duration は低い |
ネットワーク / forwarder / 結果転送 |
|
接続確立 / pool / TLS / Tailscale 経路 |
forwarder の |
forwarder → TiDB 経路 |
参考実測値(2026-07-03): Tailscale 経由の AWS Tokyo ⇄ 自宅 RTT ≈ 8ms。SELECT 1 は ~18ms ≈ 2 往復(sqlx の test_before_acquire による取得前 ping の 1 往復を含む)。
ダッシュボード
CloudWatch Dashboards の d-st-observability(dev)/ p-st-observability(prd)。定義は iac/aws/lib/api/observability-construct.ts。レイテンシ系はすべて p50 / p95 / p99、rate window は 15 分。
ウィジェット |
メトリクス(計測元) |
見方 |
|---|---|---|
Lambda request latency |
|
ハンドラ全体のエンドツーエンド。全ルートのリクエストが対象で、5 分毎の |
DB client latency |
|
Lambda から見た実クエリのレイテンシ。SELECT 1 は含まれない( |
DB baseline latency (SELECT 1) |
|
クエリ実行以外の経路コスト全部。Tailscale の片道遅延そのものではなく「RTT × 往復回数 + forwarder 中継」( |
DB connection latency |
|
pool の新規接続確立。ここだけ高ければ pool / TLS / Tailscale 経路 |
Forwarder upstream connect latency |
|
forwarder → TiDB の接続確立 |
Forwarder connections |
|
アクティブ接続数と accept 数(15 分増分)。accept の急増は再接続の嵐を疑う |
Forwarder errors |
|
中継エラーとタイムアウト(15 分増分)。平常時はゼロ。loopback からの ECS ヘルスチェック接続は計測対象外(ハマりどころ参照) |
Lambda cold starts / DB errors |
|
cold start 回数とクエリエラー数(15 分増分) |
テレメトリ一覧
送信元は blog-api(Lambda / Rust)と tidb-proxy(forwarder / Go)の 2 つ。どちらも OTLP で ADOT collector(tidb-proxy タスクの sidecar)に送り、collector が traces → X-Ray、metrics → CloudWatch Metrics に振り分ける。
blog-api(Lambda / Rust)
計装の実体は apps/blog-api/{api,adapter}/src/observability.rs。
テレメトリ |
シグナル |
型 |
利用先(ダッシュボードのウィジェット等) |
|---|---|---|---|
|
metrics |
Histogram |
Lambda request latency |
|
metrics |
Histogram |
DB client latency |
|
metrics |
Histogram |
DB baseline latency (SELECT 1) |
|
metrics |
Histogram |
DB connection latency |
|
metrics |
Counter |
Lambda cold starts / DB errors |
|
metrics |
Counter |
Lambda cold starts / DB errors |
segment |
traces |
Span |
X-Ray(トレース検索・ウォーターフォール) |
tidb-proxy(forwarder / Go)
計装の実体は apps/tidb-proxy/cmd/forwarder/otel.go。
テレメトリ |
シグナル |
型 |
利用先(ダッシュボードのウィジェット等) |
|---|---|---|---|
|
metrics |
Histogram |
Forwarder upstream connect latency |
|
metrics |
Observable Gauge |
Forwarder connections |
|
metrics |
Counter |
Forwarder connections |
|
metrics |
Counter |
Forwarder errors |
|
metrics |
Counter |
Forwarder errors |
|
metrics |
Histogram |
(ダッシュボード未使用。PromQL で直接参照) |
|
metrics |
Counter |
(ダッシュボード未使用。PromQL で直接参照) |
segment |
traces |
Span |
X-Ray(トレース検索・ウォーターフォール) |
テレメトリが出ないとき
# |
確認 |
方法 |
|---|---|---|
1 |
既知の欠損でないか |
「SELECT 1 ベースライン」はプローブ由来で大部分欠損する仕様(ハマりどころ参照)。リクエスト由来のメトリクスで判断する |
2 |
collector が生きているか |
ECS タスクの |
3 |
export が失敗していないか |
|
定常運用
やること |
方法 |
|---|---|
collector 疎通確認 |
|
再デプロイ |
Deploy workflow の workflow_dispatch( |
止める(コスト削減) |
task def から |
バックアップとリストア
本番 DB blog_prd の論理ダンプ取得と、開発 DB blog_dev への復元手順。dev / prd は同一 TiDB クラスタ上の database 分離なので、同じダンプを -D で流し込む先を変えるだけで環境間コピーになる。経緯とハマりどころ(mysqldump 8.x の --single-transaction が TiDB と非互換など)は 本番 TiDB (blog_prd) の論理ダンプ手順 を参照。
前提は Tailscale ログイン済みであること(接続先 tidb.<tailnet>:4000 を自動解決)。ローカルに mysqldump / mysql / jq が必要。
本番バックアップ
# リポジトリルートで
bun run dump:prd
出力は
backup/blog_prd-<timestamp>.sql。backup/は gitignore 済み(本番データはコミットしない)最新ダンプへは
backup/blog_prd-latest.sqlの symlink が張られる実体は
scripts/dump-tidb.sh。ダンプ末尾のフッター検証とテーブル別行数の表示まで行う任意の DB / 出力先を指定する場合は
./scripts/dump-tidb.sh --database blog_dev --out-dir /tmp
開発 (blog_dev) へのリストア
ダンプは CREATE DATABASE / USE を含まない(DB 名を位置引数で渡している)ため、-D で復元先スキーマを自由に選べる。
export TAILNET=$(tailscale status --json | jq -r '.MagicDNSSuffix')
# 本番データを dev へ
mysql -h "tidb.${TAILNET}" -P 4000 -u root -D blog_dev < backup/blog_prd-latest.sql
# 行数のサニティチェック
mysql -h "tidb.${TAILNET}" -P 4000 -u root -D blog_dev \
-e "SELECT COUNT(*) AS articles FROM articles; SELECT COUNT(*) AS users FROM users;"
注意:
ダンプには
DROP TABLE IF EXISTSが含まれるため、blog_devの既存データはテーブルごと置き換わるリストア先の DB 自体は存在している前提。無ければ先に
CREATE DATABASE blog_devを実行する同名リストア(
-D blog_prd)も同じ形でできるが、本番を書き戻す操作なので実行前にダンプの中身と接続先を必ず確認する
ノード再起動
MiniPC(node1〜3)を OS 再起動するときの手順。ノード配置の前提は 構築計画 の「ノード別配置の想定」を参照。
前提と原則
項目 |
内容 |
|---|---|
control plane |
node1 のみ(apiserver / etcd / controller-manager / scheduler)。etcd は単一で、データは node1 のディスクにのみ存在する |
PD / TiKV / TiDB |
各ノードに 1 つずつ(3 レプリカ)。1 台落ちても raft quorum(2/3)が保たれるので DB は継続する |
原則 1 |
必ず 1 台ずつ再起動する。2 台同時に落とすと PD / TiKV の quorum が崩れて DB 全体が止まる。前のノードが復帰して Pod が Running に戻ってから次へ進む |
原則 2 |
TiKV の |
原則 3 |
node1 停止中は kubectl / Pod 再スケジュール / Service エンドポイント更新がすべて止まる。node2/3 で動いている既存 Pod はそのまま動き続ける |
再起動後の自動復旧は構築時の永続化設定で担保されている。swap は /etc/fstab でコメントアウト済み、カーネルモジュールは /etc/modules-load.d/k8s.conf、sysctl は /etc/sysctl.d/k8s.conf に永続化済み。kubelet / containerd / tailscaled は systemd で enable されており、control plane(apiserver / etcd)は static pod なので kubelet が上がれば自動で復活する。
事前確認
# 再起動対象ノード(以降の手順はすべてこの変数を参照する)
export NODE=node2
# 自動起動の enable 確認(3 つとも enabled であること)
ssh "$NODE" 'systemctl is-enabled kubelet containerd tailscaled'
# tidb-public の Tailscale proxy Pod がどのノードに載っているか確認する。
# 再起動対象ノードに載っている場合、そのノード停止中は
# Fargate proxy → tidb.<tailnet>:4000 のブログ DB 経路が止まる
kubectl -n tailscale get pods -o wide
# クラスタが健全であること
kubectl get nodes
kubectl -n tidb-cluster get pods -o wide
worker(node2 / node3)の再起動
kubectl cordon "$NODE"
# 【事前確認で ts-tidb-public が対象ノードに載っていた場合のみ】
# proxy Pod を消して別ノードへ退避させる(ブログ DB 経路のダウンを数十秒に抑える)。
# Tailscale のデバイス状態は Secret 保存のため、移動しても `tidb` の
# ホスト名・identity は維持される。SPOF 許容方針なので、退避せず数分止めてもよい
kubectl -n tailscale delete pod ts-tidb-public-<suffix>-0
kubectl -n tailscale get pods -o wide # 別ノードで Running になるまで待つ
ssh "$NODE" 'sudo reboot'
# Ready に戻るまで待つ
kubectl get nodes -w
kubectl uncordon "$NODE"
# TiDB クラスタの Pod がすべて Running に戻るのを確認してから次のノードへ
kubectl -n tidb-cluster get pods -o wide
kubectl drainでノード丸ごと退避するのは不可。TiKV / PD は local PV に紐付いていて他ノードへ移動できず、Pending で待つだけの無駄な churn が起きる。動かすのは tailscale proxy Pod だけでよいts-node-grafana-public/ts-tidb-dashboard-public/ tailscale operator / hubble-ui は退避不要。観測系なので再起動の数分止まっても実害はない(operator は既存 proxy の通信に関与しない)
control plane(node1)の再起動
worker と同じ手順でよい。以下の点だけ異なる。
再起動中は kubectl が返らなくなるが正常。node1 の kubelet が上がると static pod(apiserver / etcd)が自動復旧し、kubectl も戻る
kubectl が戻ったら
kubectl uncordon node1から手順を再開する。kubectl get nodes -wの切断で手順が中断されるため uncordon を忘れやすい。忘れても既存 Pod は動き続けるので気付けず、後日 Pod を作り直したとき(TiKV rolling restart 等)にvolume node affinity conflictで Pending になって発覚する(実績: 2026-07-05)アクセスの少ない時間帯に行う
ts-tidb-publicの proxy Pod が node1 に載っている場合、退避は必ず再起動前に行う。apiserver も同時に停止するため、落ちてからでは他ノードへ退避できず、ブログ DB 経路が node1 復帰まで止まる万一 node1 のディスクが死ぬと etcd(クラスタ状態)を失う。TiDB のデータ自体は TiKV 3 レプリカなので無事
復帰確認
kubectl get nodes # 全ノード Ready。SchedulingDisabled が残っていたら uncordon 忘れ
kubectl -n kube-system get pods # control plane / cilium / coredns が Running
kubectl -n tidb-cluster get pods -o wide # PD / TiKV / TiDB が Running
kubectl -n tailscale get pods -o wide # tidb-public の proxy Pod が Running
curl -s https://api.shuntaka.dev/health/db # ブログ DB 経路の疎通
クエリリファレンス
Athena(proxy アクセスログ / Iceberg)
tidb-proxy のログは FireLens で振り分けられ、INFO 系(squid アクセスログ・forwarder イベント)が S3 上の Iceberg テーブル tidb_proxy_logs.logs に、WARN / ERROR・非 JSON 行が CloudWatch Logs /ecs/tidb-proxy に入る。設計は tidb-proxy ログの S3/Iceberg 検索基盤 を参照。
よく使うクエリは Saved queries(WorkGroup tidb-proxy-logs)に CDK(iac/aws/lib/analytics/tidb-proxy-log-analytics-construct.ts)で登録済み。Athena コンソールで WorkGroup を tidb-proxy-logs に切り替えて Saved queries から実行する。
Named Query |
用途 |
|---|---|
|
直近のアクティビティ一覧(ECS ヘルスチェックのノイズ除外) |
|
直近7日の外部通信の宛先別サマリ(egress 監査、想定外の phone-home 検知) |
|
TCP_DENIED / HTTP 4xx・5xx の検出(squid の egress 制限に触れた通信調査) |
いずれも時刻カラムは JST(ts_jst / last_seen_jst)で返す。
自分でクエリを書くときの注意:
ECS ヘルスチェック(127.0.0.1 から 30 秒ごとの
nc -z)がsquid_accessのノイズ行になる。client_ip <> '127.0.0.1'で除外し、forwarder 行(client_ipが NULL)も残す場合はclient_ip IS DISTINCT FROM '127.0.0.1'を使うtsは string(ISO8601 UTC)で保存されている。時刻演算はfrom_iso8601_timestamp(ts)、期間絞り込みはfrom_iso8601_timestamp(ts) > current_timestamp - interval '7' dayの形JST 表示は
format_datetime(from_iso8601_timestamp(ts) AT TIME ZONE 'Asia/Tokyo', 'yyyy-MM-dd HH:mm:ss')WARN / ERROR は Iceberg 側に入らない。障害調査は CloudWatch Logs の
fluentbit-warnerr-*/fluentbit-fallback-*ストリームを見る
X-Ray(CloudWatch > トレース)
annotation キーはドットがアンダースコアに変換される。
OTel 属性 |
検索キー |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
app.route に入る値(axum の MatchedPath そのまま。health のルートは末尾スラッシュ付き /health/ になる点に注意):
|
メソッド |
内容 |
|---|---|---|
|
GET |
記事一覧( |
|
GET |
タグファセット集計(絞り込みパネル用。tags なしは |
|
GET |
記事詳細 |
|
GET |
ヘルスチェック |
|
GET |
DB ヘルスチェック(5 分毎の SELECT 1 プローブが叩く) |
|
POST |
GitHub Webhook(記事同期。 |
|
GET |
Swagger UI( |
クエリサンプル:
用途 |
クエリ |
|---|---|
環境で絞る(X-Ray グループに登録しておくとドロップダウンで常時絞れる) |
|
環境 + ルート |
|
遅いリクエスト(1 秒以上) |
|
ハマりどころ
現象 |
原因 |
対処 |
|---|---|---|
「SELECT 1 ベースライン」がデータなし |
5 分毎プローブのテレメトリが Lambda freeze で大部分欠損する(実測 3 時間で 2/30 程度) |
仕様。対策候補は |
|
OTLP histogram は native histogram のまま格納される |
|
|
counter の命名規約チェック |
無害。無視してよい |
発生したはずのリクエストが |
1 サンプルしか持たない系列(1 回だけ invoke されたサンドボックス)は cumulative のため rate 計算できない |
リクエスト単位の悉皆データは X-Ray で見る |
|
annotation 検索が効くのは segment( |
ルートや duration で絞ってウォーターフォールで見る |
トレースマップで Lambda と forwarder が繋がらない |
生 TCP にトレースコンテキストを伝播できないため意図的に非連結 |
時間軸で突き合わせる |
|
TCP 接続(= MySQL セッション)の寿命を計測している |
リクエストレイテンシではないので正常 |
|
ECS ヘルスチェック( |
forwarder が loopback からの接続を dial・テレメトリ対象外にして解消済み(2026-07) |
ダッシュボードが "Something went wrong" |
chart ウィジェットの |
|