Learning Platform
Глоссарий Troubleshooting
Урок 07.01 · 22 мин
Средний
dockeroverlayfslayersunionfsstorage

Слои и 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.

Overlayfs: lowerdirs + upperdir + merged
Несколько read-only lowerdirs + writable upperdir = единая merged view для процесса
L0: baselowerdir N (самый глубокий): базовый слой образа, обычно Debian rootfs.
L1: aptlowerdir 1: apt-installed пакеты, поверх base.
L2: applowerdir 0 (самый верхний read-only): приложение и конфиг.
upperdirwritableupperdir: writable layer контейнера. Все записи -- сюда. Удаляется при docker rm.
mergedчто видит процессmerged: union view. Для read -- сверху вниз ищет файл по upperdir, потом по lowerdirs. Для write -- copy-up в upperdir.

Один 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 -- один слой; данные внутри слоя удалены ДО создания слоя
WARNING

Слой создаётся ПОСЛЕ выполнения всей инструкции. Если внутри одного 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:

  1. Overlayfs создаёт workdir-копию файла (через rename(2) на той же fs — атомарно)
  2. Перемещает её в upperdir под тем же путём
  3. Применяет запись к копии

Стоимость 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.

Copy-up: что происходит при первой записи в файл из lowerdir
Чтение файла из lower — бесплатно; первая запись — copy-up в upper
Process: writeШаг 1: процесс делает echo new > /etc/foo.conf. Foo.conf лежит только в lowerdir.
trigger
workdir: copyШаг 2: overlayfs копирует foo.conf из lowerdir в workdir (на той же fs).
rename
upperdir: readyШаг 3: атомарный rename workdir -> upperdir; теперь foo.conf лежит в upperdir, готов к записи.
modify
upperdir/foo.confmodifiedШаг 4: запись применяется к копии в upperdir. lowerdir не тронут. При docker commit / docker save содержимое upperdir станет новым слоем.

Где это всё лежит на диске

На 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 в удобном виде.


Проверка знанийKnowledge check
В Dockerfile есть две инструкции: RUN curl -O https://huge.com/data.tar.gz (200MB), затем RUN tar xzf data.tar.gz && rm data.tar.gz. После сборки финальный размер образа на 200MB больше чем ожидалось. В чём ошибка и как исправить?
ОтветAnswer
Слой создаётся после каждого RUN. Первый RUN создал слой с data.tar.gz (200MB). Второй RUN распаковал и удалил архив -- но удаление произошло в слое 2 через whiteout, файл всё ещё лежит в read-only слое 1, и образ его содержит. Исправление: объединить в один RUN: RUN curl -O https://... && tar xzf data.tar.gz && rm data.tar.gz. Тогда снапшот слоя берётся ПОСЛЕ rm, в нём data.tar.gz нет, только распакованные результаты.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Какие компоненты участвуют в overlayfs-монтировании контейнера?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4