14.3. Локальный registry
Цели
После этого материала вы сможете:
- запустить registry в container с хранением данных в volume;
- объяснить, почему
myregistry/apiне работает, аmyregistry:5000/apiработает; - настроить TLS и объяснить, что именно проверяет клиент;
- назвать конкретные последствия insecure registry и условия, при которых он допустим;
- поднять pull-through cache и сократить обращения к внешнему registry;
- использовать локальный registry в интеграционных тестах.
Предварительные знания
Ключевые термины
| Термин | Объяснение |
|---|---|
registry:2 | Официальная реализация registry от Docker |
insecure registry | Registry без TLS или с непроверяемым сертификатом |
pull-through cache | Registry, кэширующий образы внешнего |
SAN | Список имён, для которых действителен сертификат |
garbage collect | Освобождение места после удаления манифестов |
Теория
Минимальный запуск
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).
Проверка работоспособности:
curl -s http://localhost:5000/v2/ # {} при успехе
curl -s http://localhost:5000/v2/_catalog # список репозиториев
Имя должно содержать точку или порт
Это правило из урока 14.1 здесь становится практическим ограничением.
| Имя | Как понимается |
|---|---|
myregistry/api | Namespace myregistry на Docker Hub — не то |
myregistry:5000/api | Registry myregistry:5000 — верно |
registry.local/api | Registry registry.local — верно |
localhost:5000/api | Registry localhost:5000 — верно |
Отсюда следствие для сети Docker: обращение к registry по имени сервиса без порта не работает. В Compose нужно указывать порт: registry:5000/api.
Insecure registry: что именно происходит
По умолчанию Docker требует HTTPS для всех registry, кроме localhost и 127.0.0.1.
Разрешить HTTP можно явно:
{
"insecure-registries": ["registry.local:5000", "192.168.1.0/24"]
}
Что это отключает:
| Проверка | Без TLS |
|---|---|
| Шифрование канала | Нет: слои и учётные данные передаются открыто |
| Подлинность сервера | Нет: подмена адреса не обнаруживается |
| Целостность в канале | Частично: digest защищает содержимое, но не метаданные |
Третья строка требует уточнения. Digest слоя защищает от подмены содержимого: клиент вычислит sha256 и увидит несовпадение. Но манифест запрашивается по тегу, и подменивший сервер вернёт манифест, указывающий на свои слои с корректными digest'ами. Проверка пройдёт.
Практический вывод: insecure registry защищает от повреждения, но не от подмены.
Условия, при которых он допустим:
| Условие | Почему |
|---|---|
| Изолированная сеть без выхода наружу | Нет пути для атакующего |
| Только для разработки и тестов | Артефакт не попадёт в эксплуатацию |
| Данные не конфиденциальны | Открытая передача не создаёт утечки |
| Аутентификация не используется | Нет пароля для перехвата |
Последняя строка существенна: HTTP плюс docker login — сочетание, которого быть не должно. Пароль уходит открытым текстом в каждом запросе.
TLS
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
Клиент проверяет три вещи:
- Сертификат подписан доверенным центром — или его корень лежит в
/etc/docker/certs.d/<хост>:<порт>/ca.crt. - Срок действия не истёк.
- Имя хоста входит в SAN сертификата.
Третий пункт — источник большинства отказов. Поле CN современными клиентами игнорируется; имя должно быть в Subject Alternative Name.
Для самоподписанного сертификата корень размещают так:
/etc/docker/certs.d/registry.local:5000/ca.crt
Каталог называется точно так, как пишется имя в docker pull, включая порт. Несовпадение — частая причина того, что сертификат «не подхватывается».
Pull-through cache
Registry может работать зеркалом внешнего:
proxy:
remoteurl: https://registry-1.docker.io
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
Клиенты настраиваются на зеркало:
{
"registry-mirrors": ["http://mirror.local:5000"]
}
| Свойство | Значение |
|---|---|
| Первый запрос образа | Скачивается из внешнего registry и кэшируется |
| Последующие | Отдаются из кэша |
| Расход лимита Docker Hub | Один на организацию вместо одного на машину |
| Публикация в зеркало | Невозможна: режим только для чтения |
Последняя строка важна: зеркало и собственный registry для своих образов — это разные экземпляры. Один container не может быть и тем, и другим.
Registry в интеграционных тестах
Локальный registry полезен там, где тест должен проверить публикацию и получение:
| Что проверяют | Зачем нужен настоящий registry |
|---|---|
| Сборка публикует образ | Проверка push целиком |
| Развёртывание берёт по digest | Проверка совпадения digest |
| Скрипт очистки удаляет старое | Проверка работы с API |
| Многоплатформенная сборка | Проверка index |
Требования к тестовому экземпляру:
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'ом.
Внутренний механизм
Как хранятся данные
/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 — и даже после этого до запуска сборщика мусора.
Сборка мусора
docker exec registry bin/registry garbage-collect \
/etc/docker/registry/config.yml --dry-run
Процесс проходит все манифесты, отмечает достижимые blob'ы и удаляет остальные.
Требование: во время сборки мусора в registry не должно быть записи. Иначе blob, загруженный, но ещё не связанный манифестом, будет сочтён мусором и удалён. Registry следует перевести в режим только для чтения:
storage:
maintenance:
readonly:
enabled: true
Команды и примеры
Запуск и проверка
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}')
"
Ожидаемый вывод:
═══ запуск с 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 пересоздан, данные на месте.
Имя без точки не работает
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
Ожидаемый вывод:
═══ подключаем 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: сертификат и проверка имени
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
Ожидаемый вывод:
═══ генерируем сертификат с 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
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
Ожидаемый вывод:
═══ разбор рисков ═══
аспект защищён пояснение
────────────────────────────────────────────────────────────────────────────────────────────────────────────
Содержимое слоёв да 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
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
Ожидаемый вывод:
═══ запуск зеркала ═══
зеркало отвечает: 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 для интеграционных тестов
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
Ожидаемый вывод:
═══ запуск тестов ═══
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 и проверьте его свойства.
Требования:
- Запустить registry с сохранением данных и подтвердить, что они переживают пересоздание container'а.
- Показать, что имя без точки или порта уходит на Docker Hub.
- Настроить TLS с корректным SAN и показать разницу с доверием к корню и без.
- Показать, что insecure registry защищает содержимое, но не защищает от подмены манифеста по тегу.
- Поднять pull-through cache и показать, что публикация в него невозможна.
- Написать интеграционные тесты, проверяющие публикацию и совпадение digest.
Подсказки
Подсказка 1
Для пункта 1 достаточно docker rm -f и повторный запуск с тем же volume.
Подсказка 2
Пункт 4 доказывается рассуждением о протоколе: манифест по тегу выбирает сервер, по digest — клиент. Покажите разницу командами pull по тегу и по digest.
Подсказка 3
Для пункта 6 используйте -p 0:5000 и узнавайте порт через docker port — так тесты можно запускать параллельно.
Решение
Показать решение
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"
Ожидаемый вывод:
═══ Требование 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 установлен; иначе решение сообщает об этом, а не засчитывает шаг.
Проверка результата
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 | По привычке | Данные должны исчезать вместе с тестом |
Контрольные вопросы
На понимание:
- Почему
myregistry/apiобращается не к вашему серверу? - Что именно проверяет клиент в сертификате registry? Назовите три вещи.
- Что защищает digest при работе по HTTP и что не защищает?
- Почему нельзя совместить зеркало и собственный registry в одном экземпляре?
- Почему удаление тега не освобождает место?
На применение:
- Как запустить registry так, чтобы данные пережили перезагрузку?
- Как настроить доверие к самоподписанному сертификату?
- Как поднять registry для параллельных интеграционных тестов?
На диагностику:
docker pullдаётpull access deniedпри работающем локальном registry. Причина?- Сертификат выпущен и корень установлен, но
docker pullотвергает соединение. Что проверить?
Краткое резюме
- Registry запускают с volume — иначе данные исчезнут при пересоздании container'а.
- Имя без точки, двоеточия или
localhostпонимается как namespace на Docker Hub. - В Compose к сервису-registry обращаются с портом:
reg:5000/образ. - Клиент проверяет подпись, срок и имя хоста в SAN; поле CN игнорируется.
- Корень самоподписанного сертификата кладут в
/etc/docker/certs.d/<хост>:<порт>/ca.crt. - Insecure registry защищает содержимое слоёв, но не защищает от подмены манифеста по тегу.
- Запрос по digest защищён и без TLS: манифест выбирает клиент, а не сервер.
- HTTP в сочетании с
docker loginпередаёт пароль открытым текстом. - Pull-through cache работает только на чтение; публикация в него невозможна.
- Зеркало и собственный registry — два разных экземпляра.
- Удаление манифеста требует
REGISTRY_STORAGE_DELETE_ENABLED, а освобождение места — сборки мусора. - Тестовый registry поднимают на случайном порту и без volume.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Distribution registry | https://distribution.github.io/distribution/ | Запуск, конфигурация |
| Registry: configuration | https://distribution.github.io/distribution/about/configuration/ | Переменные окружения, proxy |
| Registry: TLS | https://distribution.github.io/distribution/about/deploying/#run-an-externally-accessible-registry | Настройка сертификатов |
| Registry: garbage collection | https://distribution.github.io/distribution/about/garbage-collection/ | Требование режима только для чтения |
| Docker: insecure registries | https://docs.docker.com/reference/cli/dockerd/#insecure-registries | Формат insecure-registries |
| Docker: registry mirror | https://docs.docker.com/docker-hub/image-library/mirror/ | Pull-through cache |
| Docker: certificates | https://docs.docker.com/engine/security/certificates/ | Каталог certs.d |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Стратегия tagging
Главное оглавление