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

**URL:** <https://forum.samohosting.ru/t/keshirovanie-doker-obrazov-v-lokalnoj-seti-pri-pomoshhi-zot/2819>\
**Category:** Утилиты самохостинга\
**Tags:** docker, homelab\
**Created:** [05.Сентябрь.2026 08:39:15 UTC](https://forum.samohosting.ru/t/keshirovanie-doker-obrazov-v-lokalnoj-seti-pri-pomoshhi-zot/2819 "2026-09-05T08:39:15Z")\
**Posts on this page:** 2\
**Page:** 1

<div class="post-metadata">

**Author:** ![KRom](https://forum.samohosting.ru/user_avatar/forum.samohosting.ru/krom/32/362_2.png) [@KRom](https://forum.samohosting.ru/u/KRom)\
**Post date:** [05.Сентябрь.2026 08:39:15 UTC](https://forum.samohosting.ru/t/keshirovanie-doker-obrazov-v-lokalnoj-seti-pri-pomoshhi-zot/2819/1 "2026-09-05T08:39:15Z")

</div>

## 1. Зачем это дома

Docker Hub ограничивает скачивание образов: **100 pull/6ч для анонимных** и **200 pull/6ч для авторизованного личного аккаунта**. В лабе с несколькими нодами и регулярными обновлениями образов лимит выгорает быстро — пулы падают с `429 Too Many Requests`.

Локальное зеркало решает это: образ качается с Docker Hub **один раз** , дальше все ноды берут его из локальной сети. Плюс скорость в LAN и независимость от внешних дёрганий.

## 2. Выбор системы кеширования

| Инструмент | Что кеширует | Особенности |
| --- | --- | --- |
| registry:2 (pull-through) | только [docker.io](http://docker.io) | нет встроенной очистки, не сохраняет digest |
| **zot** | любой OCI-registry (sync extension) | один бинарник, UI, retention/GC, LDAP/OIDC |
| Harbor | [docker.io](http://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 (зеркало)

```yaml
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. Конфиг зеркала

```json
{
  "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](http://hub.docker.com) → Settings → Security → **New access token** (Read-only достаточно).
2. Файл `/etc/zot/secrets/sync-credentials.json` — в docker-формате, `auth` = base64 от `login:token`:

```json
{
  "auths": {
    "registry-1.docker.io": {
      "auth": "ZG9ja2VyLXVzZXI6ZHBhdF9leGFtcGxldG9rZW4wMDAwMDA="
    }
  }
}

```

(в примере закодировано `docker-user:dpat_exampletoken000000` — подставь свои)

Сгенерировать строку `auth`:

```bash
echo -n 'docker-user:dpat_exampletoken000000' | base64

```

1. Путь к файлу указан в `sync.credentialsFile`. **zot не читает переменные окружения — только файлы** , поэтому секреты рендерятся в том, а не в env.
2. `chmod 600` и не коммитить.

Лимит 200 pull/6ч считается на аккаунт токена — держи отдельный бесплатный аккаунт под зеркало.

Почему аутентификация только для [hub.docker.com](http://hub.docker.com) написал в отдельном комментарии ниже

## 8. Настройка клиентов

### daemon.json — работает только для Docker Hub

```json
{
    "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](http://docker.io). Для ghcr/lscr/quay/k8s на каждой ноде заводится свой hosts.toml с переписыванием адреса на зеркало:

```auto
/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`:

```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. Админка

 ![image](https://forum.samohosting.ru/uploads/default/original/2X/b/b43ce146276bc078d6eaa4696e4184a6729aacd4.jpeg)

Плюс по сравнению с registry:2 / прокси-кэшем — **встроенный сканер уязвимостей (Trivy)**. Не отдельный контейнер, не sidecar — прямо в zot, по тем же образам, что лежат в кэше. Сканер включается через `extensions.search.cve` (search уже и так нужен для metadb, см. раздел 6):

```json
"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](http://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](http://quay.io/ghcr/k8s) с узкими `content`-префиксами под реально используемые репозитории. Для [docker.io](http://docker.io) — только on-demand + длинное retention-окно, чтобы образ физически скачивался один раз за месяцы.

Итого формула экономии лимита: PAT (200/6ч вместо 100) + on-demand дедуп + retention 720h, чтобы тёплый кэш не уезжал в cold.

---

<div class="post-metadata">

**Author:** ![KRom](https://forum.samohosting.ru/user_avatar/forum.samohosting.ru/krom/32/362_2.png) [@KRom](https://forum.samohosting.ru/u/KRom)\
**Post date:** [05.Сентябрь.2026 08:54:05 UTC](https://forum.samohosting.ru/t/keshirovanie-doker-obrazov-v-lokalnoj-seti-pri-pomoshhi-zot/2819/2 "2026-09-05T08:54:05Z")

</div>

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

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

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

**Коротко: нет. Кроме 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` минимальным** — это меньшее число секретов, которые нужно ротировать, и меньше причин для внезапной деградации.
