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

14.3. Локальный registry

Цели

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

  • запустить registry в container с хранением данных в volume;
  • объяснить, почему myregistry/api не работает, а myregistry:5000/api работает;
  • настроить TLS и объяснить, что именно проверяет клиент;
  • назвать конкретные последствия insecure registry и условия, при которых он допустим;
  • поднять pull-through cache и сократить обращения к внешнему registry;
  • использовать локальный registry в интеграционных тестах.

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

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

ТерминОбъяснение
registry:2Официальная реализация registry от Docker
insecure registryRegistry без TLS или с непроверяемым сертификатом
pull-through cacheRegistry, кэширующий образы внешнего
SANСписок имён, для которых действителен сертификат
garbage collectОсвобождение места после удаления манифестов

Теория

Минимальный запуск

bash
docker run -d --name registry \
    -p 5000:5000 \
    -v registry-data:/var/lib/registry \
    --restart unless-stopped \
    registry:2
ПараметрЗачем
-p 5000:5000Порт API
-v registry-data:/var/lib/registryОбязательно: без volume данные исчезнут
--restart unless-stoppedПереживёт перезагрузку

Без volume образы хранятся в writable layer container'а и теряются при его пересоздании (урок 7.1).

Проверка работоспособности:

bash
curl -s http://localhost:5000/v2/          # {} при успехе
curl -s http://localhost:5000/v2/_catalog  # список репозиториев

Имя должно содержать точку или порт

Это правило из урока 14.1 здесь становится практическим ограничением.

ИмяКак понимается
myregistry/apiNamespace myregistry на Docker Hub — не то
myregistry:5000/apiRegistry myregistry:5000 — верно
registry.local/apiRegistry registry.local — верно
localhost:5000/apiRegistry localhost:5000 — верно

Отсюда следствие для сети Docker: обращение к registry по имени сервиса без порта не работает. В Compose нужно указывать порт: registry:5000/api.

Insecure registry: что именно происходит

По умолчанию Docker требует HTTPS для всех registry, кроме localhost и 127.0.0.1.

Разрешить HTTP можно явно:

json
{
  "insecure-registries": ["registry.local:5000", "192.168.1.0/24"]
}

Что это отключает:

ПроверкаБез TLS
Шифрование каналаНет: слои и учётные данные передаются открыто
Подлинность сервераНет: подмена адреса не обнаруживается
Целостность в каналеЧастично: digest защищает содержимое, но не метаданные

Третья строка требует уточнения. Digest слоя защищает от подмены содержимого: клиент вычислит sha256 и увидит несовпадение. Но манифест запрашивается по тегу, и подменивший сервер вернёт манифест, указывающий на свои слои с корректными digest'ами. Проверка пройдёт.

Практический вывод: insecure registry защищает от повреждения, но не от подмены.

Условия, при которых он допустим:

УсловиеПочему
Изолированная сеть без выхода наружуНет пути для атакующего
Только для разработки и тестовАртефакт не попадёт в эксплуатацию
Данные не конфиденциальныОткрытая передача не создаёт утечки
Аутентификация не используетсяНет пароля для перехвата

Последняя строка существенна: HTTP плюс docker login — сочетание, которого быть не должно. Пароль уходит открытым текстом в каждом запросе.

TLS

bash
docker run -d --name registry \
    -p 443:443 \
    -v registry-data:/var/lib/registry \
    -v "$PWD/certs:/certs:ro" \
    -e REGISTRY_HTTP_ADDR=0.0.0.0:443 \
    -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/cert.pem \
    -e REGISTRY_HTTP_TLS_KEY=/certs/key.pem \
    registry:2

Клиент проверяет три вещи:

  1. Сертификат подписан доверенным центром — или его корень лежит в /etc/docker/certs.d/<хост>:<порт>/ca.crt.
  2. Срок действия не истёк.
  3. Имя хоста входит в SAN сертификата.

Третий пункт — источник большинства отказов. Поле CN современными клиентами игнорируется; имя должно быть в Subject Alternative Name.

Для самоподписанного сертификата корень размещают так:

text
/etc/docker/certs.d/registry.local:5000/ca.crt

Каталог называется точно так, как пишется имя в docker pull, включая порт. Несовпадение — частая причина того, что сертификат «не подхватывается».

Pull-through cache

Registry может работать зеркалом внешнего:

yaml
proxy:
  remoteurl: https://registry-1.docker.io
bash
docker run -d --name mirror -p 5000:5000 \
    -v mirror-data:/var/lib/registry \
    -e REGISTRY_PROXY_REMOTEURL=https://registry-1.docker.io \
    registry:2

Клиенты настраиваются на зеркало:

json
{
  "registry-mirrors": ["http://mirror.local:5000"]
}
СвойствоЗначение
Первый запрос образаСкачивается из внешнего registry и кэшируется
ПоследующиеОтдаются из кэша
Расход лимита Docker HubОдин на организацию вместо одного на машину
Публикация в зеркалоНевозможна: режим только для чтения

Последняя строка важна: зеркало и собственный registry для своих образов — это разные экземпляры. Один container не может быть и тем, и другим.

Registry в интеграционных тестах

Локальный registry полезен там, где тест должен проверить публикацию и получение:

Что проверяютЗачем нужен настоящий registry
Сборка публикует образПроверка push целиком
Развёртывание берёт по digestПроверка совпадения digest
Скрипт очистки удаляет староеПроверка работы с API
Многоплатформенная сборкаПроверка index

Требования к тестовому экземпляру:

bash
docker run -d --name test-registry -p 0:5000 \
    -e REGISTRY_STORAGE_DELETE_ENABLED=true \
    registry:2

Порт 0 означает «любой свободный» — так параллельные тесты не конфликтуют. Фактический порт узнают через docker port.

Переменная REGISTRY_STORAGE_DELETE_ENABLED нужна, если тест проверяет удаление: по умолчанию удаление манифестов отключено.

Данные тестового экземпляра держат без volume: они должны исчезать вместе с container'ом.


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

Как хранятся данные

text
/var/lib/registry/docker/registry/v2/
├── blobs/sha256/<2 символа>/<полный digest>/data
└── repositories/<имя>/
    ├── _layers/sha256/<digest>/link       ← ссылка на blob
    ├── _manifests/tags/<тег>/current/link ← куда указывает тег
    └── _manifests/revisions/sha256/<digest>/link

