「CIクリティカルパスを壊さずに時系列メトリクスを蓄積する設計」を説明する図。開発者が、Docker、API、ログなどのCIデータを安全にSQLiteで時系列メトリクスとして収集するフローを示している。非致命的な設計が重要であることを強調。

はじめに

CIホストのようなインフラでは、「ジョブの取得・解放」や「死活監視」といったクリティカルパスに手を加えるのはとてもリスクが高い作業です。ログに障害の兆候を出力するだけなら簡単ですが、「Dockerイメージの容量が右肩上がりになっていないか」「同時実行の枠が足りているか」といったトレンドを判断するには、時系列で数値を蓄積する仕組みが必要になります。

今回は、既に安定稼働しているCIホストに、後から時系列メトリクスの蓄積層を追加した際の設計判断と、実装時に踏んだ具体的な罠を整理します。書き込み処理を本体に混ぜる以上、「計測のために本体を壊す」ことだけは絶対に避けなければなりません。

こんな人におすすめ

  • 稼働中のクリティカルなインフラに、後から監視・計測機能を追加しようとしている方
  • bashスクリプトからsqliteを操作する機会がある方
  • 「壊れてはいけない処理」に副次的な機能を安全に追加する設計パターンを知りたい方
  • CI/CDパイプラインの運用改善に関わっている方

罠1: 非fatal設計を徹底しないと計測層が本体を壊す

計測機能を追加する際、最も重要なのは「計測が失敗しても本体の処理は絶対に止まらない」という原則です。sqlite3コマンドが存在しない、DBがロックされている、ディレクトリ作成に失敗する——こうした状況は起こり得ますが、そのすべてで処理を継続させる必要があります。

metrics_enabled() {
  [ "${METRICS_ENABLED:-1}" = "1" ] || return 1
  command -v sqlite3 >/dev/null 2>&1 || return 1
  return 0
}

metrics_sql() {
  metrics_enabled || return 0
  sqlite3 -cmd ".timeout ${_METRICS_BUSY_MS}" "$METRICS_DB" 2>/dev/null || return 0
}

計測用の全関数の先頭に metrics_enabled || return 0 を置き、失敗時は常に return 0(正常終了扱いのno-op)にします。CIのゲート処理のような場所に書き込みを挟む場合、これが唯一の安全策になります。

また、専用の収集デーモンを新たに立てるのではなく、既に動いている定期実行サイクル(例えば5分おきのヘルスチェック)に処理を1行相乗りさせる設計も有効です。プロセス管理や監視設定を新設するコストがかからず、追加コストをほぼゼロに抑えられます。

罠2: bashの文字列置換でバックスラッシュを使うと起きる罠

sqliteに文字列を渡す際は、SQLインジェクション対策としてシングルクォートを二重化する必要があります。bashの ${var//pattern/replacement} でこれを行おうとすると、思わぬ罠に遭遇します。

# NG: 置換文字列に直接バックスラッシュを書くパターン
# "${s//\'/\'\'}" のような書き方は、bash 3.2 (macOS標準) で
# エスケープの解釈がずれて意図通りに動かないことがある

# OK: クォート文字自体を変数経由で渡す
_metrics_sql_quote() {
  local s="${1:-}" q="'" qq="''"
  printf "%s" "${s//$q/$qq}"
}

パターンと置換文字列の両方をいったん変数に格納してから ${s//$q/$qq} の形で使うと、バックスラッシュの解釈に依存しない安全な置換になります。特にbash 3.2がデフォルトになっているmacOS環境では、この書き方の違いが動作の差として表面化しやすいので注意が必要です。

罠3: PRAGMAはstdoutに値を漏らす

sqlite3で PRAGMA busy_timeout=5000; のようなPRAGMA文を実行すると、設定した値そのもの(この例では 5000)がstdoutに出力されます。収集スクリプトの出力をパイプで後続処理に渡している場合、これが意図しないデータ混入になります。

# PRAGMA busy_timeout=5000; だと "5000" がstdoutに出てしまう
# ドットコマンドの .timeout なら副作用なしで同じ設定ができる
sqlite3 -cmd ".timeout ${_METRICS_BUSY_MS}" "$METRICS_DB" 2>/dev/null

.timeout はPRAGMAではなくsqlite3固有のドットコマンドなので、標準出力を汚しません。-cmd オプションで接続前の初期設定として渡しておけば、以降のクエリ結果だけがクリーンにstdoutへ流れます。

罠4: NULLとの文字列連結は行ごと消える

sqliteの文字列連結演算子 || は、片方の値がNULLだと結果全体がNULLになります。集計テーブルが空だったり、まだ計測されていない項目があったりする状態でサマリ行を組み立てると、行そのものが消えてしまうことがあります。

-- NG: value が NULL だと label || ': ' || value 全体が NULL になる
SELECT label || ': ' || value FROM metrics_summary;

-- OK: COALESCE で NULL を先に潰しておく
SELECT label || ': ' || COALESCE(value, '?') FROM metrics_summary;

サマリ生成のようにテキストを連結する箇所では、COALESCE(value, '?') や COALESCE(value, 0) を徹底しておくと、未計測データが混じっていても行が丸ごと消える事故を防げます。

罠5: critical sectionの中に重い処理を入れない

セマフォなどで排他制御している区間の中でsqlite3コマンドを起動すると、コマンド起動のオーバーヘッド分だけロックの保持時間が伸びてしまいます。ジョブの取得・解放のたびにロックを取るような処理では、これが全体のスループット低下に直結します。

cmd_release() {
  local rel=0 rel_holder="" rel_token="" rel_wait="" rel_hold=""
  _gc_lock || true
  # ロック中に必要な値だけをローカル変数へ退避する
  rel_holder="$(_meta_get "$SEM_DIR/$tok" holder)"
  rel_wait="$(_meta_get "$SEM_DIR/$tok" wait_seconds)"
  rel=1
  _gc_unlock
  # ロック解放後にメトリクス書き込みを実行する
  [ "$rel" = "1" ] && _record_ci_event released "$rel_holder" "$rel_wait"
}

ロック区間の中では必要な値をローカル変数に退避するだけにとどめ、_gc_unlock を呼んだ後にsqlite3の書き込みを行うようにすると、ロック保持時間を最小限に抑えられます。

つまづきやすいポイント

  • 一度廃止した機能を再導入する場合、廃止理由を消さずに残し「なぜ今回はOKなのか」を併記しておくと、後から見返したときの判断根拠が追いやすくなります
  • 「非fatal」を謳うなら、正常系だけでなくsqlite3コマンド不在・ロック競合・ディレクトリ作成失敗のすべての異常系で動作確認しておく必要があります
  • bashのバージョン差(特にmacOS標準の3.2系とLinux環境の差)は、文字列操作のテストで見落としがちです

まとめ

計測機能を後付けする際に最優先すべきは、機能そのものより「壊れないこと」です。

全APIの先頭に非fatalガードを置く、既存の定期実行サイクルに相乗りする、critical sectionの外に重い処理を追い出す——これらはどれも地味ですが、クリティカルなインフラに計測層を安全に追加するための実践的な原則だと感じます。

sqliteとbashの組み合わせ特有の罠(文字列置換のエスケープ、PRAGMAのstdout汚染、NULL連結)も、知っていれば数分で回避できるものばかりです。同じような設計に取り組む際の参考になれば幸いです。