Образ vs контейнер: template и runtime
В предыдущем уроке мы провели аналогию: образ это класс, контейнер это экземпляр. Эта метафора полезна на 80% случаев, но за ней скрывается важная техническая деталь — copy-on-write. Понимание этого механизма объясняет, почему контейнеры стартуют за миллисекунды, почему пять одинаковых контейнеров не занимают пятикратно места и почему docker commit существует, но является антипаттерном.
В этом уроке погружаемся в то, что физически происходит между docker run и появлением процесса в ps.
Что делает docker run
Когда ты пишешь docker run nginx:1.27, последовательность примерно такая:
- Resolve образа. Docker daemon смотрит в локальный кэш. Нет — обращается к registry, скачивает манифест и все слои.
- Создание контейнера. Внутри
/var/lib/docker/containers/<id>/создаётся метадата (config.v2.json, hostconfig.json). - Подготовка rootfs. Это самая интересная часть. Docker создаёт новый writable layer в
/var/lib/docker/overlay2/<container-id>/diff/— изначально пустую директорию. Затем настраивает overlayfs: lowerdirs = read-only слои образа, upperdir = только что созданный writable layer, merged = что увидит процесс. - Создание namespaces. clone() с флагами CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET и т.д. — новый процесс рождается в изолированном PID/network/mount-неймспейсе.
- Запуск процесса. В новом неймспейсе выполняется
chroot(точнееpivot_root) в merged-директорию, затемexecкоманды из image config (CMD/ENTRYPOINT).
Шаг 3 это копирование? Нет. Никакие файлы не копируются. Создаётся только пустая директория upperdir. Это и есть copy-on-write: данные копируются только когда процесс реально пишет в файл из read-only слоя.
open(), read(), write() — системные вызовы для файлов
Copy-on-write шаг за шагом
Допустим, в образе есть файл /etc/nginx/nginx.conf (200 байт), который лежит в read-only слое 2. Контейнер запущен. Происходит четыре сценария:
Сценарий 1: процесс читает файл. cat /etc/nginx/nginx.conf. Overlayfs смотрит upperdir — файла нет. Смотрит lowerdirs сверху вниз — находит в слое 2. Возвращает содержимое read-only слоя. Никаких записей на диск, файл не копируется.
Сценарий 2: процесс пишет в файл. echo "client_max_body_size 100M;" >> /etc/nginx/nginx.conf. Overlayfs делает copy-up: копирует файл из lowerdir в upperdir (теперь занимает 200 байт в writable layer), затем дописывает изменения в копию. Read-only слой остался нетронутым.
Сценарий 3: процесс создаёт новый файл. echo "test" > /tmp/foo.txt. Файла нет ни в одном lowerdir. Просто создаётся в upperdir. Никакого copy-up.
Сценарий 4: процесс удаляет файл из образа. rm /etc/nginx/nginx.conf. В upperdir создаётся whiteout-маркер — специальный character device (или файл с особым префиксом, зависит от драйвера) с тем же именем. Overlayfs, видя whiteout, считает что файл удалён, даже если он есть в lowerdirs. Read-only слой опять не тронут.
Copy-on-write имеет цену: первая запись в большой файл из read-only слоя триггерит копирование целиком. Если у тебя 1GB-файл базы данных в образе и контейнер делает dd if=/dev/zero of=/var/lib/mysql/data.db bs=1M count=1, overlayfs скопирует весь гигабайт в writable layer перед записью. Поэтому изменяемые данные надо держать в volumes, а не внутри контейнера.
Что происходит при остановке и удалении
docker stop <id> посылает процессу SIGTERM (если процесс не реагирует за --stop-timeout секунд, далее SIGKILL). Процесс умирает, неймспейсы освобождаются. Но writable layer остаётся — он лежит на диске, занимает место. Контейнер в состоянии Exited можно перезапустить через docker start <id> — те же самые изменения, которые процесс наделал в файловой системе, будут на месте.
docker rm <id> удаляет writable layer (а заодно метадату контейнера). После этого восстановить состояние невозможно.
docker run --rm — флаг, который автоматически делает rm после exit процесса. В CI это must-have: интеграционные тесты должны быть stateless, после прогона ничего не остаётся.
$ docker run -d --name n1 nginx:1.27
$ docker exec n1 sh -c "echo 'changed' > /tmp/marker"
$ docker stop n1
$ docker start n1
$ docker exec n1 cat /tmp/marker
changed # writable layer сохранился через stop/start
$ docker rm -f n1
$ docker run -d --name n1 nginx:1.27
$ docker exec n1 cat /tmp/marker
cat: can't open '/tmp/marker': No such file or directory # новый контейнер -- свой пустой layer
docker commit: образ из контейнера
Допустим, ты запустил контейнер, поставил внутри несколько пакетов, что-то отредактировал, и захотел “заморозить” этот state в новый образ. Для этого существует docker commit:
$ docker run -it --name lab1 ubuntu:24.04 bash
root@a1b2c3:/# apt update && apt install -y curl jq
root@a1b2c3:/# echo "custom config" > /etc/myapp.conf
root@a1b2c3:/# exit
$ docker commit lab1 my-ubuntu-tools:v1
sha256:8f9a2b3c4d5e...
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
my-ubuntu-tools v1 8f9a2b3c4d5e 3 seconds ago 142MB
ubuntu 24.04 af1f4f... 4 weeks ago 78MB
Что произошло:
- Docker взял writable layer контейнера lab1
- Упаковал его в tar
- Сделал из этого новый read-only слой
- Создал новый image manifest, где layers = layers исходного ubuntu:24.04 + один новый слой
- Сохранил в локальный кэш с именем
my-ubuntu-tools:v1
Теперь docker run my-ubuntu-tools:v1 запустит новый контейнер с уже установленными curl и jq и файлом /etc/myapp.conf.
docker commit это инструмент для отладки и snapshot’ов, не для production. Образ, полученный через commit, не имеет описания того, как он был собран — нет Dockerfile, нет истории команд (точнее история есть, но без deterministic mapping на команды). Воспроизвести такой образ на другой машине невозможно. В продуктовых пайплайнах ВСЕГДА используй Dockerfile. Commit полезен только когда нужно зафиксировать промежуточное состояние для дебага.
Сколько ресурсов занимает контейнер
Слои образа на диске лежат один раз, независимо от количества контейнеров. Что добавляет каждый контейнер:
- Writable layer: изначально 0 байт, растёт по мере записей. Для типичного “stateless” контейнера (Postgres с volume для данных) — единицы МБ.
- Container metadata: ~10-50 KB в
/var/lib/docker/containers/<id>/. - Network namespace и veth-pair: ~несколько KB ядерных структур.
- Process memory: зависит от приложения, ничего общего со слоями.
Запустить 50 контейнеров одного образа на dev-машине — нормально и недорого. Запустить 50 разных образов — будет тяжелее, потому что каждый образ это отдельный набор слоёв.
Попробуй сам
Понаблюдай за overlayfs изнутри:
# Запусти контейнер с подсчётом diff'ов
docker run -d --name probe alpine:3.21 sh -c "sleep 3600"
# Узнай ID и путь к merged
docker inspect probe --format '{{.GraphDriver.Data.MergedDir}}'
# /var/lib/docker/overlay2/abc123.../merged
# Изменим файл внутри контейнера
docker exec probe sh -c "echo 'hello' > /etc/hostname"
# Посмотрим, что появилось в upperdir
docker inspect probe --format '{{.GraphDriver.Data.UpperDir}}'
# /var/lib/docker/overlay2/abc123.../diff
# (на Linux) sudo ls -la /var/lib/docker/overlay2/abc123.../diff/etc/
# hostname -- новый файл, скопированный из lowerdir и изменённый
# Удаляем контейнер
docker rm -f probe
# upperdir исчезает вместе с контейнером
Если ты на macOS с Docker Desktop или OrbStack — всё это лежит внутри Linux VM, и достать оттуда напрямую можно только через docker run --privileged -v /var/lib/docker:/host alpine sh, что мы делать не будем. Но команды docker inspect работают и на mac.