1. Зачем это дома
Docker Hub ограничивает скачивание образов: 100 pull/6ч для анонимных и 200 pull/6ч для авторизованного личного аккаунта. В лабе с несколькими нодами и регулярными обновлениями образов лимит выгорает быстро — пулы падают с 429 Too Many Requests.
Локальное зеркало решает это: образ качается с Docker Hub один раз, дальше все ноды берут его из локальной сети. Плюс скорость в LAN и независимость от внешних дёрганий.
2. Выбор системы кеширования
| Инструмент | Что кеширует | Особенности |
|---|---|---|
| registry:2 (pull-through) | только docker.io | нет встроенной очистки, не сохраняет digest |
| zot | любой OCI-registry (sync extension) | один бинарник, UI, retention/GC, LDAP/OIDC |
| Harbor | docker.io + прочие | тяжёлый (контейнеров + БД), для дома избыточен |
| Nexus / Artifactory | всё | JVM, ест ресурсы |
Важно про zot и типы артефактов: zot кеширует только образы (OCI artifacts). Это не артефакт-репозиторий общего назначения — jar/npm/pip/iso он не отдаст, для этого нужны отдельные сервисы. Внутриобразовые слои и манифесты кешируются с дедупликацией (dedupe).
3. Проблема анонимности и два инстанса
Ключевое ограничение Docker daemon: он не отправляет креды на registry из registry-mirrors и не умеет отработать анонимный Basic-challenge. Хуже того: как только в zot включается любая basic-аутентификация (htpasswd/LDAP/OIDC) с accessControl, zot отвечает 401 на /v2/ ping для docker-клиентов — и зеркала в daemon.json перестают работать.
Решение — разделить на два инстанса zot:
- docker-mirror — анонимный pull-through cache публичных образов, без аутентификации вообще;
- registry — приватные self-built образы (
homelab/**), с LDAP/OIDC через SSO и ролевой моделью (accessControl), push только по авторизации.
Оба — отдельные docker compose стеки.
4. docker compose (зеркало)
mirror:
image: ghcr.io/project-zot/zot:${ZOT_TAG:-v2.1.20}
container_name: zot-mirror
restart: unless-stopped
stop_grace_period: 30s
ports:
- "5000:5000"
volumes:
- ./config-mirror.json:/etc/zot/config.json:ro
- ./secrets:/etc/zot/secrets:ro
- /mnt/storage/mirror:/var/lib/registry
command: ["serve", "/etc/zot/config.json"]
security_opt:
- no-new-privileges:true
Вместо network_mode: host — публикация порта 5000:5000. Секреты (PAT для Docker Hub) — отдельным томом; в конфиге только путь к файлу.
5. Хранилище
Кэш образов занимает много места: multi-arch манифесты + слои всех платформ, которые запрашивали ноды. Десятки гигабайт за месяцы — норма. Поэтому:
- том
/var/lib/registryвыносим на NAS (в примере —/mnt/storage/mirror); - на локальном SSD миника кэшу делать нечего.
Для NFS-накопителя: dedupe полагается на hard links и boltdb-кэш — работает, но медленнее, чем на локальной ФС.
6. Конфиг зеркала
{
"distSpecVersion": "1.1.1",
"storage": {
"rootDirectory": "/var/lib/registry",
"dedupe": true,
"gc": true,
"gcDelay": "1h",
"gcInterval": "12h",
"maxRepos": 800,
"retention": {
"dryRun": false,
"delay": "168h",
"policies": [
{
"repositories": ["docker/**", "ghcr/**", "lscr/**", "quay/**", "k8s/**"],
"deleteReferrers": true,
"deleteUntagged": true,
"keepTags": [
{ "patterns": [".*"], "pulledWithin": "720h" }
],
"keepUntagged": { "pulledWithin": "720h", "pushedWithin": "720h" }
},
{
"repositories": ["**"],
"deleteReferrers": true,
"deleteUntagged": true,
"keepTags": [ { "patterns": [".*"] } ]
}
]
}
},
"http": {
"address": "0.0.0.0",
"port": "5000",
"externalUrl": "http://docker-mirror.domain.com:5000",
"compat": ["docker2s2"]
},
"extensions": {
"search": { "enable": true },
"ui": { "enable": true },
"sync": {
"enable": true,
"credentialsFile": "/etc/zot/secrets/sync-credentials.json",
"registries": [
{
"urls": ["https://registry-1.docker.io"],
"onDemand": true,
"syncTimeout": "10m",
"tlsVerify": true,
"preserveDigest": true,
"syncLegacyCosignTags": false,
"maxRetries": 3,
"retryDelay": "5m",
"content": [
{ "prefix": "**", "destination": "/docker", "stripPrefix": false }
]
},
{
"urls": ["https://ghcr.io"],
"onDemand": true,
"syncTimeout": "10m",
"tlsVerify": true,
"preserveDigest": true,
"syncLegacyCosignTags": false,
"maxRetries": 3,
"retryDelay": "5m",
"content": [
{ "prefix": "**", "destination": "/ghcr", "stripPrefix": false }
]
},
{
"urls": ["https://lscr.io"],
"onDemand": true,
"syncTimeout": "10m",
"tlsVerify": true,
"preserveDigest": true,
"syncLegacyCosignTags": false,
"maxRetries": 3,
"retryDelay": "5m",
"content": [
{ "prefix": "**", "destination": "/lscr", "stripPrefix": false }
]
},
{
"urls": ["https://quay.io"],
"onDemand": true,
"syncTimeout": "10m",
"tlsVerify": true,
"preserveDigest": true,
"syncLegacyCosignTags": false,
"maxRetries": 3,
"retryDelay": "5m",
"content": [
{ "prefix": "**", "destination": "/quay", "stripPrefix": false }
]
},
{
"urls": ["https://registry.k8s.io"],
"onDemand": true,
"syncTimeout": "10m",
"tlsVerify": true,
"preserveDigest": true,
"syncLegacyCosignTags": false,
"maxRetries": 3,
"retryDelay": "5m",
"content": [
{ "prefix": "**", "destination": "/k8s", "stripPrefix": false }
]
}
]
}
},
"log": { "level": "warn" }
}
Что для чего:
compat: ["docker2s2"] + preserveDigest: true— хранит Docker-манифесты без конвертации в OCI, digests совпадают с оригинальными. Обязательно, если пинишь образы по@sha256:или проверяешь cosign-подписи.dedupe— общие слои хранятся один раз.onDemand: true+content prefix "**"— ничего не качается заранее; образ fetch’ится с Docker Hub при первом запросе и ложится в/docker/...(для ghcr/quay — своиdestination).syncLegacyCosignTags: false— не тянуть legacy.sig/.sbom-теги, которые только мусорят.maxRepos— потолок числа репозиториев (свыше — 429 на создание нового): защита от разрастания каталога из-за опечаток в именах.- search + UI включены — на search завязан metadb, без него
keepUntaggedигнорируется.
Как работает очистка
- keepTags — тег сохраняется, если скачивался за последние
pulledWithin(720h = 30 дней). Правила внутри одной записи — OR. Всё, что не попало ни под одно keep-правило, удаляется. - Политики матчатся по glob
repositories, выбирается первая совпавшая. Поэтому в конце стоит catch-all"**"cpatterns: [".*"]— он не удаляет ничего; это страховка от «репозиторий не совпал ни с одной политикой». - deleteUntagged + keepUntagged + delay — перетёртые при обновлении
latestманифесты становятся untagged; они живут ещёdelay(168h) и удаляются, если не скачивались в окно keepUntagged. Это главный освободитель места на зеркале. - deleteReferrers — чистит осиротевшие артефакты (подписи/SBOM без родителя).
- gc/gcDelay/gcInterval — сборщик удаляет блобы (слои), на которые не осталось ссылок;
gcDelay— буфер, чтобы не удалить блоб, который прямо сейчас докачивают. - Квоты по объёму нет — только по времени/количеству. Диск заполняется → рычаг один: уменьшить
pulledWithin.
Перед применением: zot verify config.json; первый прогон — с "dryRun": true (лог удалений без реального удаления).
7. PAT для Docker Hub
Анонимные запросы живут под чужими общими IP и режутся сильнее. Зеркало ходит со своим токеном:
- hub.docker.com → Settings → Security → New access token (Read-only достаточно).
- Файл
/etc/zot/secrets/sync-credentials.json— в docker-формате,auth= base64 отlogin:token:
{
"auths": {
"registry-1.docker.io": {
"auth": "ZG9ja2VyLXVzZXI6ZHBhdF9leGFtcGxldG9rZW4wMDAwMDA="
}
}
}
(в примере закодировано docker-user:dpat_exampletoken000000 — подставь свои)
Сгенерировать строку auth:
echo -n 'docker-user:dpat_exampletoken000000' | base64
- Путь к файлу указан в
sync.credentialsFile. zot не читает переменные окружения — только файлы, поэтому секреты рендерятся в том, а не в env. chmod 600и не коммитить.
Лимит 200 pull/6ч считается на аккаунт токена — держи отдельный бесплатный аккаунт под зеркало.
Почему аутентификация только для hub.docker.com написал в отдельном комментарии ниже
8. Настройка клиентов
daemon.json — работает только для Docker Hub
{
"registry-mirrors": [
"http://docker-mirror.domain.com:5000"
],
"insecure-registries": ["docker-mirror.domain.com:5000"]
}
sudo systemctl restart docker.
При http без TLS строка
insecure-registriesобязательна — осознанный trade-off для доверенной LAN. Если позже закроешь зеркало reverse proxy с TLS, она не нужна, а в зеркало меняется только схема.
Все остальные registry — /etc/docker/certs.d
registry-mirrors действует только на docker.io. Для ghcr/lscr/quay/k8s на каждой ноде заводится свой hosts.toml с переписыванием адреса на зеркало:
/etc/docker/certs.d/
├── ghcr.io/hosts.toml
├── lscr.io/hosts.toml
├── quay.io/hosts.toml
└── registry.k8s.io/hosts.toml
Пример certs.d/ghcr.io/hosts.toml:
server = "https://ghcr.io"
[host."http://docker-mirror.domain.com:5000/v2/ghcr"]
capabilities = ["pull", "resolve"]
override_path = true
override_path = true— зеркало складывает образы в префикс/ghcr(совпадает сdestinationв sync, раздел 6);server— fallback: при недоступности зеркала docker сам уйдёт в upstream, кэш не является точкой отказа;- для остальных реестров — тот же файл с заменой хоста и префикса:
/v2/lscr,/v2/quay,/v2/k8s; - путь в host включает
/v2/..., потому что distribution-API zot корневана в/v2/; - на Docker 25+ с containerd image store hosts.toml — штатный способ; рестарт docker после правки не требуется, применяется со следующего пула.
9. Что меняется в жизни
После этой настройки пути к образам менять не надо ни в одном compose/k8s манифесте: image: nginx:latest по-прежнему «качается с Docker Hub» — прокладка через зеркало прозрачна для клиента. Выигрывают скорость, квота Docker Hub и устойчивость лабы.
10. Админка
Плюс по сравнению с registry:2 / прокси-кэшем — встроенный сканер уязвимостей (Trivy). Не отдельный контейнер, не sidecar — прямо в zot, по тем же образам, что лежат в кэше. Сканер включается через extensions.search.cve (search уже и так нужен для metadb, см. раздел 6):
"search": {
"enable": true,
"cve": {
"updateInterval": "24h",
"trivy": {
"dbRepository": "ghcr.io/aquasecurity/trivy-db",
"javaDBRepository": "ghcr.io/aquasecurity/trivy-java-db",
"vulnSeveritySources": ["auto"]
}
}
}
- БД уязвимостей обновляется раз в
updateInterval(по умолчанию 24h) из публичных репозиториев Aquasecurity на ghcr.io — тот же ghcr, что уже зеркалируется, так что при желании можно сослаться и на локальный путь/ghcr/aquasecurity/trivy-db. - В UI видны CVE per-tag с разбиением по severity, фильтры по fixed-in-version. Search-API (
/v2/_zot/search) отдаёт результаты в JSON — можно дёргать из Grafana/алёртов. - Важно про зеркало:
cveсканирует то, что лежит в storage зеркала. Это кэшированные образы, а не вся Docker Hub — сканер не пробегает upstream, только локальные блобы. Для свежегоnginx:latestуязвимости видны сразу после первого пулла через зеркало; для образов, которые ни разу не запросили, сканировать нечего. - Сканирование съедает CPU и место под кэш БД (Trivy-DB ~50–100 МБ). На малинке/atom’е имеет смысл увеличить
updateIntervalдо48h/72hлибо держать отдельный инстанс zot-сканера на более мощной ноде и указать ему тот же storage.
11. Аутентификация и доступ к админке
Важная особенность зеркала: внутри zot auth включать нельзя (см. раздел 3 — 401 на /v2/ ломает registry-mirrors). То есть веб-UI и search-API (/v2/_zot/*) доступны любому, кто достучится до порта 5000.
Что делать:
- Граница — LAN. Зеркало не публикуется из интернета (нет DNS-записи наружу / firewall-правило на порт). Внутри сети это приемлемо: в анонимном кэше публичных образов секретов нет.
- UI можно вообще выключить:
"ui": {"enable": false}— кэшированию это не мешает (search не трогаем, он нужен metadb). - Если нужен доступ извне — не включать auth в zot, а закрывать paths на reverse proxy (Traefik): роут на
PathPrefix('/v2/') && !PathPrefix('/v2/_zot/')— анонимный, приоритет ниже; всё остальное (главная, UI,/v2/_zot/*) — под OIDC-middleware (Authentik и т.п.), приоритет выше. Docker-трафик не трогается, морда за аутентификацией.
12. Снижение нагрузки на Docker Hub
- Дедупликация одновременных запросов. Если несколько нод одновременно просят один и тот же отсутствующий образ/слой, zot блокирует digest на уровне индекса: первый запрос реально идёт на Docker Hub, остальные ждут и получают уже кэшированное. Для «все ноды одновременно обновились» это 1 pull вместо N — сильная экономия лимита.
- Но проверка метаданных тега всё равно ходит наружу. Каждый on-demand-запрос валидирует digest манифеста тега в upstream — иначе зеркало не может отдать актуальную версию (теги мутабельны). Эти запросы расходуют лимит Docker Hub, но это дёшево по сравнению с перескачививанием слоёв. Поэтому единственный рычаг уменьшения числа таких запросов — дедуп одновременных (см. п. 1) и retention 720h, чтобы тёплый образ физически не перескачививался. Настраиваемого TTL для этой проверки в on-demand режиме нет.
- Режим периодического sync (
pollInterval+content) — это как раз «отдавать всегда из кэша, а сам zot по расписанию проверяет и докачивает новое». Технически zot так умеет, но для Docker Hub это неверный путь: у Docker Hub нет catalog API и включены rate limit’ы — поллинг поprefix: "**"сожжёт квоту. Polling имеет смысл для quay.io/ghcr/k8s с узкимиcontent-префиксами под реально используемые репозитории. Для docker.io — только on-demand + длинное retention-окно, чтобы образ физически скачивался один раз за месяцы.
Итого формула экономии лимита: PAT (200/6ч вместо 100) + on-demand дедуп + retention 720h, чтобы тёплый кэш не уезжал в cold.
