Образ Ubuntu в Docker весит несколько сотен мегабайт. Запустить сотню контейнеров на его основе можно за секунды, и при этом диск не заполнится терабайтом копий. Каждый контейнер получает полноценную файловую систему, может писать файлы, удалять системные конфиги, устанавливать пакеты - и всё это никак не затрагивает образ и не мешает соседним контейнерам. За этой кажущейся магией стоит механизм ядра Linux, существующий с 2014 года и вошедший в mainline в версии 3.18.

OverlayFS - это объединяющая файловая система, реализованная прямо в ядре. Её идея проста: взять несколько директорий, наложить их друг на друга как слои прозрачной плёнки и предоставить единый связный вид. Нижние слои доступны только для чтения, верхний слой - для записи. Файл читается из того слоя, где он найден первым сверху вниз. Файл создаётся или изменяется только в верхнем слое. Это и есть всё, что нужно знать для понимания устройства контейнерного хранилища.

Четыре директории OverlayFS и что живёт в каждой из них

Для монтирования OverlayFS требуется четыре компонента. lowerdir - одна или несколько директорий только для чтения, составляющих базовые слои. upperdir - единственная директория для чтения и записи, куда попадают все изменения. workdir - служебная директория, которую OverlayFS использует внутри для атомарных операций. merged - точка монтирования, через которую процессы видят объединённый результат всех слоёв.

Воспроизвести это вручную, без Docker, можно буквально пятью командами:

mkdir -p /tmp/overlay/{lower1,lower2,upper,work,merged}

# Создать файлы в нижних слоях
echo "from layer 1" > /tmp/overlay/lower1/base.txt
echo "shared file" > /tmp/overlay/lower1/shared.txt
echo "from layer 2" > /tmp/overlay/lower2/app.txt
echo "layer 2 version" > /tmp/overlay/lower2/shared.txt

# Смонтировать overlayfs
mount -t overlay overlay \
  -o lowerdir=/tmp/overlay/lower2:/tmp/overlay/lower1,\
upperdir=/tmp/overlay/upper,\
workdir=/tmp/overlay/work \
  /tmp/overlay/merged

# Посмотреть объединённый результат
ls /tmp/overlay/merged
cat /tmp/overlay/merged/shared.txt  # покажет версию из lower2

Файл shared.txt существует в обоих нижних слоях, но в merged будет виден только вариант из lower2 - слой, указанный первым в lowerdir, имеет приоритет. Это важный принцип: при конфликте имён побеждает слой, стоящий выше в порядке перечисления.

При записи или изменении любого файла OverlayFS записывает его в upperdir, не трогая нижние слои:

# Изменить файл из нижнего слоя
echo "modified" > /tmp/overlay/merged/base.txt

# lower1/base.txt остался нетронутым
cat /tmp/overlay/lower1/base.txt    # "from layer 1"

# Изменение появилось только в upper
cat /tmp/overlay/upper/base.txt     # "modified"

Размонтировать и очистить:

umount /tmp/overlay/merged

Механизм copy-up и почему первая запись стоит дороже последующих

Когда контейнер впервые записывает в файл, который существует в нижнем слое, OverlayFS не может изменить нижний слой - он доступен только для чтения. Вместо этого выполняется операция copy-up: полная копия файла создаётся в upperdir, и только после этого запись применяется к копии.

Это имеет конкретные практические последствия. Первая запись в большой файл из нижнего слоя вызывает его полное копирование в upperdir - даже если изменяется один байт. OverlayFS работает на уровне файлов, а не на уровне блоков. Это означает, что операции copy-up копируют файл целиком, даже если файл большой и изменяется только небольшая его часть. Последующие записи в тот же файл работают быстро - файл уже в upperdir и не требует повторного копирования.

Высокое число copy-up операций - то, что делает изменение метаданных файла (например, прав доступа, временных меток, владельца) дорогой операцией: она вызывает полное копирование файла, даже если содержимое не изменилось. Это важно учитывать при написании Dockerfile - команды chmod и chown на большие файлы из предыдущих слоёв обходятся дорого.

# Наблюдать операции copy-up в реальном времени через inotify
inotifywait -m -r /tmp/overlay/upper &

# Любое изменение файла из нижнего слоя
echo "test" >> /tmp/overlay/merged/base.txt
# inotifywait покажет CREATE и MODIFY события в upper

Whiteout-файлы и как OverlayFS реализует удаление в нижних слоях

Нижний слой доступен только для чтения - удалить из него файл напрямую невозможно. OverlayFS решает это через специальные файлы-маркеры, называемые whiteout. Когда процесс удаляет файл через merged, OverlayFS создаёт в upperdir символ устройства с нулевыми major и minor номерами и именем удалённого файла. Этот маркер сигнализирует ядру: файл с таким именем считается несуществующим, даже если он есть в нижних слоях.

# Создать файл в нижнем слое и смонтировать overlay
mkdir -p /tmp/wo/{lower,upper,work,merged}
echo "will be deleted" > /tmp/wo/lower/deleteme.txt
mount -t overlay overlay \
  -o lowerdir=/tmp/wo/lower,upperdir=/tmp/wo/upper,workdir=/tmp/wo/work \
  /tmp/wo/merged

# Удалить файл через merged
rm /tmp/wo/merged/deleteme.txt

# Файл исчез из merged, но в lower остался
ls /tmp/wo/merged/       # deleteme.txt не видно
ls /tmp/wo/lower/        # deleteme.txt на месте

