Что такое Docker-образ
Когда Data Engineer запускает локальный Airflow или поднимает Postgres для своего DAG’а, он не загружает программу в обычном смысле слова. Он скачивает образ — иммутабельный артефакт, который описывает корневую файловую систему процесса до последнего байта. И в отличие от установки пакетов через apt или pip, образ одинаков на всех машинах: то, что собралось в CI на Ubuntu, будет в точности тем же на macOS с OrbStack, на коллегиной Windows с WSL2 и на production-сервере. Один SHA256 — одно поведение.
В этом уроке разберём, что внутри образа физически лежит, почему он состоит из слоёв и зачем нужен манифест.
Образ это не “архив с программой”
Распространённое заблуждение: образ это zip-архив с приложением. На самом деле образ это набор связанных артефактов:
- Один или несколько read-only слоёв — каждый слой это tar-архив с фрагментом файловой системы (обычно diff относительно предыдущего слоя)
- Манифест (manifest) — JSON, описывающий какие слои есть, в каком порядке их применять, какая платформа (linux/amd64, linux/arm64) и где взять конфиг
- Config (image config) — JSON с метаданными: какая команда выполняется по умолчанию (
CMD), какие переменные окружения, рабочая директория, история создания каждого слоя - Digest (sha256:…) — криптографический хэш манифеста. Уникально идентифицирует образ. Изменился хоть один байт — изменился digest
Все эти артефакты хранятся в registry (Docker Hub, GHCR, ECR) и адресуются по content-addressable принципу: имя слоя это его собственный хэш. Это не случайность — это краеугольный камень дедупликации и безопасности.
Современный формат образа стандартизирован OCI (Open Container Initiative). Спецификация называется image-spec и описывает структуру манифеста, конфига и слоёв. Docker, Podman, containerd, Kubernetes — все говорят на одном языке OCI. Это значит, что образ, собранный Docker, можно запустить через Podman или развернуть в k8s без конвертации.
Анатомия образа: что физически лежит на диске
Возьмём типичный образ для DE — python:3.13-slim — и посмотрим, что мы скачиваем:
$ docker pull python:3.13-slim
3.13-slim: Pulling from library/python
6e909acdb790: Pull complete # base layer (debian:12-slim rootfs, ~30MB)
4f4fb700ef54: Pull complete # пустой слой (метаданные)
b1c40a1ec76d: Pull complete # apt-installed пакеты (~15MB)
8b3e1a3e9b7d: Pull complete # python runtime (~35MB)
3e2fa3a9c8e8: Pull complete # pip + setuptools (~5MB)
Digest: sha256:5b7b9c0f6c9e3e8a...
Status: Downloaded newer image for python:3.13-slim
Пять слоёв, каждый ровно одна инструкция Dockerfile, который собирал этот образ. Если ты потом скачаешь python:3.13-slim-bookworm (того же семейства), Docker увидит, что базовый слой Debian уже есть локально, и пропустит его — Pull complete сменится на Already exists. Это и есть layer reuse.
Манифест: чертёж образа
Манифест это короткий JSON, который описывает образ “сверху”. Запросим его руками через registry API:
$ TOKEN=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/python:pull" | jq -r .token)
$ curl -s -H "Authorization: Bearer $TOKEN" \
-H "Accept: application/vnd.oci.image.manifest.v1+json" \
"https://registry-1.docker.io/v2/library/python/manifests/3.13-slim" | jq
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": "sha256:c0a3...",
"size": 5147
},
"layers": [
{"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:6e90...", "size": 28983291},
{"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:4f4f...", "size": 32},
{"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:b1c4...", "size": 14829003}
]
}
Заметь две вещи. Первая: каждый слой адресуется своим digest’ом. Docker daemon берёт этот digest и спрашивает у registry “дай мне blob sha256:6e90…” — registry возвращает tar-архив, daemon проверяет, что хэш совпадает, и кладёт слой в /var/lib/docker/overlay2/. Если хэш не совпал — Docker отвергнет файл. Это защита от подмены.
Вторая: размер каждого слоя известен заранее. Это позволяет Docker показывать прогресс-бар при pull и параллельно скачивать слои.
Иммутабельность: почему хэш это подпись
Содержимое образа полностью определяет его digest. Если ты пересоберёшь образ с другой версией pip, digest изменится. Если изменишь одну переменную в ENV, digest изменится. Если поменяется порядок инструкций в Dockerfile — digest изменится (потому что слои применяются по очереди, и нижний слой влияет на хэш upper-слоёв через chain-id).
Это означает три практических вещи для Data Engineer:
1. image@sha256:... это контракт. Если в проде запущен airflow@sha256:abc123, то на коллегиной машине с тем же digest’ом будет в точности тот же байт-в-байт runtime. Никаких “у меня работает”.
2. Невозможно “тихо” подменить образ. Если кто-то перезальёт library/python:3.13-slim с другим содержимым (например, добавит вредоносный код), digest изменится — твой CI с pinned python@sha256:... упадёт с ошибкой “digest mismatch”, а не молча запустит зловреда.
3. Слои дедуплицируются между образами. python:3.13-slim и python:3.13-slim-bookworm шарят базовый Debian-слой. На диске он лежит один раз. Docker считает количество ссылок и удаляет слой только когда последний образ, который на него ссылался, удалён.
Когда подписываешь production-deployment, всегда используй digest: image: postgres@sha256:5b7b..., а не image: postgres:16. Тег postgres:16 mutable — мейнтейнеры могут перезалить под этим тегом новый билд с патчами. Digest гарантирует bit-for-bit reproducibility.
VFS — виртуальная файловая система Linux
Образ vs контейнер: класс vs экземпляр
Самая частая путаница новичков: чем образ отличается от контейнера. Используем аналогию из ООП:
| Образ | Контейнер |
|---|---|
| Класс (template, blueprint) | Экземпляр (instance, runtime) |
| Read-only, иммутабельный | Read-write, изменяемый |
| Лежит в registry / на диске | Существует, пока процесс жив |
| Один образ — много контейнеров | Каждый контейнер привязан к одному образу |
docker pull postgres:16 | docker run postgres:16 |
| Размер: фиксирован (по слоям) | Размер: образ + writable layer |
| Не запускается | Запускается, останавливается, удаляется |
Когда ты делаешь docker run postgres:16, Docker:
- Проверяет, есть ли образ локально (если нет —
pull) - Создаёт новый writable-слой поверх read-only слоёв образа
- Объединяет всё через overlayfs в один merged view (то, что видит процесс)
- Запускает entrypoint из image config
Образ при этом остаётся неизменным. Ты можешь запустить десять контейнеров из одного образа — они будут изолированы друг от друга, потому что у каждого свой writable-слой. Удалишь контейнеры — образ останется лежать на диске, готовый создать новые экземпляры.
Попробуй сам
Скачай маленький образ и посмотри на его внутренности:
# Pull маленького образа (~5MB)
docker pull alpine:3.21
# Посмотреть список локальных образов
docker images
# REPOSITORY TAG IMAGE ID CREATED SIZE
# alpine 3.21 aded1e1a5b37 2 weeks ago 7.83MB
# Получить digest
docker inspect alpine:3.21 --format '{{.RepoDigests}}'
# [alpine@sha256:21dc6063fd678b478f57c0e13f47560d0...]
# Запустить 3 контейнера из одного образа
docker run -d --name a1 alpine:3.21 sleep 3600
docker run -d --name a2 alpine:3.21 sleep 3600
docker run -d --name a3 alpine:3.21 sleep 3600
# Все три — один образ, три экземпляра
docker ps
# Размер образа на диске НЕ умножается на 3 — слои шарятся
docker system df
# Cleanup
docker rm -f a1 a2 a3
Обрати внимание на docker system df: даже с тремя контейнерами alpine занимает всё те же ~8MB (плюс крошечный writable layer на каждый контейнер). Это сила слоёв.