Pull-through кэши и зеркала: пережить rate limits, не отдавая устаревшие образы
Pull-through кэш — это реестр в режиме прокси: отдаёт блобы слоёв локально и тянет из upstream лишь промахи. Он схлопывает bandwidth флота и обходит rate limits Docker Hub — но сдвинутый тег может отдать устаревший манифест, если разрешение тега не авторитетно.
Автоскейлер сделал ровно свою работу, и в этом была проблема. Всплеск трафика отскейлил деплоймент с 30 до 300 подов за четыре минуты, и каждый новый узел тянул одну и ту же базу node:20 плюс три слоя приложения из Docker Hub. Около пода 140 pull начали возвращать 429 Too Many Requests: egress NAT кластера предъявлял Docker Hub один общий IP, и анонимный лимит pull 100 за 6 часов на IP был пробит за минуты. Новые поды застряли в ImagePullBackOff, scale-up встал ровно в момент, когда был нужен, а канал инцидента наполнился людьми в недоумении, что лимит Docker Hub может уронить их собственный сервис. Постоянный фикс был одной строкой в конфиге демона, указывающей на pull-through кэш, что они запустили внутри кластера: после того как первый узел забрал node:20, каждый другой узел тянул слои из внутрикластерного зеркала на скорости LAN, upstream увидел один pull вместо 300, и rate limit больше не приближался — превратив примерно 300 × 180 МБ повторной upstream-передачи в один забор.
Что pull-through кэш реально делает
Если флот тянет образы напрямую из Docker Hub, ты платишь стоимость bandwidth на каждом pull и делишь одну rate-limit квоту на весь кластер. Pull-through кэш (pull-through cache — кэш с проксированием промахов в upstream) решает обе проблемы одним механизмом — и понимание того, как он работает, сразу показывает, где он может сломаться. Pull-through кэш (зеркало реестра) — это обычный реестр в режиме прокси: настроенный с upstream (proxy.remoteurl: https://registry-1.docker.io), он отвечает на тот же /v2/ pull API, но на запрос сначала проверяет локальное хранилище и тянется в upstream лишь на промахе, сохраняя забранное. Рычаг целиком в контент-адресуемом слое блобов: поскольку имя блоба-слоя это его sha256:-дайджест, как только любой узел протянул node:20 через зеркало, зеркало держит эти блобы, и GET /v2/.../blobs/sha256:… каждого последующего узла это локальное попадание, отданное на bandwidth LAN. Раскатка 180-МБ образа на 300 узлов перестаёт быть 300 × 180 МБ ≈ 54 ГБ upstream-передачи и становится одним ~180 МБ upstream-забором плюс 299 LAN-чтений. Клиенты указывают на него, задав registry-mirrors в конфиге демона Docker (или registry host config у containerd); для клиента это просто более быстрый реестр.
// /etc/docker/daemon.json — клиенты тянут через внутрикластерное зеркало сначала
{ "registry-mirrors": ["https://mirror.internal:5000"] }Два выигрыша — bandwidth и rate limits, и именно rate-limit кусает первым. Docker Hub enforce-ит 100 pull за 6 часов для анонимных и 200 за 6 часов для аутентифицированных бесплатных аккаунтов, считая на IP источника (или на аккаунт при логине) и на pull манифеста. NAT-ленный кластер делит один egress IP, так что его весь флот делит одну квоту — сбой из Hook. Pull-through кэш фронтит upstream одной личностью: тысячи pull флота становятся горсткой upstream-заборов манифестов, держа тебя под лимитом по построению.
▸Почему это работает
Почему кэширование блобов работает так чисто, а кэширование маппинга тег→манифест опасно? Потому что две половины протокола имеют противоположную изменяемость. Блоб неизменяем — его дайджест это его контент — поэтому кэшированный блоб всегда верно отдавать; он не может устареть, лишь отсутствовать. Тег изменяем — это указатель, что upstream может перенацелить — поэтому кэшированный ответ ‘тег X = манифест Y’ может молча устареть в момент, когда upstream пере-пушит X. Вот почему корректный pull-through кэш свободно кэширует блобы, но должен держать разрешение тега авторитетным (ревалидировать манифест против upstream или пиннить клиентов на дайджесты): неизменяемую половину безопасно кэшировать вечно, изменяемую — нет.
Сбой: кэширование изменяемой половины
Вот ловушка, что превращает зеркало из спасителя в инцидент. Pull разрешает тег в манифест сначала, затем тянет блобы. Блобы безопасно кэшировать вечно. Но если зеркало также кэширует разрешение тег→манифест и отдаёт его без ревалидации, то после того как upstream пере-пушит node:20 на новый дайджест, зеркало продолжает выдавать старый манифест — и поскольку оно ещё держит блобы старого манифеста локально, каждый узел радостно собирает устаревший образ без единой ошибки. Получаешь инверсию инцидента из урока 2: там тег сдвинулся, и все получили новый образ; здесь тег сдвинулся, а твоё зеркало пиннит всех на старый. Корректное поведение — трактовать разрешение тега как всегда ревалидировать против upstream (условный запрос манифеста; блобы, будучи неизменяемыми, не нуждаются в ревалидации) — или, куда надёжнее, заставить клиентов тянуть по дайджесту, что делает кэш тривиально корректным, ведь запрос по дайджесту может быть лишь попаданием в ровно нужные байты или промахом, чтобы их забрать.
Pull-through кэш кэширует и блобы, и разрешения тег→манифест без ревалидации тегов. Upstream пере-пушит app:stable на новый дайджест. Что теперь получают узлы, тянущие app:stable через зеркало?
Зеркала, сайзинг и операционная картина
Несколько senior-моментов завершают это. Зеркало может означать две связанные вещи: pull-through кэш (ленивый, наполняется по требованию) или полностью реплицированный реестр (жадная копия, для гео-распределения или air-gapped площадок). Для облегчения rate-limit и bandwidth внутри одного кластера ленивый pull-through кэш — верный инструмент и более дешёвый. Сайзинг важен: кэшу нужен диск под рабочий набор слоёв (пара сотен ГБ покрывает большинство флотов, ведь базовые образы доминируют, а дедупликация по дайджесту автоматична), и надо запускать сборку мусора на нём, как на любом реестре, иначе он растёт безгранично. Cache hit ratio (доля запросов, обслуженных локально, без обращения в upstream) — метрика, доказывающая выигрыш: здоровое зеркало флота сидит хорошо выше 90% попаданий на blob-запросах, как только базовые образы прогреты, и это соотношение и есть снижение upstream-трафика и rate-limit. И нюанс аутентифицированного upstream: указание зеркала на Docker Hub с креденшелами поднимает твой общий лимит до аутентифицированного тира и консолидирует весь флот под одной подотчётной личностью, что и выше, и наблюдаемо. Сквозная мысль: кэшируй неизменяемый слой блобов агрессивно, держи изменяемый слой тегов честным, и одно общее кэш превращает шторм pull масштаба флота в один upstream-забор.
Почему одно внутрикластерное pull-through кэш даёт раскатке на 300 узлов уйти от per-IP rate limit pull у Docker Hub?
- 01Объясни механически, как pull-through кэш режет и bandwidth, и давление rate-limit Docker Hub для большой раскатки.
- 02Почему кэширование блобов всегда безопасно, а кэширование разрешения тега опасно, и каковы два корректных смягчения?
Pull-through кэш — это обычный реестр в режиме прокси: говорит на том же /v2/ pull API, но сначала проверяет локальное хранилище и тянется в настроенный upstream лишь на промахе, сохраняя забранное. Весь его рычаг — контент-адресуемый слой блобов: имя слоя это его sha256-дайджест, так что как только любой узел протянул образ через зеркало, blob-GET каждого позднего узла — локальные попадания на bandwidth LAN, превращая раскатку 180-МБ образа на 300 узлов из примерно 54 ГБ повторной upstream-передачи в один ~180 МБ забор плюс LAN-чтения. Та же консолидация бьёт rate limits Docker Hub — 100 pull за 6 часов анонимно, 200 аутентифицированно бесплатно, считая на IP источника — что NAT-ленный кластер иначе делит как одну квоту масштаба флота, которую событие автоскейла пробивает за минуты; за зеркалом тысячи pull флота становятся горсткой upstream-заборов под одной личностью. Опасность — изменяемая половина протокола: блобы неизменяемы и всегда безопасны для кэша, но разрешение тег→манифест это изменяемый указатель, и зеркало, что кэширует его без ревалидации, отдаст устаревший-но-самосогласованный образ после upstream-пере-пуша, молча, ведь оно ещё держит старые блобы. Держи разрешение тега авторитетным (ревалидируй манифесты; блобам это не нужно) или, лучше, тяни по дайджесту, чтобы кэш был корректен по построению. Операционно: сайзь кэш под рабочий набор базовых слоёв, делай GC на нём, как на любом реестре, следи за blob-hit ratio, что должен сидеть выше 90% прогретым, и подумай об аутентификации upstream, чтобы поднять квоту и консолидировать флот под одной подотчётной личностью. Кэшируй неизменяемый слой агрессивно, держи изменяемый слой честным, и шторм pull масштаба флота становится одним upstream-забором. Теперь, когда увидишь ImagePullBackOff при автоскейле или узлы, молча прогоняющие старый образ после upstream-пере-пуша, ты знаешь, какую половину протокола проверять первой.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.