Предел числа слоёв и размера образа: где Docker начинает мстить за небрежность
Dockerfile, который писали правкой поверх правки — сначала один разработчик добавил RUN, потом второй, потом третий «на всякий случай» — рано или поздно превращается в файл на две сотни строк с полусотней инструкций RUN подряд. Явного сообщения об ошибке при этом не будет: Docker соберёт такой образ и на первый взгляд ничего не сломается. Мстить он будет тихо — минутами лишнего времени на сборку, гигабайтами лишнего веса и деплоем, который вместо секунд занимает ощутимую часть рабочего дня. Разберём, где здесь настоящий технический предел, а где просто накопленная небрежность, и что с этим делать.
Содержание
- Есть ли у Docker жёсткий предел числа слоёв
- Что на самом деле растёт вместе с числом слоёв
- Объединение RUN-команд и порядок слоёв для кэша сборки
- Multi-stage build и .dockerignore закрывают то, что не решает порядок слоёв
- Где реально начинаются проблемы с большими образами
- Как измерить и держать размер под контролем
Есть ли у Docker жёсткий предел числа слоёв
Формального лимита, зафиксированного в документации текущих версий Docker, для стандартного драйвера overlay2 нет. Это не то же самое, что «лимита не существует в принципе»: ограничение переносится с уровня Docker на уровень ядра и файловой системы. У overlay-монтирования есть предел на длину строки параметров монтирования (в неё попадают пути ко всем нижним слоям), и при очень большом числе слоёв с длинными путями можно упереться именно в это — но порог для этого практически недостижим при разумной сборке, счёт там идёт на многие десятки слоёв с длинными идентификаторами, а не на 15-20 инструкций в обычном Dockerfile.
Исторически более жёсткое ограничение было у драйвера aufs, который Docker использовал до перехода на overlay2 по умолчанию — там был документированный потолок в 127 слоёв, и его действительно можно было пробить на большом монолитном образе. Сегодня aufs в новых инсталляциях почти не встречается, но если вы работаете с легаси-хостом или самостоятельно собранным ядром без overlay-поддержки, стоит явно проверить, какой storage driver фактически используется:
docker info | grep "Storage Driver"
Практический вывод простой: гнаться за тем, чтобы формально не упереться в лимит слоёв, почти всегда бессмысленно — вы столкнётесь с последствиями раздутого Dockerfile (сборка, размер, скорость деплоя) на порядок раньше, чем с самим лимитом. Число слоёв — это не тот параметр, который нужно минимизировать ради лимита; это симптом, по которому удобно диагностировать, что Dockerfile не оптимизирован.
Что на самом деле растёт вместе с числом слоёв
Каждый слой — это не просто запись в манифесте, а реальные данные на диске плюс метаданные, которые Docker обязан обработать при каждой операции с образом. С ростом их числа страдают три разные вещи, и стоит различать их, потому что решения для каждой немного отличаются.
Время сборки. Каждая инструкция RUN, COPY, ADD — это отдельный шаг: Docker создаёт промежуточный контейнер, выполняет команду, коммитит результат в новый слой. Даже если сама команда почти мгновенная (RUN mkdir /app), накладные расходы на создание и коммит слоя никуда не деваются. На полусотне тривиальных RUN эти накладные расходы складываются в заметную часть времени сборки — не из-за тяжести самих команд, а из-за их количества.
Размер образа. Об этом чаще всего думают в первую очередь, и не зря: слои Docker иммутабельны, файл, записанный на одном слое, физически остаётся в образе, даже если следующий слой помечает его удалённым. Если пакеты ставятся в одном RUN, а кэш пакетного менеджера чистится в другом — вес кэша всё равно зашит в образ навсегда. Подробно этот механизм и конкретные приёмы разобраны в статье Docker-образ: как уменьшить размер — здесь не буду повторять то, что там уже разложено по полочкам, только зафиксирую связь: раздутое число слоёв почти всегда коррелирует с раздутым размером именно потому, что каждый лишний слой — это лишняя точка, где можно случайно не почистить за собой.
Время pull и push. Здесь у числа слоёв есть и прямой эффект: Docker скачивает и загружает слои параллельно, но с ограничением на число одновременных соединений, и каждый слой — это отдельный HTTP-запрос к реестру со своими накладными расходами (заголовки, TLS handshake при отсутствии keep-alive, проверка контрольной суммы). Десять слоёв по 50 МБ обычно скачиваются быстрее, чем сто слоёв на тот же суммарный объём — просто потому, что накладные расходы на слой умножаются на их число. На VPS с не самым широким каналом до реестра или при деплое на слабом соединении это ощущается напрямую, особенно если реестр физически далеко от сервера.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверОбъединение RUN-команд и порядок слоёв для кэша сборки
Базовое правило: связанные по смыслу операции — установка пакетов и чистка за ними, копирование зависимостей и их сборка — должны быть одной инструкцией RUN, а не цепочкой из нескольких. Это одновременно снижает число слоёв, итоговый размер (кэш пакетного менеджера не попадает в финальный слой) и время сборки (меньше накладных расходов на коммит промежуточных состояний):
# Плохо: три слоя, кэш apt всё равно останется в образе навсегда
RUN apt-get update
RUN apt-get install -y curl git build-essential
RUN rm -rf /var/lib/apt/lists/*
# Хорошо: один слой, кэш чистится до коммита
RUN apt-get update && \
apt-get install -y --no-install-recommends curl git build-essential && \
rm -rf /var/lib/apt/lists/*
Но объединение слоёв — не единственная переменная. Не менее важен порядок инструкций, потому что от него зависит эффективность кэша сборки. Docker кэширует слои последовательно: как только меняется содержимое одного слоя (или контекст, который в него попадает), инвалидируются он и все последующие слои, даже если их содержимое не менялось. Отсюда правило: то, что меняется редко, должно идти раньше в Dockerfile; то, что меняется часто (исходный код приложения), — ближе к концу.
Типичная ошибка для Node.js-проекта — сначала копировать весь код, потом ставить зависимости:
# Плохо: любое изменение кода инвалидирует кэш npm install
COPY . .
RUN npm ci --omit=dev
При такой последовательности правка одной строки в коде приложения заставляет Docker заново выполнять npm ci при каждой сборке — минуты впустую на то, что не менялось. Правильный порядок — сначала файлы с описанием зависимостей, установка, и только потом код:
# Хорошо: npm ci кэшируется, пока не меняются package*.json
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
Тот же принцип работает для любого языка: сначала go.mod/go.sum и go mod download, потом исходники; сначала requirements.txt и pip install, потом код Python. Правильный порядок слоёв не уменьшает итоговый размер образа сам по себе, но радикально ускоряет повторные сборки на CI и локально — а на практике именно скорость итерации, а не разовый размер образа, чаще всего съедает время команды.
Multi-stage build и .dockerignore закрывают то, что не решает порядок слоёв
Даже идеально сгруппированные RUN и правильный порядок кэша не убирают из образа то, что вообще не должно было туда попасть. Два инструмента закрывают эту проблему с разных сторон.
Multi-stage build решает вопрос инструментов сборки: компилятор, dev-зависимости, полный тулчейн языка нужны только на этапе сборки, а не в рантайме. Несколько инструкций FROM в одном Dockerfile позволяют держать промежуточные стадии со всем этим набором отдельно, а в финальный образ копировать через COPY --from=<стадия> только готовые артефакты — бинарник, собранный фронтенд, установленные production-зависимости. Итоговый образ не содержит ни исходного кода, использованного для сборки, ни компилятора, ни кэша сборочных инструментов. Детальный разбор с примерами для разных стеков — в статье Docker multi-stage build: оптимизация образа.
.dockerignore решает другую проблему — что вообще попадает в контекст сборки, который Docker целиком упаковывает и передаёт демону ещё до первой инструкции Dockerfile. Без него в контекст попадает .git с полной историей репозитория, локальные node_modules, .env с секретами, логи и кэши IDE — часть этого может случайно утечь в образ через широкий COPY . .. Минимальный набор для большинства проектов:
.git
node_modules
.env
.env.*
*.md
dist
coverage
__pycache__
Эффект от .dockerignore в первую очередь про скорость сборки (меньше данных передаётся демону, особенно заметно при удалённом Docker-хосте) и про безопасность, а не про итоговый размер образа — тот определяется в основном тем, что реально попадает в RUN/COPY внутри уже упакованного контекста.
Где реально начинаются проблемы с большими образами
Абстрактный «большой образ» — не проблема сам по себе, пока он не встречается с конкретными ограничениями инфраструктуры. Вот где это происходит на практике.
Диск на узлах. Каждый хост, где запускается контейнер, хранит локальную копию всех слоёв образа в /var/lib/docker/overlay2/. На VPS с диском 20-40 ГБ несколько версий тяжёлых образов (старая версия ещё не удалена, новая уже скачана — типичная ситуация при rolling-деплое) быстро съедают заметную долю места. Проверить текущее потребление:
docker system df
du -sh /var/lib/docker/overlay2/* 2>/dev/null | sort -rh | head -20
Если у вас несколько узлов, на каждый из которых доезжает большой образ, — расход диска умножается на число узлов, и это часто недооценивают при планировании ёмкости кластера. Подробный разбор причин и признаков нехватки места из-за Docker — в статье Docker занимает всё место на диске: причины и решение.
Скорость деплоя. Каждый rolling update или пересоздание контейнера означает docker pull образа на узел, если его там ещё нет или он изменился. Образ весом несколько гигабайт вместо нескольких сотен мегабайт превращает деплой из операции на секунды в операцию на минуты — причём это время умножается на число узлов, если раскатка идёт последовательно, а не параллельно. При частых релизах (несколько деплоев в день) такая разница накапливается в часы простоя или ожидания за неделю.
Затраты на хранение и трафик реестра. Если вы используете приватный реестр — каждая версия образа занимает место в его хранилище, и без политики ретеншена (удаления старых тегов) объём растёт бесконечно. Push и pull больших образов также нагружают канал между CI, реестром и продакшн-узлами — при не самом широком канале это может стать узким местом раньше, чем ресурсы самого сервера.
Холодный старт в масштабируемых средах. Если инфраструктура автоматически добавляет узлы под нагрузку (autoscaling), новый узел должен сначала скачать образ, прежде чем сможет принять трафик. Крупный образ напрямую увеличивает время, за которое система реагирует на всплеск нагрузки — иногда это критичнее, чем экономия дискового пространства как таковая.
Важная оговорка: конкретные пороги, при которых это становится ощутимой проблемой, зависят от канала, диска, числа узлов и частоты деплоев на конкретной инфраструктуре — универсального числа мегабайт, после которого «пора беспокоиться», не существует, и я не буду его выдумывать. Ориентир один: если размер образа заметно вырос без соответствующего роста функциональности приложения — это повод проверить Dockerfile, а не просто докупить диск.
Как измерить и держать размер под контролем
Оптимизация вслепую чаще тратит время, чем экономит. Порядок действий, который работает на практике:
- Зафиксировать текущее состояние.
docker images myappпокажет итоговый размер,docker history myapp:latest --no-trunc— вклад каждого слоя по отдельности. Часто проблема видна сразу: одинRUN apt-get installбез флага--no-install-recommendsвесит в разы больше, чем весь код приложения.
- Посмотреть на файлы внутри слоёв, если история не даёт ответа. Сторонние инструменты вроде
diveпоказывают содержимое каждого слоя файл за файлом — полезно, когда непонятно, откуда взялся вес, а не только какая инструкция его добавила.
- Свести число «лишних» слоёв к минимуму, объединив логически связанные
RUN, и проверить порядок инструкций с точки зрения кэша, как описано выше.
- Ввести бюджет размера в CI, если образ используется в команде не в одиночку. Простая проверка после сборки — сравнить размер с предыдущим тегом и упасть с ошибкой при превышении разумного порога — ловит регрессии («кто-то добавил тяжёлую зависимость») раньше, чем они доедут до продакшн-узлов:
SIZE=$(docker image inspect myapp:latest --format='{{.Size}}')
echo "Image size: $((SIZE / 1024 / 1024)) MB"
- Периодически чистить неиспользуемые образы и слои на самих узлах — со временем даже без изменений в Dockerfile
/var/lib/docker/overlay2/накапливает мусор от старых версий и остановленных контейнеров:
docker system prune -a --filter "until=168h"
Флаг until ограничивает чистку объектами старше недели — это безопаснее, чем прунить вообще всё, если на узле параллельно идёт активная разработка с частыми локальными сборками.
Ни один из этих шагов не требует переписывать приложение — только дисциплины в Dockerfile и регулярности в проверках. Разовая оптимизация даёт разовый эффект, а вот привычка следить за размером образа при каждом изменении зависимостей — тот самый рычаг, который не даёт проблеме накопиться незаметно за несколько месяцев разработки.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Сколько слоёв в Dockerfile считается «слишком много»?
Формального числа нет. Ориентируйтесь не на счётчик слоёв, а на итоговые показатели: время сборки, размер образа, время деплоя. Если Dockerfile содержит десятки не сгруппированных RUN, это почти всегда можно и стоит сократить — но не потому, что где-то рядом лимит, а потому что каждый лишний слой добавляет накладные расходы.
Влияет ли число слоёв на скорость запуска уже скачанного образа?
Практически нет. Основные накладные расходы, связанные со слоями, — это сборка, pull/push и место на диске. Когда образ уже локально распакован в overlay2, запуск контейнера — это создание тонкого read-write слоя поверх готовых read-only слоёв, и число последних на эту операцию почти не влияет.
Стоит ли специально уменьшать число слоёв, если объединение RUN-команд усложняет чтение Dockerfile?
Разумный компромисс — объединять по смыслу (установка + чистка кэша — вместе), а не гнаться за минимальным абсолютным числом слоёв в ущерб читаемости. Комментарии и переносы строк внутри одной длинной RUN-команды через \ решают проблему читаемости, не создавая лишних слоёв.
ADD и COPY одинаково влияют на число и размер слоёв?
Обе создают отдельный слой на каждый вызов, разница в поведении: ADD умеет автоматически распаковывать локальные архивы и скачивать файлы по URL, COPY — только копирует. Из-за менее предсказуемого поведения ADD для копирования файлов проекта обычно рекомендуют COPY, оставляя ADD для редких случаев, где действительно нужна распаковка архива на лету.
Помогает ли BuildKit (используется в Docker по умолчанию в актуальных версиях) с проблемой числа слоёв?
BuildKit ускоряет саму сборку за счёт параллельного выполнения независимых стадий и более умного кэширования (включая кэш между сборками через --cache-from), но не меняет саму механику: каждая инструкция RUN/COPY/ADD по-прежнему создаёт слой, и правила по их объединению остаются актуальными независимо от того, какой сборщик их выполняет.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →