MAATRIX / Блог / Как overlayfs собирает файловую систему контейнера из чужих слоёв

Как overlayfs собирает файловую систему контейнера из чужих слоёв

MAATRIX

Когда вы запускаете docker run nginx, файловая система внутри контейнера выглядит как обычная — есть /etc, /usr, /var, всё на своих местах. На самом деле там нет ни одной настоящей директории в привычном смысле: то, что вы видите, — это склейка из нескольких неизменяемых слоёв образа плюс тонкая записываемая надстройка сверху, и всю эту иллюзию единой файловой системы держит модуль ядра overlayfs. Разберёмся, как он это делает и почему изменение одного файла иногда обходится дороже, чем кажется.

Слои образа: что на самом деле создаёт Dockerfile

Каждая инструкция в Dockerfile, которая меняет файловую систему — RUN, COPY, ADD — обычно создаёт новый слой образа. Слой — это набор файловых изменений относительно предыдущего состояния: какие файлы появились, какие изменились, какие исчезли. Инструкции, которые не трогают файлы (ENV, EXPOSE, CMD, LABEL), новый слой не создают, а только добавляют метаданные к конфигурации образа.

Посмотреть слои готового образа можно так:

docker image history nginx:latest

Вывод покажет цепочку слоёв сверху вниз — от самого свежего к базовому. У официального образа nginx там будет с десяток слоёв: базовая система, установка пакетов, копирование конфигов и бинарников самого nginx.

Ключевое здесь — слои только для чтения. Ни один из них после сборки образа не меняется. Если два разных образа собраны на одной и той же базе (например, оба используют FROM debian:12-slim), у них будет один и тот же базовый слой — с одним и тем же содержимым и, что важно, с одним и тем же идентификатором (хешем содержимого). Docker хранит слои в локальном хранилище по этому хешу, поэтому если слой уже есть на диске, скачивать или пересобирать его второй раз не нужно.

Физически слои лежат в /var/lib/docker/overlay2/, каждый — в своей директории с именем-хешем. Внутри — директория diff с самими файлами этого слоя и несколько служебных файлов (link, lower), о которых расскажу дальше. Как именно из этих отдельных слоёв собирается связная файловая система для конкретного запущенного контейнера — задача overlayfs.

overlayfs: lowerdir, upperdir, merged и как это монтируется

overlayfs — это драйвер объединяющей (union) файловой системы, встроенный в ядро Linux. Его задача — взять несколько директорий и показать процессу одну объединённую директорию, где файлы из "верхних" слоёв перекрывают одноимённые файлы из "нижних".

У overlayfs для одного монтирования есть четыре ключевых параметра:

  • lowerdir — один или несколько слоёв только для чтения. Это как раз слои образа, каждый в своей diff-директории. Их может быть несколько, и они перечисляются в порядке приоритета — то, что стоит первым, перекрывает остальные.
  • upperdir — единственный записываемый слой. Именно сюда попадают все изменения, которые контейнер делает во время работы: новые файлы, изменённые файлы, удаления.
  • workdir — служебная директория, которую overlayfs использует для внутренних атомарных операций (например, при переименовании файлов). Она должна находиться на той же файловой системе, что и upperdir, и вы никогда не работаете с ней напрямую.
  • merged — точка монтирования, то самое единое дерево файлов, которое видит процесс внутри контейнера как корень своей файловой системы.

Когда Docker запускает контейнер, он вызывает примерно такое монтирование (упрощённо, реальная команда собирается автоматически):

mount -t overlay overlay \
  -o lowerdir=/var/lib/docker/overlay2/l/LAYER3:/var/lib/docker/overlay2/l/LAYER2:/var/lib/docker/overlay2/l/LAYER1,\
upperdir=/var/lib/docker/overlay2/CONTAINER_ID/diff,\
workdir=/var/lib/docker/overlay2/CONTAINER_ID/work \
  merged

Все read-only слои образа становятся списком lowerdir (Docker переиспользует их из кеша, не копируя), а для конкретного запущенного контейнера создаётся новая пустая директория, которая становится его upperdir. Именно поэтому у десяти запущенных контейнеров из одного образа будет десять разных upperdir, но один и тот же набор lowerdir — общая, неизменяемая база.

Проверить это на живом сервере можно так:

docker inspect <container_id> --format '{{json .GraphDriver.Data}}'

В выводе будут явно видны пути LowerDir, UpperDir, MergedDir — можно зайти в UpperDir и увидеть ровно те файлы, которые контейнер успел создать или изменить с момента запуска. Если контейнер ничего не писал на диск — эта директория будет практически пустой.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Copy-on-write на уровне файлов: как выглядит copy-up

