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

14.1. Основы registry

Цели

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

  • разобрать полное имя образа на составляющие и назвать, что подставляется по умолчанию;
  • объяснить, что происходит при push и pull на уровне манифеста и слоёв;
  • объяснить, почему повторная публикация большого образа занимает секунды;
  • назвать случай, когда те же слои загружаются заново, и почему;
  • различить три разных digest'а, относящихся к одному образу;
  • понять механизм ограничений на скачивание, не полагаясь на конкретные числа.

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

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

ТерминОбъяснение
registryСервис хранения образов
repositoryНабор образов под одним именем
namespaceВладелец репозитория
manifestОписание образа: слои, конфигурация
indexСписок манифестов для разных платформ
blobСодержимое слоя или конфигурации
digestКонтрольная сумма содержимого

Теория

Полное имя образа

text
registry[:порт]/namespace/repository[:тег][@digest]
ПримерЧто подставляется
pythondocker.io/library/python:latest
python:3.13-slimdocker.io/library/python:3.13-slim
myorg/api:1.2docker.io/myorg/api:1.2
ghcr.io/myorg/api:1.2Ничего: имя полное
localhost:5000/api:devНичего

Три правила подстановки:

  1. Отсутствие registry означает docker.io.
  2. Отсутствие namespace на Docker Hub означает library — это официальные образы (урок 12.7).
  3. Отсутствие тега означает latest.

Ловушка портов. Docker отличает registry от namespace по наличию точки или двоеточия в первой части. localhost:5000/api — это registry localhost:5000. А myregistry/api без точки будет понято как namespace myregistry на Docker Hub.

Отсюда правило для локального registry: имя хоста должно содержать точку или порт.

Registry, repository, tag

text
registry                     ghcr.io
   └── namespace             myorg
         └── repository      api
               ├── tag       1.2.0
               ├── tag       1.2
               └── tag       latest    ← все три могут указывать на один образ

Тег — это имя-указатель, а не свойство образа. Присвоение трёх тегов одному образу не создаёт трёх копий: в хранилище остаётся один набор blob'ов и три записи в таблице тегов.

Следствие: docker tag выполняется мгновенно и не занимает места.

Что происходит при push

text
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

text
1. Запрашивается манифест по тегу или digest
       GET /v2/<repo>/manifests/<ссылка>
2. Если это index (мультиплатформенный образ) —
   выбирается манифест под платформу клиента
3. Для каждого слоя проверяется локальное наличие
   ├── есть  → пропускается
   └── нет   → скачивается и распаковывается
4. Скачивается конфигурация

Второй шаг объясняет, почему docker pull одного и того же тега на разных машинах может дать разные образы: индекс содержит манифесты для amd64, arm64 и других, и каждая машина берёт свой.

Третий шаг — причина того, что второй pull образа с общей базой быстрый: слои python:3.13-slim уже есть локально.

Три разных digest

Путаница здесь встречается постоянно.

ЧтоГде виденЧему соответствует
Index digestRepoDigests после pull мультиплатформенного образаСписку манифестов всех платформ
Manifest digestdocker manifest inspect для конкретной платформыОдному образу под одну платформу
Config digestdocker images в колонке IMAGE IDКонфигурации образа
bash
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 Hubdocker.ioПо умолчанию; ограничения на скачивание
GitHub Container Registryghcr.ioПрава наследуются от репозитория
GitLab Registryregistry.gitlab.comВстроен в проект
Amazon ECR*.dkr.ecr.*.amazonaws.comТокен действует 12 часов
Google Artifact Registry*-docker.pkg.devПрава через IAM
Quayquay.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; здесь важно само разделение: удаление тега и освобождение места — разные операции.


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

Разбор имени образа

bash
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('      подставлено: ничего — имя полное')
"

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

text
═══ разбор пяти ссылок ═══
  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 подставлен дважды.

Исправление:

bash
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

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

text
  docker.io/library/python:latest
  docker.io/myorg/api:1.2
  ghcr.io/myorg/api:1.2
  localhost:5000/api@sha256:abc123

Правило, из-за которого нужна отдельная функция: часть, определённая как registry, не дополняется значением по умолчанию — она уже конечная.

Локальный registry для наблюдения

bash
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/^/  /'

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

text
═══ запуск 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 МБ занял секунды и мегабайты трафика.

Дедупликация работает в пределах репозитория

bash
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

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

text
═══ тот же образ в ДРУГОЙ репозиторий ═══
  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 одного образа

bash
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

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

text
═══ 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:, — источник постоянной путаницы. Различает их только то, что именно захешировано: конфигурация, индекс или манифест платформы.

Целостность при получении

bash
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

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

text
═══ digest до публикации ═══
  Id локально: sha256:3f8a2b1c4d5e6f7a8b9c0d1e2f3a...
═══ удаляем локально и получаем заново ═══
  Id после pull: sha256:3f8a2b1c4d5e6f7a8b9c0d1e2f3a...