# В upper появился whiteout-файл
ls -la /tmp/wo/upper/    # символ устройства 0,0

# Проверить тип файла
stat /tmp/wo/upper/deleteme.txt  # character special file

umount /tmp/wo/merged

При удалении директорий OverlayFS использует "opaque whiteout" - специальный файл с именем .wh..wh..opq внутри директории. Он сигнализирует, что вся директория со всем содержимым должна считаться удалённой, независимо от того, что находится в нижних слоях.

Как Docker строит слои образа поверх OverlayFS

Docker использует overlay2 в качестве хранилища по умолчанию, нативно поддерживая до 128 нижних слоёв OverlayFS. Каждая инструкция RUN, COPY и ADD в Dockerfile создаёт новый слой. При загрузке образа каждый его слой распаковывается в отдельную директорию в /var/lib/docker/overlay2/<layer-id>/diff. При запуске контейнера Docker собирает монтирование overlay из этих директорий.

# Убедиться что Docker использует overlay2
docker info | grep "Storage Driver"

# Посмотреть слои конкретного образа
docker history nginx:latest

# Инспектировать структуру слоёв
docker inspect nginx:latest | python3 -m json.tool | grep -A5 GraphDriver

# Посмотреть директории слоёв напрямую
ls /var/lib/docker/overlay2/

# Структура одного слоя
ls /var/lib/docker/overlay2/<layer-id>/
# diff/   - содержимое слоя
# link    - короткий идентификатор слоя
# lower   - ссылка на родительский слой
# merged/ - объединённый вид (только для запущенных контейнеров)
# work/   - служебная директория

Директория upper содержит содержимое слоя чтения-записи контейнера и соответствует upperdir в терминах OverlayFS. Директория merged - это точка монтирования объединённого вида для работающего контейнера. Директория work используется OverlayFS внутри.

# Найти upperdir запущенного контейнера
CONTAINER_ID=$(docker run -d nginx)
docker inspect $CONTAINER_ID | python3 -m json.tool | grep -A6 GraphDriver

# Посмотреть что контейнер уже записал в upper
UPPER=$(docker inspect $CONTAINER_ID \
  --format '{{.GraphDriver.Data.UpperDir}}')
ls -la $UPPER

# Посмотреть полную строку монтирования overlay
mount | grep overlay | grep $CONTAINER_ID

Именно благодаря этой архитектуре Docker повторно использует общие слои образов (Ubuntu, Alpine и другие) среди множества контейнеров, при этом предоставляя каждому контейнеру собственный слой для записи.

Ограничения OverlayFS и ситуации когда они важны на практике

OverlayFS имеет ряд ограничений, которые могут удивить при использовании вне стандартных сценариев. Директории lowerdir и upperdir должны находиться на одном типе файловой системы. NFS не поддерживается как upperdir и нередко не работает даже как lowerdir. Жёсткие ссылки не работают между слоями: если файл в нижнем слое имеет жёсткую ссылку, она не сохраняется при копировании в upperdir. Некоторые инструменты (например, пакетные менеджеры) опираются на жёсткие ссылки и могут вести себя неожиданно под OverlayFS.

Практически значимое ограничение для разработчиков - невозможность использовать определённые системные вызовы на файлах в нижних слоях. rename() для файла, ещё не скопированного в upperdir, возвращает ошибку EXDEV на некоторых конфигурациях. Некоторые базы данных используют rename() для атомарной замены файлов, и это может вызвать неожиданные ошибки при запуске в контейнере.

Для диагностики проблем с хранилищем контейнеров полезны несколько команд:

# Использование дискового пространства по слоям
docker system df -v

# Размер слоёв конкретного образа
docker history --no-trunc nginx:latest

# Удалить неиспользуемые слои и освободить пространство
docker system prune

# Статистика использования inode (важна для overlay2)
df -i /var/lib/docker/overlay2

Оптимизация Dockerfile с пониманием слоёв

Понимание copy-up и слоёв напрямую влияет на размер образов и скорость сборки. Каждый слой Dockerfile - это diff относительно предыдущего. Удалённый в следующем слое файл по-прежнему занимает место в предыдущем слое.

# Плохо - три слоя, файл пакетного кэша остаётся во втором слое
RUN apt-get update
RUN apt-get install -y nginx
RUN rm -rf /var/lib/apt/lists/*

# Правильно - один слой, кэш удаляется в той же операции
RUN apt-get update && apt-get install -y nginx \
    && rm -rf /var/lib/apt/lists/*

Порядок инструкций влияет на эффективность кэширования. Слои, которые меняются редко (установка зависимостей), должны идти раньше слоёв, которые меняются часто (копирование кода приложения). Это позволяет Docker переиспользовать кэшированные слои при пересборке образа.

# Посмотреть реальный размер каждого слоя
docker history --format "{{.Size}}\t{{.CreatedBy}}" nginx:latest \
  | sort -rh | head -10

# Анализ образа через dive (если установлен)
dive nginx:latest

OverlayFS - это механизм, который делает контейнеры практически применимыми при работе с образами реального размера. Без него запуск каждого контейнера означал бы копирование гигабайтов данных и ожидание в секундах. С ним - мгновенный старт, общие слои между сотнями контейнеров и изоляция записи без дополнительных затрат. Понимание четырёх директорий, copy-up и whiteout превращает поведение Docker из чёрного ящика в предсказуемую систему с ясными характеристиками производительности.