Три уровня косвенности объясняют поведение:

Blob'ы лежат отдельно от репозиториев. Один blob может быть связан с несколькими репозиториями через файлы link.

Тег — это файл со ссылкой на digest манифеста. Переназначение тега переписывает один маленький файл.

Удаление тега убирает link, но не blob. Blob остаётся, пока на него ссылается хоть один link — и даже после этого до запуска сборщика мусора.

Сборка мусора

bash
docker exec registry bin/registry garbage-collect \
    /etc/docker/registry/config.yml --dry-run

Процесс проходит все манифесты, отмечает достижимые blob'ы и удаляет остальные.

Требование: во время сборки мусора в registry не должно быть записи. Иначе blob, загруженный, но ещё не связанный манифестом, будет сочтён мусором и удалён. Registry следует перевести в режим только для чтения:

yaml
storage:
  maintenance:
    readonly:
      enabled: true

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

Запуск и проверка

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

echo "═══ запуск с volume ═══"
docker run -d --name reg -p 5000:5000 \
    -v reg-data:/var/lib/registry \
    -e REGISTRY_STORAGE_DELETE_ENABLED=true \
    registry:2 > /dev/null
sleep 4

echo "═══ проверка API ═══"
printf '  /v2/          '
curl -s -w 'HTTP %{http_code}  тело: ' http://localhost:5000/v2/ && echo
printf '  /v2/_catalog  '
curl -s -w 'HTTP %{http_code}  тело: ' http://localhost:5000/v2/_catalog && echo

echo "═══ публикуем образ ═══"
docker pull -q alpine:3.21 > /dev/null
docker tag alpine:3.21 localhost:5000/test/alpine:1.0
docker push localhost:5000/test/alpine:1.0 2>&1 | tail -2 | sed 's/^/  /'

echo "═══ что теперь в каталоге ═══"
curl -s http://localhost:5000/v2/_catalog | python3 -m json.tool | sed 's/^/  /'
curl -s http://localhost:5000/v2/test/alpine/tags/list | python3 -m json.tool | sed 's/^/  /'

echo "═══ данные переживают пересоздание container'а ═══"
docker rm -f reg > /dev/null
docker run -d --name reg -p 5000:5000 -v reg-data:/var/lib/registry \
    registry:2 > /dev/null
sleep 4
curl -s http://localhost:5000/v2/_catalog | python3 -c "
import json, sys
d = json.load(sys.stdin)
repos = d.get('repositories') or []
print(f'  репозиториев после пересоздания: {len(repos)} {repos}')
"

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

text
═══ запуск с volume ═══
═══ проверка API ═══
  /v2/          HTTP 200  тело: {}
  /v2/_catalog  HTTP 200  тело: {"repositories":[]}
═══ публикуем образ ═══
  1f3e2d4c5b6a: Pushed
  1.0: digest: sha256:8a3f2b1c... size: 528
═══ что теперь в каталоге ═══
  {
      "repositories": [
          "test/alpine"
      ]
  }
  {
      "name": "test/alpine",
      "tags": [
          "1.0"
      ]
  }
═══ данные переживают пересоздание container'а ═══
  репозиториев после пересоздания: 1 ['test/alpine']

Последняя строка — проверка того, ради чего нужен volume: container пересоздан, данные на месте.

Имя без точки не работает

bash
cd /tmp/localreg
echo "═══ подключаем registry к пользовательской сети ═══"
docker network create reg-net > /dev/null 2>&1
docker network connect reg-net reg 2>/dev/null || true

echo "═══ обращение БЕЗ порта ═══"
docker run --rm --network reg-net alpine:3.21 sh -c '
    apk add --no-cache curl > /dev/null 2>&1
    curl -s -o /dev/null -w "  curl к reg:5000/v2/ → HTTP %{http_code}\n" http://reg:5000/v2/
' 2>/dev/null

echo "═══ что делает docker pull с именем без точки ═══"
timeout 25 docker pull reg/test/alpine:1.0 2>&1 | head -2 | sed 's/^/  /' || true

echo "═══ то же имя С портом ═══"
docker run --rm --network reg-net docker:cli sh -c '
    docker pull reg:5000/test/alpine:1.0 2>&1 | tail -1
' 2>/dev/null | sed 's/^/  /' || echo "  (требует доступа к socket — см. пояснение ниже)"

echo "═══ правило ═══"
cat <<'TXT'
  reg/test/alpine       → docker.io/reg/test/alpine   ✗ уходит на Docker Hub
  reg:5000/test/alpine  → registry reg:5000            ✓ верно

  Первая часть считается адресом registry, только если содержит
  точку, двоеточие или равна localhost.

  Практическое следствие для Compose: обращаться к сервису-registry
  нужно с портом — reg:5000/образ, а не reg/образ.
TXT

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

text
═══ подключаем registry к пользовательской сети ═══
═══ обращение БЕЗ порта ═══
  curl к reg:5000/v2/ → HTTP 200
═══ что делает docker pull с именем без точки ═══
  Error response from daemon: pull access denied for reg/test/alpine,
  repository does not exist or may require 'docker login'
═══ то же имя С портом ═══
  (требует доступа к socket — см. пояснение ниже)
═══ правило ═══
  reg/test/alpine       → docker.io/reg/test/alpine   ✗ уходит на Docker Hub
  reg:5000/test/alpine  → registry reg:5000            ✓ верно

  Первая часть считается адресом registry, только если содержит
  точку, двоеточие или равна localhost.

  Практическое следствие для Compose: обращаться к сервису-registry
  нужно с портом — reg:5000/образ, а не reg/образ.

Сообщение pull access denied ... repository does not exist при работающем registry — характерный признак: запрос ушёл на Docker Hub, а не на ваш сервер.

TLS: сертификат и проверка имени

bash
cd /tmp/localreg
mkdir -p certs

echo "═══ генерируем сертификат с SAN ═══"
openssl req -x509 -newkey rsa:2048 -nodes -days 30 \
    -keyout certs/key.pem -out certs/cert.pem \
    -subj "/CN=registry.local" \
    -addext "subjectAltName=DNS:registry.local,DNS:localhost,IP:127.0.0.1" \
    2>/dev/null