═══ сравнение ═══
  ✓ совпадает: получен тот же образ
═══ как проверяется целостность ═══
  Имя blob'а — это sha256 его содержимого. Клиент, скачав слой,
  вычисляет sha256 и сравнивает с именем, которое запрашивал.

  Несовпадение означает повреждение или подмену — docker pull
  завершится ошибкой, а не отдаст испорченные данные.

  Поэтому отдельная проверка контрольных сумм не нужна:
  она встроена в адресацию по содержимому.

  Что это НЕ даёт: гарантии, что образ собран тем, кем вы думаете.
  Это отдельный вопрос — подпись ([урок 12.7]).

Последний абзац отделяет два разных свойства. Целостность — «содержимое не испортилось» — обеспечивается устройством registry. Подлинность — «это собрали мы» — не обеспечивается ничем без подписи.


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

Задание. Измерьте, что именно передаётся при публикации.

Требования:

  1. Разобрать пять ссылок на образы, показав подстановки по умолчанию.
  2. Опубликовать два образа с общей базой и показать, сколько слоёв передано повторно.
  3. Опубликовать тот же образ в другой репозиторий и показать, что слои переданы заново.
  4. Показать три разных digest одного образа и объяснить, для чего каждый.
  5. Подтвердить целостность: сравнить образ до публикации и после получения.
  6. Написать проверку, определяющую по ссылке, что подставится по умолчанию.

Подсказки

Подсказка 1

Для пункта 2 нужны два образа, различающиеся только последним слоем: одинаковый FROM и одинаковая установка зависимостей.

Подсказка 2

Считайте строки Layer already exists и Pushed в выводе docker push — это и есть измерение.

Подсказка 3

Первая часть имени — registry, если содержит точку, двоеточие или равна localhost. Иначе это namespace на Docker Hub.

Решение

Показать решение
bash
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"

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

text
═══ Требование 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 остаётся неизмеренным.

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

bash
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 считают «последней версией»НазваниеОбычный тег; может указывать на что угодно

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

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

  1. Разберите python:3.13-slim на составляющие. Что подставлено?
  2. Почему myregistry/api не обращается к серверу myregistry?
  3. Что происходит при push, если слой уже есть в registry?
  4. Почему тот же слой в другом репозитории загружается заново?
  5. Три digest одного образа — назовите и укажите, для чего каждый.

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

  1. Как измерить, сколько слоёв передалось при публикации?
  2. Как закрепить базовый образ так, чтобы сборка была воспроизводимой?
  3. Как снизить расход лимита на скачивание?

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

  1. Публикация обновления заняла столько же, сколько первая. Гипотеза?
  2. docker pull одного тега на двух машинах дал разные образы. Как это возможно?

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

  1. Полное имя: registry/namespace/repository:тег@digest; отсутствующее подставляется.
  2. Отсутствие registry означает docker.io, отсутствие namespace там же — library.
  3. Первая часть считается registry только при наличии точки, двоеточия или имени localhost.
  4. Тег — указатель на манифест; несколько тегов на один образ места не занимают.
  5. При push каждый слой проверяется запросом HEAD и передаётся только при отсутствии.
  6. Проверка выполняется в пределах репозитория: в другом репозитории слои загрузятся заново.
  7. Варианты одного приложения выгоднее держать в одном репозитории с разными тегами.
  8. Id — локальный config digest; для закрепления используют RepoDigests.
  9. Мультиплатформенный образ — это index; каждая платформа получает свой манифест.
  10. Целостность обеспечена адресацией по содержимому; подлинность — нет, для неё нужна подпись.
  11. Лимит скачивания расходуется на запрос манифеста, а не слоёв — повторный pull не бесплатен.
  12. Конкретные пороги лимитов меняются; проверять их следует в текущей документации.

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

ИсточникСсылкаЧто подтверждает
OCI Distribution Spechttps://github.com/opencontainers/distribution-spec/blob/main/spec.mdПротокол push/pull, HEAD на blob
OCI Image Spechttps://github.com/opencontainers/image-spec/blob/main/manifest.mdМанифест, index, digest
Docker: registry overviewhttps://docs.docker.com/docker-hub/repos/Repository и namespace
Docker: docker pushhttps://docs.docker.com/reference/cli/docker/image/push/Поведение при публикации
Docker: docker manifesthttps://docs.docker.com/reference/cli/docker/manifest/Просмотр манифестов
Docker Hub: usage and limitshttps://docs.docker.com/docker-hub/usage/Механизм ограничений (значения — там же)
Distribution: blob mounthttps://github.com/opencontainers/distribution-spec/blob/main/spec.md#mounting-a-blob-from-another-repositoryCross-repository blob mount

Навигация

Вернуться к разделу
Следующий материал → Аутентификация
Главное оглавление

Markdown на GitHub ↗