OCI image spec: index, manifest, config и слои как Merkle DAG из дескрипторов
OCI Image Spec — вендор-нейтральный стандарт, отделившийся от Docker schema2 в 2016. Образ — это Merkle DAG из дескрипторов: index к манифестам, манифест к config плюс слои, каждое ребро — digest и size. Неверные media-типы — и старый реестр ответит manifest unknown.
Образ собрался нормально, запушился нормально и запускался нормально на любом ноутбуке разработчика. Потом деплой в изолированный кластер заказчика упал на pull с одной строкой: manifest unknown. Реестром был старый Harbor, который никто годами не трогал, и он отвергал манифест с media-типом application/vnd.oci.image.manifest.v1+json. Сборочную машину обновили до версии Docker, которая поставляется с хранилищем образов containerd, а это хранилище по умолчанию эмитит OCI media-типы — не старый application/vnd.docker.distribution.manifest.v2+json (Docker schema2), который старый реестр умел хранить. В байтах образа не было ничего неправильного. Слои были теми же tar-архивами, которые реестр принял бы под любым именем. Реестр делал сравнение строк по полю mediaType манифеста, находил vnd.oci-строку, под которую у него не было ветки кода, и отказывал всему DAG. Фикс был в четырёх символах флага сборки — эмитить schema2-тип, — но на его поиск ушёл вечер, потому что команда никогда не задумывалась о том, что OCI-образ — это типизированный граф документов, а не непрозрачный блоб. Как только видишь граф дескрипторов, manifest unknown перестаёт быть загадкой и становится несовпадением media-типов, которое можно прочитать прямо из ошибки.
Как только видишь граф дескрипторов, manifest unknown перестаёт быть загадкой и становится несовпадением media-типов, которое можно прочитать прямо из ошибки и починить за минуты.
Четыре типа документов, связанных дескрипторами
Зачем разбираться в спеке? Потому что когда что-то ломается на уровне реестра или цепочки поставки, сообщение об ошибке всегда на языке спеки — media-типы, дескрипторы, дайджесты, — и без словаря ты отлаживаешь с одной связанной рукой. OCI Image Spec достиг v1.0 в 2017, через два года после того, как проект сформировался в 2015, чтобы вынуть Docker schema2 манифест из одного вендора в нейтральный стандарт. Он определяет четыре типа JSON-документов и один примитив, который их склеивает, — дескриптор. Дескриптор — единственный способ, которым один OCI-документ ссылается на другой, и это всего три поля:
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:7f0a3c…",
"size": 2089
}Это весь механизм связывания. mediaType говорит, какого рода вещь на том конце, digest — это sha256, который контентно-адресует её, а size — её длина в байтах, чтобы клиент мог выделить память и проверить ещё до того, как начнёт качать. Каждая ссылка в OCI-образе — index на манифест, манифест на config, манифест на каждый слой — это дескриптор. Четыре типа документов, которые он связывает:
- Image index —
application/vnd.oci.image.index.v1+json. Мультиархитектурный разворот: список дескрипторов, по одному на платформу, каждый ссылается на манифест. Примерно 1–3 КБ для горстки платформ. - Image manifest —
application/vnd.oci.image.manifest.v1+json. Рецепт одной платформы: дескрипторconfigи упорядоченный массивlayersиз дескрипторов. Обычно около 2 КБ. - Image config —
application/vnd.oci.image.config.v1+json. Тот JSON, что ты встретил в прошлом уроке: Env, Entrypoint, rootfs.diff_ids, history. Его digest — это image ID. - Layer blobs —
application/vnd.oci.image.layer.v1.tar+gzip. Gzip-tar-архивы файловой системы, основная масса байтов.
Pull разрешает тег в верхний дескриптор, качает тот документ, читает дескрипторы внутри него, качает их и рекурсивно спускается вниз, пока не получит config и каждый слой.
Манифест ссылается на свой config и три слоя. Каким механизмом манифест указывает на каждый из этих четырёх блобов?
Почему это Merkle DAG
Поскольку каждая связь — это digest того, на что она указывает, ссылки складываются в Merkle DAG — ту же структуру, что и граф git-коммитов. Digest config вычисляется из байтов config. Манифест встраивает тот digest config плюс digest каждого слоя, поэтому собственный digest манифеста вычисляется из байтов, которые включают все эти дочерние digest. Index встраивает digest манифестов, поэтому digest index покрывает всё ниже него. Следствие — это свойство, на которое опирается вся цепочка поставки: измени любой блоб, и каждый digest над ним изменится. Переверни один байт в базовом слое — и digest того слоя изменится, что изменит манифест, который его перечисляет, что изменит index, который перечисляет манифест. Нельзя изменить содержимое где-либо в графе так, чтобы верхний digest тега не сдвинулся. Именно поэтому docker pull image@sha256:… — это жёсткая гарантия: пин верхнего digest пинит весь транзитивный набор байтов, так же как хеш git-коммита пинит целое дерево.
▸Почему это работает
Зачем стандартизировать этот типизированный граф вместо одного толстого манифеста со всем встроенным? Потому что косвенность дескриптора — это то, что делает образы и шарящимися, и проверяемыми разом. Разделение малых типизированных документов и больших блобов содержимого позволяет реестру хранить каждый блоб однажды по digest, а клиенту — качать лишь те дескрипторы, что нужны, чтобы решить, что тянуть: он может прочесть index в 1 КБ, выбрать единственный манифест платформы под свой CPU и пропустить слои остальных архитектур целиком. А поскольку каждое ребро несёт ожидаемые digest и size, клиент валидирует каждый блоб в момент его прихода, ещё не доверяя ни единому байту. Один однородный примитив — дескриптор — даёт тебе контентную адресацию, ленивое качание, выбор мультиарха и доказуемость подмены, всё из тех же трёх полей.
Война media-типов: OCI против schema2
Дескрипторы работают, лишь если оба конца согласны насчёт словаря, а в дикой природе два словаря. Оригинал Docker — это schema2: application/vnd.docker.distribution.manifest.v2+json для манифеста и application/vnd.docker.image.rootfs.diff.tar.gzip для слоёв. У OCI — семейство application/vnd.oci.image.* выше. Байты, которые несёт слой, в обоих случаях те же gzip-tar; различается лишь строка типа. Но старые реестры и старые клиенты диспетчеризуют по этой строке, поэтому образ, помеченный OCI media-типами и запушенный в реестр, который кодит только под schema2, падает с manifest unknown или ошибкой pull — не потому, что что-то повреждено, а потому, что у получателя нет обработчика под этот mediaType. Поэтому сборочный тулинг выставляет флаги формата: --output type=image,oci-mediatypes=true|false у BuildKit и --provenance=false у buildx (provenance-аттестации форсят OCI index, который некоторые реестры отвергают). Современный Docker OCI-совместим и с хранилищем образов на snapshotter containerd демон по умолчанию эмитит OCI media-типы — что и есть та перемена, которая удивила команду в начале. Когда упираешься в manifest unknown против старого реестра, первый ход — перепушить со schema2 media-типами и посмотреть, уйдёт ли ошибка.
На том же дескрипторном водопроводе едет и пятая вещь — механизм referrers / subject. Манифест может нести дескриптор subject, указывающий на манифест другого образа по digest, что прикрепляет артефакт — подпись, SBOM, provenance-аттестацию — к тому образу, не меняя его digest. Referrers API реестра позволяет спросить «что прикреплено к sha256:…?» и получить обратно дескрипторы каждой подписи и SBOM. Прикрепление по digest, поэтому оно наследует ту же доказуемость подмены: артефакт называет ровно те байты образа, за которые ручается.
Push в старый реестр падает с manifest unknown, при том что тот же образ запускается везде ещё. Сборочный хост недавно обновили до хранилища образов containerd. Наиболее вероятная причина?
- 01Назови четыре типа OCI-документов, их media-типы и как каждый ссылается на следующий.
- 02Почему OCI-образ — это Merkle DAG и что на самом деле значит ошибка manifest unknown против старого реестра?
OCI Image Spec — это вендор-нейтральный стандарт, который вынул Docker schema2 манифест из одного вендора: проект сформировался в 2015, отделил media-типы в 2016 и выпустил v1.0 в 2017. Он определяет четыре типа JSON-документов и один связующий примитив. Примитив — дескриптор: ровно {mediaType, digest, size}, где тип называет, что на том конце, sha256 digest контентно-адресует это, а size позволяет клиенту проверить до качания. Каждая ссылка в образе — дескриптор. Index (application/vnd.oci.image.index.v1+json) — мультиархитектурный разворот, по одному дескриптору на платформу; каждый ссылается на манифест (application/vnd.oci.image.manifest.v1+json, около 2 КБ), который ссылается на один config (application/vnd.oci.image.config.v1+json, чей digest — это image ID) и упорядоченный массив блобов слоёв (application/vnd.oci.image.layer.v1.tar+gzip). Поскольку каждое ребро — digest своей цели, весь образ — это Merkle DAG как граф git: измени любой блоб, и каждый digest над ним сдвинется, поэтому пин image@sha256 пинит весь транзитивный набор байтов. Media-типы важны не меньше байтов: schema2 (vnd.docker.distribution.manifest.v2+json) и OCI несут идентичные tar-архивы слоёв, но старые реестры и клиенты диспетчеризуют по строке типа, поэтому образ с OCI-тегами, запушенный в schema2-only реестр, падает с manifest unknown — несовпадение, а не повреждение, — и фикс в флагах формата сборки (oci-mediatypes, provenance), чтобы эмитить schema2. Современный Docker OCI-совместим, и демон эмитит OCI media-типы по умолчанию, как только включено хранилище образов на snapshotter containerd, что и есть ровно та перемена, которая ломает работу против легаси-реестров. На том же дескрипторном водопроводе поле subject/referrers прикрепляет подписи и SBOM к образу, нацеливаясь на digest его манифеста, наследуя доказуемость подмены DAG. Читай образ как типизированный, контентно-адресуемый граф — и manifest unknown перестаёт быть загадкой: это media-тип, который ты можешь назвать. Теперь, когда увидишь эту ошибку против реестра, которого ты не контролируешь, первый ход — выяснить, какое семейство media-типов реестр понимает, и эмитить именно его флагом сборки, а не предполагать, что образ сломан.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.