Слои и overlayfs: как устроена файловая система контейнера
В прошлом модуле мы говорили об образах “сверху”: digest, manifest, теги. В этом модуле спускаемся на уровень ниже, к storage-driver’у. Когда ты пишешь Dockerfile с десятью инструкциями RUN, COPY, ADD — Docker создаёт десять слоёв. Когда запускается контейнер, эти десять read-only слоёв и один writable layer сшиваются в одну видимость через overlayfs — union filesystem в Linux ядре. Эта склейка происходит на каждом docker run, и понимание её механизма даёт ответы на множество “странных” вопросов: почему RUN rm -rf /var/lib/apt/lists не уменьшает образ, почему bind mount ведёт себя странно поверх образа, почему overlayfs на ext4 быстрее чем на btrfs.
В этом уроке разберёмся, как overlayfs устроен изнутри.
UnionFS: идея
UnionFS это семейство файловых систем, которые объединяют несколько директорий в одну видимость.
VFS — единый интерфейс над ext4, xfs, btrfs, tmpfs Идея простая: возьми директорию A с файлами и директорию B с файлами, смонтируй их union — увидишь файлы из обеих в одной точке монтирования. Если файл есть в обеих — выигрывает upper (по convention).
/lower/a.txt → /merged/a.txt (только из lower)
/lower/b.txt → /merged/b.txt (из lower, перекрыт upper если есть)
/upper/b.txt → /merged/b.txt (выигрывает upper)
/upper/c.txt → /merged/c.txt (только из upper)
В Linux есть несколько реализаций UnionFS: AUFS, overlayfs, btrfs subvolumes. Docker использует overlayfs как default (storage driver overlay2) — он встроен в mainline kernel начиная с 3.18 и не требует kernel patches.
Терминология overlayfs
В overlayfs четыре участника:
- lowerdir — read-only слой(и). Может быть несколько, перечисляются через
:. Нижний lowerdir это «глубокий» (perekrоется верхними). - upperdir — writable слой. Сюда уходят все записи.
- workdir — служебная директория (на той же файловой системе, что upperdir), нужная overlayfs для атомарных операций (rename, copy-up).
- merged — точка монтирования, где видна объединённая файловая система. Это то, что увидит процесс контейнера как
/.
Команда монтирования (упрощённо):
mount -t overlay overlay \
-o lowerdir=/var/lib/docker/overlay2/L1/diff:/var/lib/docker/overlay2/L0/diff,\
upperdir=/var/lib/docker/overlay2/CONTAINER/diff,\
workdir=/var/lib/docker/overlay2/CONTAINER/work \
/var/lib/docker/overlay2/CONTAINER/merged
После такого монтирования процесс внутри контейнера видит merged-директорию как / и может работать с ней как с обычной файловой системой. Все read из lowerdirs (если файла нет в upperdir), все write — в upperdir с copy-up.
Один RUN, один слой
Каждая инструкция Dockerfile, которая модифицирует файловую систему (RUN, COPY, ADD), создаёт новый слой:
FROM python:3.13-slim # слой 0 (base): debian rootfs + python
RUN apt update # слой 1: обновлённые apt-lists
RUN apt install -y curl # слой 2: установлен curl + deps
COPY . /app # слой 3: добавлены файлы приложения
RUN pip install -r requirements.txt # слой 4: установлены python-пакеты
5 слоёв (плюс несколько «пустых» от метадаты). Каждый — diff относительно предыдущего: содержит ровно те файлы, которые были созданы / изменены / удалены этой инструкцией.
Важная особенность: удаление файла в верхнем слое НЕ уменьшает размер образа. Удалённый файл всё равно лежит в lowerdir. Overlayfs просто прячет его через whiteout. Поэтому классический антипаттерн:
# Антипаттерн: 250MB добавлены, потом 240MB "удалены", но образ всё равно вырос на 250MB
RUN curl -o data.tar.gz https://example.com/data.tar.gz # слой N: +250MB
RUN tar xzf data.tar.gz && rm data.tar.gz # слой N+1: но 250MB всё ещё в слое N!
Правильно:
# Образ +10MB (только распакованные результаты)
RUN curl -o data.tar.gz https://example.com/data.tar.gz && \
tar xzf data.tar.gz && \
rm data.tar.gz
# Один RUN -- один слой; данные внутри слоя удалены ДО создания слоя
Слой создаётся ПОСЛЕ выполнения всей инструкции. Если внутри одного RUN ты скачал файл и удалил его, слой не содержит этот файл — удаление произошло до snapshot’а. Если между скачиванием и удалением стоит другой RUN — скачанный файл попадает в финальный snapshot первого RUN’а и остаётся в образе навсегда.
Whiteout: как удалить файл из read-only слоя
Если процесс делает rm /etc/foo.conf, где foo.conf лежит в read-only lowerdir, overlayfs не может физически удалить файл из lowerdir (он immutable). Вместо этого создаётся whiteout-маркер в upperdir.
В overlayfs whiteout это специальный character device с major:minor = 0:0 и тем же именем, что удалённый файл:
# На Linux, внутри upperdir контейнера
$ ls -la /var/lib/docker/overlay2/<id>/diff/etc/
crw-r--r-- 1 root root 0, 0 May 15 10:23 foo.conf
# major=0 minor=0 -- это whiteout
Overlayfs, разрешая merged-вид, видит whiteout и считает что foo.conf удалён, даже если он есть в lowerdirs. Процесс в контейнере получает ENOENT при попытке открыть файл.
Для удаления целой директории используется opaque directory marker — у директории выставляется xattr trusted.overlay.opaque=y. Это означает «не показывать ничего из lowerdir под этой директорией, только то, что в upperdir».
$ getfattr -n trusted.overlay.opaque /var/lib/docker/overlay2/<id>/diff/var/cache/apt
# trusted.overlay.opaque="y"
open/read/write/close — четыре syscall, на которых стоит весь file I/O
Copy-up: цена первой записи
Когда процесс пытается изменить файл, существующий только в lowerdir, происходит copy-up:
- Overlayfs создаёт workdir-копию файла (через
rename(2)на той же fs — атомарно) - Перемещает её в upperdir под тем же путём
- Применяет запись к копии
Стоимость copy-up = размер файла. Для маленьких конфигов это копейки, для больших — заметно. Особо болезненно с big files:
# Контейнер с базой 1GB в образе (антипаттерн)
docker run -d --name pg image-with-baked-in-db
# Первый INSERT в БД триггерит copy-up на 1GB файла -- замедление, лишняя запись
Поэтому изменяемые данные надо держать в volumes (bind mount или named volume), которые не идут через overlayfs.
Где это всё лежит на диске
На Linux с storage driver overlay2:
/var/lib/docker/overlay2/
├── <layer-id>/
│ ├── diff/ # фактические файлы слоя (read-only после первого создания)
│ ├── lower # текстовый файл со списком "глубже стоящих" слоёв (chain)
│ ├── link # короткое имя, используется в lowerdir mount-параметрах
│ └── work/ # служебная директория overlayfs (для активных контейнеров)
├── <container-id>/
│ ├── diff/ # upperdir контейнера (writable layer)
│ ├── merged/ # точка монтирования (то, что видит процесс)
│ └── work/
└── l/ # симлинки на link-имена для коротких mount-параметров
Команда docker inspect показывает пути:
$ docker run -d --name test alpine:3.21 sleep 3600
$ docker inspect test --format '{{json .GraphDriver.Data}}' | jq
{
"LowerDir": "/var/lib/docker/overlay2/a1b2.../diff",
"MergedDir": "/var/lib/docker/overlay2/c3d4.../merged",
"UpperDir": "/var/lib/docker/overlay2/c3d4.../diff",
"WorkDir": "/var/lib/docker/overlay2/c3d4.../work"
}
На macOS и Windows этих директорий нет на хост-fs — Docker Desktop и OrbStack крутят Linux VM, и все эти пути живут внутри VM. Доступ через docker run --privileged -v /var/lib/docker:/host alpine или через UI инструменты.
Сколько слоёв — много?
Технически overlayfs поддерживает до 128 lowerdir’ов (в современных ядрах). Раньше было 40. В практике хороший Dockerfile укладывается в 10-20 слоёв, плюс несколько метадата-слоёв. Если у тебя 50+ слоёв — это сигнал, что Dockerfile плохо написан (каждая мелочь в отдельном RUN). При сборке docker build объединяет в одном RUN всё, что должно происходить «вместе» (apt update + apt install + cleanup), и это уменьшает количество слоёв.
# Плохо: 5 слоёв, apt-lists остаётся в слое 1
RUN apt update
RUN apt install -y curl
RUN apt install -y jq
RUN apt install -y vim
RUN rm -rf /var/lib/apt/lists/*
# Хорошо: 1 слой, apt-lists очищены до создания снапшота
RUN apt update && \
apt install -y curl jq vim && \
rm -rf /var/lib/apt/lists/*
Попробуй сам
Посмотри на структуру overlayfs изнутри контейнера:
# Запусти контейнер с двумя записями
docker run -d --name probe alpine:3.21 sh -c "sleep 3600"
docker exec probe sh -c "echo 'new file' > /tmp/foo.txt"
docker exec probe sh -c "echo 'modified' >> /etc/hostname"
docker exec probe sh -c "rm /etc/issue"
# Посмотреть путь к upperdir
UPPER=$(docker inspect probe --format '{{.GraphDriver.Data.UpperDir}}')
echo "Upperdir: $UPPER"
# На Linux:
# ls -laR $UPPER
# Увидишь:
# - /tmp/foo.txt (новый файл)
# - /etc/hostname (copy-up из lowerdir)
# - /etc/issue (whiteout-маркер char-device 0:0)
# На macOS / Windows тот же эффект увидишь через docker diff:
docker diff probe
# C /tmp
# A /tmp/foo.txt
# C /etc/hostname
# D /etc/issue
# A=added, C=changed, D=deleted
# Cleanup
docker rm -f probe
docker diff показывает diff контейнера относительно образа — это и есть содержимое upperdir в удобном виде.