5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Docker Registry v2 の古いイメージを自動削除する

5
Last updated at Posted at 2026-06-03

はじめに

プライベート Docker Registry を運用していると、push されたイメージがどんどん溜まっていきます。放置するとディスクを圧迫するので、定期的に古いイメージを掃除する仕組みが必要です。

本記事では、保持期間世代数の2軸で古いマニフェストを削除し、最後に registry garbage-collect でディスクを解放するシェルスクリプトを紹介します。

対象環境

  • Docker Registry: docker/distribution v2.6.xdocker-distribution RPM パッケージ版)
  • OS: CentOS 7 / RHEL 7 系
  • 依存: curl, jq, find

※RHEL8以上の場合registryコンテナを利用するケースがあるが、コンテナ向けに処理を修正すれば同じロジックで動作可能

マニフェスト・レイヤー・ブロブの関係図

Docker Registry v2 の内部構造を理解するための図です。

registry_structure.png

この図のポイント

概念 説明
タグ (tag) 人間が読める名前。current/link ファイルでマニフェストのダイジェストを指す
マニフェスト (manifest) イメージの「設計図」。どの config と layer で構成されるかを記述した JSON
ブロブ (blob) 実データ。config(イメージのメタ情報)と layer(ファイルシステムの差分 tar.gz)がある
孤立マニフェスト タグの上書き push 等で、どのタグからも参照されなくなったマニフェスト。本スクリプトで DELETE する
未参照ブロブ マニフェスト削除後、どのマニフェストからも参照されなくなったブロブ。garbage-collect で削除される

削除の2段階プロセス

レジストリのイメージを削除するには、次の2段階で処理を行う必要があります。

  1. レジストリのAPIでマニフェストを削除
  2. ガベージコレクション(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

注意点:

  • maintenancestorage セクションの子要素として配置する必要があります
  • 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 で異常終了時も確実に復帰
5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?