echo "  сертификат создан"

echo "═══ что в сертификате ═══"
openssl x509 -in certs/cert.pem -noout -subject -dates -ext subjectAltName 2>/dev/null \
    | sed 's/^/  /'

echo "═══ запуск registry с TLS ═══"
docker rm -f reg > /dev/null 2>&1
docker run -d --name reg-tls -p 5443:443 \
    -v reg-data:/var/lib/registry \
    -v "$PWD/certs:/certs:ro" \
    -e REGISTRY_HTTP_ADDR=0.0.0.0:443 \
    -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/cert.pem \
    -e REGISTRY_HTTP_TLS_KEY=/certs/key.pem \
    registry:2 > /dev/null
sleep 4

echo "═══ проверка соединения ═══"
printf '  без доверия к корню:  '
curl -s -o /dev/null -w 'HTTP %{http_code}\n' https://localhost:5443/v2/ 2>&1 \
    || echo "отказ проверки сертификата (ожидаемо)"
printf '  с указанием корня:    '
curl -s --cacert certs/cert.pem -o /dev/null -w 'HTTP %{http_code}\n' \
    https://localhost:5443/v2/ 2>&1 || echo "не удалось"

echo "═══ что проверяет клиент ═══"
cat <<'TXT'
  1. Подпись доверенным центром — или корень в
       /etc/docker/certs.d/<хост>:<порт>/ca.crt
  2. Срок действия
  3. Имя хоста в SAN (поле CN современные клиенты ИГНОРИРУЮТ)

  Каталог для корня называется ТОЧНО как имя в docker pull,
  включая порт:
    /etc/docker/certs.d/registry.local:5443/ca.crt

  Несовпадение имени каталога и имени в pull — самая частая
  причина «сертификат не подхватывается».
TXT

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

text
═══ генерируем сертификат с SAN ═══
  сертификат создан
═══ что в сертификате ═══
  subject=CN = registry.local
  notBefore=Jul 31 10:22:14 2026 GMT
  notAfter=Aug 30 10:22:14 2026 GMT
  X509v3 Subject Alternative Name: 
      DNS:registry.local, DNS:localhost, IP Address:127.0.0.1
═══ запуск registry с TLS ═══
═══ проверка соединения ═══
  без доверия к корню:  отказ проверки сертификата (ожидаемо)
  с указанием корня:    HTTP 200
═══ что проверяет клиент ═══
  1. Подпись доверенным центром — или корень в
       /etc/docker/certs.d/<хост>:<порт>/ca.crt
  2. Срок действия
  3. Имя хоста в SAN (поле CN современные клиенты ИГНОРИРУЮТ)
  ...

Две строки проверки — суть TLS: без доверия к корню соединение отвергается, с доверием проходит. Никакой промежуточной степени нет.

Что даёт и чего не даёт insecure registry

bash
cd /tmp/localreg
cat > insecure-risk.py <<'PY'
"""Что защищено и что не защищено при работе с registry без TLS."""
from __future__ import annotations

ASPECTS = [
    ("Содержимое слоёв", True,
     "digest слоя проверяется клиентом: подменённый слой не пройдёт"),
    ("Конфиденциальность слоёв", False,
     "слои передаются открытым текстом и читаются в канале"),
    ("Подлинность сервера", False,
     "подмена адреса не обнаруживается: DNS, ARP, маршрут"),
    ("Целостность манифеста при запросе по тегу", False,
     "поддельный сервер вернёт СВОЙ манифест со СВОИМИ корректными digest"),
    ("Целостность при запросе по digest", True,
     "digest манифеста задан клиентом и проверяется"),
    ("Учётные данные", False,
     "docker login по HTTP передаёт пароль открытым текстом"),
]

CONDITIONS = [
    ("Изолированная сеть без выхода наружу", "нет пути для атакующего"),
    ("Только разработка и тесты", "артефакт не попадёт в эксплуатацию"),
    ("Аутентификация НЕ используется", "нет пароля для перехвата"),
    ("Образы не конфиденциальны", "открытая передача не создаёт утечки"),
]


def main() -> None:
    print(f"  {'аспект':<44} {'защищён':<9} пояснение")
    print("  " + "─" * 108)
    for name, protected, why in ASPECTS:
        mark = "да" if protected else "НЕТ"
        print(f"  {name:<44} {mark:<9} {why}")

    print()
    print("  Ключевой вывод строк 4 и 5:")
    print("    docker pull ОБРАЗ:тег        уязвим к подмене")
    print("    docker pull ОБРАЗ@sha256:... защищён даже без TLS")
    print()
    print("  Условия допустимости insecure registry — ВСЕ сразу:")
    for cond, why in CONDITIONS:
        print(f"    · {cond:<42} {why}")
    print()
    print("  Недопустимое сочетание: HTTP + docker login.")
    print("  Пароль уходит открытым текстом в каждом запросе.")


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

echo "═══ разбор рисков ═══"
python3 insecure-risk.py

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

text
═══ разбор рисков ═══
  аспект                                       защищён   пояснение
  ────────────────────────────────────────────────────────────────────────────────────────────────────────────
  Содержимое слоёв                             да        digest слоя проверяется клиентом: подменённый слой не пройдёт
  Конфиденциальность слоёв                     НЕТ       слои передаются открытым текстом и читаются в канале
  Подлинность сервера                          НЕТ       подмена адреса не обнаруживается: DNS, ARP, маршрут
  Целостность манифеста при запросе по тегу    НЕТ       поддельный сервер вернёт СВОЙ манифест со СВОИМИ корректными digest
  Целостность при запросе по digest            да        digest манифеста задан клиентом и проверяется

  Ключевой вывод строк 4 и 5:
    docker pull ОБРАЗ:тег        уязвим к подмене
    docker pull ОБРАЗ@sha256:... защищён даже без TLS

  Условия допустимости insecure registry — ВСЕ сразу:
    · Изолированная сеть без выхода наружу     нет пути для атакующего
    · Только разработка и тесты                артефакт не попадёт в эксплуатацию
    · Аутентификация НЕ используется           нет пароля для перехвата
    · Образы не конфиденциальны                открытая передача не создаёт утечки

  Недопустимое сочетание: HTTP + docker login.
  Пароль уходит открытым текстом в каждом запросе.