Здесь начинается самое важное отличие от "блочного" copy-on-write, который используется, скажем, в снапшотах qcow2 у виртуальных машин. Там единица копирования — блок диска фиксированного размера. В overlayfs единица копирования — целый файл.

Пока процесс внутри контейнера только читает файлы, overlayfs просто транслирует чтение в нижний слой, где этот файл реально лежит — никакого копирования не происходит. Работает это примерно так же, как последовательный поиск: overlayfs проверяет upperdir, не находит там файл, идёт по списку lowerdir сверху вниз, находит первое совпадение и отдаёт его содержимое. Файл физически как лежал в read-only слое образа, так и лежит — читают его хоть один контейнер, хоть сто одновременно.

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

  1. overlayfs обнаруживает, что файл, который нужно изменить, находится в lowerdir, а не в upperdir.
  2. Он копирует файл целиком — от первого до последнего байта — из lowerdir в upperdir, сохраняя относительный путь.
  3. Только после этого запрошенное изменение (запись, смена атрибутов, chmod, chown) применяется к уже скопированной в upperdir версии.
  4. С этого момента у файла в upperdir и файла в lowerdir разные inode. При любом следующем обращении к этому пути overlayfs находит версию в upperdir раньше, чем в lowerdir, и отдаёт её — исходная версия в read-only слое остаётся нетронутой и просто становится невидимой поверх.

Важное следствие: если вам нужно поменять один байт в файле размером в несколько сотен мегабайт (например, лежащий в базовом слое дамп данных или большая статическая библиотека), overlayfs всё равно скопирует его целиком, прежде чем поменять этот байт. Это не баг и не недоработка — это прямое следствие того, что нижние слои неизменяемы по определению, и единственный способ "изменить" файл в них — создать новую полную копию поверх.

То же самое, кстати, происходит и при простой смене прав доступа (chmod) или владельца (chown) файла из lowerdir — метаданные в overlayfs неотделимы от содержимого файла на уровне copy-up, так что и chmod +x на файле из базового образа вызовет полное копирование.

Удаление файлов и особые случаи: whiteout и opaque-директории

Раз нижние слои неизменяемы, а контейнер может не только менять, но и удалять файлы — как overlayfs "удаляет" то, что физически лежит в read-only слое и никуда не денется?

Ответ — whiteout-файлы. Когда процесс удаляет файл, который существует в lowerdir, overlayfs не может стереть его физически (он в чужом read-only слое). Вместо этого в upperdir создаётся специальный файл-заглушка с character device 0/0 и тем же именем. При сборке объединённого дерева overlayfs видит эту заглушку и трактует её как маркер "здесь ничего нет", полностью скрывая одноимённый файл из всех lowerdir, даже если он есть сразу в нескольких из них.

Похожая механика — opaque-директории. Если контейнер удаляет содержимое целой директории и создаёт её заново, overlayfs помечает такую директорию специальным расширенным атрибутом (trusted.overlay.opaque), означающим "не заглядывай в нижние слои внутри этой директории вообще, даже если её саму overlayfs не пересоздавал". Без этого механизма удалённые когда-то файлы могли бы "воскресать" из нижних слоёв при определённых операциях.

Обе эти детали обычно не требуют ручного вмешательства — Docker и overlayfs справляются сами — но они объясняют, почему docker diff <container> иногда показывает удаления файлов, которых, казалось бы, там и не должно быть видно: под капотом это whiteout-запись в upperdir, а не физическое стирание.

Почему это экономит место: общие базовые слои между контейнерами

Главная практическая выгода всей этой конструкции — совместное использование неизменяемых слоёв между произвольным числом контейнеров и образов. Если у вас на сервере крутится десяток контейнеров, собранных на одном и том же базовом образе (скажем, debian:12-slim или python:3.12-slim), то этот базовый слой физически хранится на диске один раз, а не по разу на каждый контейнер.

Это работает благодаря тому, что:

  • lowerdir у overlayfs — это ссылка на директорию слоя по её адресу в общем хранилище /var/lib/docker/overlay2/, а не копия. Каждый контейнер, использующий этот слой, монтирует его в свой список lowerdir, но не дублирует данные.
  • Пока контейнер только читает файлы из общего слоя, он не создаёт собственных копий — см. предыдущий раздел про copy-up. Место расходуется только на upperdir каждого контейнера, а он обычно на порядки меньше, чем полный образ, если приложение не пишет много данных прямо в файловую систему контейнера.
  • При удалении контейнера удаляется только его upperdir (и workdir) — сами слои образа остаются на диске, потому что могут использоваться другими контейнерами или просто ещё не были удалены явной командой docker image prune / docker rmi.

Отсюда и практический совет, который часто упускают: если на сервере много разных сервисов, стоит по возможности собирать их на общих базовых образах (одна и та же версия Debian/Alpine, один и тот же базовый слой рантайма), а не на случайном наборе разных базовых образов под каждый сервис. Экономия получается не только на месте на диске, но и на времени docker pull — если базовый слой уже скачан для одного образа, для другого образа с той же базой Docker его повторно не потянет.

Ту же логику стоит держать в голове при проектировании Dockerfile: чем раньше в файле стоит инструкция, тем более "нижним" и более переиспользуемым получается соответствующий слой. Подробнее о том, как именно docker run разворачивает эти слои в рабочий контейнер, можно посмотреть в статье про то, что происходит при docker run по слоям.

Ограничения и практические последствия

У этой архитектуры есть несколько нюансов, с которыми стоит считаться, а не удивляться им в проде.

Записываемый слой — не место для больших объёмов данных. upperdir физически хранится на том же диске, что и весь Docker (обычно /var/lib/docker), и никак не ограничен по размеру самой overlayfs — расти он будет, пока не кончится место на разделе. Базы данных, логи, любые изменяемые данные приложения правильнее выносить в volume, который монтируется отдельно от union-стека overlayfs и не участвует в copy-up вообще: запись в volume идёт напрямую на файловую систему хоста, без промежуточного копирования файлов. О том, какие типы volume для этого подходят, — в статье про типы Docker volumes.

Частая модификация больших файлов из базового слоя — источник неожиданных задержек. Если приложение при старте контейнера правит конфигурационный файл, который лежит глубоко в базовом образе и весит заметно больше типичного текстового конфига, каждый такой запуск контейнера будет платить временем на copy-up этого файла. На практике это редко становится узким местом, но стоит держать в уме, если видите на старте контейнера необъяснимую паузу на операциях с диском.

Слишком много слоёв в образе — не бесплатно. Хотя современный overlayfs в Linux умеет работать с большим числом lowerdir в одном монтировании, каждый дополнительный слой — это дополнительный шаг поиска файла (overlayfs идёт по списку сверху вниз, пока не найдёт совпадение) и лишние метаданные, которые Docker должен отслеживать. Поэтому осмысленное объединение инструкций Dockerfile в меньшее число слоёв (через && в одном RUN, через multi-stage сборку) — это не только про размер образа, но и немного про производительность поиска файлов. Практические приёмы такого объединения разобраны в статье про multi-stage сборку и оптимизацию образа, а если задача — просто уменьшить итоговый размер образа безотносительно числа слоёв, пригодится статья про уменьшение размера Docker-образа.

overlayfs требует поддержки на уровне ядра и файловой системы хоста. Не любая комбинация нижележащей ФС и настроек ядра одинаково хорошо поддерживает все возможности overlayfs (например, работу с extended attributes, которые нужны для opaque-директорий). На большинстве современных дистрибутивов с ext4 или XFS в стандартной конфигурации всё работает без дополнительных плясок, но если вы разворачиваете Docker на нестандартной файловой системе хоста, стоит явно проверить, что overlay2 — действительно активный storage driver, а не запасной вариант:

docker info | grep -i "storage driver"

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Что будет, если lowerdir у образа изменится после того, как контейнер уже запущен?

Ничего — Docker всегда монтирует конкретные, уже зафиксированные по хешу слои. Даже если вы пересоберёте образ с тем же тегом, уже работающий контейнер продолжит использовать старые слои, на которые он был примонтирован при старте. Изменения увидят только новые контейнеры, запущенные из обновлённого образа.

Почему docker diff иногда показывает изменённый файл, который я не трогал?

Некоторые процессы сами меняют метаданные файлов при старте — например, обновляют время доступа, права или расширенные атрибуты. Любое такое изменение, даже не связанное с содержимым, запускает полноценный copy-up, и файл появляется в списке изменений upperdir.

Можно ли посмотреть, сколько места реально занимает upperdir конкретного контейнера?

Да, через docker inspect можно получить путь UpperDir из GraphDriver.Data и затем обычным du -sh посмотреть его размер на хосте. Это полезно, когда нужно понять, какой из контейнеров активно копирует файлы из базового образа вместо использования volume.

overlayfs — единственный storage driver для Docker?

Нет, но на подавляющем большинстве современных установок Linux используется именно overlay2 как драйвер по умолчанию — он проще в поддержке и обычно быстрее альтернатив вроде devicemapper или btrfs-driver, которые встречаются главным образом в старых или специфических конфигурациях.

Нужно ли вручную чистить /var/lib/docker/overlay2?

Руками туда лезть не стоит — структура слоёв и служебные ссылки там нетривиальны, и ручное удаление легко сломает другие образы или контейнеры, которые эти слои разделяют. Для очистки неиспользуемых слоёв и upperdir есть штатные команды docker system prune и docker image prune.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →