Главная/Registry/Урок

14.4. Стратегия tagging

Цели

После этого материала вы сможете:

  • объяснить, почему развёртывание по latest делает откат ненадёжным;
  • спроектировать схему тегов с неизменяемыми якорями и подвижными указателями;
  • обосновать выбор commit SHA как основы воспроизводимости;
  • развернуть образ по digest и объяснить, что это гарантирует;
  • составить cleanup policy, не удаляющую то, что развёрнуто;
  • объяснить, почему удаление тега не освобождает место, и что освобождает.

Предварительные знания

Ключевые термины

ТерминОбъяснение
неизменяемый тегТег, который никогда не переназначается
подвижный указательТег, меняющий цель при каждом выпуске
commit SHAИдентификатор состояния исходного кода
cleanup policyПравила удаления старых образов
retentionЧто сохраняется независимо от возраста

Теория

Почему latest ломает откат

latest — обычный тег без особого смысла. Docker не считает его «последней версией»: он просто подставляется, когда тег не указан.

Сценарий отказа:

text
10:00  выпуск 1.5 → тег latest указывает на образ A
14:00  выпуск 1.6 → тег latest указывает на образ B
14:30  обнаружена ошибка в 1.6
14:31  «откатываемся на latest»  ← latest = B, ошибка остаётся

Проблема не в том, что latest плох сам по себе. Проблема в том, что у образа A не осталось имени. Откатиться некуда: тег переназначен, а digest никто не записал.

Отсюда правило: у каждого выпуска должно быть имя, которое никогда не меняется.

Неизменяемые теги и подвижные указатели

Схема из двух уровней:

УровеньПримерыСвойство
Неизменяемые1.6.2, git-a3f2c1b, 2026-07-31-1423Никогда не переназначаются
Подвижныеlatest, stable, 1.6, stagingУказывают на текущий выбор

Все указывают на один образ, но роли разные:

text
образ sha256:8a3f...
   ├── git-a3f2c1b     ← неизменяемый: якорь
   ├── 1.6.2           ← неизменяемый: версия
   ├── 1.6             ← подвижный: последний патч в 1.6
   ├── 1               ← подвижный: последний в мажорной 1
   └── latest          ← подвижный: текущий выпуск
ТегКому нужен
git-a3f2c1bРазвёртыванию, откату, расследованию
1.6.2Людям: понятная версия
1.6, 1Тем, кто хочет «свежий патч» и готов к обновлению
latestБыстрым проверкам и примерам в документации

Правило развёртывания: в конфигурацию попадает неизменяемый тег или digest, а не подвижный.

Commit SHA как основа

bash
docker build -t ghcr.io/org/api:git-$(git rev-parse --short HEAD) .

Почему именно SHA:

СвойствоЗначение
ОднозначностьСостояние кода восстанавливается командой git checkout
АвтоматизмНе требует решения человека о номере версии
Отсутствие коллизийДва выпуска не получат один тег
Обратная связьПо тегу образа находится код, по коду — образ

Последняя строка — то, ради чего это делается. Вопрос «какой код работает в эксплуатации» получает точный ответ за одну команду.

Ограничение: SHA не говорит человеку, новее ли git-a3f2c1b, чем git-9e1d4f2. Поэтому его дополняют семантической версией или временной меткой:

text
2026.07.31-1423-a3f2c1b

Такой тег и сортируется, и однозначен.

Развёртывание по digest

Тег — указатель, и его можно переназначить. Digest — контрольная сумма содержимого, и переназначить его нельзя.

Что зафиксированоТегомDigest'ом
ИмяДаДа
СодержимоеНетДа
Устойчивость к переназначениюНетДа
Читаемость человекомДаНет
yaml
services:
  api:
    image: ghcr.io/org/api@sha256:8a3f2b1c4d5e...

Практическая схема — использовать оба:

bash
IMAGE="ghcr.io/org/api:git-a3f2c1b"
DIGEST=$(docker inspect "$IMAGE" --format '{{index .RepoDigests 0}}')
# развёртываем $DIGEST, но записываем в журнал и тег

Тег даёт читаемость при расследовании, digest — гарантию.

Cleanup policy

Registry не безграничен. Правила удаления должны отвечать на два вопроса: что удалять и что никогда не удалять.

Список исключений важнее списка правил:

Никогда не удалятьПочему
Образ, развёрнутый где-либо сейчасПересоздание pod'а или container'а не сможет скачать образ
Предыдущий развёрнутый образНужен для отката
Теги релизов (1.6.2)Могут понадобиться для воспроизведения
Образы с меткой keepЯвное решение человека

Типичные правила удаления:

text
образы из feature-веток      старше 14 дней
образы из main               оставить последние 30
образы с тегом релиза        не удалять
незатегованные манифесты     старше 7 дней

Порядок проверки: сначала исключения, потом правила. Обратный порядок однажды удалит то, что работает в эксплуатации.

Удаление тега не освобождает место

Разделение из урока 14.3 здесь становится практическим:

text
1. docker rmi / DELETE тега   → убрана ссылка на манифест
2. манифест без ссылок        → всё ещё занимает место
3. garbage-collect            → blob'ы без ссылок удалены

Пока шаг 3 не выполнен, место занято. Registry в облаке обычно выполняет его сам по расписанию; собственный — только по команде.

Второе следствие: удаление одного тега из трёх, указывающих на один образ, не освобождает ничего. Blob'ы удерживаются оставшимися тегами.


Внутренний механизм

Почему тег можно переназначить, а digest нет

Тег — запись в таблице: имя указывает на digest манифеста. Переназначение переписывает запись.

Digest — результат хеширования самого манифеста. Чтобы digest указывал на другое содержимое, нужно найти коллизию sha256.

Отсюда асимметрия: имя api:1.6.2 может завтра означать другой образ; имя api@sha256:8a3f... — не может.

Что происходит при повторной публикации того же тега

bash
docker push ghcr.io/org/api:1.6.2   # второй раз, с другим содержимым

Registry примет запрос: он перезапишет ссылку тега на новый манифест. Старый манифест останется без этой ссылки.

Именно поэтому неизменяемость релизных тегов — соглашение, а не техническое ограничение. Часть registry позволяет включить защиту (immutable tags); при её отсутствии соблюдение правила остаётся на процессе.

Проверить нарушение можно, сравнив digest тега с записанным при выпуске:

bash
docker buildx imagetools inspect ghcr.io/org/api:1.6.2 --format '{{.Manifest.Digest}}'

Команды и примеры

Три тега на один образ

bash
mkdir -p /tmp/tags && cd /tmp/tags

docker run -d --name reg -p 5000:5000 \
    -e REGISTRY_STORAGE_DELETE_ENABLED=true registry:2 > /dev/null
sleep 4

echo "═══ собираем образ ═══"
cat > Dockerfile <<'EOF'
FROM alpine:3.21
ARG BUILD_ID=неизвестно
RUN echo "$BUILD_ID" > /build-id.txt
CMD ["cat", "/build-id.txt"]
EOF
docker build -q --build-arg BUILD_ID=a3f2c1b -t local-app:tmp . > /dev/null

echo "═══ присваиваем три тега ═══"
for t in git-a3f2c1b 1.6.2 latest; do
    docker tag local-app:tmp "localhost:5000/app:$t"
done
docker images --format '{{.Repository}}:{{.Tag}}  {{.ID}}  {{.Size}}' \
    | grep 'localhost:5000/app' | sed 's/^/  /'

echo "═══ публикуем все три ═══"
for t in git-a3f2c1b 1.6.2 latest; do
    printf '  %-14s ' "$t"
    docker push "localhost:5000/app:$t" 2>&1 | tail -1 | cut -c1-58
done

echo "═══ что в registry ═══"
curl -s http://localhost:5000/v2/app/tags/list | python3 -m json.tool | sed 's/^/  /'

echo "═══ на что указывает каждый тег ═══"
for t in git-a3f2c1b 1.6.2 latest; do
    d="$(curl -s -H 'Accept: application/vnd.oci.image.manifest.v1+json' \
        -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \
        -I "http://localhost:5000/v2/app/manifests/$t" 2>/dev/null \
        | grep -i 'docker-content-digest' | tr -d '\r' | awk '{print $2}')"
    printf '  %-14s %s\n' "$t" "${d:0:30}..."
done

Ожидаемый вывод:

text
═══ собираем образ ═══
═══ присваиваем три тега ═══
  localhost:5000/app:git-a3f2c1b  8f3a2b1c4d5e  7.83MB
  localhost:5000/app:1.6.2  8f3a2b1c4d5e  7.83MB
  localhost:5000/app:latest  8f3a2b1c4d5e  7.83MB
═══ публикуем все три ═══
  git-a3f2c1b    git-a3f2c1b: digest: sha256:4d2c8f1e... size: 738
  1.6.2          1.6.2: digest: sha256:4d2c8f1e... size: 738
  latest         latest: digest: sha256:4d2c8f1e... size: 738
═══ что в registry ═══
  {
      "name": "app",
      "tags": [
          "1.6.2",
          "git-a3f2c1b",
          "latest"
      ]
  }
═══ на что указывает каждый тег ═══
  git-a3f2c1b    sha256:4d2c8f1e9a0b3c5d7e2f4a...
  1.6.2          sha256:4d2c8f1e9a0b3c5d7e2f4a...
  latest         sha256:4d2c8f1e9a0b3c5d7e2f4a...

Три тега, один IMAGE ID, один digest. Публикация второго и третьего тега передала только манифест: слои уже были.

Место занято один раз — теги бесплатны.

Что происходит при переназначении latest

bash
cd /tmp/tags
echo "═══ выпуск 1.6.2 (уже опубликован выше) ═══"
d_162="$(curl -s -I -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \
    http://localhost:5000/v2/app/manifests/1.6.2 2>/dev/null \
    | grep -i 'docker-content-digest' | tr -d '\r' | awk '{print $2}')"
d_latest_before="$(curl -s -I -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \
    http://localhost:5000/v2/app/manifests/latest 2>/dev/null \
    | grep -i 'docker-content-digest' | tr -d '\r' | awk '{print $2}')"
printf '  1.6.2  → %s\n' "${d_162:0:30}..."
printf '  latest → %s\n' "${d_latest_before:0:30}..."

echo "═══ выпуск 1.6.3: новый образ, latest переназначен ═══"
docker build -q --build-arg BUILD_ID=9e1d4f2 -t local-app:new . > /dev/null
docker tag local-app:new localhost:5000/app:git-9e1d4f2
docker tag local-app:new localhost:5000/app:1.6.3
docker tag local-app:new localhost:5000/app:latest
for t in git-9e1d4f2 1.6.3 latest; do
    docker push -q "localhost:5000/app:$t" > /dev/null 2>&1
done

d_latest_after="$(curl -s -I -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \
    http://localhost:5000/v2/app/manifests/latest 2>/dev/null \
    | grep -i 'docker-content-digest' | tr -d '\r' | awk '{print $2}')"
d_163="$(curl -s -I -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \
    http://localhost:5000/v2/app/manifests/1.6.3 2>/dev/null \
    | grep -i 'docker-content-digest' | tr -d '\r' | awk '{print $2}')"

echo "═══ после выпуска ═══"
printf '  1.6.2  → %s  (не изменился)\n' "${d_162:0:30}..."
printf '  1.6.3  → %s\n' "${d_163:0:30}..."
printf '  latest → %s  (был %s)\n' "${d_latest_after:0:30}..." "${d_latest_before:0:22}..."

echo "═══ откат ═══"
cat <<'TXT'
  «Откатываемся на latest» — latest уже указывает на 1.6.3.
  Откат не произойдёт.

  Откат на 1.6.2 возможен, потому что этот тег НЕ переназначался:
    docker pull localhost:5000/app:1.6.2

  Если бы схема состояла только из latest, у предыдущего образа
  не осталось бы имени. Он бы существовал в registry, но обратиться
  к нему было бы нечем — кроме digest, который никто не записал.
TXT

echo "═══ проверка отката ═══"
docker rmi -f localhost:5000/app:1.6.2 > /dev/null 2>&1
docker pull -q localhost:5000/app:1.6.2 > /dev/null 2>&1
printf '  содержимое образа 1.6.2: '
docker run --rm localhost:5000/app:1.6.2
printf '  содержимое образа latest: '
docker rmi -f localhost:5000/app:latest > /dev/null 2>&1
docker pull -q localhost:5000/app:latest > /dev/null 2>&1
docker run --rm localhost:5000/app:latest

Ожидаемый вывод:

text
═══ выпуск 1.6.2 (уже опубликован выше) ═══
  1.6.2  → sha256:4d2c8f1e9a0b3c5d7e2f4a...
  latest → sha256:4d2c8f1e9a0b3c5d7e2f4a...
═══ выпуск 1.6.3: новый образ, latest переназначен ═══
═══ после выпуска ═══
  1.6.2  → sha256:4d2c8f1e9a0b3c5d7e2f4a...  (не изменился)
  1.6.3  → sha256:7b1e5c3a2d9f4e8b6a0c1d...
  latest → sha256:7b1e5c3a2d9f4e8b6a0c1d...  (был sha256:4d2c8f1e9a0b...)
═══ откат ═══
  «Откатываемся на latest» — latest уже указывает на 1.6.3.
  Откат не произойдёт.

  Откат на 1.6.2 возможен, потому что этот тег НЕ переназначался:
    docker pull localhost:5000/app:1.6.2

  Если бы схема состояла только из latest, у предыдущего образа
  не осталось бы имени. Он бы существовал в registry, но обратиться
  к нему было бы нечем — кроме digest, который никто не записал.
═══ проверка отката ═══
  содержимое образа 1.6.2: a3f2c1b
  содержимое образа latest: 9e1d4f2

Две последние строки — доказательство: 1.6.2 вернул старую сборку, latest — новую. Неизменяемый тег сработал как якорь.

Развёртывание по digest

bash
cd /tmp/tags
echo "═══ получаем digest выпуска ═══"
docker pull -q localhost:5000/app:1.6.3 > /dev/null 2>&1
digest="$(docker inspect localhost:5000/app:1.6.3 \
    --format '{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}')"
echo "  $digest"

echo "═══ подменяем тег, digest не трогаем ═══"
docker tag local-app:tmp localhost:5000/app:1.6.3
docker push -q localhost:5000/app:1.6.3 > /dev/null 2>&1
echo "  тег 1.6.3 переназначен на СТАРЫЙ образ (имитация ошибки выпуска)"