Различие строк 4 и 5 — то, ради чего написана таблица. Запрос по тегу доверяет серверу выбор манифеста; запрос по digest не доверяет ничего.

Отсюда практическое усиление: развёртывание по digest даёт защиту от подмены даже при работе по HTTP (урок 14.4).

Pull-through cache

bash
cd /tmp/localreg
echo "═══ запуск зеркала ═══"
docker run -d --name mirror -p 5100:5000 \
    -v mirror-data:/var/lib/registry \
    -e REGISTRY_PROXY_REMOTEURL=https://registry-1.docker.io \
    registry:2 > /dev/null
sleep 4
curl -s -o /dev/null -w '  зеркало отвечает: HTTP %{http_code}\n' http://localhost:5100/v2/

echo "═══ настройка клиента ═══"
cat <<'TXT'
  /etc/docker/daemon.json:
    {
      "registry-mirrors": ["http://mirror.local:5100"]
    }

  После перезапуска daemon любой docker pull публичного образа
  сначала обращается к зеркалу.
TXT

echo "═══ проверка: что кэшируется ═══"
curl -s http://localhost:5100/v2/_catalog | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\"  в кэше до обращений: {d.get('repositories') or 'пусто'}\")
"

echo "═══ попытка публикации в зеркало ═══"
docker tag alpine:3.21 localhost:5100/my/app:1.0 2>/dev/null
docker push localhost:5100/my/app:1.0 2>&1 | tail -2 | sed 's/^/  /'

echo "═══ вывод ═══"
cat <<'TXT'
  Зеркало работает ТОЛЬКО на чтение. Публикация в него невозможна.

  Значит, нужны ДВА экземпляра:
    зеркало           для публичных образов, режим proxy
    свой registry     для собственных образов, обычный режим

  Один container не может совмещать роли: REGISTRY_PROXY_REMOTEURL
  переводит весь экземпляр в режим кэша.

  Что даёт зеркало:
    один запрос к Docker Hub на организацию вместо одного на машину
    независимость от доступности внешнего registry для уже скачанного
    ускорение повторных сборок в CI
TXT

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

text
═══ запуск зеркала ═══
  зеркало отвечает: HTTP 200
═══ настройка клиента ═══
  /etc/docker/daemon.json:
    {
      "registry-mirrors": ["http://mirror.local:5100"]
    }

  После перезапуска daemon любой docker pull публичного образа
  сначала обращается к зеркалу.
═══ проверка: что кэшируется ═══
  в кэше до обращений: пусто
═══ попытка публикации в зеркало ═══
  unsupported: The operation is unsupported.
═══ вывод ═══
  Зеркало работает ТОЛЬКО на чтение. Публикация в него невозможна.
  ...

unsupported: The operation is unsupported — прямое подтверждение: экземпляр в режиме proxy запись не принимает.

Registry для интеграционных тестов

bash
cd /tmp/localreg
cat > test_registry.py <<'PY'
"""Интеграционные тесты с настоящим registry.

Показывает три вещи, которые нельзя проверить без него:
публикацию, совпадение digest после получения и работу с API.
"""
from __future__ import annotations

import json
import subprocess
import time

import pytest

IMAGE_SOURCE = "alpine:3.21"


def run(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
    return subprocess.run(args, capture_output=True, text=True, check=check)


@pytest.fixture(scope="module")
def registry() -> str:
    """Поднимает registry на СЛУЧАЙНОМ порту — параллельные тесты не конфликтуют."""
    name = f"pytest-registry-{int(time.time())}"
    run("docker", "run", "-d", "--name", name,
        "-p", "0:5000",                     # 0 = любой свободный порт
        "-e", "REGISTRY_STORAGE_DELETE_ENABLED=true",
        "registry:2")
    try:
        # Узнаём фактический порт
        out = run("docker", "port", name, "5000/tcp").stdout.strip()
        port = out.splitlines()[0].rsplit(":", 1)[1]
        base = f"localhost:{port}"

        # Ждём готовности
        for _ in range(30):
            probe = run("curl", "-sf", f"http://{base}/v2/", check=False)
            if probe.returncode == 0:
                break
            time.sleep(0.5)
        else:
            pytest.fail("registry не поднялся")

        yield base
    finally:
        run("docker", "rm", "-f", name, check=False)


@pytest.fixture(scope="module")
def published(registry: str) -> str:
    ref = f"{registry}/testapp:1.0"
    run("docker", "pull", "-q", IMAGE_SOURCE)
    run("docker", "tag", IMAGE_SOURCE, ref)
    run("docker", "push", ref)
    yield ref
    run("docker", "rmi", "-f", ref, check=False)


def test_registry_отвечает(registry: str) -> None:
    out = run("curl", "-s", f"http://{registry}/v2/").stdout
    assert json.loads(out) == {}


def test_образ_появился_в_каталоге(registry: str, published: str) -> None:
    out = run("curl", "-s", f"http://{registry}/v2/_catalog").stdout
    assert "testapp" in json.loads(out)["repositories"]


def test_тег_виден_через_api(registry: str, published: str) -> None:
    out = run("curl", "-s", f"http://{registry}/v2/testapp/tags/list").stdout
    assert json.loads(out)["tags"] == ["1.0"]


def test_digest_совпадает_после_получения(registry: str, published: str) -> None:
    """Ключевая проверка: полученный образ идентичен опубликованному."""
    before = run("docker", "inspect", published, "--format", "{{.Id}}").stdout.strip()
    run("docker", "rmi", "-f", published, check=False)
    run("docker", "pull", "-q", published)
    after = run("docker", "inspect", published, "--format", "{{.Id}}").stdout.strip()
    assert before == after, "образ изменился при передаче через registry"


def test_получение_по_digest(registry: str, published: str) -> None:
    """Получение по digest — то, чем развёртывание отличается от pull по тегу."""
    repo_digest = run(
        "docker", "inspect", published,
        "--format", "{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}",
    ).stdout.strip()
    assert repo_digest, "RepoDigests пуст: образ не публиковался"
    assert "@sha256:" in repo_digest

    run("docker", "rmi", "-f", published, check=False)
    result = run("docker", "pull", "-q", repo_digest, check=False)
    assert result.returncode == 0, result.stderr


def test_несуществующий_тег_даёт_404(registry: str) -> None:
    code = run("curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
               f"http://{registry}/v2/testapp/manifests/несуществующий").stdout
    assert code == "404"
PY

echo "═══ запуск тестов ═══"
docker run --rm \
    -v /var/run/docker.sock:/var/run/docker.sock \
    -v "$PWD:/w" -w /w \
    --network host \
    python:3.13-slim sh -c '
    apt-get update -qq > /dev/null 2>&1
    apt-get install -y -qq curl > /dev/null 2>&1
    pip install --quiet --no-cache-dir pytest==9.1.1 > /dev/null 2>&1
    pip install --quiet --no-cache-dir docker > /dev/null 2>&1
    command -v docker > /dev/null || {
        apt-get install -y -qq docker.io > /dev/null 2>&1
    }
    python -m pytest test_registry.py -v --tb=short 2>&1 | tail -18
' 2>/dev/null | sed 's/^/  /' \
    || echo "  (тесты требуют доступа к Docker socket из container'а)"

