Кеширование докер образов в локальной сети при помощи zot

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:

  1. docker-mirror — анонимный pull-through cache публичных образов, без аутентификации вообще;
  2. 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 "**" c patterns: [".*"] — он не удаляет ничего; это страховка от «репозиторий не совпал ни с одной политикой».
  • 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 и режутся сильнее. Зеркало ходит со своим токеном:

  1. hub.docker.com → Settings → Security → New access token (Read-only достаточно).
  2. Файл /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
  1. Путь к файлу указан в sync.credentialsFile. zot не читает переменные окружения — только файлы, поэтому секреты рендерятся в том, а не в env.
  2. 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

  1. Дедупликация одновременных запросов. Если несколько нод одновременно просят один и тот же отсутствующий образ/слой, zot блокирует digest на уровне индекса: первый запрос реально идёт на Docker Hub, остальные ждут и получают уже кэшированное. Для «все ноды одновременно обновились» это 1 pull вместо N — сильная экономия лимита.
  2. Но проверка метаданных тега всё равно ходит наружу. Каждый on-demand-запрос валидирует digest манифеста тега в upstream — иначе зеркало не может отдать актуальную версию (теги мутабельны). Эти запросы расходуют лимит Docker Hub, но это дёшево по сравнению с перескачививанием слоёв. Поэтому единственный рычаг уменьшения числа таких запросов — дедуп одновременных (см. п. 1) и retention 720h, чтобы тёплый образ физически не перескачививался. Настраиваемого TTL для этой проверки в on-demand режиме нет.
  3. Режим периодического 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.

И дополнение про секреты

В этой схеме ключ от докерхаба лежит в безопасном месте и в одном месте, клиенты в локальной сети его не видят, что является архитектурным плюсом

Стоит ли добавлять аутентификацию куда-то, кроме докерхаба?

Коротко: нет. Кроме Docker Hub, креды добавлять незачем

Разбираю по реестрам из repositories:

Реестр Лимит анонимно Что даёт PAT Итог
registry-1.docker.io 100 пул/6ч на IPv4, 200/6ч для Personal, безлимит для Pro/Team/Business Docker Hub pull…and limits ×2 на бесплатном, снимает лимит вообще на платном нужны
ghcr.io публичных лимитов на pull нет (официально), только abuse-троттлинг ничего для публичных; нужен только для приватных пакетов (read:packages) не нужны
lscr.io лимитов нет ничего не нужны
quay.io анонимные пулы публичных репо не ограничены, лимиты «в крайних случаях» ничего для публичных; robot-токен — только для приватных не нужны
registry.k8s.io есть per-IP rate limit аккаунтов там нет, авторизация не помогает — лимит привязан к IP не нужны, только зеркалировать

Единственный реальный триггер добавить секцию в authsприватный образ в ghcr/quay. Тогда это GitHub fine-grained PAT со scope read:packages или Quay robot token.

Почему «на всякий случай» — хуже, чем полезно

  • Просроченный токен ломает зеркало. Для ghcr/quay/K8s у тебя выигрыш = 0, а риск: GitHub fine-grained PAT обязателен с expiry. Токен протухает → zot получает 401 вместо анонимного фолбэка → no basic auth credentials на образах, которые всегда тянулись анонимно, и молча, потому что log.level: warn.
  • Держать auths минимальным — это меньшее число секретов, которые нужно ротировать, и меньше причин для внезапной деградации.