
はじめに
セルフホストのCIランナーを運用していると、ディスク容量を守るために定期的な docker イメージのクリーンアップジョブを仕込むことがよくあります。ところが、あるプロジェクトで運用していたクリーンアップスクリプトが、本来は保護したいはずのCI専用ローカルビルドイメージまで毎月削除してしまうという事故が起きました。
厄介だったのは、コードの中に「保護対象を除外するための正規表現」がちゃんと定義されていたことです。読んだ人は誰でも「これで除外されているはず」と思い込んでしまう構造になっていました。しかし実際には、その変数は削除コマンドの引数には一切渡っていませんでした。
この記事では、この事故の原因を技術的に分解し、docker image prune の仕様上の落とし穴と、より安全な削除ロジックへの書き換え方を紹介します。
こんな人におすすめ
- セルフホストCIランナーやオンプレ環境でDockerイメージの定期クリーンアップを運用している方
docker image pruneの--filterオプションの挙動を正確に理解したい方- 「変数は定義されているのに実際のロジックに使われていない」というコードレビューで見落としがちなバグパターンを知りたい方
- 破壊的な定期ジョブに対する回帰テストの設計に悩んでいる方
罠1: docker image prune の --filter に「除外」の指定はない
まず前提として、docker image prune の --filter は「これより古いものを消す」(until=Nh など)という条件を足すためのオプションであり、「このパターンに一致するものは除外する」という指定はできません。除外したいイメージがある場合、prune 任せにはできず、削除候補を自前で列挙して絞り込む必要があります。
事故前のコードは、次のような形をしていました。
OLD_IMGS=$(docker images --format '{{.Repository}}:{{.Tag}}' | grep -vE "$PROTECT_PATTERN" || true)
if [ -n "$OLD_IMGS" ]; then
# PROTECT_PATTERN は「削除するものが1件でもあるか」の空判定にしか使われていない
docker image prune -a -f --filter "until=${IMAGE_AGE_H}h"
fi
一見すると「保護パターンで除外したリストを作ってから prune している」ように読めます。しかし実際に prune に渡っているのは --filter until=... だけで、OLD_IMGS は「1件でも削除候補があるか」を確認するためだけに使われていました。保護パターンは削除の実行有無の判定に紛れ込んでいただけで、削除そのものには一切効いていなかったのです。
罠2: 定義した変数と、実際に使われているロジックは別問題
このバグの本質は、「除外用の正規表現を定義したこと」と「それが実際に削除コマンドへ渡っていること」が別問題である、という点です。変数名や周辺のコメントは意図を正しく説明していても、実装がその意図通りに繋がっているとは限りません。
レビューでも見落とされやすいパターンです。PROTECT_PATTERN という名前と「除外フィルタとして機能している」というコメントを見れば、多くの人は疑わずに読み飛ばしてしまいます。動く変数と、実際にロジックへ配線されている変数を、目視だけで区別するのは簡単ではありません。
罠3: 「作成時刻」基準の削除はビルドキャッシュ次第で挙動が変わる
さらに事故の原因究明を難しくしていたのが、削除対象の判定基準が「未使用かどうか」ではなく「作成時刻」だったことです。
Dockerfileに変更がなく完全にビルドキャッシュがヒットした場合、そのイメージのIDも作成時刻も更新されません。つまり、キャッシュがヒットしたイメージだけが相対的に「古い」扱いになり、単独で削除対象に入ってしまいます。逆に再ビルドされたイメージは作成時刻が更新されるため、閾値をすり抜けます。
実行するたびに被害範囲が変わるため、「先月は消えなかったのに今月は消えた」という再現性のない障害になり、原因の切り分けを難しくしていました。
解決策: 候補を明示列挙してから個別に削除する
これらの問題を踏まえて書き直したのが、次のロジックです。ポイントは、prune に任せず、削除候補を1件ずつ明示的に列挙し、複数の除外条件をその場で評価することです。
while IFS='|' read -r image_id repo_tag created; do
[ -n "${image_id:-}" ] || continue
case "$repo_tag" in *'<none>'*) continue ;; esac # dangling は別処理に委譲
if printf '%s' "$repo_tag" | grep -qE "$PROTECT_IMAGE_RE"; then
continue # 保護パターンに一致
fi
if printf '%s\n' "$IMAGE_IDS_IN_USE" | grep -qxF "$image_id"; then
continue # コンテナが参照中
fi
created_epoch=$(date -j -f '%Y-%m-%d %H:%M:%S %z' "$created" +%s 2>/dev/null) || continue
[ "$created_epoch" -lt "$CUTOFF_EPOCH" ] || continue
candidates="${candidates}${repo_tag}\n"
done <<EOF
$(docker images --no-trunc --format '{{.ID}}|{{.Repository}}:{{.Tag}}|{{.CreatedAt}}')
EOF
printf '%b' "$candidates" | while read -r repo_tag; do
[ -n "${repo_tag:-}" ] || continue
docker rmi "$repo_tag"
done
「保護パターン一致」「コンテナ参照中」「dangling」「年齢閾値内」の4条件をすべて満たさなかったものだけが削除候補に残る構造です。
なお、削除は docker rmi を repo:tag 単位で1件ずつ行っています。同じイメージIDに複数のタグが付いている場合、IDを指定して削除しようとすると1回目の実行で全タグが外れてしまい、2回目以降が失敗します。タグ単位のuntagとして扱うほうが安全です。
また、-f を付けない docker rmi は、コンテナから参照中のイメージを拒否してくれます。事前のコンテナ参照チェックが漏れていた場合でも、これが二重の安全弁として機能します。
回帰テストで再発を防ぐ
同じ事故を繰り返さないために、削除対象のイメージ一覧と保護パターンを突き合わせる回帰テストも用意しました。ポイントは、テスト側に定数をハードコードするのではなく、本体のスクリプトから直接読み込むことです。
# 本体の削除対象一覧から全イメージタグを読み込み、
# 保護パターンで実際にマッチするか検証する。
# 新しいCI用イメージを追加してパターン更新を忘れると、このテストが落ちる。
while read -r tag; do
if printf '%s\n' "$tag" | grep -qE "$PROTECT_IMAGE_RE"; then
echo "ok: protected: $tag"
else
echo "FAIL: not protected: $tag"
fi
done <<EOF
$BUILD_TAGS
EOF
定数をテスト側に複製すると、本体側の変更に追随できず形骸化してしまいます。grep などで本体の定義行から直接値を読み込む方式にしておくと、テストが本体と乖離しにくくなります。
つまづきやすいポイント
docker image pruneの--filterは「除外」ではなく「条件を足す」ためのオプションです。除外したいならpruneに任せず、候補を自前で列挙する必要があります- 除外用の変数やパターンを定義しただけでは不十分です。実際にそれが削除ロジックの引数として使われているか、配線まで確認する必要があります
- 破壊的な定期ジョブの失敗・異常通知は、通常の監視通知の間引き(throttle)設定に埋もれやすい傾向があります。破壊的な処理の失敗通知だけは間引き対象から外す、別チャンネルにするといった設計が有効です
- dry-runモードをデフォルトにし、実削除は明示的なフラグでのみ有効化する運用にしておくと、同種の事故の被害を抑えやすくなります
まとめ
docker image prune は便利なコマンドですが、「除外指定ができない」という仕様上の制約を理解しないまま使うと、保護したいイメージまで巻き込んで削除してしまうリスクがあります。除外用の変数を定義することと、それが実際に削除ロジックへ渡っていることは別問題であり、この乖離はレビューでも見落とされやすいポイントです。破壊的な定期ジョブについては、候補の明示列挙・複数条件での絞り込み・回帰テストによる保護パターンの検証をセットで用意しておくことをおすすめします。