echo "═══ ключевые решения в этих тестах ═══"
cat <<'TXT'
  -p 0:5000               случайный порт: параллельные тесты не конфликтуют
  без volume              данные исчезают вместе с container'ом
  DELETE_ENABLED=true     нужен, если тест проверяет удаление
  scope="module"          registry поднимается один раз на файл тестов
  проверка готовности     цикл с curl вместо фиксированного sleep

  Последнее существенно: фиксированная пауза либо избыточна,
  либо недостаточна на медленной машине.
TXT

docker rm -f reg reg-tls mirror > /dev/null 2>&1
docker network rm reg-net > /dev/null 2>&1
docker volume rm reg-data mirror-data > /dev/null 2>&1
cd /tmp && rm -rf /tmp/localreg

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

text
═══ запуск тестов ═══
  test_registry.py::test_registry_отвечает PASSED                       [ 16%]
  test_registry.py::test_образ_появился_в_каталоге PASSED               [ 33%]
  test_registry.py::test_тег_виден_через_api PASSED                     [ 50%]
  test_registry.py::test_digest_совпадает_после_получения PASSED        [ 66%]
  test_registry.py::test_получение_по_digest PASSED                     [ 83%]
  test_registry.py::test_несуществующий_тег_даёт_404 PASSED             [100%]
  
  ============================ 6 passed in 24.31s ============================
═══ ключевые решения в этих тестах ═══
  -p 0:5000               случайный порт: параллельные тесты не конфликтуют
  без volume              данные исчезают вместе с container'ом
  DELETE_ENABLED=true     нужен, если тест проверяет удаление
  scope="module"          registry поднимается один раз на файл тестов
  проверка готовности     цикл с curl вместо фиксированного sleep

  Последнее существенно: фиксированная пауза либо избыточна,
  либо недостаточна на медленной машине.

Тест test_digest_совпадает_после_получения — тот, ради которого нужен настоящий registry. Он проверяет весь путь: публикация, удаление локальной копии, получение, сравнение.


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

Задание. Разверните локальный registry и проверьте его свойства.

Требования:

  1. Запустить registry с сохранением данных и подтвердить, что они переживают пересоздание container'а.
  2. Показать, что имя без точки или порта уходит на Docker Hub.
  3. Настроить TLS с корректным SAN и показать разницу с доверием к корню и без.
  4. Показать, что insecure registry защищает содержимое, но не защищает от подмены манифеста по тегу.
  5. Поднять pull-through cache и показать, что публикация в него невозможна.
  6. Написать интеграционные тесты, проверяющие публикацию и совпадение digest.

Подсказки

Подсказка 1

Для пункта 1 достаточно docker rm -f и повторный запуск с тем же volume.

Подсказка 2

Пункт 4 доказывается рассуждением о протоколе: манифест по тегу выбирает сервер, по digest — клиент. Покажите разницу командами pull по тегу и по digest.

Подсказка 3

Для пункта 6 используйте -p 0:5000 и узнавайте порт через docker port — так тесты можно запускать параллельно.

Решение

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

# ─── Проверка свойств registry ────────────────────────────────────────
cat > registry-props.sh <<'SH'
#!/usr/bin/env bash
# Проверка свойств локального registry.
set -uo pipefail

BASE="${1:?укажите host:port}"
SCHEME="${2:-http}"
CA="${3:-}"

curl_args=(-s)
[ -n "$CA" ] && curl_args+=(--cacert "$CA")

probe() {
    curl "${curl_args[@]}" -o /dev/null -w '%{http_code}' "$SCHEME://$BASE$1" 2>/dev/null || echo "000"
}

printf '  %-30s %s\n' "/v2/" "$(probe /v2/)"
printf '  %-30s %s\n' "/v2/_catalog" "$(probe /v2/_catalog)"
repos="$(curl "${curl_args[@]}" "$SCHEME://$BASE/v2/_catalog" 2>/dev/null \
    | python3 -c "
import json, sys
try:
    print(len(json.load(sys.stdin).get('repositories') or []))
except Exception:
    print('?')
" 2>/dev/null)"
printf '  %-30s %s\n' "репозиториев" "$repos"
SH
chmod +x registry-props.sh

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

printf '\n═══ Требование 1: данные переживают пересоздание ═══\n'
docker volume create lab3-data > /dev/null
docker run -d --name lab3-reg -p 5200:5000 \
    -v lab3-data:/var/lib/registry \
    -e REGISTRY_STORAGE_DELETE_ENABLED=true \
    registry:2 > /dev/null
sleep 4
docker pull -q alpine:3.21 > /dev/null
docker tag alpine:3.21 localhost:5200/persist/app:1.0
docker push -q localhost:5200/persist/app:1.0 > /dev/null 2>&1

before="$(curl -s http://localhost:5200/v2/_catalog \
    | python3 -c "import json,sys; print(len(json.load(sys.stdin).get('repositories') or []))")"
printf '    репозиториев до пересоздания: %s\n' "$before"

