fail openを意識する。テストの成功は問題ないことを意味しない
テスト・検査・readinessは、それ自体が壊れると失敗ではなく成功として出てくる(fail open)。成功が信用できなくなる壊れ方を分類し、実測した5つの事例と、fail closedに書き直すための3つの技法をテストコード付きでまとめた。
テストが成功した。それは「問題がない」ことを意味しない。 「このテストが見た範囲では、問題が見つからなかった」ことしか意味しない。 そしてテスト自体が壊れているとき、「見た範囲」はゼロかもしれない。
この2日間、検証用のリポジトリでまさにこの形の穴を5回実測した。 本体のコードではなく、確かめる側——CI の検査、readiness の判定、依存の検証——が 壊れていて、そのすべてが「成功」として出てきた。個別の話としてではなく、 構造として整理しておく。
fail open とは
制御や検査の機構そのものが壊れたとき、結果が「許可・成功」として出てしまうことを fail open と呼ぶ。逆に、壊れたら「拒否・失敗」として出るのが fail closed。 もともとはセキュリティの語彙(認証サーバが落ちたとき全員を通すか、全員を拒むか) だが、「何かを確かめる仕組み」全般に同じ力学が働く。
重要なのは次の一点だ。テストや検査は、放っておくと fail open になる。 検査は「問題を見つけたら失敗する」という構造をしているので、 問題を見つけられない理由——検査が動かない、対象に届かない、対象が空、 緩すぎて素通し——のすべてが「成功」と同じ見た目になる。
成功が信用できなくなる壊れ方の分類
実測した事例は、壊れ方で分類するとこうなる。
| 壊れ方 | 成功に見える理由 |
|---|---|
| 1. 検査の実行自体が失敗した | エラーと「該当なし」が同じ見た目になる |
| 2. 検出対象がその環境に存在できない | 論理は正しく、対象が空なので必ず「該当なし」 |
| 3. 検査が期待と別の実体に届いた | 別の相手が正常に応答してしまう |
| 4. 検証リクエストが対象に届いていない | ツールが期待と別の経路を通る |
| 5. 意図と別のコードを検証した | 依存解決の仕様としては正しく、ビルドは成功する |
どの行にも共通するのは、壊れた場所がテストコードの中に書かれていないことだ。 stderr の扱い、検査の置き場所、名前解決の実装、依存の解決規則——テストの 本文をいくらレビューしても見つからない場所で壊れる。
実測した5つの事例
事例1(実行の失敗): 「禁止した形の外部キーが無いこと」を確かめる SQL が、 存在しないカラムを参照していてエラーになっていた。呼び出し側が stderr を 捨てていたため、見えたのは空の結果だけ。「該当なし」と読んだが、実際には 何も検査されていない。
事例2(対象が存在できない): その外部キー検査を、サービス単位の CI ジョブに 置いた。各ジョブは自分のサービスの分しかマイグレーションを適用しないので、 相手側のテーブルが存在しない。参照先が無い場所に外部キーは作れないため、 このジョブの中では検出対象が定義上存在できず、検査は永遠に成功し続ける。
事例3(別の実体に届いた): 認証なしのプロセスを止めて認証ありで再起動する
CI 手順で、完了判定を /healthz の 200 にしていた。実際には旧プロセスが
ポートを握ったまま生き残り、新プロセスは bind に失敗して即死。ポーリングは
生き残った旧プロセスの 200 を拾い、以降の検査は全部、認証なしの相手に走った。
事例4(リクエストが届いていない): コンテナ内の名前解決を DNS のエイリアスで
曲げる構成を、コンテナ内から curl で疎通確認した。curl 8.x は *.localhost を
DNS を引かずにループバックへ解決する(RFC 6761 をツール側で実装している)
ため、検証リクエストはエイリアスの先ではなく自分自身に届いていた。
DNS の設定は正しかった。それを使わないツールで確認していた。
事例5(別のコードを検証した): Go のマルチモジュール構成で、隣のモジュールへの
replace 指定を忘れた。リポジトリが公開されていてパッケージも存在したため、
Go は公開されている古い版を取得してビルドを成功させた。手元のコードは
1行も使われていない。
fail closed に書き直す3つの技法
対策はどれも「検査を fail closed に寄せる」の変奏で、3つに集約できる。 それぞれ、修正前後のコードで示す。
技法1: 見たものを出力し、前提が崩れたら検査自体を失敗させる
事例1・2への対策。修正前の形はこうだった。
# fail open: 実行エラーも「該当なし」も、同じ空文字列になる
violations=$(mysql -N -e "$CHECK_SQL" 2>/dev/null)
if [ -z "$violations" ]; then
echo "ok"
fi
修正後。実行エラーはその場で失敗にし、検査の前提(対象が存在すること)を 先に確かめ、成功の出力に「何を見たか」を含める。
set -euo pipefail # 実行の失敗を「該当なし」に化けさせない
# 検査対象を引数で必須にする。0件では起動できない形にする
[ "$#" -ge 2 ] || { echo "usage: $0 <db> <db>..." >&2; exit 2; }
# 前提の検証: 対象にテーブルが無ければ、検出対象は定義上存在できない。
# それは「成功」ではなく「検査不能」であり、検査不能は失敗として扱う
for db in "$@"; do
n=$(mysql -N -e "SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema='$db'")
[ "$n" -gt 0 ] || { echo "NG: $db にテーブルが無い(前提が崩れている)" >&2; exit 1; }
done
violations=$(mysql -N -e "$CHECK_SQL") # 2>/dev/null を付けない
if [ -z "$violations" ]; then
echo "ok: 違反なし(見た database: $*)" # 根拠を出力に残す
fi
「ok」と「ok: 違反なし(見た database: a, b)」の差は、後からログを読む人が 成功の根拠を検証できるかの差だ。
技法2: readiness は「期待する実体だけが返せる応答」で測る
事例3への対策。「生きている」と「期待するものが生きている」は別の命題で、
/healthz は前者しか言えない。
# fail open: 旧プロセスが生き残っていても、この 200 は成功に見える
until curl -sf http://localhost:8100/healthz; do sleep 1; done
修正後。認証ミドルウェアの有無で応答コードが変わるリクエストを使う。 認証ありの実体だけが 401 を返せる(認証なしの実体は認証を素通りして 本体のバリデーションまで届き、空のリクエストに 400 を返す)。
# fail closed: 401 は「認証ミドルウェアを持つ新しい実体」だけが返せる値
for i in $(seq 1 40); do
c=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST http://localhost:8100/check \
-H "Content-Type: application/json" -d '{}')
[ "$c" = 401 ] && exit 0
sleep 2
done
echo "NG: 期待する実体が起動していない (last=$c)" >&2
exit 1
この修正を入れた瞬間、それまで隠れていた真因(旧プロセスが kill に掛からない)が 初めてエラーとして出てきた。fail open を closed に直すと、 溜まっていた問題が音を立てて出てくる。
技法3: 破ってみせてから信用する
最後は Go のテストコードで。「トランザクションの中から外部ストレージを 呼ばない」という規約を守らせたい場面を考える(行ロックを握ったまま 外部 I/O を待つと、ロック待ちが後続に伝播するため)。
素朴に書いたテストはこうなる。
// FakeAtomic は単に fn を呼ぶだけのテストダブル
type FakeAtomic struct{}
func (FakeAtomic) Do(ctx context.Context, fn func(context.Context) error) error {
return fn(ctx)
}
func TestConfirmOrder(t *testing.T) {
uc := NewOrderCommands(FakeAtomic{}, repo, storage)
got, err := uc.ConfirmOrder(ctx, orderID)
// 結果と、ストレージが1回呼ばれたことだけを検証している
}
このテストは fail open だ。FakeAtomic が「tx の中か外か」を区別しないので、 規約違反の実装(tx の中でストレージを呼ぶ)でもこのテストは成功する。 直したことを確かめたつもりで、何も確かめていない。
修正後。「今 tx の中か」を観測できるダブルと、tx の中から呼ばれたら 失敗するストレージを組む。
// txTracker は「今 tx の中か」を持つ Atomic
type txTracker struct{ inside bool }
func (t *txTracker) Do(ctx context.Context, fn func(context.Context) error) error {
t.inside = true
defer func() { t.inside = false }()
return fn(ctx)
}
// txAwareStorage は tx の内側から呼ばれたら失敗するストレージ
type txAwareStorage struct {
*fakeStorage
tx *txTracker
}
func (s *txAwareStorage) Stat(ctx context.Context, key string) (Info, error) {
if s.tx.inside {
return Info{}, errors.New("Stat が tx の中で呼ばれた(行ロックを握って外部 I/O を待つ形)")
}
return s.fakeStorage.Stat(ctx, key)
}
func TestConfirmOrderKeepsStorageOutOfTx(t *testing.T) {
tx := &txTracker{}
storage := &txAwareStorage{fakeStorage: &fakeStorage{}, tx: tx}
uc := NewOrderCommands(tx, repo, storage)
if _, err := uc.ConfirmOrder(ctx, orderID); err != nil {
t.Fatal(err)
}
}
そして仕上げが本題で、このテスト自体が fail open でないことを、破って確認する。 実装をわざと規約違反の形(tx の中でストレージを呼ぶ)に戻して走らせ、
--- FAIL: TestConfirmOrderKeepsStorageOutOfTx
Stat が tx の中で呼ばれた(行ロックを握って外部 I/O を待つ形)
が出ることを見てから、修正版で成功を見る。この順番を踏んで初めて、 このテストの成功に意味が生まれる。
同じことは検査スクリプトにも適用できる。今回、シェルの検査に対して 「対象の行を1行消す」形の変異を数種類作って走らせたところ、変異の1つが すり抜けて、検査スクリプト自体のバグ(grep のパターンが緩く、2層あるうち 1層が残っていれば通ってしまう)が見つかった。検査も、破られてみせて 初めて信用できる。
まとめ
- テストの成功は「問題がない」ではなく「このテストが見た範囲では見つからなかった」
- テスト・検査・readiness は構造上、壊れると成功として出てくる(fail open)。 壊れる場所はテストコードの外——実行環境、置き場所、経路、依存解決——にある
- fail closed に寄せる技法は3つ: 見たものを出力する、前提が崩れたら 検査自体を失敗させる、破ってみせてから信用する
- 本体のコードは失敗すれば気づける。検査は失敗しても成功に見える。 だから疑うべきは、ずっと成功し続けているものである