14.1. Основы registry
Цели
После этого материала вы сможете:
- разобрать полное имя образа на составляющие и назвать, что подставляется по умолчанию;
- объяснить, что происходит при
pushиpullна уровне манифеста и слоёв; - объяснить, почему повторная публикация большого образа занимает секунды;
- назвать случай, когда те же слои загружаются заново, и почему;
- различить три разных digest'а, относящихся к одному образу;
- понять механизм ограничений на скачивание, не полагаясь на конкретные числа.
Предварительные знания
- 3.1. Строение образа;
- 3.3. Теги и digest;
- 11.6. Supply chain — закрепление по digest.
Ключевые термины
| Термин | Объяснение |
|---|---|
registry | Сервис хранения образов |
repository | Набор образов под одним именем |
namespace | Владелец репозитория |
manifest | Описание образа: слои, конфигурация |
index | Список манифестов для разных платформ |
blob | Содержимое слоя или конфигурации |
digest | Контрольная сумма содержимого |
Теория
Полное имя образа
registry[:порт]/namespace/repository[:тег][@digest]
| Пример | Что подставляется |
|---|---|
python | docker.io/library/python:latest |
python:3.13-slim | docker.io/library/python:3.13-slim |
myorg/api:1.2 | docker.io/myorg/api:1.2 |
ghcr.io/myorg/api:1.2 | Ничего: имя полное |
localhost:5000/api:dev | Ничего |
Три правила подстановки:
- Отсутствие registry означает
docker.io. - Отсутствие namespace на Docker Hub означает
library— это официальные образы (урок 12.7). - Отсутствие тега означает
latest.
Ловушка портов. Docker отличает registry от namespace по наличию точки или двоеточия в первой части. localhost:5000/api — это registry localhost:5000. А myregistry/api без точки будет понято как namespace myregistry на Docker Hub.
Отсюда правило для локального registry: имя хоста должно содержать точку или порт.
Registry, repository, tag
registry ghcr.io
└── namespace myorg
└── repository api
├── tag 1.2.0
├── tag 1.2
└── tag latest ← все три могут указывать на один образ
Тег — это имя-указатель, а не свойство образа. Присвоение трёх тегов одному образу не создаёт трёх копий: в хранилище остаётся один набор blob'ов и три записи в таблице тегов.
Следствие: docker tag выполняется мгновенно и не занимает места.
Что происходит при push
1. Клиент вычисляет digest каждого слоя
2. Для каждого слоя спрашивает registry: «этот blob у тебя есть?»
HEAD /v2/<repo>/blobs/<digest>
├── 200 OK → слой пропускается
└── 404 Not Found → слой загружается
3. Загружается конфигурация (тоже blob)
4. Загружается манифест — он и связывает всё вместе
5. Тег привязывается к манифесту
Ключевой шаг — второй. Registry хранит blob'ы по содержимому, поэтому уже имеющийся слой не передаётся.
Практическое следствие: публикация обновлённой версии образа на 1 ГБ, где изменился только код приложения, передаёт мегабайты, а не гигабайт. Именно поэтому порядок инструкций в Dockerfile влияет не только на сборку, но и на скорость публикации (урок 5.5).
Существенное ограничение: проверка HEAD /v2/<repo>/blobs/ выполняется в пределах репозитория. Тот же слой, публикуемый в другой репозиторий, по умолчанию загружается заново.
Обойти это позволяет cross-repository blob mount — запрос, при котором клиент указывает, откуда взять уже имеющийся blob. Его поддерживают не все registry и не во всех случаях.
Отсюда практический вывод: держать варианты одного приложения (api, api-staging, api-dev) в одном репозитории с разными тегами дешевле, чем в трёх репозиториях.
Что происходит при pull
1. Запрашивается манифест по тегу или digest
GET /v2/<repo>/manifests/<ссылка>
2. Если это index (мультиплатформенный образ) —
выбирается манифест под платформу клиента
3. Для каждого слоя проверяется локальное наличие
├── есть → пропускается
└── нет → скачивается и распаковывается
4. Скачивается конфигурация
Второй шаг объясняет, почему docker pull одного и того же тега на разных машинах может дать разные образы: индекс содержит манифесты для amd64, arm64 и других, и каждая машина берёт свой.
Третий шаг — причина того, что второй pull образа с общей базой быстрый: слои python:3.13-slim уже есть локально.
Три разных digest
Путаница здесь встречается постоянно.
| Что | Где виден | Чему соответствует |
|---|---|---|
| Index digest | RepoDigests после pull мультиплатформенного образа | Списку манифестов всех платформ |
| Manifest digest | docker manifest inspect для конкретной платформы | Одному образу под одну платформу |
| Config digest | docker images в колонке IMAGE ID | Конфигурации образа |
docker images --format '{{.ID}}' # config digest (короткий)
docker inspect ОБРАЗ --format '{{.Id}}' # config digest
docker inspect ОБРАЗ --format '{{index .RepoDigests 0}}' # index или manifest digest
Для закрепления версии используют RepoDigests, а не Id: первый существует в registry и понятен другим машинам, второй локален.
Проверить легко: Id двух машин, скачавших один и тот же образ, совпадут — но Id нельзя передать в docker pull.
Публичные registry
| Registry | Адрес | Особенность |
|---|---|---|
| Docker Hub | docker.io | По умолчанию; ограничения на скачивание |
| GitHub Container Registry | ghcr.io | Права наследуются от репозитория |
| GitLab Registry | registry.gitlab.com | Встроен в проект |
| Amazon ECR | *.dkr.ecr.*.amazonaws.com | Токен действует 12 часов |
| Google Artifact Registry | *-docker.pkg.dev | Права через IAM |
| Quay | quay.io | Сканирование в комплекте |
Ограничения на скачивание
Docker Hub ограничивает число операций скачивания. Механизм важнее конкретных чисел:
| Свойство | Значение |
|---|---|
| Что считается | Запрос манифеста, а не слоёв |
| Для анонимных | Учёт по IP-адресу |
| Для аутентифицированных | Учёт по учётной записи |
| Окно | Скользящее, несколько часов |
Два следствия из первой строки:
Повторный pull уже имеющегося образа расходует лимит. Слои не скачиваются, но манифест запрашивается — это и есть учитываемая операция.
Общий IP означает общий лимит. Офисный NAT или пул CI-сборщиков расходуют один лимит на всех, и он кончается неожиданно быстро.
Конкретные пороги менялись неоднократно и различаются для типов учётных записей. Актуальные значения смотрите в документации Docker Hub — в этом уроке они намеренно не приводятся, чтобы не устареть.
Меры при упоре в лимит:
| Мера | Эффект |
|---|---|
docker login даже для публичных образов | Учёт по аккаунту, а не по IP |
| Зеркало через pull-through cache | Один запрос на организацию |
| Собственный registry для базовых образов | Полная независимость |
| Закрепление по digest | Не снижает число запросов |
Последняя строка часто удивляет: digest даёт воспроизводимость, но манифест всё равно запрашивается.
Внутренний механизм
Почему blob'ы адресуются содержимым
Имя blob'а — это sha256 его содержимого. Отсюда три свойства:
Дедупликация бесплатна. Два образа с одинаковым слоем ссылаются на один blob; проверка на совпадение — сравнение строк.
Целостность проверяется автоматически. Клиент вычисляет sha256 скачанного и сравнивает с запрошенным именем. Подмена содержимого невозможна без изменения имени.
Изменить слой нельзя. Изменение содержимого даёт другой digest, то есть другой blob. Слои неизменяемы не по соглашению, а по устройству.
Почему удаление тега не освобождает место
Удаление тега убирает ссылку на манифест. Сам манифест и его blob'ы остаются: на них могут ссылаться другие теги.
Освобождение выполняет сборщик мусора registry — отдельный процесс, который находит blob'ы без ссылок и удаляет их. Пока он не запущен, место занято.
Это разбирается подробно в уроке 14.4; здесь важно само разделение: удаление тега и освобождение места — разные операции.
Команды и примеры
Разбор имени образа
mkdir -p /tmp/reg && cd /tmp/reg
cat > parse-ref.py <<'PY'
"""Разбирает ссылку на образ и показывает, что подставляется по умолчанию."""
from __future__ import annotations
import json
import sys
DEFAULT_REGISTRY = "docker.io"
DEFAULT_NAMESPACE = "library"
DEFAULT_TAG = "latest"
def is_registry(part: str) -> bool:
"""Первая часть — registry, если содержит точку, двоеточие или равна localhost."""
return "." in part or ":" in part or part == "localhost"
def parse(ref: str) -> dict[str, object]:
digest = None
if "@" in ref:
ref, digest = ref.split("@", 1)
parts = ref.split("/")
подставлено = []
if is_registry(parts[0]):
registry = parts[0]
rest = parts[1:]
else:
registry = DEFAULT_REGISTRY
rest = parts
подставлено.append(f"registry={DEFAULT_REGISTRY}")
name = rest[-1]
tag = None
if ":" in name:
name, tag = name.rsplit(":", 1)
if tag is None and digest is None:
tag = DEFAULT_TAG
подставлено.append(f"тег={DEFAULT_TAG}")
namespace = "/".join(rest[:-1])
if not namespace:
if registry == DEFAULT_REGISTRY:
namespace = DEFAULT_NAMESPACE
подставлено.append(f"namespace={DEFAULT_NAMESPACE} (официальный образ)")
else:
namespace = "—"
return {
"исходная_ссылка": ref + (f"@{digest}" if digest else ""),
"registry": registry,
"namespace": namespace,
"repository": name,
"тег": tag,
"digest": digest,
"подставлено": подставлено,
"полное_имя": f"{registry}/{namespace}/{name}" + (f":{tag}" if tag else "")
+ (f"@{digest}" if digest else ""),
}
if __name__ == "__main__":
refs = sys.argv[1:]
print(json.dumps([parse(r) for r in refs], ensure_ascii=False, indent=2))
PY
echo "═══ разбор пяти ссылок ═══"
python3 parse-ref.py \
"python" \
"python:3.13-slim" \
"myorg/api:1.2" \
"ghcr.io/myorg/api:1.2" \
"localhost:5000/api@sha256:abc123def456" \
| python3 -c "
import json, sys
for d in json.load(sys.stdin):
print(f\" {d['исходная_ссылка']}\")
print(f\" → {d['полное_имя']}\")
if d['подставлено']:
print(f\" подставлено: {', '.join(d['подставлено'])}\")
else:
print(' подставлено: ничего — имя полное')
"
Ожидаемый вывод:
═══ разбор пяти ссылок ═══
python
→ docker.io/library/python:latest
подставлено: registry=docker.io, тег=latest, namespace=library (официальный образ)
python:3.13-slim
→ docker.io/library/python:3.13-slim
подставлено: registry=docker.io, namespace=library (официальный образ)
myorg/api:1.2
→ docker.io/myorg/api:1.2
подставлено: registry=docker.io
ghcr.io/myorg/api:1.2
→ docker.io/ghcr.io/myorg/api:1.2
подставлено: ничего — имя полное
localhost:5000/api@sha256:abc123def456
→ localhost:5000/—/api@sha256:abc123def456
подставлено: ничего — имя полное
Четвёртая строка показывает ошибку в моём разборе: ghcr.io/myorg/api определён как полное имя правильно, но поле полное_имя собрано неверно — registry подставлен дважды.
Исправление:
cd /tmp/reg
python3 - <<'PY'
"""Исправление: полное имя собирается из уже разобранных частей."""
import re
def full_name(registry: str, namespace: str, repo: str, tag: str | None, digest: str | None) -> str:
parts = [registry]
if namespace and namespace != "—":
parts.append(namespace)
parts.append(repo)
name = "/".join(parts)
if tag:
name += f":{tag}"
if digest:
name += f"@{digest}"
return name
cases = [
("docker.io", "library", "python", "latest", None),
("docker.io", "myorg", "api", "1.2", None),
("ghcr.io", "myorg", "api", "1.2", None),
("localhost:5000", "—", "api", None, "sha256:abc123"),
]
for c in cases:
print(f" {full_name(*c)}")
PY
Ожидаемый вывод:
docker.io/library/python:latest
docker.io/myorg/api:1.2
ghcr.io/myorg/api:1.2
localhost:5000/api@sha256:abc123
Правило, из-за которого нужна отдельная функция: часть, определённая как registry, не дополняется значением по умолчанию — она уже конечная.
Локальный registry для наблюдения
cd /tmp/reg
echo "═══ запуск registry ═══"
docker run -d --name reg -p 5000:5000 \
-v reg-data:/var/lib/registry registry:2 > /dev/null
sleep 3
curl -s http://localhost:5000/v2/ -o /dev/null -w ' ответ /v2/: HTTP %{http_code}\n'
echo "═══ готовим два образа с общей базой ═══"
cat > Dockerfile.a <<'EOF'
FROM python:3.13-slim
RUN pip install --no-cache-dir click==8.3.0
COPY app-a.txt /app.txt
EOF
cat > Dockerfile.b <<'EOF'
FROM python:3.13-slim
RUN pip install --no-cache-dir click==8.3.0
COPY app-b.txt /app.txt
EOF
echo "версия A" > app-a.txt
echo "версия B" > app-b.txt
docker build -q -f Dockerfile.a -t localhost:5000/demo:a . > /dev/null
docker build -q -f Dockerfile.b -t localhost:5000/demo:b . > /dev/null
docker images --format ' {{.Repository}}:{{.Tag}} {{.Size}}' | grep 'localhost:5000/demo'
echo "═══ первая публикация ═══"
docker push localhost:5000/demo:a 2>&1 | tail -6 | sed 's/^/ /'
echo "═══ вторая публикация (общие слои) ═══"
docker push localhost:5000/demo:b 2>&1 | tail -6 | sed 's/^/ /'
Ожидаемый вывод:
═══ запуск registry ═══
ответ /v2/: HTTP 200
═══ готовим два образа с общей базой ═══
localhost:5000/demo:a 178MB
localhost:5000/demo:b 178MB
═══ первая публикация ═══
The push refers to repository [localhost:5000/demo]
9c1a2b3d4e5f: Pushed
8b7a6c5d4e3f: Pushed
7a6b5c4d3e2f: Pushed
6f5e4d3c2b1a: Pushed
a: digest: sha256:4d2c8f1e... size: 1163
═══ вторая публикация (общие слои) ═══
The push refers to repository [localhost:5000/demo]
1f2e3d4c5b6a: Pushed
8b7a6c5d4e3f: Layer already exists
7a6b5c4d3e2f: Layer already exists
6f5e4d3c2b1a: Layer already exists
b: digest: sha256:9e7f3a2b... size: 1163
Layer already exists три раза из четырёх — это и есть проверка HEAD /v2/<repo>/blobs/<digest>, вернувшая 200.
Передан только один слой: тот, где различаются файлы. Второй образ на 126 МБ занял секунды и мегабайты трафика.
Дедупликация работает в пределах репозитория
cd /tmp/reg
echo "═══ тот же образ в ДРУГОЙ репозиторий ═══"
docker tag localhost:5000/demo:a localhost:5000/другой-репозиторий:a
docker push localhost:5000/другой-репозиторий:a 2>&1 | tail -6 | sed 's/^/ /'
echo "═══ что произошло ═══"
cat <<'TXT'
В новом репозитории слоёв нет — проверка HEAD вернула 404
для КАЖДОГО слоя, и все они загружены заново.
Проверка выполняется по пути /v2/<РЕПОЗИТОРИЙ>/blobs/<digest>:
репозиторий входит в адрес. Хранилище может дедуплицировать
байты у себя, но клиенту приходится их передать.
Обойти это позволяет cross-repository blob mount — запрос
POST /v2/<новый>/blobs/uploads/?mount=<digest>&from=<старый>.
Его поддерживают не все registry.
Практический вывод: варианты одного приложения держат
в ОДНОМ репозитории с разными тегами:
ghcr.io/org/api:1.2.0 ✓
ghcr.io/org/api:staging ✓
а не:
ghcr.io/org/api-prod:1.2.0 ✗ слои загрузятся дважды
ghcr.io/org/api-staging:1.2.0
TXT
Ожидаемый вывод:
═══ тот же образ в ДРУГОЙ репозиторий ═══
The push refers to repository [localhost:5000/другой-репозиторий]
9c1a2b3d4e5f: Pushed
8b7a6c5d4e3f: Pushed
7a6b5c4d3e2f: Pushed
6f5e4d3c2b1a: Pushed
a: digest: sha256:4d2c8f1e... size: 1163
═══ что произошло ═══
В новом репозитории слоёв нет — проверка HEAD вернула 404
для КАЖДОГО слоя, и все они загружены заново.
...
Сравните с предыдущим блоком: там было три Layer already exists, здесь — четыре Pushed.
Обратите внимание на последнюю строку обеих публикаций: digest: sha256:4d2c8f1e... совпадает. Образ тот же, digest тот же — но передан дважды.
Три digest одного образа
cd /tmp/reg
docker pull -q python:3.13-slim > /dev/null
echo "═══ config digest (локальный идентификатор) ═══"
docker inspect python:3.13-slim --format ' Id: {{.Id}}' | cut -c1-60
echo "═══ RepoDigest (то, что существует в registry) ═══"
docker inspect python:3.13-slim --format ' RepoDigest: {{index .RepoDigests 0}}' | cut -c1-80
echo "═══ манифесты по платформам ═══"
docker manifest inspect python:3.13-slim 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
if 'manifests' in d:
print(f\" тип: index, манифестов: {len(d['manifests'])}\")
for m in d['manifests'][:4]:
p = m.get('platform', {})
if p.get('architecture') == 'unknown':
continue
print(f\" {p.get('os','?')}/{p.get('architecture','?')}\"
f\"{'/' + p['variant'] if p.get('variant') else '':<8} \"
f\"{m['digest'][:26]}...\")
else:
print(' тип: одиночный манифест')
"
echo "═══ какой digest для чего ═══"
cat <<'TXT'
Id (config digest) локальный; в docker pull НЕ передаётся
RepoDigest то, что публикуется; годится для закрепления
manifest digest конкретная платформа; берут при явном выборе
Закрепление в Dockerfile использует RepoDigest:
FROM python:3.13-slim@sha256:<RepoDigest>
Он указывает на INDEX, поэтому сборка на amd64 и arm64
получит разные слои — и это правильно.
TXT
Ожидаемый вывод:
═══ config digest (локальный идентификатор) ═══
Id: sha256:9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d
═══ RepoDigest (то, что существует в registry) ═══
RepoDigest: python@sha256:4a1e2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a
═══ манифесты по платформам ═══
тип: index, манифестов: 12
linux/amd64 sha256:7f3e2a1b9c8d4e5f6a7b...
linux/arm64/v8 sha256:2c1d0e9f8a7b6c5d4e3f...
linux/arm/v7 sha256:5b4a3c2d1e0f9a8b7c6d...
linux/386 sha256:8e7d6c5b4a3f2e1d0c9b...
═══ какой digest для чего ═══
Id (config digest) локальный; в docker pull НЕ передаётся
RepoDigest то, что публикуется; годится для закрепления
manifest digest конкретная платформа; берут при явном выборе
Закрепление в Dockerfile использует RepoDigest:
FROM python:3.13-slim@sha256:<RepoDigest>
Он указывает на INDEX, поэтому сборка на amd64 и arm64
получит разные слои — и это правильно.
Три разных значения, начинающихся с sha256:, — источник постоянной путаницы. Различает их только то, что именно захешировано: конфигурация, индекс или манифест платформы.
Целостность при получении
cd /tmp/reg
echo "═══ digest до публикации ═══"
before="$(docker inspect localhost:5000/demo:a --format '{{.Id}}')"
echo " Id локально: ${before:0:32}..."
echo "═══ удаляем локально и получаем заново ═══"
docker rmi localhost:5000/demo:a localhost:5000/другой-репозиторий:a > /dev/null 2>&1
docker pull -q localhost:5000/demo:a > /dev/null
after="$(docker inspect localhost:5000/demo:a --format '{{.Id}}')"
echo " Id после pull: ${after:0:32}..."
echo "═══ сравнение ═══"
if [ "$before" = "$after" ]; then
echo " ✓ совпадает: получен тот же образ"
else
echo " ✗ различается — этого не должно происходить"
fi
echo "═══ как проверяется целостность ═══"
cat <<'TXT'
Имя blob'а — это sha256 его содержимого. Клиент, скачав слой,
вычисляет sha256 и сравнивает с именем, которое запрашивал.
Несовпадение означает повреждение или подмену — docker pull
завершится ошибкой, а не отдаст испорченные данные.
Поэтому отдельная проверка контрольных сумм не нужна:
она встроена в адресацию по содержимому.
Что это НЕ даёт: гарантии, что образ собран тем, кем вы думаете.
Это отдельный вопрос — подпись ([урок 12.7]).
TXT
docker rm -f reg > /dev/null 2>&1
docker volume rm reg-data > /dev/null 2>&1
cd /tmp && rm -rf /tmp/reg
Ожидаемый вывод:
═══ digest до публикации ═══
Id локально: sha256:3f8a2b1c4d5e6f7a8b9c0d1e2f3a...
═══ удаляем локально и получаем заново ═══
Id после pull: sha256:3f8a2b1c4d5e6f7a8b9c0d1e2f3a...
═══ сравнение ═══
✓ совпадает: получен тот же образ
═══ как проверяется целостность ═══
Имя blob'а — это sha256 его содержимого. Клиент, скачав слой,
вычисляет sha256 и сравнивает с именем, которое запрашивал.
Несовпадение означает повреждение или подмену — docker pull
завершится ошибкой, а не отдаст испорченные данные.
Поэтому отдельная проверка контрольных сумм не нужна:
она встроена в адресацию по содержимому.
Что это НЕ даёт: гарантии, что образ собран тем, кем вы думаете.
Это отдельный вопрос — подпись ([урок 12.7]).
Последний абзац отделяет два разных свойства. Целостность — «содержимое не испортилось» — обеспечивается устройством registry. Подлинность — «это собрали мы» — не обеспечивается ничем без подписи.
Практическое упражнение
Задание. Измерьте, что именно передаётся при публикации.
Требования:
- Разобрать пять ссылок на образы, показав подстановки по умолчанию.
- Опубликовать два образа с общей базой и показать, сколько слоёв передано повторно.
- Опубликовать тот же образ в другой репозиторий и показать, что слои переданы заново.
- Показать три разных digest одного образа и объяснить, для чего каждый.
- Подтвердить целостность: сравнить образ до публикации и после получения.
- Написать проверку, определяющую по ссылке, что подставится по умолчанию.
Подсказки
Подсказка 1
Для пункта 2 нужны два образа, различающиеся только последним слоем: одинаковый FROM и одинаковая установка зависимостей.
Подсказка 2
Считайте строки Layer already exists и Pushed в выводе docker push — это и есть измерение.
Подсказка 3
Первая часть имени — registry, если содержит точку, двоеточие или равна localhost. Иначе это namespace на Docker Hub.
Решение
Показать решение
mkdir -p /tmp/reglab && cd /tmp/reglab
# ─── Разбор ссылок ────────────────────────────────────────────────────
cat > refparse.py <<'PY'
"""Разбор ссылки на образ с указанием подстановок по умолчанию.
Правило различения registry и namespace: первая часть считается
registry, если содержит точку, двоеточие или равна localhost.
Иначе это namespace на Docker Hub.
"""
from __future__ import annotations
import json
import sys
DEFAULT_REGISTRY = "docker.io"
DEFAULT_NAMESPACE = "library"
DEFAULT_TAG = "latest"
def looks_like_registry(part: str) -> bool:
return "." in part or ":" in part or part == "localhost"
def parse(ref: str) -> dict[str, object]:
original = ref
digest = None
if "@" in ref:
ref, digest = ref.split("@", 1)
parts = ref.split("/")
defaults: list[str] = []
if looks_like_registry(parts[0]):
registry, rest = parts[0], parts[1:]
else:
registry, rest = DEFAULT_REGISTRY, parts
defaults.append(f"registry={DEFAULT_REGISTRY}")
if not rest:
raise ValueError(f"нет имени репозитория: {original}")
repo = rest[-1]
tag = None
if ":" in repo:
repo, tag = repo.rsplit(":", 1)
if tag is None and digest is None:
tag, _ = DEFAULT_TAG, defaults.append(f"тег={DEFAULT_TAG}")
namespace = "/".join(rest[:-1])
if not namespace:
if registry == DEFAULT_REGISTRY:
namespace = DEFAULT_NAMESPACE
defaults.append(f"namespace={DEFAULT_NAMESPACE} → официальный образ")
else:
namespace = ""
# Полное имя собирается из РАЗОБРАННЫХ частей, а не дополнением исходной
segments = [registry] + ([namespace] if namespace else []) + [repo]
full = "/".join(segments)
if tag:
full += f":{tag}"
if digest:
full += f"@{digest}"
return {
"ссылка": original,
"registry": registry,
"namespace": namespace or "(нет)",
"repository": repo,
"тег": tag,
"digest": digest,
"подставлено": defaults,
"полное_имя": full,
"официальный": registry == DEFAULT_REGISTRY and namespace == DEFAULT_NAMESPACE,
}
if __name__ == "__main__":
print(json.dumps([parse(r) for r in sys.argv[1:]],
ensure_ascii=False, indent=2))
PY
# ─── Измерение публикации ─────────────────────────────────────────────
cat > measure-push.sh <<'SH'
#!/usr/bin/env bash
# Считает, сколько слоёв передано и сколько пропущено при публикации.
set -uo pipefail
REF="${1:?укажите образ}"
out="$(docker push "$REF" 2>&1)"
pushed="$(echo "$out" | grep -c ': Pushed' || echo 0)"
exists="$(echo "$out" | grep -c ': Layer already exists' || echo 0)"
digest="$(echo "$out" | grep -o 'digest: sha256:[0-9a-f]*' | head -1 | cut -d' ' -f2)"
total=$((pushed + exists))
printf '%s|%s|%s|%s\n' "$pushed" "$exists" "$total" "${digest:-нет}"
SH
chmod +x measure-push.sh
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
printf '\n═══ Требование 1: разбор пяти ссылок ═══\n'
python3 refparse.py \
"python" "python:3.13-slim" "myorg/api:1.2" \
"ghcr.io/myorg/api:1.2" "localhost:5000/svc@sha256:abc123" \
| python3 -c "
import json, sys
rows = json.load(sys.stdin)
print(f\" {'ссылка':<34} {'полное имя':<44} офиц.\")
print(' ' + '─' * 88)
for r in rows:
print(f\" {r['ссылка']:<34} {r['полное_имя']:<44} {'да' if r['официальный'] else 'нет'}\")
print()
for r in rows:
if r['подставлено']:
print(f\" {r['ссылка']}: {', '.join(r['подставлено'])}\")
"
official_count="$(python3 refparse.py python python:3.13-slim myorg/api:1.2 \
ghcr.io/myorg/api:1.2 "localhost:5000/svc@sha256:abc123" \
| python3 -c "import json,sys; print(sum(1 for r in json.load(sys.stdin) if r['официальный']))")"
ghcr_full="$(python3 refparse.py "ghcr.io/myorg/api:1.2" \
| python3 -c "import json,sys; print(json.load(sys.stdin)[0]['полное_имя'])")"
printf ' официальных образов: %s\n' "$official_count"
printf ' ghcr.io собран как: %s\n' "$ghcr_full"
[ "$official_count" -eq 2 ] && [ "$ghcr_full" = "ghcr.io/myorg/api:1.2" ] \
&& ok "подстановки определены верно, registry не задвоен" \
|| bad "официальных=$official_count, ghcr=$ghcr_full"
printf '\n═══ Подготовка: локальный registry ═══\n'
docker run -d --name lab-reg -p 5000:5000 -v lab-reg-data:/var/lib/registry \
registry:2 > /dev/null 2>&1
sleep 4
code="$(curl -s -o /dev/null -w '%{http_code}' http://localhost:5000/v2/ 2>/dev/null)"
printf ' registry отвечает: HTTP %s\n' "$code"
[ "$code" = "200" ] || { echo " registry не поднялся"; exit 1; }
cat > Dockerfile.base <<'EOF'
FROM python:3.13-slim
RUN pip install --no-cache-dir click==8.3.0
ARG VARIANT=a
RUN echo "вариант ${VARIANT}" > /variant.txt
EOF
docker build -q -f Dockerfile.base --build-arg VARIANT=a -t localhost:5000/app:a . > /dev/null
docker build -q -f Dockerfile.base --build-arg VARIANT=b -t localhost:5000/app:b . > /dev/null
printf ' два образа собраны\n'
printf '\n═══ Требование 2: повторная публикация общих слоёв ═══\n'
r_a="$(./measure-push.sh localhost:5000/app:a)"
r_b="$(./measure-push.sh localhost:5000/app:b)"
IFS='|' read -r pa ea ta da <<EOF
$r_a
EOF
IFS='|' read -r pb eb tb db <<EOF
$r_b
EOF
printf ' %-28s %-9s %-12s %s\n' "публикация" "передано" "пропущено" "всего слоёв"
printf ' %s\n' "──────────────────────────────────────────────────────────────"
printf ' %-28s %-9s %-12s %s\n' "app:a (первая)" "$pa" "$ea" "$ta"
printf ' %-28s %-9s %-12s %s\n' "app:b (общая база)" "$pb" "$eb" "$tb"
printf ' экономия на второй: %s из %s слоёв не передавались\n' "$eb" "$tb"
[ "${eb:-0}" -gt 0 ] && [ "${pb:-0}" -lt "${pa:-99}" ] \
&& ok "общие слои пропущены: проверка HEAD вернула 200" \
|| bad "передано=$pb пропущено=$eb"
printf '\n═══ Требование 3: другой репозиторий ═══\n'
docker tag localhost:5000/app:a localhost:5000/second/app:a
r_c="$(./measure-push.sh localhost:5000/second/app:a)"
IFS='|' read -r pc ec tc dc <<EOF
$r_c
EOF
printf ' %-28s %-9s %-12s %s\n' "second/app:a (тот же образ)" "$pc" "$ec" "$tc"
printf ' digest app:a %s\n' "${da:0:30}..."
printf ' digest second/app:a %s\n' "${dc:0:30}..."
if [ "$da" = "$dc" ]; then
printf ' digest СОВПАДАЕТ — образ тот же\n'
same_digest=1
else
printf ' digest различается\n'
same_digest=0
fi
[ "${pc:-0}" -ge "${pa:-0}" ] && [ "$same_digest" = "1" ] \
&& ok "тот же образ передан заново: дедупликация в пределах репозитория" \
|| bad "передано=$pc (в первый раз $pa), digest совпал=$same_digest"
printf '\n═══ Требование 4: три digest ═══\n'
docker pull -q python:3.13-slim > /dev/null 2>&1
cfg="$(docker inspect python:3.13-slim --format '{{.Id}}')"
repo="$(docker inspect python:3.13-slim --format '{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}')"
printf ' %-22s %s\n' "config digest (Id)" "${cfg:0:40}..."
printf ' %-22s %s\n' "RepoDigest" "${repo:0:56}..."
plat="$(docker manifest inspect python:3.13-slim 2>/dev/null | python3 -c "
import json, sys
try:
d = json.load(sys.stdin)
except Exception:
print(''); raise SystemExit
ms = [m for m in d.get('manifests', [])
if m.get('platform', {}).get('architecture') not in (None, 'unknown')]
if ms:
print(f\"манифестов платформ: {len(ms)}; первый: {ms[0]['digest'][:30]}...\")
else:
print('одиночный манифест')
" 2>/dev/null)"
printf ' %-22s %s\n' "manifest digest" "${plat:-недоступен без сети}"
printf '\n назначение каждого:\n'
printf ' Id локальный идентификатор конфигурации; в pull не передаётся\n'
printf ' RepoDigest существует в registry; им закрепляют версию в FROM\n'
printf ' manifest конкретная платформа; выбирается при pull автоматически\n'
[ -n "$cfg" ] && [ -n "$repo" ] && [ "$cfg" != "$repo" ] \
&& ok "три значения различны; для закрепления пригоден RepoDigest" \
|| bad "Id=$cfg RepoDigest=$repo"
printf '\n═══ Требование 5: целостность ═══\n'
id_before="$(docker inspect localhost:5000/app:a --format '{{.Id}}')"
printf ' Id до удаления: %s\n' "${id_before:0:40}..."
docker rmi -f localhost:5000/app:a localhost:5000/second/app:a > /dev/null 2>&1
docker pull -q localhost:5000/app:a > /dev/null 2>&1
id_after="$(docker inspect localhost:5000/app:a --format '{{.Id}}')"
printf ' Id после pull: %s\n' "${id_after:0:40}..."
[ "$id_before" = "$id_after" ] \
&& ok "получен тот же образ; проверка sha256 встроена в адресацию" \
|| bad "Id различается: было $id_before, стало $id_after"
printf '\n═══ Требование 6: проверка подстановок ═══\n'
cat > check-ref.sh <<'SH'
#!/usr/bin/env bash
# Предупреждает о ссылках, полагающихся на подстановки по умолчанию.
set -uo pipefail
issues=0
for ref in "$@"; do
info="$(python3 refparse.py "$ref")"
n_def="$(echo "$info" | python3 -c "import json,sys; print(len(json.load(sys.stdin)[0]['подставлено']))")"
full="$(echo "$info" | python3 -c "import json,sys; print(json.load(sys.stdin)[0]['полное_имя'])")"
tag="$(echo "$info" | python3 -c "import json,sys; print(json.load(sys.stdin)[0]['тег'] or '')")"
dig="$(echo "$info" | python3 -c "import json,sys; print(json.load(sys.stdin)[0]['digest'] or '')")"
printf ' %-34s → %s\n' "$ref" "$full"
if [ "$tag" = "latest" ] && [ -z "$dig" ]; then
printf ' ⚠ тег latest подставлен: образ не закреплён\n'
issues=$((issues + 1))
fi
if [ -z "$dig" ] && [ "$n_def" -gt 0 ]; then
printf ' · подстановок по умолчанию: %s\n' "$n_def"
fi
[ -n "$dig" ] && printf ' ✓ закреплён по digest\n'
done
printf '\n ссылок, требующих внимания: %s\n' "$issues"
[ "$issues" -gt 0 ] && exit 1
exit 0
SH
chmod +x check-ref.sh
./check-ref.sh "python" "python:3.13-slim" "ghcr.io/org/api@sha256:abc123"; rc_warn=$?
printf ' код возврата при неявных ссылках: %s\n' "$rc_warn"
./check-ref.sh "ghcr.io/org/api@sha256:abc123" > /dev/null 2>&1; rc_clean=$?
printf ' код возврата на закреплённой ссылке: %s\n' "$rc_clean"
[ "$rc_warn" -eq 1 ] && [ "$rc_clean" -eq 0 ] \
&& ok "проверка различает закреплённые и неявные ссылки" \
|| bad "коды: $rc_warn и $rc_clean"
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все требования выполнены" || echo " ЕСТЬ ПРОВАЛЫ"
docker rm -f lab-reg > /dev/null 2>&1
docker volume rm lab-reg-data > /dev/null 2>&1
docker rmi -f localhost:5000/app:a localhost:5000/app:b > /dev/null 2>&1
cd /tmp && rm -rf /tmp/reglab
exit "$fail"
Ожидаемый вывод:
═══ Требование 1: разбор пяти ссылок ═══
ссылка полное имя офиц.
────────────────────────────────────────────────────────────────────────────────────────
python docker.io/library/python:latest да
python:3.13-slim docker.io/library/python:3.13-slim да
myorg/api:1.2 docker.io/myorg/api:1.2 нет
ghcr.io/myorg/api:1.2 ghcr.io/myorg/api:1.2 нет
localhost:5000/svc@sha256:abc123 localhost:5000/svc@sha256:abc123 нет
python: registry=docker.io, тег=latest, namespace=library → официальный образ
python:3.13-slim: registry=docker.io, namespace=library → официальный образ
myorg/api:1.2: registry=docker.io
официальных образов: 2
ghcr.io собран как: ghcr.io/myorg/api:1.2
✓ подстановки определены верно, registry не задвоен
═══ Подготовка: локальный registry ═══
registry отвечает: HTTP 200
два образа собраны
═══ Требование 2: повторная публикация общих слоёв ═══
публикация передано пропущено всего слоёв
──────────────────────────────────────────────────────────────
app:a (первая) 5 0 5
app:b (общая база) 1 4 5
экономия на второй: 4 из 5 слоёв не передавались
✓ общие слои пропущены: проверка HEAD вернула 200
═══ Требование 3: другой репозиторий ═══
second/app:a (тот же образ) 5 0 5
digest app:a sha256:4d2c8f1e9a0b3c5d7e2f4a...
digest second/app:a sha256:4d2c8f1e9a0b3c5d7e2f4a...
digest СОВПАДАЕТ — образ тот же
✓ тот же образ передан заново: дедупликация в пределах репозитория
═══ Требование 4: три digest ═══
config digest (Id) sha256:9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f...
RepoDigest python@sha256:4a1e2b3c4d5e6f7a8b9c0d1e2f3a4b5c...
manifest digest манифестов платформ: 8; первый: sha256:7f3e2a1b9c8d4e...
назначение каждого:
Id локальный идентификатор конфигурации; в pull не передаётся
RepoDigest существует в registry; им закрепляют версию в FROM
manifest конкретная платформа; выбирается при pull автоматически
✓ три значения различны; для закрепления пригоден RepoDigest
═══ Требование 5: целостность ═══
Id до удаления: sha256:3f8a2b1c4d5e6f7a8b9c0d1e2f3a4b5c...
Id после pull: sha256:3f8a2b1c4d5e6f7a8b9c0d1e2f3a4b5c...
✓ получен тот же образ; проверка sha256 встроена в адресацию
═══ Требование 6: проверка подстановок ═══
python → docker.io/library/python:latest
⚠ тег latest подставлен: образ не закреплён
· подстановок по умолчанию: 3
python:3.13-slim → docker.io/library/python:3.13-slim
· подстановок по умолчанию: 2
ghcr.io/org/api@sha256:abc123 → ghcr.io/org/api@sha256:abc123
✓ закреплён по digest
ссылок, требующих внимания: 1
код возврата при неявных ссылках: 1
код возврата на закреплённой ссылке: 0
✓ проверка различает закреплённые и неявные ссылки
═══ ИТОГ ═══
все требования выполнены
Все требования выполнены.
Требования 2 и 3 вместе дают главный результат: «1 передано, 4 пропущено» против «5 передано» — при одинаковом digest. Тот же образ, та же контрольная сумма, а трафик отличается впятеро. Разница только в имени репозитория.
Три решения, определяющие качество.
Полное имя собирается из разобранных частей, а не дополнением исходной строки. Наивная реализация — приписать docker.io/ к ссылке, где registry не распознан, — даёт docker.io/ghcr.io/myorg/api для полного имени. Ошибка выглядит безобидной, но именно она превращает опечатку в имени registry в обращение к Docker Hub, где может оказаться чужой образ (урок 12.7). Отдельная проверка следит за этим.
Требование 3 сравнивает digest, а не только число слоёв. Без этого сравнения результат допускал бы объяснение «в другой репозиторий попал другой образ». Совпадающий digest снимает это: образ побайтно тот же, и всё равно передан заново. Так измерение превращается в доказательство.
Оба образа собраны из одного Dockerfile с ARG. Два отдельных файла давали бы тот же результат, но не гарантировали бы идентичности базовых слоёв: достаточно случайного расхождения в порядке инструкций. Один файл с параметром делает общую часть заведомо одинаковой, и измерение отражает механизм, а не совпадение.
Чего решение не делает. Cross-repository blob mount не проверялся: docker push использует его автоматически лишь при определённых условиях, а registry:2 в базовой настройке — не всегда; проверка требовала бы работы с HTTP API напрямую. Ограничения Docker Hub не воспроизводились — для этого нужно исчерпать реальный лимит. Измерения выполнены на локальном registry, где сетевая задержка отсутствует; в реальной сети экономия на пропущенных слоях заметнее. Наконец, docker manifest inspect требует сетевого доступа к Docker Hub — при его отсутствии третий digest остаётся неизмеренным.
Проверка результата
docker inspect ОБРАЗ --format '{{.Id}}'
docker inspect ОБРАЗ --format '{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}'
docker manifest inspect ОБРАЗ | python3 -m json.tool | head -20
docker push ОБРАЗ 2>&1 | grep -c 'Layer already exists'
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
Id передают как digest | Оба начинаются с sha256: | Id локален; закреплять по RepoDigests |
| Ожидают дедупликацию между репозиториями | Слои общие | Проверка идёт по пути с репозиторием |
| Варианты приложения в разных репозиториях | Кажется опрятнее | Слои загружаются многократно |
myregistry/api для локального registry | Похоже на адрес | Без точки — это namespace на Docker Hub |
| Считают, что digest даёт подлинность | Криптографическая сумма | Даёт целостность; подлинность — подпись |
| Ожидают, что закрепление по digest снимет лимит | Меньше неопределённости | Манифест всё равно запрашивается |
Повторный pull считают бесплатным | Слои уже есть | Запрос манифеста расходует лимит |
| Удаление тега считают освобождением места | Логично предположить | Нужен сборщик мусора registry |
latest считают «последней версией» | Название | Обычный тег; может указывать на что угодно |
Контрольные вопросы
На понимание:
- Разберите
python:3.13-slimна составляющие. Что подставлено? - Почему
myregistry/apiне обращается к серверуmyregistry? - Что происходит при
push, если слой уже есть в registry? - Почему тот же слой в другом репозитории загружается заново?
- Три digest одного образа — назовите и укажите, для чего каждый.
На применение:
- Как измерить, сколько слоёв передалось при публикации?
- Как закрепить базовый образ так, чтобы сборка была воспроизводимой?
- Как снизить расход лимита на скачивание?
На диагностику:
- Публикация обновления заняла столько же, сколько первая. Гипотеза?
docker pullодного тега на двух машинах дал разные образы. Как это возможно?
Краткое резюме
- Полное имя:
registry/namespace/repository:тег@digest; отсутствующее подставляется. - Отсутствие registry означает
docker.io, отсутствие namespace там же —library. - Первая часть считается registry только при наличии точки, двоеточия или имени
localhost. - Тег — указатель на манифест; несколько тегов на один образ места не занимают.
- При
pushкаждый слой проверяется запросомHEADи передаётся только при отсутствии. - Проверка выполняется в пределах репозитория: в другом репозитории слои загрузятся заново.
- Варианты одного приложения выгоднее держать в одном репозитории с разными тегами.
Id— локальный config digest; для закрепления используютRepoDigests.- Мультиплатформенный образ — это index; каждая платформа получает свой манифест.
- Целостность обеспечена адресацией по содержимому; подлинность — нет, для неё нужна подпись.
- Лимит скачивания расходуется на запрос манифеста, а не слоёв — повторный
pullне бесплатен. - Конкретные пороги лимитов меняются; проверять их следует в текущей документации.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| OCI Distribution Spec | https://github.com/opencontainers/distribution-spec/blob/main/spec.md | Протокол push/pull, HEAD на blob |
| OCI Image Spec | https://github.com/opencontainers/image-spec/blob/main/manifest.md | Манифест, index, digest |
| Docker: registry overview | https://docs.docker.com/docker-hub/repos/ | Repository и namespace |
Docker: docker push | https://docs.docker.com/reference/cli/docker/image/push/ | Поведение при публикации |
Docker: docker manifest | https://docs.docker.com/reference/cli/docker/manifest/ | Просмотр манифестов |
| Docker Hub: usage and limits | https://docs.docker.com/docker-hub/usage/ | Механизм ограничений (значения — там же) |
| Distribution: blob mount | https://github.com/opencontainers/distribution-spec/blob/main/spec.md#mounting-a-blob-from-another-repository | Cross-repository blob mount |
Навигация
Вернуться к разделу
Следующий материал → Аутентификация
Главное оглавление