Почему первая запись в файл внутри контейнера дороже всех последующих
Вы меняете один байт в файле, который лежит в образе контейнера, и эта операция вдруг занимает секунды, а не микросекунды. Второй раз — снова мгновенно. Дело не в диске и не в приложении: overlayfs при первом изменении файла из read-only слоя обязана скопировать его целиком в записываемый слой, прежде чем применить вашу правку. Дальше файл уже «наверху», и все следующие обращения к нему идут по обычной, быстрой дорожке.
Содержание
Слои образа read-only, и почему их нельзя трогать напрямую
Файловая система контейнера — это не одна файловая система, а стопка. Каждый слой образа (lowerdir в терминах overlayfs) — это неизменяемый набор файлов, который лежит на диске хоста один раз и переиспользуется всеми контейнерами, запущенными из этого образа. Если у вас на сервере крутится двадцать контейнеров из одного и того же образа postgres:16, слои этого образа физически лежат на диске в единственном экземпляре — overlayfs просто монтирует их всем двадцати контейнерам одновременно как read-only основу.
Поверх этой стопки для каждого контейнера отдельно монтируется upperdir — пустой (в начале) записываемый слой, куда попадает всё, что контейнер меняет в рантайме. Merged-представление, которое видит процесс внутри контейнера, — это математическое объединение: файл берётся из верхнего слоя, если он там есть, иначе — из первого нижнего слоя, где он найден.
Отсюда прямое следствие: изменить байт в файле, который физически лежит в lowerdir, попросту нельзя. Этот файл — общий ресурс, разделяемый между контейнерами и, возможно, между слоями других образов, которые от него наследуются. Если бы ядро позволяло писать прямо в lowerdir, один контейнер сломал бы файловую систему всем остальным, кто её переиспользует. Поэтому у overlayfs есть только один путь исполнить запись в файл, которого ещё нет в верхнем слое: сначала полностью перенести его туда.
Подробнее о том, как устроена сама стопка слоёв и как overlayfs их монтирует, — в статье про сборку файловой системы контейнера по слоям.
Copy-up: что именно происходит в момент первой записи
Момент, когда процесс внутри контейнера впервые открывает файл из lowerdir на запись (или делает chmod, chown, truncate, меняет время модификации — то есть любую операцию, которая должна изменить содержимое или метаданные инода), ядро запускает процедуру, которую в overlayfs называют copy-up. Последовательность примерно такая:
- Драйвер overlay находит файл в самом верхнем из нижних слоёв, где он присутствует (слои просматриваются сверху вниз, побеждает первое совпадение).
- Если родительских директорий этого файла ещё нет в
upperdir— они тоже создаются наверху. Это дешёвая операция: копируется только «оболочка» директории (права, владелец, имя), а не её содержимое — дочерние файлы копируются по отдельности, каждый в свой момент первой записи. - В
upperdirсоздаётся новый инод, и в него целиком копируется содержимое файла — байт в байт, вся его текущая длина, а не только тот кусок, который вы собираетесь поменять. - Копируются расширенные атрибуты, права доступа, владелец, временные метки.
- И только после того, как копия готова и на неё указывает merged-путь, к файлу применяется исходная операция, ради которой всё это затевалось, — та самая запись одного байта.
Ключевой и часто упускаемый момент: стоимость copy-up зависит от размера файла, а не от размера изменения. Дозаписать один килобайт в конец лог-файла на 50 МБ или перезаписать первый байт файла на 50 МБ — по стоимости copy-up это одна и та же операция: скопировать 50 МБ. Ваша фактическая правка добавляется уже потом, поверх готовой копии.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверПочему это особенно заметно на больших файлах
Небольшие конфиги, короткие скрипты, файлы на десятки-сотни килобайт — copy-up для них проходит незаметно, в пределах обычного шума файловых операций. Проблема становится ощутимой, когда в образе лежит крупный файл, который приложение решает модифицировать на месте:
- файл базы данных или её шаблон, «запечённый» в образ, в который сервис СУБД начинает писать сразу после старта;
- дамп или seed-файл, который приложение распаковывает или дополняет при первом запуске;
- модель или веса (checkpoint) в контейнере инференса, если пайплайн что-то дозаписывает в тот же файл вместо того, чтобы читать его как есть;
- медиа-ассеты или архивы, зашитые в образ, которые сервис индексирует и параллельно помечает/модифицирует прямо в файле;
- любой «рабочий» файл, который по ошибке архитектуры оказался внутри слоёв образа, а не смонтирован отдельно.
Здесь важно разделять две вещи, которые легко перепутать. Само по себе чтение большого файла из lowerdir — дешёвая операция, overlayfs не копирует файл только потому, что его читают; чтение идёт напрямую с нижнего слоя, как с обычной файловой системы. Дорогим оказывается именно первое изменение. Поэтому симптом узнаваем: контейнер стартует нормально, что-то читает из образа быстро, а затем на первой попытке что-то в этом файле поменять — ощутимая пауза, которая больше никогда не повторяется для этого конкретного файла в рамках жизни этого контейнера.
Если приложение обслуживает запросы и первое изменение крупного файла происходит внутри обработки живого запроса или прямо в критическом пути healthcheck — эта задержка становится задержкой ответа пользователю или ложным «не готов» от оркестратора. Именно поэтому первый запрос после старта контейнера иногда заметно медленнее всех последующих, даже если приложение уже прогрето и JIT/кэши ни при чём — виновата файловая система под ним.
Почему вторая и все следующие записи в тот же файл — быстрые
После того как copy-up для конкретного файла один раз отработал, у overlayfs больше нет причин к нему возвращаться. Дентри в merged-представлении теперь указывает на файл в upperdir напрямую — при разрешении пути overlay находит файл уже в верхнем слое и даже не смотрит вниз, в lowerdir. С этого момента файл — просто обычный файл на файловой системе, из которой собран upperdir (чаще всего это тот же ext4 или xfs, что и на всём остальном диске хоста).
Вторая запись — это уже не «скопировать файл, потом записать один байт», а просто «записать один байт» с обычной стоимостью такой операции для нижележащей ФС: столько I/O, сколько реально нужно для изменённого диапазона, плюс обычные механизмы журналирования и кэширования этой файловой системы — никакого отношения к overlayfs это уже не имеет. Поэтому разница между первой и второй записью в один и тот же большой файл может быть не в разы, а на порядки: первая тянет на себе полный объём файла, вторая — только реально изменённые байты.
Это же объясняет, почему проблема воспроизводится ровно один раз за жизнь контейнера на файл. Перезапустили контейнер (не путать с рестартом процесса внутри него) — upperdir пересоздаётся с нуля, и при следующей первой записи в тот же файл из образа copy-up отработает заново.
Как обойти лишний copy-up архитектурно
Самый надёжный способ не платить за copy-up — не давать overlayfs повода его делать. Если файл или директория предназначены для того, чтобы приложение их меняло в рантайме, им вообще не место в слоях образа.
- Именованные volume или bind mount для директорий с данными, которые реально пишутся:
/var/lib/postgresql/data,/var/lib/mysql, каталоги с загруженными пользователями файлами, кэши, которые должны переживать пересоздание контейнера. Volume — это отдельная точка монтирования в стороне от overlay-стопки, запись туда идёт напрямую в файловую систему хоста, без всякого copy-up. Про то, какой тип volume выбирать под какую задачу, — в статье про типы Docker volumes. - tmpfs для scratch-директорий и промежуточных файлов, которым не нужна персистентность между рестартами, — это вообще выносит операции в оперативную память, минуя и overlay, и диск.
- Если крупный мутируемый файл по архитектурным причинам обязан быть частью образа (редкий, но встречающийся случай — например, шаблон, который должен «приехать» вместе с образом, а потом дорабатываться), стоит явно спровоцировать copy-up на старте контейнера, до того как на него пойдёт боевой трафик: например, entrypoint делает по этому файлу безобидную операцию, которая гарантированно требует copy-up (изменение времени модификации через
touch, либо чтение-и-перезапись самого себя), и только после этого контейнер сигнализирует оркестратору о готовности через healthcheck. - Не держите в
Dockerfileслои с большими файлами, которые заведомо будут перезаписаны в рантайме, — это не только грозит copy-up, но и раздувает сам образ и время его выгрузки на сервер.
Важная оговорка: если файл предполагается менять один раз за жизнь контейнера и цена одного copy-up вас устраивает — можно ничего не оптимизировать, это не баг, а осознанная плата за удобство «зашить всё в образ». Проблема возникает конкретно там, где эта разовая плата попадает в горячий путь пользовательского запроса.
Как убедиться, что дело именно в copy-up
Copy-up происходит внутри ядра прозрачно для процесса — он не порождает дополнительных системных вызовов, которые можно было бы увидеть трассировкой вроде strace на уровне приложения. Процесс просто видит, что его собственный write() (или open() с флагом на запись) выполнялся дольше обычного. Диагностировать стоит по косвенным признакам:
docker diff <container>показывает список файлов, изменённых или добавленных в верхнем слое контейнера относительно образа. Если файл там уже отмечен (C— changed,A— added), copy-up для него уже отработал.- Размер верхнего слоя контейнера растёт скачками, синхронно с первыми записями в крупные файлы. Путь к
upperdirможно узнать черезdocker inspect, полеGraphDriver.Data.UpperDir(точное расположение зависит от драйвера хранения и от того, куда указывает корень Docker — проверяйте черезdocker info, поле Docker Root Dir, прежде чем полагаться на путь по умолчанию).
docker inspect -f '{{.GraphDriver.Data.UpperDir}}' my-container
du -sh /var/lib/docker/overlay2/<id>/diff
- На хосте во время первой записи в большой файл
iostat -x 1покажет всплеск и чтения (с блочного устройства, если файл ещё не в page cache), и записи (вupperdir), пропорциональный размеру файла, а не размеру вашей правки. - Если файл к моменту copy-up уже был прочитан приложением раньше и осел в page cache — часть работы (чтение исходных данных) может обойтись почти бесплатно, кэш уже тёплый. А вот запись копии в
upperdirна диск всё равно происходит и стоит полного объёма файла — экономия только на чтении, не на записи. - Проще и надёжнее всего — измерить на уровне приложения: залогировать длительность конкретной операции записи в файл при первом её выполнении и сравнить со второй попыткой в идентичных условиях. Разница на порядок и больше — верный признак именно copy-up, а не случайной просадки диска.
Если после диагностики выяснилось, что копируется файл на несколько гигабайт, а изменение в нём — символическое (например, дозапись метки в конец большого архива), это почти всегда сигнал, что файл стоит вынести из образа на volume, а не оставлять как есть.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Copy-up затрагивает файлы, которые я вообще не трогаю?
Нет. Overlayfs копирует наверх только тот конкретный файл, для которого реально выполняется операция, требующая записи содержимого или метаданных: открытие на запись, truncate, chmod, chown, изменение времени модификации. Соседние файлы в той же директории остаются в lowerdir нетронутыми, пока их саму не начнут менять.
Если я меняю один байт в файле на несколько гигабайт — правда копируется весь файл?
Да, ровно так устроен базовый механизм: copy-up переносит наверх текущее содержимое файла целиком, а уже потом применяется ваша правка. Размер самого изменения на стоимость copy-up не влияет — влияет только текущий размер файла.
А для директорий тоже копируется всё содержимое сразу?
Нет, и это частая путаница. Copy-up директории создаёт в верхнем слое только «оболочку» — саму директорию с её правами и именем, без рекурсивного копирования файлов внутри. Каждый файл в этой директории получает свой собственный copy-up отдельно, в момент, когда его действительно начинают менять.
Это специфика именно Docker или общий механизм ядра Linux?
Это поведение самого модуля ядра overlay, а не конкретного инструмента поверх него. Работает одинаково независимо от того, управляет контейнером Docker, Podman, containerd или nerdctl — все они в конечном счёте монтируют одну и ту же файловую систему overlayfs с теми же правилами copy-up.
Помогает ли увеличение размера страничного кэша (RAM) избавиться от задержки?
Частично. Если исходный файл уже в page cache, чтение при copy-up обходится дешевле — но запись копии в верхний слой на диск всё равно происходит и стоит полного объёма файла. Больше RAM ускорит одну из двух частей операции, но не уберёт её целиком.
Проявляется ли то же самое при обычной записи на хосте, без контейнера?
Нет — на файловой системе хоста без overlay нет двух слоёв и нет причины что-либо копировать перед записью, вы просто пишете в файл напрямую. Двухступенчатая стоимость первой записи — это специфика именно наложенных read-only и read-write слоёв overlayfs.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →