はじめに
プライベート Docker Registry を運用していると、push されたイメージがどんどん溜まっていきます。放置するとディスクを圧迫するので、定期的に古いイメージを掃除する仕組みが必要です。
本記事では、保持期間と世代数の2軸で古いマニフェストを削除し、最後に registry garbage-collect でディスクを解放するシェルスクリプトを紹介します。
対象環境
- Docker Registry:
docker/distribution v2.6.x(docker-distributionRPM パッケージ版) - OS: CentOS 7 / RHEL 7 系
- 依存:
curl,jq,find
※RHEL8以上の場合registryコンテナを利用するケースがあるが、コンテナ向けに処理を修正すれば同じロジックで動作可能
マニフェスト・レイヤー・ブロブの関係図
Docker Registry v2 の内部構造を理解するための図です。
この図のポイント
| 概念 | 説明 |
|---|---|
| タグ (tag) | 人間が読める名前。current/link ファイルでマニフェストのダイジェストを指す |
| マニフェスト (manifest) | イメージの「設計図」。どの config と layer で構成されるかを記述した JSON |
| ブロブ (blob) | 実データ。config(イメージのメタ情報)と layer(ファイルシステムの差分 tar.gz)がある |
| 孤立マニフェスト | タグの上書き push 等で、どのタグからも参照されなくなったマニフェスト。本スクリプトで DELETE する |
| 未参照ブロブ | マニフェスト削除後、どのマニフェストからも参照されなくなったブロブ。garbage-collect で削除される |
削除の2段階プロセス
レジストリのイメージを削除するには、次の2段階で処理を行う必要があります。
- レジストリのAPIでマニフェストを削除
- ガベージコレクション(GC)により参照されていないブロブを削除
スクリプト全文
registry_gc.sh
#!/bin/bash
set -euo pipefail
#=============================================================================
# Docker Registry GC Script
# - GC前にread-onlyモードに切り替え、終了時に必ず復帰する
# - 保持期間 or 世代数ベースで古いマニフェストを削除
# - タグに紐づかない孤立マニフェストも削除
#=============================================================================
# 定数・設定値(大文字)
WORKDIR=$(mktemp -d /tmp/regDelWork.XXXXXX)
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
IMAGELIST="${WORKDIR}/image_list"
DELETELIST="${WORKDIR}/delete_list"
REGISTRY_BASE="/var/lib/registry/docker/registry/v2"
REGISTRY_URL="http://localhost:5000/v2"
REGISTRY_CONFIG="/etc/docker-distribution/registry/config.yml"
RETENTION_PERIOD="100 days"
RETENTION_GENERATION=5
# 除外パターン(grepの正規表現)
EXCLUDE_PATTERN="[除外したい文字列]/"
# ドライランモード(--dry-run で有効)
DRY_RUN=false
# 一時ファイル保持(--keep-tmp で有効)
KEEP_TMP=false
for arg in "$@"; do
case "${arg}" in
--dry-run|-n) DRY_RUN=true ;;
--keep-tmp) KEEP_TMP=true ;;
esac
done
#=============================================================================
# 関数定義
#=============================================================================
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}
cleanup() {
if [ "${KEEP_TMP}" = true ]; then
log "一時ファイルを保持: ${WORKDIR}"
else
log "一時ファイルを削除: ${WORKDIR}"
rm -rf "${WORKDIR}"
fi
}
enable_readonly() {
log "Registry を read-only モードに切り替え..."
# 初回のみバックアップを取得
if [ ! -f "${REGISTRY_CONFIG}.org" ]; then
cp -p "${REGISTRY_CONFIG}" "${REGISTRY_CONFIG}.org"
log "バックアップ作成: ${REGISTRY_CONFIG}.org"
fi
python "${SCRIPT_DIR}/toggle_readonly.py" "${REGISTRY_CONFIG}" enable
systemctl restart docker-distribution
sleep 3
log "Registry read-only モード有効"
}
disable_readonly() {
log "Registry を通常モードに復帰..."
python "${SCRIPT_DIR}/toggle_readonly.py" "${REGISTRY_CONFIG}" disable
systemctl restart docker-distribution
log "Registry 通常モード復帰完了"
}
# スクリプト異常終了時の安全策(read-onlyのまま死んだ場合に復帰)
trap 'disable_readonly 2>/dev/null; cleanup' EXIT
#=============================================================================
# メイン処理
#=============================================================================
# 保持期間の基準日を算出
DELETE_IMAGE_DATE=$(date -d "${RETENTION_PERIOD} ago" +%Y%m%d%H%M)
#-----------------------------------------------------------------------------
# イメージリスト作成(ページネーション対応)
#-----------------------------------------------------------------------------
log "イメージリスト取得開始..."
while true; do
if [ ! -e "${IMAGELIST}" ]; then
count1=0
response=$(curl -sf "${REGISTRY_URL}/_catalog?n=100") || {
log "ERROR: カタログ取得に失敗しました"
exit 1
}
echo "${response}" | /usr/local/bin/jq -r '.repositories[]' >> "${IMAGELIST}"
count2=$(wc -l < "${IMAGELIST}")
else
last=$(tail -1 "${IMAGELIST}")
count1=$(wc -l < "${IMAGELIST}")
response=$(curl -sf "${REGISTRY_URL}/_catalog?n=100&last=${last}") || {
log "ERROR: カタログ取得に失敗しました (last=${last})"
exit 1
}
echo "${response}" | /usr/local/bin/jq -r '.repositories[]' >> "${IMAGELIST}"
count2=$(wc -l < "${IMAGELIST}")
fi
log "カタログ取得中: ${count1} -> ${count2}"
if [ "${count1}" -eq "${count2}" ]; then
break
fi
done
log "イメージリスト取得完了: $(wc -l < "${IMAGELIST}") イメージ"
#-----------------------------------------------------------------------------
# イメージリストを順番に読み込み、削除対象を特定
#-----------------------------------------------------------------------------
while read -r image; do
tags_dir="${REGISTRY_BASE}/repositories/${image}/_manifests/tags"
revisions_dir="${REGISTRY_BASE}/repositories/${image}/_manifests/revisions/sha256"
# タグディレクトリが存在しない場合はスキップ
if [ ! -d "${tags_dir}" ]; then
log "SKIP: タグなし ${image}"
continue
fi
# current/link ファイル一覧を取得
mapfile -t tag_links < <(find "${tags_dir}" -path "*/current/link" | sort)
if [ ${#tag_links[@]} -eq 0 ]; then
continue
fi
# 保持期間より新しいタグの数をカウント(latestを除く)
recent_count=0
for link in "${tag_links[@]}"; do
tag_name=$(echo "${link}" | awk -F/ '{print $(NF-2)}')
if [ "${tag_name}" = "latest" ]; then
continue
fi
if [[ "${tag_name}" > "${DELETE_IMAGE_DATE}" ]]; then
recent_count=$((recent_count + 1))
fi
done
# 削除対象の判定
if [ "${recent_count}" -le "${RETENTION_GENERATION}" ]; then
# 保持期間内のタグが世代数以下 → 古い方から世代数分を残して削除
non_latest_links=()
for link in "${tag_links[@]}"; do
tag_name=$(echo "${link}" | awk -F/ '{print $(NF-2)}')
if [ "${tag_name}" != "latest" ]; then
non_latest_links+=("${link}")
fi
done
# 新しい方からRETENTION_GENERATION個を残す(末尾がソート上新しい)
delete_count=$(( ${#non_latest_links[@]} - RETENTION_GENERATION ))
if [ "${delete_count}" -gt 0 ]; then
for ((i=0; i<delete_count; i++)); do
manifest=$(cat "${non_latest_links[$i]}")
echo "curl -sf -XDELETE \"${REGISTRY_URL}/${image}/manifests/${manifest}\"" >> "${DELETELIST}"
done
fi
else
# 保持期間より古いタグを削除
for link in "${tag_links[@]}"; do
tag_name=$(echo "${link}" | awk -F/ '{print $(NF-2)}')
if [ "${tag_name}" = "latest" ]; then
continue
fi
if [[ "${tag_name}" < "${DELETE_IMAGE_DATE}" ]]; then
manifest=$(cat "${link}")
echo "curl -sf -XDELETE \"${REGISTRY_URL}/${image}/manifests/${manifest}\"" >> "${DELETELIST}"
fi
done
fi
# tagに紐づくマニフェスト一覧を取得(このイメージ分のみ)
currents_file="${WORKDIR}/currents_${image//\//_}"
for link in "${tag_links[@]}"; do
cat "${link}" >> "${currents_file}"
done
# マニフェスト一覧を取得し、tagに紐付かないマニフェストをリスト化
if [ -d "${revisions_dir}" ]; then
while read -r revision_link; do
manifest=$(cat "${revision_link}")
if ! grep -qF "${manifest}" "${currents_file}" 2>/dev/null; then
echo "curl -sf -XDELETE \"${REGISTRY_URL}/${image}/manifests/${manifest}\"" >> "${DELETELIST}"
fi
done < <(find "${revisions_dir}" -name "link" -type f)
fi
# イメージごとのcurrentsファイルを削除
rm -f "${currents_file}"
done < <(grep -v "${EXCLUDE_PATTERN}" "${IMAGELIST}")
#-----------------------------------------------------------------------------
# 削除実行
#-----------------------------------------------------------------------------
if [ -f "${DELETELIST}" ]; then
total=$(wc -l < "${DELETELIST}")
if [ "${DRY_RUN}" = true ]; then
log "[DRY-RUN] 削除対象: ${total} マニフェスト"
log "[DRY-RUN] 削除リスト:"
cat "${DELETELIST}"
log "[DRY-RUN] 実際の削除は行いません"
else
log "削除対象: ${total} マニフェスト"
count=0
while read -r line; do
count=$((count + 1))
if ! eval "${line}"; then
log "WARN: 削除失敗 (${count}/${total}): ${line}"
fi
done < "${DELETELIST}"
log "削除完了: ${count}/${total}"
fi
else
log "削除対象なし"
fi
#-----------------------------------------------------------------------------
# Garbage Collection 実行(read-only にしてから実行)
#-----------------------------------------------------------------------------
if [ "${DRY_RUN}" = true ]; then
log "[DRY-RUN] garbage-collect をスキップ"
else
enable_readonly
log "Garbage Collection 開始..."
registry garbage-collect "${REGISTRY_CONFIG}"
log "Garbage Collection 完了"
disable_readonly
fi
toggle_readonly.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Toggle storage.maintenance.readonly.enabled in Docker Registry config.yml
Usage:
python toggle_readonly.py <config_path> enable
python toggle_readonly.py <config_path> disable
"""
import sys
import yaml
def main():
if len(sys.argv) != 3:
print("Usage: {} <config_path> <enable|disable>".format(sys.argv[0]))
sys.exit(1)
config_path = sys.argv[1]
action = sys.argv[2]
if action not in ("enable", "disable"):
print("Error: action must be 'enable' or 'disable'")
sys.exit(1)
with open(config_path) as f:
conf = yaml.safe_load(f)
# storage セクションがなければ作成
if "storage" not in conf or conf["storage"] is None:
conf["storage"] = {}
if "maintenance" not in conf["storage"] or conf["storage"]["maintenance"] is None:
conf["storage"]["maintenance"] = {}
if "readonly" not in conf["storage"]["maintenance"] or conf["storage"]["maintenance"]["readonly"] is None:
conf["storage"]["maintenance"]["readonly"] = {}
conf["storage"]["maintenance"]["readonly"]["enabled"] = (action == "enable")
with open(config_path, "w") as f:
yaml.dump(conf, f, default_flow_style=False)
print("{}: storage.maintenance.readonly.enabled = {}".format(
action, action == "enable"
))
if __name__ == "__main__":
main()
解説
前提: なぜ GC が必要なのか
Docker Registry v2 では、API で DELETE /v2/<image>/manifests/<digest> を叩いても、実際のレイヤー(blob)はディスクから消えません。マニフェストへの参照が消えるだけです。
実際にディスクを解放するには registry garbage-collect コマンドを実行する必要があります。このコマンドは、どのマニフェストからも参照されていない blob を特定して削除します。
タグの命名規則(前提条件)
このスクリプトは タグ名が日時ベース(例: 202605291430) であることを前提としています。タグ名を文字列比較することで新旧を判定しているため、セマンティックバージョニング(v1.2.3)などのタグには対応していません。
削除ロジックの2パターン
パターン1: 世代数ベース(タグが少ない場合)
保持期間内の新しいタグ数が RETENTION_GENERATION(上記シェルでは5)以下の場合に適用されます。
- 全タグ(latest除く)をソートし、新しい方から5個を残す
- それより古いものを削除対象にする
パターン2: 期間ベース(タグが多い場合)
保持期間内の新しいタグが RETENTION_GENERATION より多い場合に適用されます。
- 基準日(100日前)より古いタグを削除対象にする
孤立マニフェストの削除
タグベースの削除に加えて、どのタグからも参照されていないマニフェスト(revisions に存在するが current/link から参照されていないもの)も削除対象にします。
これは過去にタグを上書き push した場合などに発生する「ゴミ」です。
Read-Only モード
registry garbage-collect は「mark(参照されている blob を記録)→ sweep(未参照 blob を削除)」の2フェーズで動きます。この GC 実行中に push が行われると、push 途中のレイヤーがまだマニフェストに紐づいていない状態で「未参照」と判定され、削除されてしまいます。結果としてイメージが壊れます。
これを防ぐため、garbage-collect の実行直前に Registry を read-only モードに切り替えます。
storage:
maintenance:
readonly:
enabled: true
注意点:
-
maintenanceはstorageセクションの子要素として配置する必要があります - read-only モードでは push だけでなく DELETE API も拒否されます
- そのため、マニフェストの DELETE は read-only にする前に実行し、read-only は GC の間だけ有効にします
実行順序:
1. 削除対象のリスト化(ファイルシステム読み取りのみ)
2. DELETE API でマニフェスト参照を削除(通常モード)
3. read-only ON
4. registry garbage-collect 実行(未参照 blob をディスクから削除)
5. read-only OFF
trap EXIT を使って、スクリプトが異常終了しても read-only が解除されるようにしています。
ディレクトリ構造の理解
Registry v2 のファイルシステム構造:
/var/lib/registry/docker/registry/v2/
└── repositories/
└── <image>/
└── _manifests/
├── tags/
│ ├── <tag1>/
│ │ └── current/
│ │ └── link ← sha256:xxxx(現在のダイジェスト)
│ └── <tag2>/
│ └── current/
│ └── link
└── revisions/
└── sha256/
├── <digest1>/
│ └── link
└── <digest2>/
└── link
-
tags/*/current/link: 各タグが現在指しているマニフェストのダイジェスト -
revisions/sha256/*/link: そのイメージに関連する全マニフェストのダイジェスト
設定パラメータ
| パラメータ | デフォルト値 | 説明 |
|---|---|---|
RETENTION_PERIOD |
100 days |
この期間より古いタグを削除対象とする |
RETENTION_GENERATION |
5 |
最低限残す世代数 |
EXCLUDE_PATTERN |
任意 |
削除対象から除外するイメージのパターン |
REGISTRY_URL |
http://localhost:5000/v2 |
Registry API の URL |
REGISTRY_CONFIG |
/etc/docker-distribution/registry/config.yml |
Registry 設定ファイルのパス |
注意事項
- 事前にAPI での DELETE を許可する設定が必要です
- GC 中は push ができなくなるため、CI/CD のスケジュールと被らない時間帯に実行してください
- 初回実行前に、
EXCLUDE_PATTERNを自分の環境に合わせて変更してください - タグ名が日時フォーマット(
YYYYMMDDHHmm)でない場合は、削除ロジックの修正が必要です
まとめ
- Docker Registry v2 は放置するとディスクが膨らみ続ける
- API での DELETE +
garbage-collectの2段階が必要 - 保持期間と世代数の2軸で「消しすぎ」を防止
- GC 中は read-only にして整合性を担保
-
trap EXITで異常終了時も確実に復帰