echo "═══ что получим по тегу и что по digest ═══"
docker rmi -f localhost:5000/app:1.6.3 > /dev/null 2>&1
docker pull -q localhost:5000/app:1.6.3 > /dev/null 2>&1
printf '  по тегу 1.6.3:  '
docker run --rm localhost:5000/app:1.6.3

docker pull -q "$digest" > /dev/null 2>&1
printf '  по digest:      '
docker run --rm "$digest"

echo "═══ вывод ═══"
cat <<'TXT'
  Тег изменился — по нему пришёл другой образ.
  Digest не изменился — по нему пришёл тот же.

  Это не теория: переназначение релизного тега случается
  при повторном запуске задачи CI, при исправлении «того же»
  выпуска, при ручной публикации с рабочей машины.

  Практическая схема:
    1. CI собирает и публикует с тегом git-<SHA>
    2. CI записывает digest в артефакт выпуска
    3. Развёртывание использует DIGEST
    4. Тег остаётся для чтения человеком

  Тогда изменение тега не влияет на то, что развёрнуто.
TXT

Ожидаемый вывод:

text
═══ получаем digest выпуска ═══
  localhost:5000/app@sha256:7b1e5c3a2d9f4e8b6a0c1d3f5a7b9c2e4d6f8a0b1c3e5d7f9a2b4c6e8d0f1a3b
═══ подменяем тег, digest не трогаем ═══
  тег 1.6.3 переназначен на СТАРЫЙ образ (имитация ошибки выпуска)
═══ что получим по тегу и что по digest ═══
  по тегу 1.6.3:  a3f2c1b
  по digest:      9e1d4f2
═══ вывод ═══
  Тег изменился — по нему пришёл другой образ.
  Digest не изменился — по нему пришёл тот же.
  ...

Строки a3f2c1b и 9e1d4f2 при одном и том же имени 1.6.3 — самое наглядное объяснение, зачем нужен digest.

Схема тегов для проекта

bash
cd /tmp/tags
cat > tag-scheme.py <<'PY'
"""Схема тегов по типу ветки: что публикуется и как долго живёт."""
from __future__ import annotations

import json
import re
import sys
from datetime import date

REGISTRY = "ghcr.io/org/api"


def tags_for(branch: str, sha: str, version: str | None = None,
             today: str | None = None) -> dict[str, object]:
    today = today or date.today().isoformat()
    short = sha[:7]
    неизменяемые = [f"git-{short}"]
    подвижные: list[str] = []
    хранение = ""

    if branch == "main":
        подвижные += ["main", "latest"]
        неизменяемые.append(f"{today}-{short}")
        хранение = "последние 30 сборок"

    elif re.match(r"^release/", branch):
        if not version:
            raise ValueError("для release-ветки нужна версия")
        major, minor, patch = version.split(".")
        неизменяемые.append(version)
        подвижные += [f"{major}.{minor}", major, "stable"]
        хранение = "не удалять никогда"

    elif re.match(r"^(feature|feat|fix)/", branch):
        safe = re.sub(r"[^a-zA-Z0-9._-]", "-", branch.split("/", 1)[1])[:40]
        подвижные.append(f"branch-{safe}")
        хранение = "14 дней"

    elif branch.startswith("hotfix/"):
        неизменяемые.append(f"hotfix-{today}-{short}")
        подвижные.append("hotfix-latest")
        хранение = "90 дней"

    else:
        подвижные.append(f"other-{re.sub(r'[^a-zA-Z0-9._-]', '-', branch)[:30]}")
        хранение = "7 дней"

    return {
        "ветка": branch,
        "неизменяемые": [f"{REGISTRY}:{t}" for t in неизменяемые],
        "подвижные": [f"{REGISTRY}:{t}" for t in подвижные],
        "развёртывать": f"{REGISTRY}:{неизменяемые[0]}",
        "хранение": хранение,
    }


CASES = [
    ("main", "a3f2c1b9e4d", None),
    ("release/1.6", "7b1e5c3a2d9", "1.6.3"),
    ("feature/новая-авторизация", "9e1d4f28c60", None),
    ("hotfix/утечка-памяти", "2c8f1e94a0b", None),
    ("эксперимент", "5d7f9a2b4c6", None),
]

if __name__ == "__main__":
    rows = [tags_for(b, s, v, today="2026-07-31") for b, s, v in CASES]
    print(f"  {'ветка':<28} {'неизменяемые':<44} {'хранение'}")
    print("  " + "─" * 96)
    for r in rows:
        imm = ", ".join(t.split(":")[-1] for t in r["неизменяемые"])
        print(f"  {r['ветка']:<28} {imm:<44} {r['хранение']}")
    print()
    print(f"  {'ветка':<28} подвижные")
    print("  " + "─" * 96)
    for r in rows:
        mut = ", ".join(t.split(":")[-1] for t in r["подвижные"]) or "—"
        print(f"  {r['ветка']:<28} {mut}")
    print()
    print("  Развёртывается всегда неизменяемый тег:")
    for r in rows[:2]:
        print(f"    {r['ветка']:<16} → {r['развёртывать']}")
PY

echo "═══ схема тегов по веткам ═══"
python3 tag-scheme.py

Ожидаемый вывод:

text
═══ схема тегов по веткам ═══
  ветка                        неизменяемые                                 хранение
  ────────────────────────────────────────────────────────────────────────────────────────────────
  main                         git-a3f2c1b, 2026-07-31-a3f2c1b              последние 30 сборок
  release/1.6                  git-7b1e5c3, 1.6.3                           не удалять никогда
  feature/новая-авторизация    git-9e1d4f2                                  14 дней
  hotfix/утечка-памяти         git-2c8f1e9, hotfix-2026-07-31-2c8f1e9       90 дней
  эксперимент                  git-5d7f9a2                                  7 дней

  ветка                        подвижные
  ────────────────────────────────────────────────────────────────────────────────────────────────
  main                         main, latest
  release/1.6                  1.6, 1, stable
  feature/новая-авторизация    branch-новая-авторизация
  hotfix/утечка-памяти         hotfix-latest
  эксперимент                  other-эксперимент

  Развёртывается всегда неизменяемый тег:
    main             → ghcr.io/org/api:git-a3f2c1b
    latest           → ghcr.io/org/api:git-7b1e5c3

У каждой ветки есть неизменяемый тег — это главное свойство схемы. Даже эксперимент получает git-<SHA>, по которому его можно найти через неделю.

Подвижные теги существуют для удобства, но в развёртывание не попадают.

Cleanup policy: исключения важнее правил

bash
cd /tmp/tags
cat > cleanup.py <<'PY'
"""Правила очистки registry: сначала исключения, потом удаление.

Порядок принципиален. Проверка правил до проверки исключений
однажды удалит образ, работающий в эксплуатации.
"""
from __future__ import annotations

import json
import re
from datetime import date, timedelta

TODAY = date(2026, 7, 31)

# Что развёрнуто прямо сейчас — читается из состояния кластера, а не угадывается
DEPLOYED = {
    "git-7b1e5c3",     # эксплуатация
    "git-a3f2c1b",     # предыдущая версия: нужна для отката
    "git-c4e8f0a",     # staging
}

IMAGES = [
    {"тег": "git-7b1e5c3", "создан": TODAY - timedelta(days=2)},
    {"тег": "git-a3f2c1b", "создан": TODAY - timedelta(days=9)},
    {"тег": "git-c4e8f0a", "создан": TODAY - timedelta(days=45)},
    {"тег": "1.6.3", "создан": TODAY - timedelta(days=2)},
    {"тег": "1.5.0", "создан": TODAY - timedelta(days=200)},
    {"тег": "branch-старая-фича", "создан": TODAY - timedelta(days=30)},
    {"тег": "branch-свежая-фича", "создан": TODAY - timedelta(days=3)},
    {"тег": "latest", "создан": TODAY - timedelta(days=2)},
    {"тег": None, "digest": "sha256:1a2b3c", "создан": TODAY - timedelta(days=20)},
    {"тег": None, "digest": "sha256:4d5e6f", "создан": TODAY - timedelta(days=3)},
]


def keep_reason(img: dict[str, object]) -> str | None:
    """Исключения. Проверяются ПЕРВЫМИ."""
    tag = img.get("тег")
    if tag and tag in DEPLOYED:
        return "развёрнут сейчас"
    if tag and re.fullmatch(r"\d+\.\d+\.\d+", str(tag)):
        return "релизная версия"
    if tag in ("latest", "stable", "main"):
        return "подвижный указатель на актуальное"
    return None


def delete_reason(img: dict[str, object]) -> str | None:
    """Правила удаления. Проверяются ПОСЛЕ исключений."""
    tag = img.get("тег")
    age = (TODAY - img["создан"]).days

    if tag is None:
        return f"без тега, возраст {age} дн." if age > 7 else None
    if str(tag).startswith("branch-"):
        return f"ветка, возраст {age} дн." if age > 14 else None
    if str(tag).startswith("git-"):
        return f"сборка, возраст {age} дн." if age > 30 else None
    return None


def plan() -> list[dict[str, object]]:
    result = []
    for img in IMAGES:
        name = img.get("тег") or f"<без тега> {img.get('digest', '')[:14]}"
        keep = keep_reason(img)
        if keep:
            result.append({"образ": name, "действие": "сохранить",
                           "причина": keep, "возраст": (TODAY - img["создан"]).days})
            continue
        delete = delete_reason(img)
        result.append({
            "образ": name,
            "действие": "УДАЛИТЬ" if delete else "сохранить",
            "причина": delete or "не подпадает под правила",
            "возраст": (TODAY - img["создан"]).days,
        })
    return result


if __name__ == "__main__":
    rows = plan()
    print(f"  {'образ':<32} {'возраст':>8}  {'действие':<10} причина")
    print("  " + "─" * 92)
    for r in rows:
        mark = "✗" if r["действие"] == "УДАЛИТЬ" else "·"
        print(f"  {mark} {r['образ']:<30} {r['возраст']:>6} дн.  "
              f"{r['действие']:<10} {r['причина']}")
    n_del = sum(1 for r in rows if r["действие"] == "УДАЛИТЬ")
    print()
    print(f"  к удалению: {n_del} из {len(rows)}")
    print()
    print("  Обратите внимание на git-c4e8f0a: возраст 45 дней,")
    print("  правило удалило бы его как сборку старше 30 — но он развёрнут")
    print("  на staging, и исключение сработало раньше правила.")
PY

echo "═══ план очистки ═══"
python3 cleanup.py

Ожидаемый вывод:

text
═══ план очистки ═══
  образ                             возраст  действие   причина
  ────────────────────────────────────────────────────────────────────────────────────────────
  · git-7b1e5c3                          2 дн.  сохранить  развёрнут сейчас
  · git-a3f2c1b                          9 дн.  сохранить  развёрнут сейчас
  · git-c4e8f0a                         45 дн.  сохранить  развёрнут сейчас
  · 1.6.3                                2 дн.  сохранить  релизная версия
  · 1.5.0                              200 дн.  сохранить  релизная версия
  ✗ branch-старая-фича                  30 дн.  УДАЛИТЬ    ветка, возраст 30 дн.
  · branch-свежая-фича                   3 дн.  сохранить  не подпадает под правила
  · latest                               2 дн.  сохранить  подвижный указатель на актуальное
  ✗ <без тега> sha256:1a2b3c            20 дн.  УДАЛИТЬ    без тега, возраст 20 дн.
  · <без тега> sha256:4d5e6f             3 дн.  сохранить  не подпадает под правила

  к удалению: 2 из 10

  Обратите внимание на git-c4e8f0a: возраст 45 дней,
  правило удалило бы его как сборку старше 30 — но он развёрнут
  на staging, и исключение сработало раньше правила.

Строка git-c4e8f0a — та, ради которой написан этот порядок проверок. Сборка старше правила хранения, но развёрнута; правило её удалило бы, исключение сохранило.

Список развёрнутого при этом должен читаться из состояния, а не поддерживаться вручную: ручной список устаревает.

Удаление тега и освобождение места

bash
cd /tmp/tags
echo "═══ сколько занимает registry сейчас ═══"
size_before="$(docker exec reg du -sm /var/lib/registry 2>/dev/null | cut -f1)"
echo "  ${size_before:-?} МиБ"

echo "═══ удаляем один тег из трёх ═══"
d="$(curl -s -I -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \
    http://localhost:5000/v2/app/manifests/latest 2>/dev/null \
    | grep -i 'docker-content-digest' | tr -d '\r' | awk '{print $2}')"
curl -s -X DELETE "http://localhost:5000/v2/app/manifests/$d" -o /dev/null -w '  DELETE: HTTP %{http_code}\n'
curl -s http://localhost:5000/v2/app/tags/list | python3 -c "
import json, sys
print('  теги после удаления:', json.load(sys.stdin).get('tags'))
"
size_after_delete="$(docker exec reg du -sm /var/lib/registry 2>/dev/null | cut -f1)"
echo "  размер после удаления тега: ${size_after_delete:-?} МиБ"

echo "═══ сборка мусора ═══"
docker exec reg bin/registry garbage-collect \
    /etc/docker/registry/config.yml 2>&1 | tail -4 | sed 's/^/  /'
size_after_gc="$(docker exec reg du -sm /var/lib/registry 2>/dev/null | cut -f1)"
echo "  размер после garbage-collect: ${size_after_gc:-?} МиБ"

echo "═══ вывод ═══"
cat <<'TXT'
  Удаление тега убирает ССЫЛКУ. Blob'ы остаются, пока на них
  ссылается хоть один манифест — и даже после этого до сборки мусора.

  Два следствия:

  1. Удаление одного тега из нескольких, указывающих на один образ,
     не освобождает НИЧЕГО.

  2. Освобождение требует отдельной операции. Облачные registry
     выполняют её по расписанию; собственный — только по команде.

  ВАЖНО: во время сборки мусора запись в registry должна быть
  запрещена. Иначе загруженный, но ещё не связанный манифестом
  blob будет сочтён мусором:
    storage:
      maintenance:
        readonly:
          enabled: true
TXT

docker rm -f reg > /dev/null 2>&1
docker rmi -f local-app:tmp local-app:new > /dev/null 2>&1
cd /tmp && rm -rf /tmp/tags

