Что такое образ на самом деле: конфиг плюс стек контентно-адресуемых слоёв, а не снапшот машины
Образ — это не tar запущенной машины, а JSON-конфиг плюс упорядоченный список контентно-адресуемых read-only слоёв. Конфиг несёт env, entrypoint и дайджесты rootfs; рантайм складывает слои и добавляет один записываемый слой. Это убивает модель «образ = снапшот ВМ».
Счёт за реестр не сходился. Платформенная команда держала 40 микросервисов, каждый — Java-образ ~900 МБ, и по арифметике хранилище должно было занимать 40 × 900 МБ = 36 ГБ на поколение тегов. Реестр показывал 4.1 ГБ. Кто-то решил, что дашборд сломан, и завёл тикет. Ответ был в docker history: каждый из этих 40 образов собирался FROM eclipse-temurin:21-jre, и эта база — JRE, userland Debian, CA-сертификаты, ~640 МБ — была одним набором слоёв с одним набором sha256-дайджестов, хранимым однажды. Различался лишь тонкий верхний слой с jar каждого сервиса, ~40 МБ. Багом была сама модель «40 × 900 МБ»: она трактовала каждый образ как независимый снапшот машины. Это не так. Образ — это конфиг-файл, указывающий на стек общих, контентно-адресуемых слоёв, и реестр, демон и диск дедуплицируют по этим дайджестам. Команда рассуждала об образах как о ВМ, а ВМ свой диск не разделяют.
Через десять минут у тебя будет модель, которая объясняет странные счета за хранилище, неожиданные image ID и ephemeral-запись в контейнерах — без единого домысла.
Что ты на самом деле тянешь
Когда ты делаешь docker pull nginx:1.27, ты скачиваешь не машину. По порядку ты получаешь: маленький манифест (JSON, несколько КБ) со списком всего; блоб конфига (JSON) с переменными окружения, entrypoint, рабочей директорией, открытыми портами и упорядоченным списком дайджестов слоёв, образующих корневую ФС; и затем сами слои — gzip-tar-архивы, каждый назван по sha256 своего содержимого. Загляни прямо в него:
$ docker manifest inspect nginx:1.27
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{ "platform": {"architecture":"amd64","os":"linux"}, "digest": "sha256:7f0a...", "size": 2295 },
{ "platform": {"architecture":"arm64","os":"linux"}, "digest": "sha256:e3c1...", "size": 2295 }
]
}Этот верхний объект — индекс: один тег, несколько манифестов под архитектуры. Демон выбирает манифест под твой CPU, затем тянет его конфиг и слои. Для nginx:1.27 на amd64 это 7 слоёв общим объёмом ~67 МБ в сжатом виде; база Debian (~29 МБ) и бинарники nginx доминируют, а прикладной слой на тег часто весит несколько сотен КБ.
Конфиг — это идентичность образа
Почему правка одной переменной окружения даёт совершенно новый image ID, хотя ни один файл на диске не изменился? Потому что image ID — это не хеш слоёв, а хеш конфига. Блоб конфига — та часть, о существовании которой забывают. Это обычный JSON:
{
"architecture": "amd64", "os": "linux",
"config": {
"Env": ["PATH=/usr/local/sbin:/usr/local/bin:...", "NGINX_VERSION=1.27.0"],
"Entrypoint": ["/docker-entrypoint.sh"],
"Cmd": ["nginx", "-g", "daemon off;"],
"ExposedPorts": {"80/tcp": {}}
},
"rootfs": {
"type": "layers",
"diff_ids": [
"sha256:1f3e... ",
"sha256:8a2c... ",
"sha256:b91d... "
]
},
"history": [ { "created_by": "RUN apt-get install ..." } ]
}Здесь важны две вещи. Во-первых, rootfs.diff_ids — это упорядоченный рецепт файловой системы: сложи эти слои снизу вверх — и получишь корневую директорию контейнера. Во-вторых, sha256 самого этого JSON-конфига — это image ID, тот sha256:..., что ты видишь в docker images --digests. Поменяй одну переменную окружения, пересобери — и байты конфига изменятся, поэтому image ID изменится, даже если каждый дайджест слоя идентичен. Образ — это его конфиг; слои — общий контент, на который конфиг ссылается.
Ты пересобираешь образ, изменив в Dockerfile лишь значение ENV, и сборка попадает в кеш на каждом шаге, меняющем ФС. Изменится ли image ID?
▸Почему это работает
Зачем вообще отделять конфиг от слоёв? Чтобы один набор слоёв обслуживал много образов. Tar базового слоя контентно-адресуется по тому, что внутри него, поэтому два образа, делящие базу, ссылаются на один дайджест блоба, и реестр хранит его однажды. Конфиг — дешёвая, попообразная часть, которая варьируется (разный env, разный entrypoint, другой верхний слой), а дорогие байты ФС под ним дедуплицируются между всеми образами на той же базе. Считай конфиг идентичностью, а слои — общим контентом, и вся модель хранения выводится сама.
Что добавляет контейнер
Образ read-only — каждый слой неизменяем, заморожен своим дайджестом. Как же запущенный контейнер пишет лог-файл? При docker run рантайм складывает read-only слои образа через union-файловую систему (overlay2 на Linux) и добавляет ровно один тонкий записываемый слой сверху. Запись попадает туда по copy-on-write: меняешь /etc/hosts — overlay2 копирует файл вверх, в записываемый слой, и правит копию; нижний read-only слой не меняется. Удалишь контейнер — этот записываемый слой отбрасывается, и именно поэтому важные данные должны жить в томе, а не в контейнере. Образ — неизменяемый стек; контейнер — этот стек плюс один одноразовый слой-черновик.
Поэтому же «это снапшот машины» вводит в заблуждение. Снапшот ВМ — монолитный непрозрачный образ диска, копируемый целиком. OCI-образ — структурированный, адресуемый, общий артефакт: тянешь дельтами (только недостающие слои), делишь базы между сотнями образов и детерминированно пересобираешь из рецепта-конфига.
Сорок сервисов собираются FROM одной базы 640 МБ. В реестре и на хосте, гоняющем все сорок, сколько примерно стоит эта база 640 МБ в хранилище?
- 01Перечисли три вида артефактов из pull (манифест/индекс, конфиг, слои) и скажи, что в каждом.
- 02Объясни, почему image ID может измениться без изменения слоёв и почему сорок образов на общей базе стоят базу однажды.
Образ — это две вещи, склеенные вместе: конфиг и стек слоёв. Конфиг — обычный JSON: Env, Entrypoint, Cmd, ExposedPorts, рабочая директория, массив history и, главное, rootfs.diff_ids — упорядоченный список дайджестов слоёв, восстанавливающих корневую ФС. sha256 этого JSON-конфига — это image ID, поэтому изменение лишь значения ENV даёт совершенно новый image ID, даже когда каждый шаг ФС попал в кеш: байты конфига сдвинулись, слои — нет. Сами слои — gzip-tar-архивы, каждый назван по sha256 своего содержимого, что делает их контентно-адресуемыми и потому общими: сорок сервисов FROM одной базы 640 МБ ссылаются на одни дайджесты слоёв, поэтому реестр и каждый хост хранят эту базу ровно однажды — арифметика «40 × 900 МБ», сломавшая оценку хранилища, была на деле протёкшей моделью снапшота ВМ. Pull — это манифест (или multi-arch индекс, указывающий на манифесты под платформы), затем конфиг, затем лишь недостающие слои. На рантайме неизменяемый стек получает один тонкий записываемый copy-on-write слой сверху, куда попадает каждая запись и который исчезает при удалении контейнера — причина, по которой устойчивое состояние принадлежит тому. Держи модель точно: конфиг — идентичность, слои — общий контент, записываемый слой (copy-on-write, эфемерный слой поверх read-only стека) — одноразовый черновик, и ничто из этого не снапшот машины. Теперь, когда увидишь число в реестре, которое не сходится с арифметикой, сначала проверь docker history на общие дайджесты базы — дедупликация почти наверняка уже работает.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
- Слои и контентная адресация: diff_id против descriptor digest, chain_id и ловушка недетерминизма gzipsenior
- OCI image spec: index, manifest, config и слои как Merkle DAG из дескрипторовsenior
- Механика Dockerfile: какие инструкции становятся слоями, как кеш ключует каждый шаг и почему важен порядокsenior
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.