Слои и контентная адресация: diff_id против descriptor digest, chain_id и ловушка недетерминизма gzip
У слоя две sha256-идентичности — diff_id по несжатому tar (для chain_id и дедупа overlay) и descriptor digest по сжатому блобу (для pull и передачи). Спутай их — и недетерминизм gzip тихо хранит байт-идентичные слои дважды и крошит кеш между раннерами.
Доля попаданий в кеш на парке CI была 30 процентов, и никто не мог объяснить почему. Сборка детерминирована — тот же lockfile, та же база, тот же исходник, — а каждый раннер заново пушил 220 МБ слоя зависимостей, который реестр клялся, что никогда не видел. Коэффициент дедупа, который должен был быть около 1.0, держался на 1.6: реестр хранил примерно 1.6 копии байтов, которые должны были быть идентичны. Инженер наконец вытянул два таких «разных» слоя, разжал оба gzip и сравнил tar: побайтово идентичны. Несжатое содержимое совпадало идеально. Сжатые блобы — нет: один раннер запускал gzip из базового образа с более новым zlib и другим уровнем по умолчанию, поэтому тот же tar выходил другой последовательностью байтов, а значит другим sha256, а значит «новым» слоем для реестра. Весь парк адресовал содержимое по неправильному хешу. У слоя нет одной идентичности. У него две, посчитанные над двумя разными потоками байтов, и сбой — ровно то, что случается, когда забываешь, на каком из них реестр держит ключ.
diff_id — это идентичность для демона и overlay2 (union-файловой системы Docker на Linux); descriptor digest — для реестра и pull-пути. К концу урока ты будешь знать, на каком хеше держит ключ каждая подсистема — и это знание отделяет 92% cache-hit rate от 31%.
Два хеша, два потока байтов
Когда наталкиваешься на неожиданный промах кеша или неожиданные расходы реестра, спроси себя: на каком хеше реально держит ключ нужная подсистема? Каждый слой — это tar-архив изменений файловой системы. Этот tar хешируется дважды, над двумя разными потоками байтов, для двух разных задач:
diff_id—sha256несжатого tar. Именно он лежит в массивеrootfs.diff_idsконфига. Демон использует его, чтобы идентифицировать содержимое слоя на диске: overlay2 называет свои нижние директории этой идентичностью, поэтому два слоя с однимdiff_idмапятся на те же данные на диске и наслаиваются, никогда не дублируясь.- descriptor digest —
sha256сжатого блоба (gzip-нутого tar в том виде, в каком он передаётся). Это полеdigestдескриптора в манифесте. Реестр хранит и передаёт блобы по ключу этого хеша. Pull спрашивает «есть ли у меня дескрипторsha256:abc…?» и пропускает блоб, если есть.
Можно увидеть оба. Конфиг перечисляет diff_ids; манифест перечисляет descriptor digests:
$ docker image inspect myapp:1 --format '{{json .RootFS.Layers}}' | jq
[
"sha256:1f3e…", # diff_id — по НЕСЖАТОМУ tar
"sha256:8a2c…",
"sha256:b91d…"
]
$ docker manifest inspect myapp:1 | jq '.layers[].digest'
"sha256:7d40…" # descriptor digest — по СЖАТОМУ блобу
"sha256:c5e9…"
"sha256:af21…"Заметь: значения различаются между двумя списками для одного слоя. Это хеши разных байтов. Слой с jar на 41 МБ может иметь diff_id sha256:b91d… (tar на 41 МБ) и descriptor sha256:af21… (gzip того tar на 16 МБ). Тот же слой, два имени.
Два CI-раннера собирают тот же исходник и дают байт-идентичные несжатые tar слоёв, но gzip одного раннера выдаёт другой поток сжатых байтов. Реестр хранит слой дважды. Какой хеш разошёлся и почему это вызывает дублирование?
chain_id: позиция — часть идентичности
Почему overlay2 (union-файловая система, которую Docker использует на Linux) не может разделить слой просто по совпадению байтов? Потому что слой — это diff, а не самостоятельная файловая система: его смысл зависит от того, что под ним. diff_id идентифицирует слой в изоляции. Но overlay2 не может переиспользовать слой чисто по его собственному содержимому — он обязан знать точный стек под ним, потому что эффект слоя зависит от того, на что он наслоен. Это и кодирует chain_id. chain_id нижнего слоя равен его diff_id. Каждый слой выше — это sha256 строки chain_id_родителя + " " + diff_id_этого_слоя:
chain_id(0) = diff_id(0)
chain_id(n) = sha256( chain_id(n-1) + " " + diff_id(n) )Следствие постоянно сбивает людей: тот же tar на другой позиции в стеке даёт другой chain_id, поэтому overlay2 не разделит его на диске. Представь два образа, оба содержащие идентичный слой node_modules (тот же diff_id). В образе A он сидит на базе debian:12; в образе B — на alpine. То же содержимое слоя, другой chain_id родителя, а значит другой chain_id у самого слоя node_modules — overlay2 материализует его на диске дважды, хотя байты идентичны. Контентная адресация дедупит блоб в реестре по diff_id, но подготовленный overlay lowerdir держит ключ на chain_id, поэтому позиция ломает разделение на диске, даже когда содержимое совпадает.
▸Почему это работает
Зачем вообще вносить позицию в идентичность — почему не держать ключ просто на сыром содержимом? Потому что слой — это diff, а не самостоятельная файловая система. Тот же RUN rm -rf /var/cache даёт идентичный tar (набор whiteout) независимо от того, что под ним, но его смысл — результирующая файловая система — целиком зависит от слоёв ниже. overlay2 обязан смонтировать полностью разрешённый нижний стек, поэтому ему приходится кешировать подготовленный результат по ключу всей цепочки, а не по содержимому одного слоя. chain_id и есть этот ключ кеша: он говорит «этот слой, применённый ровно поверх этой истории». Два стека, расходящиеся где-либо ниже, получают разные chain_id с этой точки и выше, что корректно — их разрешённые файловые системы реально различаются. Цена в том, что идентичный слой в двух разных стеках готовится на диске дважды; выгода — overlay никогда не приходится заново разрешать стек, который он уже видел.
Почему воспроизводимые сборки пинят компрессор
Сложи два факта вместе — и ловушка gzip очевидна. Реестр держит ключ на descriptor digest — хеше сжатого блоба. gzip не детерминирован между версиями, уровнями и даже реализациями: zlib, libdeflate и pigz все могут выдать разные потоки байтов для того же входа, а сам заголовок gzip может нести timestamp и байт OS. Поэтому два раннера с байт-идентичными tar, но разными тулчейнами gzip дают разные descriptor digests, реестр трактует их как два разных блоба, хранит оба, и каждый потребитель, который тянет один, потом промахивается мимо кеша по другому. Реальные числа из инцидента: слой зависимостей на 220 МБ, сжимающийся до ~74 МБ, хранился в среднем по парку 1.6× — примерно 44 МБ чистого мусора на дубликат, помноженные на сотни тегов, и доля попаданий в кеш между раннерами застряла около 30 процентов. Фикс — запинить компрессор: та же реализация gzip, тот же уровень, срезанные timestamp (воспроизводимый режим BuildKit и SOURCE_DATE_EPOCH обнуляют mtime), чтобы идентичные tar давали идентичные сжатые байты, а значит идентичные descriptor digests. diff_id был стабилен всегда; пинить надо было descriptor digest — тот, на котором реестр реально дедупит.
Идентичный слой node_modules (тот же diff_id) появляется в двух образах: один собран на debian:12, другой на alpine. На хосте, где работают оба, сколько раз overlay2 материализует разрешённый lowerdir этого слоя на диске?
- 01Назови две sha256-идентичности слоя, точный поток байтов, над которым считается каждая, и какая подсистема держит ключ на каждой.
- 02Дай определение chain_id, объясни, почему тот же слой на другой позиции в стеке не разделяется на диске, и свяжи это со сбоем из-за недетерминизма gzip.
Слой — это tar изменений файловой системы, и он хешируется дважды над двумя разными потоками байтов для двух разных задач. diff_id — это sha256 несжатого tar: он живёт в rootfs.diff_ids конфига, называет нижние директории overlay2 и является кирпичиком chain_id. descriptor digest — это sha256 сжатого блоба — gzip-нутого tar, который реально передаётся — и это дайджест, который несёт манифест и на котором реестр держит ключ, чтобы хранить, передавать и дедупить. Для одного слоя эти два значения различаются, потому что хешируют разные байты. chain_id затем вкладывает позицию в идентичность: chain_id нижнего слоя — это его diff_id, а каждый слой выше — это sha256 от chain_id родителя плюс пробел плюс diff_id этого слоя, поэтому тот же tar, уложенный на другую базу, получает другой chain_id, и overlay2 готовит его на диске дважды, несмотря на идентичное содержимое — контентная адресация дедупит блоб в реестре по diff_id, но разрешённый overlay lowerdir держит ключ на chain_id, и позиция ломает это разделение. Сбой, который связывает всё воедино, — недетерминизм gzip: поскольку реестр дедупит по descriptor digest сжатого блоба, два раннера с байт-идентичными tar, но разными версиями, уровнями или реализациями gzip выдают разные сжатые байты, дают разные descriptor digests, и реестр послушно хранит оба — слой зависимостей на 220 МБ, хранящийся примерно 1.6 раза по парку, около 44 МБ мусора на дубликат, и доля попаданий в кеш между раннерами, застрявшая около 30 процентов, и всё это пока diff_id ни разу не сдвинулся. Фикс — запинить компрессор — та же реализация, тот же уровень, mtime обнулены через режим воспроизводимых сборок — чтобы идентичные tar сжимались в идентичные блобы и descriptor digests наконец сошлись. Держи разделение точно: diff_id — идентичность несжатого содержимого для демона, descriptor digest — идентичность сжатого блоба для реестра, chain_id — позиционно-зависимая идентичность для overlay. Теперь, когда увидишь обвал cache-hit rate после миграции раннеров, сначала проверь, не изменился ли компрессор — прежде чем предполагать, что сменился исходник.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.