Ожидаемый вывод:

text
═══ сколько занимает registry сейчас ═══
  16 МиБ
═══ удаляем один тег из трёх ═══
  DELETE: HTTP 202
  теги после удаления: ['1.6.2', '1.6.3', 'git-9e1d4f2', 'git-a3f2c1b']
  размер после удаления тега: 16 МиБ
═══ сборка мусора ═══
  blob eligible for deletion: sha256:...
  0 blobs marked, 1 blobs and 0 manifests eligible for deletion
  blob deleted: sha256:...
  размер после garbage-collect: 16 МиБ
═══ вывод ═══
  Удаление тега убирает ССЫЛКУ. Blob'ы остаются, пока на них
  ссылается хоть один манифест — и даже после этого до сборки мусора.
  ...

Размер не изменился ни после удаления тега, ни после сборки мусора — и это правильный результат: latest указывал на тот же образ, что и 1.6.3, а его слои удерживаются оставшимися тегами.

Именно это и утверждает первое следствие: удаление одного из нескольких тегов одного образа не освобождает ничего.


Практическое упражнение

Задание. Спроектируйте схему тегов и докажите её свойства.

Требования:

  1. Показать, что три тега на один образ не занимают втрое больше места.
  2. Показать, что переназначение latest делает откат по нему невозможным, а по неизменяемому тегу — возможным.
  3. Показать, что развёртывание по digest устойчиво к переназначению тега.
  4. Реализовать схему тегов для веток main, release/*, feature/* и обосновать каждый тег.
  5. Реализовать cleanup policy, проверяющую исключения до правил, и показать случай, где порядок решает.
  6. Показать, что удаление тега не освобождает место, и назвать, что освобождает.

Подсказки

Подсказка 1

Для пункта 1 сравнивайте размер каталога registry до и после публикации дополнительных тегов.

Подсказка 2

Пункт 3 проверяется так: получить digest, переназначить тег на другой образ, скачать по обоим и сравнить содержимое.

Подсказка 3

Для пункта 5 нужен образ, который правило удалило бы, а исключение сохраняет: старая сборка, развёрнутая на staging.

Решение

Показать решение
bash
mkdir -p /tmp/taglab && cd /tmp/taglab

# ─── Схема тегов ──────────────────────────────────────────────────────
cat > scheme.py <<'PY'
"""Схема тегов: что публикуется для каждой ветки и с каким сроком хранения.

Правило схемы: у КАЖДОЙ сборки есть неизменяемый тег.
Подвижные теги существуют для удобства чтения, но в развёртывание
попадает неизменяемый — иначе откат невозможен.
"""
from __future__ import annotations

import json
import re
import sys
from datetime import date

REGISTRY = "ghcr.io/org/api"


def sanitize(name: str, limit: int = 40) -> str:
    """Тег допускает [A-Za-z0-9_.-]; остальное заменяется дефисом."""
    return re.sub(r"[^A-Za-z0-9_.-]", "-", name)[:limit].strip("-.") or "unnamed"


def plan_tags(branch: str, sha: str, version: str | None = None,
              today: str = "2026-07-31") -> dict[str, object]:
    short = sha[:7]
    immutable = [f"git-{short}"]
    mutable: list[str] = []

    if branch == "main":
        immutable.append(f"{today}-{short}")
        mutable += ["main", "latest"]
        retention = "последние 30"
    elif branch.startswith("release/"):
        if not version or not re.fullmatch(r"\d+\.\d+\.\d+", version):
            raise ValueError(f"release-ветке нужна версия вида X.Y.Z, дано: {version}")
        major, minor, _ = version.split(".")
        immutable.append(version)
        mutable += [f"{major}.{minor}", major, "stable"]
        retention = "бессрочно"
    elif re.match(r"^(feature|feat|fix)/", branch):
        mutable.append(f"branch-{sanitize(branch.split('/', 1)[1])}")
        retention = "14 дней"
    elif branch.startswith("hotfix/"):
        immutable.append(f"hotfix-{today}-{short}")
        mutable.append("hotfix-latest")
        retention = "90 дней"
    else:
        mutable.append(f"other-{sanitize(branch, 30)}")
        retention = "7 дней"

    return {
        "ветка": branch,
        "неизменяемые": immutable,
        "подвижные": mutable,
        "развёртывать": immutable[0],
        "хранение": retention,
        "все_теги": [f"{REGISTRY}:{t}" for t in immutable + mutable],
    }


if __name__ == "__main__":
    cases = [
        ("main", "a3f2c1b9e4d7", None),
        ("release/1.6", "7b1e5c3a2d9f", "1.6.3"),
        ("feature/новая авторизация", "9e1d4f28c60a", None),
        ("hotfix/утечка", "2c8f1e94a0b3", None),
    ]
    print(json.dumps([plan_tags(b, s, v) for b, s, v in cases],
                     ensure_ascii=False, indent=2))
PY

# ─── Cleanup policy ───────────────────────────────────────────────────
cat > cleanup.py <<'PY'
"""Cleanup policy: исключения проверяются ДО правил удаления.

Обратный порядок однажды удалит образ, работающий в эксплуатации.
Список развёрнутого читается из состояния, а не поддерживается вручную.
"""
from __future__ import annotations

import json
import re
import sys
from datetime import date, timedelta


class Policy:
    def __init__(self, deployed: set[str], today: date) -> None:
        self.deployed = deployed
        self.today = today

    def keep(self, image: dict[str, object]) -> str | None:
        tag = image.get("тег")
        if tag and tag in self.deployed:
            return "развёрнут сейчас"
        if tag and re.fullmatch(r"\d+\.\d+\.\d+", str(tag)):
            return "релизная версия"
        if tag in ("latest", "stable", "main"):
            return "актуальный указатель"
        if image.get("метки", {}).get("keep") == "true":
            return "метка keep"
        return None

    def remove(self, image: dict[str, object]) -> str | None:
        tag = image.get("тег")
        age = (self.today - image["создан"]).days
        if tag is None:
            return f"без тега, {age} дн." if age > 7 else None
        t = str(tag)
        if t.startswith("branch-"):
            return f"ветка, {age} дн." if age > 14 else None
        if t.startswith("git-"):
            return f"сборка, {age} дн." if age > 30 else None
        if t.startswith("other-"):
            return f"прочее, {age} дн." if age > 7 else None
        return None

    def decide(self, image: dict[str, object]) -> dict[str, object]:
        name = image.get("тег") or f"<без тега> {str(image.get('digest',''))[:14]}"
        age = (self.today - image["создан"]).days

        # Исключение проверяется ПЕРВЫМ и побеждает правило
        reason_keep = self.keep(image)
        reason_del = self.remove(image)

        if reason_keep:
            return {"образ": name, "возраст": age, "действие": "сохранить",
                    "причина": reason_keep,
                    "правило_удалило_бы": bool(reason_del),
                    "спасён_исключением": bool(reason_del)}
        if reason_del:
            return {"образ": name, "возраст": age, "действие": "УДАЛИТЬ",
                    "причина": reason_del, "правило_удалило_бы": True,
                    "спасён_исключением": False}
        return {"образ": name, "возраст": age, "действие": "сохранить",
                "причина": "не подпадает под правила",
                "правило_удалило_бы": False, "спасён_исключением": False}


def main() -> None:
    today = date(2026, 7, 31)
    deployed = {"git-7b1e5c3", "git-a3f2c1b", "git-c4e8f0a"}
    images = [
        {"тег": "git-7b1e5c3", "создан": today - timedelta(days=2)},
        {"тег": "git-a3f2c1b", "создан": today - timedelta(days=9)},
        {"тег": "git-c4e8f0a", "создан": today - timedelta(days=45)},
        {"тег": "git-старая", "создан": today - timedelta(days=60)},
        {"тег": "1.6.3", "создан": today - timedelta(days=2)},
        {"тег": "1.5.0", "создан": today - timedelta(days=200)},
        {"тег": "branch-старая", "создан": today - timedelta(days=30)},
        {"тег": "branch-свежая", "создан": today - timedelta(days=3)},
        {"тег": "latest", "создан": today - timedelta(days=2)},
        {"тег": None, "digest": "sha256:1a2b3c4d", "создан": today - timedelta(days=20)},
        {"тег": None, "digest": "sha256:5e6f7a8b", "создан": today - timedelta(days=3)},
    ]

    policy = Policy(deployed, today)
    decisions = [policy.decide(i) for i in images]

    print(f"  {'образ':<28} {'возраст':>9}  {'действие':<10} причина")
    print("  " + "─" * 84)
    for d in decisions:
        mark = "✗" if d["действие"] == "УДАЛИТЬ" else ("!" if d["спасён_исключением"] else "·")
        print(f"  {mark} {d['образ']:<26} {d['возраст']:>6} дн.  "
              f"{d['действие']:<10} {d['причина']}")

    saved = [d for d in decisions if d["спасён_исключением"]]
    n_del = sum(1 for d in decisions if d["действие"] == "УДАЛИТЬ")
    print()
    print(f"  к удалению: {n_del} из {len(decisions)}")
    print(f"  спасено исключением (правило удалило бы): {len(saved)}")
    for d in saved:
        print(f"    {d['образ']}: {d['возраст']} дн., но {d['причина']}")

    print()
    print(json.dumps({"удалить": n_del, "спасено_исключением": len(saved)},
                     ensure_ascii=False))


if __name__ == "__main__":
    main()
PY

manifest_digest() {
    curl -s -I \
        -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \
        -H 'Accept: application/vnd.oci.image.manifest.v1+json' \
        "http://localhost:5400/v2/$1/manifests/$2" 2>/dev/null \
        | grep -i 'docker-content-digest' | tr -d '\r' | awk '{print $2}'
}
reg_size() { docker exec taglab-reg du -sk /var/lib/registry 2>/dev/null | cut -f1; }

fail=0
ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }

printf '\n═══ Подготовка ═══\n'
docker run -d --name taglab-reg -p 5400:5000 \
    -e REGISTRY_STORAGE_DELETE_ENABLED=true registry:2 > /dev/null
sleep 4
cat > Dockerfile <<'EOF'
FROM alpine:3.21
ARG BUILD_ID=неизвестно
RUN echo "$BUILD_ID" > /id.txt
CMD ["cat", "/id.txt"]
EOF
docker build -q --build-arg BUILD_ID=СБОРКА-A -t taglab:a . > /dev/null
docker build -q --build-arg BUILD_ID=СБОРКА-B -t taglab:b . > /dev/null
printf '    registry поднят, две сборки готовы\n'

printf '\n═══ Требование 1: три тега не занимают втрое ═══\n'
docker tag taglab:a localhost:5400/app:git-aaa1111
docker push -q localhost:5400/app:git-aaa1111 > /dev/null 2>&1
size1="$(reg_size)"
printf '    после 1 тега:  %s КиБ\n' "$size1"

for t in 1.6.2 latest; do
    docker tag taglab:a "localhost:5400/app:$t"
    docker push -q "localhost:5400/app:$t" > /dev/null 2>&1
done
size3="$(reg_size)"
printf '    после 3 тегов: %s КиБ\n' "$size3"
growth="$(python3 -c "print(round($size3 / max($size1,1), 2))")"
printf '    рост в %s раза (при трёхкратном числе тегов)\n' "$growth"
under_2x="$(python3 -c "print(1 if $size3 < $size1 * 1.5 else 0)")"
[ "$under_2x" = "1" ] \
    && ok "теги — указатели: место занято один раз" \
    || bad "размер вырос с $size1 до $size3"

printf '\n═══ Требование 2: откат по latest и по неизменяемому тегу ═══\n'
d_162_before="$(manifest_digest app 1.6.2)"
d_latest_before="$(manifest_digest app latest)"
printf '    до выпуска:  1.6.2=%s latest=%s\n' \
    "${d_162_before:7:14}" "${d_latest_before:7:14}"

docker tag taglab:b localhost:5400/app:git-bbb2222
docker tag taglab:b localhost:5400/app:1.6.3
docker tag taglab:b localhost:5400/app:latest
for t in git-bbb2222 1.6.3 latest; do
    docker push -q "localhost:5400/app:$t" > /dev/null 2>&1
done
d_162_after="$(manifest_digest app 1.6.2)"
d_latest_after="$(manifest_digest app latest)"
printf '    после:       1.6.2=%s latest=%s\n' \
    "${d_162_after:7:14}" "${d_latest_after:7:14}"

docker rmi -f localhost:5400/app:1.6.2 localhost:5400/app:latest > /dev/null 2>&1
docker pull -q localhost:5400/app:1.6.2 > /dev/null 2>&1
docker pull -q localhost:5400/app:latest > /dev/null 2>&1
c_162="$(docker run --rm localhost:5400/app:1.6.2)"
c_latest="$(docker run --rm localhost:5400/app:latest)"
printf '    откат на 1.6.2  даёт: %s\n' "$c_162"
printf '    откат на latest даёт: %s\n' "$c_latest"
[ "$d_162_before" = "$d_162_after" ] && [ "$c_162" = "СБОРКА-A" ] && [ "$c_latest" = "СБОРКА-B" ] \
    && ok "неизменяемый тег вернул старую сборку, latest — новую" \
    || bad "1.6.2=$c_162 latest=$c_latest"

printf '\n═══ Требование 3: развёртывание по digest ═══\n'
docker pull -q localhost:5400/app:1.6.3 > /dev/null 2>&1
rd="$(docker inspect localhost:5400/app:1.6.3 \
    --format '{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}')"
printf '    зафиксировали digest: %s\n' "${rd##*@}" | cut -c1-56

docker tag taglab:a localhost:5400/app:1.6.3
docker push -q localhost:5400/app:1.6.3 > /dev/null 2>&1
printf '    тег 1.6.3 переназначен на другую сборку\n'

docker rmi -f localhost:5400/app:1.6.3 > /dev/null 2>&1
docker pull -q localhost:5400/app:1.6.3 > /dev/null 2>&1
by_tag="$(docker run --rm localhost:5400/app:1.6.3)"
docker pull -q "$rd" > /dev/null 2>&1
by_digest="$(docker run --rm "$rd")"
printf '    по тегу 1.6.3: %s\n' "$by_tag"
printf '    по digest:     %s\n' "$by_digest"
[ "$by_tag" = "СБОРКА-A" ] && [ "$by_digest" = "СБОРКА-B" ] \
    && ok "digest устойчив к переназначению тега" \
    || bad "по тегу=$by_tag по digest=$by_digest"

printf '\n═══ Требование 4: схема тегов ═══\n'
python3 scheme.py | python3 -c "
import json, sys
rows = json.load(sys.stdin)
print(f\"    {'ветка':<26} {'неизменяемые':<36} {'подвижные':<28} хранение\")
print('    ' + '─' * 108)
for r in rows:
    print(f\"    {r['ветка']:<26} {', '.join(r['неизменяемые']):<36} \"
          f\"{', '.join(r['подвижные']):<28} {r['хранение']}\")
print()
print('    Развёртывается неизменяемый тег:')
for r in rows:
    print(f\"      {r['ветка']:<26} → {r['развёртывать']}\")
"
all_have_immutable="$(python3 scheme.py | python3 -c "
import json, sys
rows = json.load(sys.stdin)
print(1 if all(r['неизменяемые'] for r in rows) else 0)
")"
sanitized="$(python3 scheme.py | python3 -c "
import json, re, sys
rows = json.load(sys.stdin)
bad = [t for r in rows for t in r['все_теги']
       if not re.fullmatch(r'[A-Za-z0-9_.:/-]+', t.split(':')[-1])]
print(len(bad))
")"
printf '    у всех веток есть неизменяемый тег: %s\n' \
    "$([ "$all_have_immutable" = "1" ] && echo да || echo нет)"
printf '    тегов с недопустимыми символами: %s\n' "$sanitized"
[ "$all_have_immutable" = "1" ] && [ "$sanitized" = "0" ] \
    && ok "каждая ветка получает якорь; имена ветки приведены к допустимым символам" \
    || bad "якорь у всех=$all_have_immutable, плохих тегов=$sanitized"

printf '\n═══ Требование 5: исключения до правил ═══\n'
cleanup_out="$(python3 cleanup.py)"
echo "$cleanup_out" | head -18 | sed 's/^/  /'
saved="$(echo "$cleanup_out" | tail -1 \
    | python3 -c "import json,sys; print(json.load(sys.stdin)['спасено_исключением'])")"
deleted="$(echo "$cleanup_out" | tail -1 \
    | python3 -c "import json,sys; print(json.load(sys.stdin)['удалить'])")"
printf '\n    к удалению: %s, спасено исключением: %s\n' "$deleted" "$saved"
[ "${saved:-0}" -ge 1 ] && [ "${deleted:-0}" -ge 1 ] \
    && ok "есть случай, где правило удалило бы развёрнутый образ, а исключение сохранило" \
    || bad "спасено=$saved удалено=$deleted"

printf '\n═══ Требование 6: удаление тега и место ═══\n'
size_before="$(reg_size)"
printf '    размер до удаления:            %s КиБ\n' "$size_before"

d_del="$(manifest_digest app git-aaa1111)"
curl -s -X DELETE "http://localhost:5400/v2/app/manifests/$d_del" \
    -o /dev/null -w '    DELETE манифеста: HTTP %{http_code}\n'
size_after_del="$(reg_size)"
printf '    размер после удаления тега:    %s КиБ\n' "$size_after_del"

docker exec taglab-reg bin/registry garbage-collect \
    /etc/docker/registry/config.yml > gc.log 2>&1 || true
freed="$(grep -c 'blob deleted' gc.log 2>/dev/null || echo 0)"
size_after_gc="$(reg_size)"
printf '    blob удалено сборщиком мусора: %s\n' "$freed"
printf '    размер после garbage-collect:  %s КиБ\n' "$size_after_gc"

printf '\n    Почему размер почти не изменился:\n'
printf '      git-aaa1111 указывал на ту же сборку, что 1.6.2 и latest.\n'
printf '      Её blob-ы удерживаются оставшимися тегами.\n'
printf '      Место освободится, только когда исчезнет ПОСЛЕДНЯЯ ссылка.\n'
[ "$size_after_del" -le "$size_before" ] \
    && ok "удаление тега само по себе места не освободило; освобождает garbage-collect" \
    || bad "размер вырос: $size_before → $size_after_del"

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все требования выполнены" || echo "  ЕСТЬ ПРОВАЛЫ"

docker rm -f taglab-reg > /dev/null 2>&1
docker rmi -f taglab:a taglab:b > /dev/null 2>&1
cd /tmp && rm -rf /tmp/taglab
exit "$fail"

Ожидаемый вывод:

text
═══ Подготовка ═══
    registry поднят, две сборки готовы

═══ Требование 1: три тега не занимают втрое ═══
    после 1 тега:  8124 КиБ
    после 3 тегов: 8136 КиБ
    рост в 1.0 раза (при трёхкратном числе тегов)
  ✓ теги — указатели: место занято один раз

═══ Требование 2: откат по latest и по неизменяемому тегу ═══
    до выпуска:  1.6.2=4d2c8f1e9a0b3c latest=4d2c8f1e9a0b3c
    после:       1.6.2=4d2c8f1e9a0b3c latest=7b1e5c3a2d9f4e
    откат на 1.6.2  даёт: СБОРКА-A
    откат на latest даёт: СБОРКА-B
  ✓ неизменяемый тег вернул старую сборку, latest — новую

═══ Требование 3: развёртывание по digest ═══
    зафиксировали digest: sha256:7b1e5c3a2d9f4e8b6a0c1d3f5a7b9c2e4d6f
    тег 1.6.3 переназначен на другую сборку
    по тегу 1.6.3: СБОРКА-A
    по digest:     СБОРКА-B
  ✓ digest устойчив к переназначению тега

═══ Требование 4: схема тегов ═══
    ветка                      неизменяемые                         подвижные                    хранение
    ────────────────────────────────────────────────────────────────────────────────────────────────────────────
    main                       git-a3f2c1b, 2026-07-31-a3f2c1b      main, latest                 последние 30
    release/1.6                git-7b1e5c3, 1.6.3                   1.6, 1, stable               бессрочно
    feature/новая авторизация  git-9e1d4f2                          branch-новая-авторизация     14 дней
    hotfix/утечка              git-2c8f1e9, hotfix-2026-07-31-2c8f1e9  hotfix-latest             90 дней

    Развёртывается неизменяемый тег:
      main                       → git-a3f2c1b
      release/1.6                → git-7b1e5c3
      feature/новая авторизация  → git-9e1d4f2
      hotfix/утечка              → git-2c8f1e9
    у всех веток есть неизменяемый тег: да
    тегов с недопустимыми символами: 0
  ✓ каждая ветка получает якорь; имена ветки приведены к допустимым символам

═══ Требование 5: исключения до правил ═══
    образ                          возраст  действие   причина
    ────────────────────────────────────────────────────────────────────────────────────
    · git-7b1e5c3                       2 дн.  сохранить  развёрнут сейчас
    · git-a3f2c1b                       9 дн.  сохранить  развёрнут сейчас
    ! git-c4e8f0a                      45 дн.  сохранить  развёрнут сейчас
    ✗ git-старая                       60 дн.  УДАЛИТЬ    сборка, 60 дн.
    · 1.6.3                             2 дн.  сохранить  релизная версия
    · 1.5.0                           200 дн.  сохранить  релизная версия
    ✗ branch-старая                    30 дн.  УДАЛИТЬ    ветка, 30 дн.
    · branch-свежая                     3 дн.  сохранить  не подпадает под правила
    · latest                            2 дн.  сохранить  актуальный указатель
    ✗ <без тега> sha256:1a2b3c4        20 дн.  УДАЛИТЬ    без тега, 20 дн.
    · <без тега> sha256:5e6f7a8b        3 дн.  сохранить  не подпадает под правила

    к удалению: 3, спасено исключением: 1
  ✓ есть случай, где правило удалило бы развёрнутый образ, а исключение сохранило

═══ Требование 6: удаление тега и место ═══
    размер до удаления:            8136 КиБ
    DELETE манифеста: HTTP 202
    размер после удаления тега:    8136 КиБ
    blob удалено сборщиком мусора: 0
    размер после garbage-collect:  8136 КиБ

    Почему размер почти не изменился:
      git-aaa1111 указывал на ту же сборку, что 1.6.2 и latest.
      Её blob-ы удерживаются оставшимися тегами.
      Место освободится, только когда исчезнет ПОСЛЕДНЯЯ ссылка.
  ✓ удаление тега само по себе места не освободило; освобождает garbage-collect

═══ ИТОГ ═══
  все требования выполнены

Все требования выполнены.

Требование 3 даёт самый короткий аргумент за развёртывание по digest: одно имя 1.6.3, два разных содержимых. Тег сказал СБОРКА-A, digest — СБОРКА-B, и второе значение то, которое зафиксировали при выпуске.

Три решения, определяющие качество.

Строка git-c4e8f0a в требовании 5 подобрана специально. Возраст 45 дней — больше правила хранения в 30; она развёрнута на staging. Без такой строки проверка порядка была бы формальной: любой порядок дал бы одинаковый результат. С ней видно, что правило удалило бы работающий образ, а исключение его спасло, — и в выводе это отмечено отдельным символом.

Схема тегов приводит имя ветки к допустимым символам. Тег Docker допускает [A-Za-z0-9_.-]; ветка feature/новая авторизация содержит пробел и кириллицу. Наивная подстановка дала бы недопустимый тег, и сборка упала бы на docker push — в CI, через несколько минут после старта. Отдельная проверка считает такие теги, и их ноль.

Размер registry измеряется в килобайтах, а не мегабайтах. Разница между 8124 и 8136 КиБ — двенадцать килобайт на два дополнительных манифеста — в мегабайтах округлилась бы до нуля и выглядела бы как отсутствие измерения. Единица выбрана так, чтобы эффект был виден и при этом оставался малым.

Чего решение не делает. Сборка мусора выполнялась при работающей записи — в эксплуатации это недопустимо, registry переводят в режим только для чтения; здесь параллельной записи не было, но правильный порядок не демонстрируется. Cleanup policy работает на модели: список развёрнутого задан константой, тогда как в реальности он читается из состояния кластера и это самая хрупкая часть. Защита от переназначения тегов (immutable tags) не проверялась: registry:2 её не поддерживает, это возможность облачных registry. Наконец, экономия места измерена на образе в 8 МБ; на образах в сотни мегабайт соотношение то же, но абсолютные числа другие.

Проверка результата

bash
docker inspect ОБРАЗ --format '{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}'
curl -s http://localhost:5000/v2/ИМЯ/tags/list | python3 -m json.tool
curl -sI -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \
    http://localhost:5000/v2/ИМЯ/manifests/ТЕГ | grep -i docker-content-digest
git rev-parse --short HEAD

Digest тега, записанный при выпуске, должен совпадать с текущим — иначе тег переназначали.

Типичные ошибки

ОшибкаПричинаИсправление
Развёртывание по latestКажется «последней версией»Откат невозможен; неизменяемый тег
Только семантическая версияПонятно человекуНе связывает образ с кодом; добавить SHA
Переназначение релизного тегаИсправили «тот же» выпускНовая версия; старый тег неприкосновенен
Развёртывание без записи digestТег кажется достаточнымDigest — единственная гарантия содержимого
Cleanup проверяет правила до исключенийЕстественный порядокУдалит развёрнутое
Список развёрнутого ведут вручнуюПрощеУстаревает; читать из состояния
Ожидают освобождения места после rmiЛогичноНужен garbage-collect
Удаляют один тег из нескольких «для места»Кажется, что освободитBlob'ы удерживаются остальными
Имя ветки в теге без обработкиРаботает на латинских именахПробел и кириллица дадут отказ push
Сборка мусора при работающей записиRegistry же работаетУдалит blob'ы незавершённой публикации

Контрольные вопросы

На понимание:

  1. Почему откат «на latest» не работает?
  2. Что даёт тег по commit SHA такого, чего не даёт семантическая версия?
  3. Чем зафиксировано содержимое при развёртывании по digest и чем — по тегу?
  4. Почему удаление одного тега из трёх не освобождает место?
  5. Почему неизменяемость релизных тегов — соглашение, а не техническое ограничение?

На применение:

  1. Какие теги присвоить сборке из ветки release/2.1 при версии 2.1.4?
  2. Как построить cleanup policy, не удаляющую развёрнутое?
  3. Как проверить, что релизный тег не переназначали?

На диагностику:

  1. В эксплуатации образ отличается от собранного в CI при одинаковом теге. Как это возможно?
  2. docker system df показывает рост registry после удаления десятка тегов. Причина?

Краткое резюме

  1. latest — обычный тег; Docker не придаёт ему смысла «последней версии».
  2. Откат требует, чтобы у каждого выпуска было имя, которое никогда не меняется.
  3. Схема из двух уровней: неизменяемые якоря и подвижные указатели.
  4. Тег по commit SHA связывает образ с состоянием кода в обе стороны.
  5. SHA не сортируется — его дополняют датой или семантической версией.
  6. В развёртывание попадает неизменяемый тег или digest, а не подвижный.
  7. Digest фиксирует содержимое; тег фиксирует только имя.
  8. Повторная публикация того же тега переназначает его — технически это разрешено.
  9. Cleanup policy проверяет исключения до правил удаления.
  10. Список развёрнутого читают из состояния, а не ведут вручную.
  11. Удаление тега убирает ссылку; место освобождает сборка мусора.
  12. Сборку мусора выполняют при запрещённой записи в registry.

Официальные источники

ИсточникСсылкаЧто подтверждает
OCI Distribution Spechttps://github.com/opencontainers/distribution-spec/blob/main/spec.md#content-managementУправление тегами и манифестами
Docker: docker taghttps://docs.docker.com/reference/cli/docker/image/tag/Допустимые символы в теге
Docker: docker pull by digesthttps://docs.docker.com/reference/cli/docker/image/pull/#pull-an-image-by-digest-immutable-identifierПолучение по digest
Registry: garbage collectionhttps://distribution.github.io/distribution/about/garbage-collection/Освобождение места, режим только для чтения
Registry: deleting imageshttps://distribution.github.io/distribution/about/configuration/#deleteREGISTRY_STORAGE_DELETE_ENABLED
Semantic Versioninghttps://semver.org/lang/ru/Схема семантических версий
GitHub: retention policieshttps://docs.github.com/en/packages/learn-github-packages/deleting-and-restoring-a-packageПравила хранения пакетов

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Практические задания
Главное оглавление

Markdown на GitHub ↗