docker rm -f lab3-reg > /dev/null
docker run -d --name lab3-reg -p 5200:5000 \
    -v lab3-data:/var/lib/registry \
    -e REGISTRY_STORAGE_DELETE_ENABLED=true \
    registry:2 > /dev/null
sleep 4
after="$(curl -s http://localhost:5200/v2/_catalog \
    | python3 -c "import json,sys; print(len(json.load(sys.stdin).get('repositories') or []))")"
printf '    репозиториев после:           %s\n' "$after"

docker rm -f lab3-reg > /dev/null
docker run -d --name lab3-novol -p 5201:5000 registry:2 > /dev/null
sleep 4
docker tag alpine:3.21 localhost:5201/novol/app:1.0
docker push -q localhost:5201/novol/app:1.0 > /dev/null 2>&1
docker rm -f lab3-novol > /dev/null
docker run -d --name lab3-novol -p 5201:5000 registry:2 > /dev/null
sleep 4
novol="$(curl -s http://localhost:5201/v2/_catalog \
    | python3 -c "import json,sys; print(len(json.load(sys.stdin).get('repositories') or []))")"
printf '    без volume после пересоздания: %s (для сравнения)\n' "$novol"
docker rm -f lab3-novol > /dev/null

docker run -d --name lab3-reg -p 5200:5000 -v lab3-data:/var/lib/registry \
    -e REGISTRY_STORAGE_DELETE_ENABLED=true registry:2 > /dev/null
sleep 4
[ "$before" = "$after" ] && [ "$before" -ge 1 ] && [ "$novol" -eq 0 ] \
    && ok "с volume данные сохранились ($after), без volume — потеряны ($novol)" \
    || bad "было=$before стало=$after без volume=$novol"

printf '\n═══ Требование 2: имя без точки уходит на Docker Hub ═══\n'
docker network create lab3-net > /dev/null 2>&1
docker network connect lab3-net lab3-reg 2>/dev/null || true

printf '    curl к lab3-reg:5000/v2/ изнутри сети: '
docker run --rm --network lab3-net curlimages/curl:latest \
    -s -o /dev/null -w '%{http_code}\n' http://lab3-reg:5000/v2/ 2>/dev/null \
    || echo "недоступно"

printf '    docker pull lab3-reg/persist/app:1.0 → '
pull_err="$(timeout 30 docker pull lab3-reg/persist/app:1.0 2>&1 | head -1)"
echo "$pull_err" | cut -c1-64

case "$pull_err" in
    *"pull access denied"*|*"repository does not exist"*|*"not found"*|*"unauthorized"*)
        ok "запрос ушёл на Docker Hub: имя без точки не считается адресом registry" ;;
    *) bad "неожиданный ответ: $pull_err" ;;
esac

printf '\n═══ Требование 3: TLS с корректным SAN ═══\n'
mkdir -p certs
openssl req -x509 -newkey rsa:2048 -nodes -days 30 \
    -keyout certs/key.pem -out certs/cert.pem \
    -subj "/CN=lab-registry.local" \
    -addext "subjectAltName=DNS:lab-registry.local,DNS:localhost,IP:127.0.0.1" \
    2>/dev/null
san="$(openssl x509 -in certs/cert.pem -noout -ext subjectAltName 2>/dev/null | tail -1 | xargs)"
printf '    SAN сертификата: %s\n' "$san"

docker run -d --name lab3-tls -p 5443:443 \
    -v "$PWD/certs:/certs:ro" \
    -e REGISTRY_HTTP_ADDR=0.0.0.0:443 \
    -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/cert.pem \
    -e REGISTRY_HTTP_TLS_KEY=/certs/key.pem \
    registry:2 > /dev/null
sleep 4

no_ca="$(curl -s -o /dev/null -w '%{http_code}' https://localhost:5443/v2/ 2>/dev/null || echo "отказ")"
with_ca="$(curl -s --cacert certs/cert.pem -o /dev/null -w '%{http_code}' \
    https://localhost:5443/v2/ 2>/dev/null || echo "отказ")"
wrong_name="$(curl -s --cacert certs/cert.pem --resolve "wrong.name:5443:127.0.0.1" \
    -o /dev/null -w '%{http_code}' https://wrong.name:5443/v2/ 2>/dev/null || echo "отказ")"

printf '    без доверия к корню:        %s\n' "$no_ca"
printf '    с корнем, верное имя:       %s\n' "$with_ca"
printf '    с корнем, имя ВНЕ SAN:      %s\n' "$wrong_name"
[ "$with_ca" = "200" ] && [ "$no_ca" != "200" ] && [ "$wrong_name" != "200" ] \
    && ok "проверяются и корень, и имя хоста в SAN" \
    || bad "без CA=$no_ca с CA=$with_ca чужое имя=$wrong_name"

printf '\n═══ Требование 4: что защищает digest без TLS ═══\n'
ref="localhost:5200/persist/app:1.0"
docker pull -q "$ref" > /dev/null 2>&1
repo_digest="$(docker inspect "$ref" --format '{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}')"
printf '    RepoDigest: %s\n' "${repo_digest:0:56}..."

docker rmi -f "$ref" > /dev/null 2>&1
by_tag_rc=0
docker pull -q "$ref" > /dev/null 2>&1 || by_tag_rc=1
docker rmi -f "$ref" > /dev/null 2>&1
by_digest_rc=0
docker pull -q "$repo_digest" > /dev/null 2>&1 || by_digest_rc=1

printf '    pull по тегу:    код %s\n' "$by_tag_rc"
printf '    pull по digest:  код %s\n' "$by_digest_rc"
printf '\n    Разница в том, КТО выбирает манифест:\n'
printf '      по тегу   — сервер: поддельный вернёт свой манифест со своими digest\n'
printf '      по digest — клиент: подменить содержимое невозможно, sha256 не сойдётся\n'
printf '    Поэтому развёртывание по digest защищено даже по HTTP.\n'
[ "$by_tag_rc" -eq 0 ] && [ "$by_digest_rc" -eq 0 ] \
    && ok "оба способа работают; разница — в том, чему доверяет клиент" \
    || bad "по тегу=$by_tag_rc по digest=$by_digest_rc"

printf '\n═══ Требование 5: pull-through cache ═══\n'
docker run -d --name lab3-mirror -p 5300:5000 \
    -e REGISTRY_PROXY_REMOTEURL=https://registry-1.docker.io \
    registry:2 > /dev/null
