Протокол pull/push реестра: манифесты, блобы и контент-адресуемая дистрибуция
Реестр — это контент-адресуемое хранилище блобов за небольшим HTTP API. Pull разрешает тег в манифест и тянет config и слои-блобы по sha256-дайджесту; push грузит блобы (чанками, монолитно или cross-repo mount), затем PUT манифеста. Дайджесты — настоящая идентичность.
Дежурного завалило алертами “image pull backoff” по всему флоту, а дашборд реестра был зелёным. Что происходило на самом деле, показал лишь дамп трафика: каждый узел слал GET /v2/payments-api/manifests/v2.4.1, получал 200 с крошечным JSON-телом, затем шквал GET /v2/payments-api/blobs/sha256:... — и один из этих blob-запросов возвращал 404. Ночью отработал сборщик мусора реестра, и из-за гонки между незавершённым push и фазой mark он удалил блоб-слой, на который указывал ещё используемый манифест. Манифест разрешался нормально; тег выглядел здоровым; собственный health-check реестра не тянул блобов. Но образ был оглавлением, указывающим на файл, которого больше нет. Починка — в понимании, что реестр это не “где живут образы”, а контент-адресуемое хранилище блобов с тонким индексом сверху, и индекс пережил своё содержимое.
Два вида объектов, одно адресное пространство
Прежде чем отлаживать сбой pull или рассуждать о сборке мусора, нужна ментальная модель того, что реестр реально хранит — потому что это не то, что подсказывает слово «реестр». Всё в реестре — одно из двух: манифест (маленький JSON, описывающий образ — его config плюс упорядоченный список слоёв) или блоб (непрозрачный контент-адресуемый объект: tar-слой или config JSON). Оба живут под /v2/ API, единым неймспейс-деревом OCI distribution spec:
GET /v2/<name>/manifests/<reference> # reference = тег (v2.4.1) ИЛИ дайджест (sha256:ab…)
GET /v2/<name>/blobs/<digest> # дайджест ВСЕГДА sha256:<hex>, никогда не тег
HEAD /v2/<name>/blobs/<digest> # этот слой уже есть здесь? (перед push)Это разделение и есть весь дизайн. Адрес блоба — его содержимое: sha256:9b2a… и есть SHA-256 байтов, поэтому блоб неизменяем по определению — измени один байт, и это другой объект с другим именем. Reference манифеста может быть тегом, а тег — лишь изменяемый указатель, который реестр хранит в индексе. Так что myrepo:latest и myrepo@sha256:9b2a… категорически разные: дайджест навсегда называет точный бит-в-бит образ; тег называет “то, что было запушено под этой меткой последним”. Когда тянешь тег, клиент делает GET .../manifests/v2.4.1, читает возвращённый Content-Type, чтобы узнать media type манифеста (например, application/vnd.oci.image.manifest.v1+json для одноархитектурного образа или ...image.index.v1+json для multi-arch индекса, перечисляющего per-platform манифесты), затем тянет блоб config и каждый блоб layers[].digest — дедуплицируя любой слой, уже имеющийся в локальном хранилище.
▸Почему это работает
Зачем адресовать блобы по хешу, а не по пути или id? Потому что контент-адресация делает дедупликацию и целостность бесплатными. Два образа с общим базовым слоем ссылаются на идентичный sha256:-блоб — реестр хранит его один раз, клиент скачивает один раз, а CDN кэширует навсегда, ведь URL не может означать разные байты. Целостность — по той же причине: клиент пересчитывает SHA-256 каждого скачанного блоба и сравнивает с запрошенным дайджестом, поэтому испорченный или подменённый байт ловится локально, без подписи для уровня транспортной целостности. Цена в том, что про тег ничего не проверить — верифицируем только дайджест.
Push: блобы первыми, манифест последним
В каком порядке клиент загружает вещи при push? Если перепутать, реестр отклонит манифест — и понимание правильного порядка объясняет, как работают cross-repo mount (монтирование блоба между репозиториями) и возобновляемая чанковая загрузка. Push — зеркало pull, и порядок несущий. Нельзя PUT-нуть манифест, ссылающийся на блоб, которого у реестра ещё нет, поэтому клиент грузит блобы первыми, манифест последним:
POST /v2/<name>/blobs/uploads/ # начать загрузку, получить session URL (Location:)
PATCH <session-url> # чанк: послать диапазон байтов, повторить
PUT <session-url>?digest=sha256:<hex> # финал: реестр проверяет, что байты хешатся в <digest>
PUT /v2/<name>/manifests/<tag> # только когда все ссылаемые блобы на местеЗдесь живут две senior-оптимизации. Cross-repository blob mount: перед загрузкой слоя клиент делает HEAD; если реестр уже держит этот sha256: (в любом репо, доступном креденшелу), клиент шлёт POST .../blobs/uploads/?mount=<digest>&from=<other-repo>, и слой линкуется без передачи единого байта — причина, по которой push нового тега приложения с общим базовым образом двигает лишь изменённые слои, часто пару МБ вместо сотен. Chunked vs monolithic: мелкие блобы идут одним PUT с inline-телом; большие стримятся PATCH-диапазонами, чтобы оборванное соединение возобновилось с последнего подтверждённого офсета, а не перезапускало 400-МБ слой. Финализирующий PUT ...?digest= — место, где реестр проверяет: пересчитывает хеш и отклоняет загрузку с 400 DIGEST_INVALID, если байты не совпали, так что испорченная передача не закоммитится под валидным именем.
При pull GET /v2/app/manifests/v2.4.1 возвращает 200 с валидным манифестом, но последующий GET /v2/app/blobs/sha256:9b2a… возвращает 404. Что означает это состояние?
Почему протокол формирует операции
Три операционных факта прямо следуют из дизайна — и когда ты встречаешь эти проблемы в проде, понимание дизайнерского решения сразу показывает, где искать фикс. Первое: pull — это fan-out (веерный запрос): один GET манифеста порождает N+1 GET блобов (N слоёв плюс config), поэтому 12-слойный образ это ~13 запросов на узел, а раскатка на 200 узлов — тысячи GET блобов; ровно поэтому существуют pull-through кэши и rate limits (публичный реестр вроде Docker Hub ограничивает анонимные pull до 100 за 6 часов и аутентифицированные бесплатные до 200 за 6 часов, считая по pull манифеста, троттля раскатки флота, которые пере-тянут). Второе: сборка мусора — двухфазный mark-and-sweep (пометить используемое, смести остальное) по хранилищу блобов: она обходит каждый манифест, помечая ссылаемые дайджесты, затем удаляет непомеченные блобы — и push, гоняющийся с фазой mark, может оставить ссылаемый блоб непомеченным и сметённым, тот самый сбой из Hook. Запускай GC с реестром в read-only ради корректности. Третье: дайджесты — единственный безопасный deploy-reference: пиннинг Deployment на app@sha256:… значит, что байты не изменятся под тобой, тогда как app:latest можно пере-запушить на другой манифест между pull двух узлов, и одна раскатка прогонит два разных образа.
Клиент собирается запушить новый тег приложения, чьи слои в основном общие с базовым образом, уже лежащим в реестре. Почему push передаёт лишь пару мегабайт вместо полного образа?
- 01Пройди, что docker pull app:v2.4.1 реально делает на уровне HTTP и где происходит проверка.
- 02Объясни, почему тег и дайджест не взаимозаменяемы как deploy-reference, через модель данных реестра.
Реестр — не файловая система образов; это контент-адресуемое хранилище блобов с тонким изменяемым индексом, выставленное через /v2/ API OCI distribution spec. Есть два вида объектов: манифесты (маленький JSON, перечисляющий config-блоб и упорядоченные слои-блобы, адресуемый тегом или дайджестом) и блобы (непрозрачные контент-адресуемые объекты, чьё sha256-имя — хеш их байтов, поэтому они неизменяемы по построению). Pull — это GET /v2/<name>/manifests/<ref> — с чтением media type для различения одиночного образа от multi-arch индекса — затем GET /v2/<name>/blobs/<digest> для config и каждого ещё не имеющегося слоя, каждый проверяется локально против дайджеста. Push идёт зеркальным порядком, блобы первыми: POST для открытия сессии загрузки, PATCH чанк-диапазонами для возобновляемой передачи больших слоёв, PUT ?digest= для финала, где реестр проверяет хеш, и HEAD плюс ?mount= для пропуска слоёв, что реестр уже держит, через cross-repository blob mount, передавая лишь изменённые байты. Из этой модели падают операционные истины, что несёт senior: pull — это fan-out (N+1 GET блобов на образ, причина pull-through кэшей и лимитов 100/200 за 6ч у Docker Hub), сборка мусора — mark-and-sweep, что должен идти read-only, иначе сметёт ещё используемый блоб и обездолит манифест, и лишь дайджест — никогда не тег — безопасный deploy-reference, потому что тег это указатель, который можно перенацелить под идущей раскаткой. Теперь, когда встретишь “image pull backoff” или 404 на блобе при 200 на манифесте, ты знаешь, какой слой модели сломался — и куда смотреть первым.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.