監視ダッシュボードに「dead」の文字が並ぶ。慌てて手元でdocker inspectを叩くと、コンテナはピンピンしている。プロセスも生きている。ログも普通に流れている。それなのに、監視スクリプトだけが「死んでいる」と言い張る——という状況に遭遇したことはないだろうか。
バックグラウンドで動くAIエージェントやジョブのコンテナ死活監視を自作するとき、CLIツールの出力をパースして状態判定する構成はかなり一般的だ。docker inspectの結果を見て alive/dead を判定する、kubectl get podの出力を見てPodの状態を判定する、自作のヘルスチェックコマンドの終了コードでliveness probeを組む——どれも同じ形をしている。そしてこの構成には、地味だが再現性の高い落とし穴がある(しかも気づくのはたいてい深夜のアラート経由だ)。「exit 0で成功しているのに、stderrに何か書いてくるツール」は珍しくなく、その出力を2>&1で無造作にまとめて捕捉すると、判定そのものが静かに壊れる。
この記事では、実際にこの罠を踏んで直した経緯を一般化した設計パターンとして整理する。読者が自分の監視スクリプトやliveness probeに当てはめて読めるように、固有のツール名や実装には依存しない形で書く。
この記事で学べること
- CLIツールの出力をstdout/stderr混合で捕捉すると死活判定が壊れる理由
- exit codeとstdout/stderrを分離して判定する実装パターン
- 一度のdead判定で即断せず、ストリーク方式で誤検知を減らす設計
- 「表面のシグナル(HTTPステータス等)」と「中身」を混同しないための注意点
- 自分のwrapperスクリプトやliveness probeに応用するための視点
前提条件
- CLIツールの標準出力をパースして状態判定するbash製の監視・wrapperスクリプトを書いている、あるいはこれから書く予定がある
dockerのようなCLIコマンドのexit codeとstdout/stderrの扱いについて、基本的な理解がある
誤判定の仕組み: なぜexit 0でもstderrに書くのか
docker inspectは基本的に「成功したらexit 0、対象が存在しなければexit 1」という単純なインターフェースに見える。ところが実際には、exit 0で成功しつつもstderrに何か出力してくるケースがある。Dockerのバージョンやドライバの構成によっては非推奨フラグの警告、ストレージドライバの通知、コンテキスト切り替えのメッセージなどが混じることがある(ツール側は気配りのつもりだろうが、文字列比較しか見ていない側からすれば爆弾でしかない)。これはdockerに限った話ではなく、多くのCLIツールが「本来の出力(機械可読なstdout)」と「人間向けの補足情報(stderr)」を厳密に使い分けているとは限らない、という一般的な事情による。
問題は、これを監視スクリプト側でどう捕捉するかだ。手っ取り早く書こうとすると、こういうコードになりがちだ。
# check_container_alive_broken: 壊れたバージョン(stdout/stderrを2>&1でまとめて捕捉)
check_container_alive_broken() {
local id="$1"
local out
out=$(docker inspect -f '{{.State.Running}}' "$id" 2>&1)
if [[ "$out" == "true" ]]; then
echo "alive"
else
echo "dead"
fi
}
2>&1でstderrをstdoutにマージしているため、警告が1行でも混じると$outは"true"という文字列と完全一致しなくなる。実際に踏んだ事故はまさにこれで、稼働中のコンテナに対して警告付きのレスポンスが返ってきた瞬間、比較がfalse側に倒れて「dead」と誤判定していた。コンテナは正常に動いているのに、である(皮肉なことに、"何か書いてくる律儀なツール"ほど、この罠を踏みやすい)。
実装方法
Step 1: バグを再現する
実物のdocker環境がなくても、最小のスタブで同じ現象を再現できる。以下のスクリプトを保存すれば、「exit 0だがstderrに警告を出す」状態を意図的に作れる。
mkdir -p /tmp/fakebin
cat > /tmp/fakebin/docker <<'EOF'
#!/usr/bin/env bash
if [[ "$1" == "inspect" ]]; then
echo "level=warning msg=deprecated_flag_used" >&2
echo "true"
exit 0
fi
EOF
chmod +x /tmp/fakebin/docker
export PATH="/tmp/fakebin:$PATH"
これでdocker inspectを呼ぶたびに、stdoutにtrue、stderrに警告メッセージを返す環境ができた。この状態で先のcheck_container_alive_brokenを呼ぶと、aliveのはずがdeadと表示される。
Step 2: stdout/stderrを分離して判定する
修正の方向はシンプルで、stderrを別の場所へ退避し、判定にはexit codeとstdoutだけを使う。stderrはexit code非0のときだけ、エラー内容の照合に使う。
# check_container_alive_fixed: 修正版(stderrをmktempファイルへ退避し、exit codeで分岐する)
check_container_alive_fixed() {
local id="$1"
local err_file
err_file=$(mktemp)
local out status
out=$(docker inspect -f '{{.State.Running}}' "$id" 2>"$err_file")
status=$?
if [[ $status -eq 0 ]]; then
# 成功時はstdoutだけを見る。stderrに何が混じっていても判定に影響しない
[[ "$out" == "true" ]] && echo "alive" || echo "dead"
else
# 失敗時のみstderrを使って、対象が本当に存在しないのかを確認する
if grep -q "no such object" "$err_file"; then
echo "dead"
else
echo "unknown"
fi
fi
rm -f "$err_file"
}
ポイントは「stdoutとstderrを混ぜない」ことそのものより、exit 0の系とexit 0以外の系で、判定に使う情報源を切り替えることにある。exit 0ならstdoutが正、exit 0以外ならstderrの文字列照合で「本当に存在しないのか、それとも別のエラーなのか」を分ける。この2段構えにすることで、警告混じりの成功レスポンスに引きずられなくなる。
動作確認
Step 1で作ったfakebin/dockerを使えば、壊れたバージョンと直したバージョンの差を手元で確認できる。
# 壊れたバージョン: 警告混入で誤ってdead判定になる
check_container_alive_broken "test-container"
# => dead (本当はaliveなのに)
# 修正版: stdoutだけで判定するので正しくaliveになる
check_container_alive_fixed "test-container"
# => alive
実際にこの修正を入れたときも、警告混入があっても誤判定しないことを確認する回帰テストを、上記と同じ発想のフェイクツールを使って追加している。ローカルにdockerがなくてもCIで再現・検証できるという点で、この手のフェイクスタブは監視スクリプトのテストにそのまま使い回せる。
さらに一歩: 一度のdead判定で即断しない
stdout/stderrの分離だけでも誤判定はかなり減るが、もう一段対策を重ねている実装もある。監視対象のプロセス一覧(たとえばdocker ps相当の一覧取得)だけを見ていると、一覧取得のタイミングやデーモンの一時的な不調で、実際には動いているジョブが一瞬だけ一覧から消えることがある。この「一覧に見えない」を即deadと読み替えてしまうと、瞬間的なブレだけでジョブを失敗扱いにしてしまう。
そこで、コンテナ単位の死活チェックを追加でクロスチェックしつつ、dead判定を複数回連続で観測できたときだけ確定させる、ストリーク方式が使われることがある。
CONTAINER_DEAD_CONFIRM_COUNT=2
declare -A dead_streak
record_and_decide() {
local job_id="$1" state="$2" # state: alive | dead | unknown
case "$state" in
alive)
dead_streak["$job_id"]=0
;;
dead)
dead_streak["$job_id"]=$(( ${dead_streak[$job_id]:-0} + 1 ))
if (( ${dead_streak[$job_id]} >= CONTAINER_DEAD_CONFIRM_COUNT )); then
echo "confirmed dead: $job_id"
fi
;;
unknown)
: # streakを更新しない。daemonへの一時的な到達不能などで誤ってstreakを進めない
;;
esac
}
ここで効いてくるのがunknownという第三の状態だ。Step 2の分離判定でも、失敗理由が「対象が存在しない」と断定できない場合はdeadではなくunknownを返すようにしていた。監視デーモン側であえてdead/aliveの二値にせず、判断材料が不足しているケースをunknownとして扱い、ストリークを変化させないようにする。こうしておくと、docker daemon自体に一時的に到達できないような状況でも、それだけで誤ってdead確定に近づくことがない。あわせて、この仕組みを後から追加した実装では、古い形式のジョブ記録(コンテナIDが記録されていない旧データ)に対してはチェック自体をスキップする後方互換も入れている。判断材料がそもそも無いものに無理に判定をかけない、という同じ思想の延長だ。
この一連の判定フローを図にすると、次のようになる。
| 状態 | 判定材料 | streakへの影響 |
|---|---|---|
| alive | exit 0 かつ stdout の値が真 | リセット(0に戻す) |
| dead候補 | exit 0でstdoutが偽、または対象不在と確認できた | 積み増す。閾値到達で確定 |
| unknown | 理由を特定できないエラー、daemon不到達など | 変化させない(前回の観測を保持) |
似た罠: 表面のシグナルを鵜呑みにしない
stdout/stderrの取り違えと構造的に同じ罠が、もう一つ別の場所にも潜んでいた。監視結果を外部へ通知する処理で、Webhook経由のAPIがHTTPステータス200を返しても、実際の成否はレスポンスボディのJSON側(たとえば{"ok": false}のようなフィールド)で示している場合がある。ステータスコードだけを見て「送れた」と判断すると、実際にはAPI側で拒否されているのに通知済みフラグだけが立ち、以後その通知が二度と飛ばなくなる。
同じ実装ではもう一つ、監視対象を識別するIDの取り違えも起きていた。ジョブを起動したときに使ったコンテナIDと、監視ループが「このジョブは完了した」と判定するために参照するジョブIDが、別の変数に由来していてズレていた。その結果、ジョブがまだ実行中のうちに監視ループが「対応するIDが見当たらない」と早合点し、起動直後に完了扱いでワークスペースを片付けてしまうことがあった。
どちらも根っこは同じで、成功したように見える表面のシグナルと実際に意味のある中身を取り違えている、という点で、stdout/stderr混入の話と同じ形をしている。CLIツールなら「exit codeだけ見て中身を見ない」、APIなら「HTTPステータスだけ見てボディを見ない」、ID参照なら「なんとなく似た名前の変数を使って実は別物を参照している」。監視・死活判定のコードを書くときは、「今読んでいる値は、本当に判定したい対象そのものか」を一度疑ってみる価値がある。
逆パターン: 生きているのに詰まっている
ここまでは「生きているのに死んでいると誤判定する」話だったが、逆方向の罠もある。プロセスが「生きている」ことと「正常に機能している」ことは別物だ。リモートアクセス用のシェル(mosh等)のサーバープロセスが、切断後もクリーンアップされずに何日も残り続けていた、という例がある。CPU使用時間はほぼゼロのまま存在し続けているだけで、接続先としては機能していない(維持コストはほぼタダ、その分だけ誰にも気づかれない)。プロセスの生死だけを見る監視では、この手の「生きているが詰まっている」状態を検知できない。
このケースで採られた対応は、自動でkillするのではなく、特徴に合致するプロセスを定期的に検出して通知するだけというものだった。生死だけでなく「起動からの経過時間」と「累積CPU使用時間」という別の軸を組み合わせて怪しさを判定し、判定を人間の確認に委ねる。誤検知で正常なプロセスを巻き込むリスクを考えると、いきなり自動で手を出さない設計は妥当な落としどころだろう。
死活監視を設計するときは、「死んでいるのに生きていると誤判定する」方向と「生きているのに死んでいる(機能していない)と見逃す」方向の両方を意識しておくと、どちらか一方だけを潰して安心する事故を避けやすい。
注意点・Tips
2>&1は便利だが判定ロジックの前では使わない: ログに残すだけなら2>&1で十分だが、その出力を文字列比較やパースに使うなら、必ずstdout/stderrを分離してから判定するunknownという第三の状態を用意する: alive/deadの二値に押し込めず、判断材料が不足しているケースは保留にする。ストリークやカウンタを不用意に進めない- 一度の異常観測で即断しない: 瞬間的なブレを異常確定と区別するために、連続観測回数の閾値を設ける
- 識別に使うIDの出どころを揃える: 起動時に払い出したIDと、監視ループが参照するIDが別変数由来になっていないか確認する
- 自動アクションと通知を分離する: 誤検知のリスクがある判定は、まず通知だけに留めて自動で破壊的な操作(kill・削除など)をしない選択肢も検討する
まとめ
コンテナの死活監視や汎用的なヘルスチェックは、一見単純な「動いているか、いないか」の二値問題に見える。しかし実際には、CLIツールの出力をどう捕捉するか、一度の異常観測をどこまで信じるか、表面のシグナルと中身のどちらを見るか、といった細部の設計判断が誤判定率を大きく左右する。今回のケースでは、2>&1で無造作にまとめていた出力をexit codeで分岐させ、stdoutとstderrをそれぞれ意味のある場面だけで使うようにしただけで、稼働中のコンテナを誤って落とす事故は解消した。
あなたが自分の監視スクリプトやliveness probeを書くときも、判定に使っている値が「本当にstdoutだけか」「本当に中身まで見ているか」を一度確認してみてほしい。Slack/Discord等のWebhook通知やkubectlの出力パースなど、形は違っても同じ罠は形を変えて何度でも出てくる。以前Dockerコンテナ内でAIエージェントを動かす構成を試したときも隔離と設定引き継ぎのハマりどころを書いたが、今回はその監視側、しかも「動いているように見えて実は落ちている」を見逃さないための細部にフォーカスした。あわせてシェルスクリプトのテストが信用できなくなる3つの罠で書いたように、こうした監視ロジックはフェイクスタブを使えば実環境なしでも回帰テストに落とし込める。誤判定に気づいてから直すより、テストで先回りしておくほうが確実だ。