sleep 4
mirror_code="$(curl -s -o /dev/null -w '%{http_code}' http://localhost:5300/v2/ 2>/dev/null)"
printf '    зеркало отвечает: HTTP %s\n' "$mirror_code"

docker tag alpine:3.21 localhost:5300/my/app:1.0 2>/dev/null
push_out="$(docker push localhost:5300/my/app:1.0 2>&1 | tail -1)"
printf '    попытка публикации: %s\n' "$(echo "$push_out" | cut -c1-56)"
docker rmi -f localhost:5300/my/app:1.0 > /dev/null 2>&1

case "$push_out" in
    *unsupported*|*"not supported"*|*"read-only"*)
        ok "публикация в зеркало отвергнута: режим proxy — только чтение" ;;
    *) bad "неожиданный ответ на push: $push_out" ;;
esac
printf '    следствие: нужны ДВА экземпляра — зеркало и свой registry\n'

printf '\n═══ Требование 6: интеграционные тесты ═══\n'
cat > test_reg.py <<'PY'
"""Интеграционные тесты registry: публикация, digest, API."""
from __future__ import annotations

import json
import subprocess
import time

import pytest

SOURCE = "alpine:3.21"


def run(*args: str, check: bool = True):
    return subprocess.run(args, capture_output=True, text=True, check=check)


@pytest.fixture(scope="module")
def registry():
    name = f"pytest-reg-{int(time.time() * 1000) % 100000}"
    run("docker", "run", "-d", "--name", name, "-p", "0:5000",
        "-e", "REGISTRY_STORAGE_DELETE_ENABLED=true", "registry:2")
    try:
        port = run("docker", "port", name, "5000/tcp").stdout.strip() \
            .splitlines()[0].rsplit(":", 1)[1]
        base = f"localhost:{port}"
        for _ in range(40):
            if run("curl", "-sf", f"http://{base}/v2/", check=False).returncode == 0:
                break
            time.sleep(0.5)
        else:
            pytest.fail("registry не поднялся за 20 секунд")
        yield base
    finally:
        run("docker", "rm", "-f", name, check=False)


@pytest.fixture(scope="module")
def published(registry):
    ref = f"{registry}/app:1.0"
    run("docker", "pull", "-q", SOURCE)
    run("docker", "tag", SOURCE, ref)
    run("docker", "push", ref)
    yield ref
    run("docker", "rmi", "-f", ref, check=False)


def test_api_отвечает(registry):
    assert json.loads(run("curl", "-s", f"http://{registry}/v2/").stdout) == {}


def test_образ_в_каталоге(registry, published):
    body = json.loads(run("curl", "-s", f"http://{registry}/v2/_catalog").stdout)
    assert "app" in body["repositories"]


def test_тег_виден(registry, published):
    body = json.loads(run("curl", "-s", f"http://{registry}/v2/app/tags/list").stdout)
    assert body["tags"] == ["1.0"]


def test_digest_совпадает_после_цикла(registry, published):
    before = run("docker", "inspect", published, "--format", "{{.Id}}").stdout.strip()
    run("docker", "rmi", "-f", published, check=False)
    run("docker", "pull", "-q", published)
    after = run("docker", "inspect", published, "--format", "{{.Id}}").stdout.strip()
    assert before == after


def test_pull_по_digest(registry, published):
    rd = run("docker", "inspect", published, "--format",
             "{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}").stdout.strip()
    assert "@sha256:" in rd
    run("docker", "rmi", "-f", published, check=False)
    assert run("docker", "pull", "-q", rd, check=False).returncode == 0


def test_несуществующий_тег(registry, published):
    code = run("curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
               f"http://{registry}/v2/app/manifests/нет").stdout
    assert code == "404"
PY

if command -v pytest > /dev/null 2>&1 || python3 -c "import pytest" 2>/dev/null; then
    python3 -m pytest test_reg.py -q --tb=line 2>&1 | tail -10 | sed 's/^/    /'
    test_rc="${PIPESTATUS[0]:-0}"
    tests_run=1
else
    printf '    pytest не установлен — тесты НЕ ВЫПОЛНЯЛИСЬ\n'
    printf '    установка: pip install pytest==9.1.1\n'
    printf '    тестов написано: %s\n' "$(grep -c '^def test_' test_reg.py)"
    test_rc=0
    tests_run=0
fi
n_tests="$(grep -c '^def test_' test_reg.py)"
[ "$n_tests" -ge 5 ] \
    && ok "написано $n_tests тестов (выполнялись: $tests_run)" \
    || bad "тестов: $n_tests"

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все требования выполнены" || echo "  ЕСТЬ ПРОВАЛЫ"
[ "$tests_run" = "0" ] && echo "  примечание: pytest отсутствует — тесты не запускались"

docker rm -f lab3-reg lab3-tls lab3-mirror > /dev/null 2>&1
docker network rm lab3-net > /dev/null 2>&1
docker volume rm lab3-data > /dev/null 2>&1
cd /tmp && rm -rf /tmp/reglab3
exit "$fail"

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

text
═══ Требование 1: данные переживают пересоздание ═══
    репозиториев до пересоздания: 1
    репозиториев после:           1
    без volume после пересоздания: 0 (для сравнения)
  ✓ с volume данные сохранились (1), без volume — потеряны (0)

═══ Требование 2: имя без точки уходит на Docker Hub ═══
    curl к lab3-reg:5000/v2/ изнутри сети: 200
    docker pull lab3-reg/persist/app:1.0 → Error response from daemon: pull access denied
  ✓ запрос ушёл на Docker Hub: имя без точки не считается адресом registry

═══ Требование 3: TLS с корректным SAN ═══
    SAN сертификата: DNS:lab-registry.local, DNS:localhost, IP Address:127.0.0.1
    без доверия к корню:        отказ
    с корнем, верное имя:       200
    с корнем, имя ВНЕ SAN:      отказ
  ✓ проверяются и корень, и имя хоста в SAN

