14.4. Стратегия tagging
Цели
После этого материала вы сможете:
- объяснить, почему развёртывание по
latestделает откат ненадёжным; - спроектировать схему тегов с неизменяемыми якорями и подвижными указателями;
- обосновать выбор commit SHA как основы воспроизводимости;
- развернуть образ по digest и объяснить, что это гарантирует;
- составить cleanup policy, не удаляющую то, что развёрнуто;
- объяснить, почему удаление тега не освобождает место, и что освобождает.
Предварительные знания
- 14.1. Основы registry — теги и digest;
- 11.6. Supply chain — закрепление версий;
- 3.3. Теги и digest.
Ключевые термины
| Термин | Объяснение |
|---|---|
неизменяемый тег | Тег, который никогда не переназначается |
подвижный указатель | Тег, меняющий цель при каждом выпуске |
commit SHA | Идентификатор состояния исходного кода |
cleanup policy | Правила удаления старых образов |
retention | Что сохраняется независимо от возраста |
Теория
Почему latest ломает откат
latest — обычный тег без особого смысла. Docker не считает его «последней версией»: он просто подставляется, когда тег не указан.
Сценарий отказа:
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 | Указывают на текущий выбор |
Все указывают на один образ, но роли разные:
образ 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 как основа
docker build -t ghcr.io/org/api:git-$(git rev-parse --short HEAD) .
Почему именно SHA:
| Свойство | Значение |
|---|---|
| Однозначность | Состояние кода восстанавливается командой git checkout |
| Автоматизм | Не требует решения человека о номере версии |
| Отсутствие коллизий | Два выпуска не получат один тег |
| Обратная связь | По тегу образа находится код, по коду — образ |
Последняя строка — то, ради чего это делается. Вопрос «какой код работает в эксплуатации» получает точный ответ за одну команду.
Ограничение: SHA не говорит человеку, новее ли git-a3f2c1b, чем git-9e1d4f2. Поэтому его дополняют семантической версией или временной меткой:
2026.07.31-1423-a3f2c1b
Такой тег и сортируется, и однозначен.
Развёртывание по digest
Тег — указатель, и его можно переназначить. Digest — контрольная сумма содержимого, и переназначить его нельзя.
| Что зафиксировано | Тегом | Digest'ом |
|---|---|---|
| Имя | Да | Да |
| Содержимое | Нет | Да |
| Устойчивость к переназначению | Нет | Да |
| Читаемость человеком | Да | Нет |
services:
api:
image: ghcr.io/org/api@sha256:8a3f2b1c4d5e...
Практическая схема — использовать оба:
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 | Явное решение человека |
Типичные правила удаления:
образы из feature-веток старше 14 дней
образы из main оставить последние 30
образы с тегом релиза не удалять
незатегованные манифесты старше 7 дней
Порядок проверки: сначала исключения, потом правила. Обратный порядок однажды удалит то, что работает в эксплуатации.
Удаление тега не освобождает место
Разделение из урока 14.3 здесь становится практическим:
1. docker rmi / DELETE тега → убрана ссылка на манифест
2. манифест без ссылок → всё ещё занимает место
3. garbage-collect → blob'ы без ссылок удалены
Пока шаг 3 не выполнен, место занято. Registry в облаке обычно выполняет его сам по расписанию; собственный — только по команде.
Второе следствие: удаление одного тега из трёх, указывающих на один образ, не освобождает ничего. Blob'ы удерживаются оставшимися тегами.
Внутренний механизм
Почему тег можно переназначить, а digest нет
Тег — запись в таблице: имя указывает на digest манифеста. Переназначение переписывает запись.
Digest — результат хеширования самого манифеста. Чтобы digest указывал на другое содержимое, нужно найти коллизию sha256.
Отсюда асимметрия: имя api:1.6.2 может завтра означать другой образ; имя api@sha256:8a3f... — не может.
Что происходит при повторной публикации того же тега
docker push ghcr.io/org/api:1.6.2 # второй раз, с другим содержимым
Registry примет запрос: он перезапишет ссылку тега на новый манифест. Старый манифест останется без этой ссылки.
Именно поэтому неизменяемость релизных тегов — соглашение, а не техническое ограничение. Часть registry позволяет включить защиту (immutable tags); при её отсутствии соблюдение правила остаётся на процессе.
Проверить нарушение можно, сравнив digest тега с записанным при выпуске:
docker buildx imagetools inspect ghcr.io/org/api:1.6.2 --format '{{.Manifest.Digest}}'
Команды и примеры
Три тега на один образ
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
Ожидаемый вывод:
═══ собираем образ ═══
═══ присваиваем три тега ═══
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
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
Ожидаемый вывод:
═══ выпуск 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
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
Ожидаемый вывод:
═══ получаем digest выпуска ═══
localhost:5000/app@sha256:7b1e5c3a2d9f4e8b6a0c1d3f5a7b9c2e4d6f8a0b1c3e5d7f9a2b4c6e8d0f1a3b
═══ подменяем тег, digest не трогаем ═══
тег 1.6.3 переназначен на СТАРЫЙ образ (имитация ошибки выпуска)
═══ что получим по тегу и что по digest ═══
по тегу 1.6.3: a3f2c1b
по digest: 9e1d4f2
═══ вывод ═══
Тег изменился — по нему пришёл другой образ.
Digest не изменился — по нему пришёл тот же.
...
Строки a3f2c1b и 9e1d4f2 при одном и том же имени 1.6.3 — самое наглядное объяснение, зачем нужен digest.
Схема тегов для проекта
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
Ожидаемый вывод:
═══ схема тегов по веткам ═══
ветка неизменяемые хранение
────────────────────────────────────────────────────────────────────────────────────────────────
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: исключения важнее правил
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
Ожидаемый вывод:
═══ план очистки ═══
образ возраст действие причина
────────────────────────────────────────────────────────────────────────────────────────────
· 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 — та, ради которой написан этот порядок проверок. Сборка старше правила хранения, но развёрнута; правило её удалило бы, исключение сохранило.
Список развёрнутого при этом должен читаться из состояния, а не поддерживаться вручную: ручной список устаревает.
Удаление тега и освобождение места
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
Ожидаемый вывод:
═══ сколько занимает 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, а его слои удерживаются оставшимися тегами.
Именно это и утверждает первое следствие: удаление одного из нескольких тегов одного образа не освобождает ничего.
Практическое упражнение
Задание. Спроектируйте схему тегов и докажите её свойства.
Требования:
- Показать, что три тега на один образ не занимают втрое больше места.
- Показать, что переназначение
latestделает откат по нему невозможным, а по неизменяемому тегу — возможным. - Показать, что развёртывание по digest устойчиво к переназначению тега.
- Реализовать схему тегов для веток
main,release/*,feature/*и обосновать каждый тег. - Реализовать cleanup policy, проверяющую исключения до правил, и показать случай, где порядок решает.
- Показать, что удаление тега не освобождает место, и назвать, что освобождает.
Подсказки
Подсказка 1
Для пункта 1 сравнивайте размер каталога registry до и после публикации дополнительных тегов.
Подсказка 2
Пункт 3 проверяется так: получить digest, переназначить тег на другой образ, скачать по обоим и сравнить содержимое.
Подсказка 3
Для пункта 5 нужен образ, который правило удалило бы, а исключение сохраняет: старая сборка, развёрнутая на staging.
Решение
Показать решение
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"
Ожидаемый вывод:
═══ Подготовка ═══
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 МБ; на образах в сотни мегабайт соотношение то же, но абсолютные числа другие.
Проверка результата
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'ы незавершённой публикации |
Контрольные вопросы
На понимание:
- Почему откат «на
latest» не работает? - Что даёт тег по commit SHA такого, чего не даёт семантическая версия?
- Чем зафиксировано содержимое при развёртывании по digest и чем — по тегу?
- Почему удаление одного тега из трёх не освобождает место?
- Почему неизменяемость релизных тегов — соглашение, а не техническое ограничение?
На применение:
- Какие теги присвоить сборке из ветки
release/2.1при версии2.1.4? - Как построить cleanup policy, не удаляющую развёрнутое?
- Как проверить, что релизный тег не переназначали?
На диагностику:
- В эксплуатации образ отличается от собранного в CI при одинаковом теге. Как это возможно?
docker system dfпоказывает рост registry после удаления десятка тегов. Причина?
Краткое резюме
latest— обычный тег; Docker не придаёт ему смысла «последней версии».- Откат требует, чтобы у каждого выпуска было имя, которое никогда не меняется.
- Схема из двух уровней: неизменяемые якоря и подвижные указатели.
- Тег по commit SHA связывает образ с состоянием кода в обе стороны.
- SHA не сортируется — его дополняют датой или семантической версией.
- В развёртывание попадает неизменяемый тег или digest, а не подвижный.
- Digest фиксирует содержимое; тег фиксирует только имя.
- Повторная публикация того же тега переназначает его — технически это разрешено.
- Cleanup policy проверяет исключения до правил удаления.
- Список развёрнутого читают из состояния, а не ведут вручную.
- Удаление тега убирает ссылку; место освобождает сборка мусора.
- Сборку мусора выполняют при запрещённой записи в registry.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| OCI Distribution Spec | https://github.com/opencontainers/distribution-spec/blob/main/spec.md#content-management | Управление тегами и манифестами |
Docker: docker tag | https://docs.docker.com/reference/cli/docker/image/tag/ | Допустимые символы в теге |
Docker: docker pull by digest | https://docs.docker.com/reference/cli/docker/image/pull/#pull-an-image-by-digest-immutable-identifier | Получение по digest |
| Registry: garbage collection | https://distribution.github.io/distribution/about/garbage-collection/ | Освобождение места, режим только для чтения |
| Registry: deleting images | https://distribution.github.io/distribution/about/configuration/#delete | REGISTRY_STORAGE_DELETE_ENABLED |
| Semantic Versioning | https://semver.org/lang/ru/ | Схема семантических версий |
| GitHub: retention policies | https://docs.github.com/en/packages/learn-github-packages/deleting-and-restoring-a-package | Правила хранения пакетов |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Практические задания
Главное оглавление