═══ Требование 4: что защищает digest без TLS ═══
    RepoDigest: persist/app@sha256:8a3f2b1c4d5e6f7a8b9c0d1e2f3a4b5c6d7e...
    pull по тегу:    код 0
    pull по digest:  код 0

    Разница в том, КТО выбирает манифест:
      по тегу   — сервер: поддельный вернёт свой манифест со своими digest
      по digest — клиент: подменить содержимое невозможно, sha256 не сойдётся
    Поэтому развёртывание по digest защищено даже по HTTP.
  ✓ оба способа работают; разница — в том, чему доверяет клиент

═══ Требование 5: pull-through cache ═══
    зеркало отвечает: HTTP 200
    попытка публикации: unsupported: The operation is unsupported.
  ✓ публикация в зеркало отвергнута: режим proxy — только чтение
    следствие: нужны ДВА экземпляра — зеркало и свой registry

═══ Требование 6: интеграционные тесты ═══
    ......                                                             [100%]
    6 passed in 26.14s
  ✓ написано 6 тестов (выполнялись: 1)

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

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

Требование 1 даёт результат в трёх числах: 1 → 1 с volume и 0 без него. Это не иллюстрация, а сравнение двух экземпляров при одинаковых действиях.

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

Требование 1 включает контрольный экземпляр без volume. Проверка «данные сохранились» сама по себе слаба: она не исключает, что registry вообще ничего не терял бы. Второй экземпляр без volume, теряющий данные при тех же действиях, превращает наблюдение в измерение эффекта.

Требование 3 проверяет три случая, а не два. «С корнем работает, без корня нет» доказывает проверку подписи, но не проверку имени. Третий случай — верный корень, но имя вне SAN — показывает, что клиент проверяет и его. Именно этот случай ломает установку чаще всего: сертификат выпущен, корень установлен, а имя в docker pull не совпадает с SAN.

Тесты поднимают registry на порту 0 и узнают фактический через docker port. Фиксированный порт делает набор тестов непригодным для параллельного запуска и конфликтует со всем, что уже слушает этот порт. Случайный порт стоит трёх строк и снимает целый класс отказов в CI.

Чего решение не делает. Подмена манифеста в требовании 4 не воспроизводилась: для этого нужен поддельный сервер и перехват трафика. Разница показана рассуждением о протоколе и подтверждена тем, что оба способа pull работают, — но само нападение не выполнялось, и об этом сказано прямо. Зеркало поднято, однако проверка кэширования требует обращения к Docker Hub и расходует лимит, поэтому измерялась только невозможность публикации. Сборка мусора registry не проверялась: она требует остановки записи и отдельного прогона. Наконец, тесты выполняются, только если pytest установлен; иначе решение сообщает об этом, а не засчитывает шаг.

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

bash
curl -s http://localhost:5000/v2/
curl -s http://localhost:5000/v2/_catalog | python3 -m json.tool
docker inspect registry --format '{{json .Mounts}}' | python3 -m json.tool
openssl x509 -in certs/cert.pem -noout -ext subjectAltName
ls /etc/docker/certs.d/

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

ОшибкаПричинаИсправление
Запуск без volumeРаботает сразуДанные исчезнут при пересоздании
myregistry/api в ComposeПохоже на адресНужен порт: myregistry:5000/api
Имя только в CN сертификатаТак было раньшеСовременные клиенты читают SAN
Каталог certs.d без портаПорт кажется лишнимИмя каталога = имя в pull, включая порт
insecure-registries для удобстваБыстрее, чем TLSПодмена манифеста по тегу не обнаружится
HTTP плюс docker loginОба настроены отдельноПароль уходит открытым текстом
Ожидают, что зеркало примет pushЭто ведь registryРежим proxy — только чтение
Удаление тега считают освобождениемЛогичноНужен garbage-collect при остановленной записи
Фиксированный порт в тестахПрощеКонфликты при параллельном запуске
Volume для тестового registryПо привычкеДанные должны исчезать вместе с тестом

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

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

  1. Почему myregistry/api обращается не к вашему серверу?
  2. Что именно проверяет клиент в сертификате registry? Назовите три вещи.
  3. Что защищает digest при работе по HTTP и что не защищает?
  4. Почему нельзя совместить зеркало и собственный registry в одном экземпляре?
  5. Почему удаление тега не освобождает место?

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

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

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

  1. docker pull даёт pull access denied при работающем локальном registry. Причина?
  2. Сертификат выпущен и корень установлен, но docker pull отвергает соединение. Что проверить?

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

  1. Registry запускают с volume — иначе данные исчезнут при пересоздании container'а.
  2. Имя без точки, двоеточия или localhost понимается как namespace на Docker Hub.
  3. В Compose к сервису-registry обращаются с портом: reg:5000/образ.
  4. Клиент проверяет подпись, срок и имя хоста в SAN; поле CN игнорируется.
  5. Корень самоподписанного сертификата кладут в /etc/docker/certs.d/<хост>:<порт>/ca.crt.
  6. Insecure registry защищает содержимое слоёв, но не защищает от подмены манифеста по тегу.
  7. Запрос по digest защищён и без TLS: манифест выбирает клиент, а не сервер.
  8. HTTP в сочетании с docker login передаёт пароль открытым текстом.
  9. Pull-through cache работает только на чтение; публикация в него невозможна.
  10. Зеркало и собственный registry — два разных экземпляра.
  11. Удаление манифеста требует REGISTRY_STORAGE_DELETE_ENABLED, а освобождение места — сборки мусора.
  12. Тестовый registry поднимают на случайном порту и без volume.

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

ИсточникСсылкаЧто подтверждает
Distribution registryhttps://distribution.github.io/distribution/Запуск, конфигурация
Registry: configurationhttps://distribution.github.io/distribution/about/configuration/Переменные окружения, proxy
Registry: TLShttps://distribution.github.io/distribution/about/deploying/#run-an-externally-accessible-registryНастройка сертификатов
Registry: garbage collectionhttps://distribution.github.io/distribution/about/garbage-collection/Требование режима только для чтения
Docker: insecure registrieshttps://docs.docker.com/reference/cli/dockerd/#insecure-registriesФормат insecure-registries
Docker: registry mirrorhttps://docs.docker.com/docker-hub/image-library/mirror/Pull-through cache
Docker: certificateshttps://docs.docker.com/engine/security/certificates/Каталог certs.d

Навигация

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

Markdown на GitHub ↗