Миф: Docker всегда медленнее, чем без него
«Мы гоняли приложение без контейнеров, потому что Docker всё тормозит» — фраза, которую слышал почти каждый, кто хоть раз спорил в чате команды о переезде на контейнеры. В ней есть крупица правды: контейнеризация — это не бесплатный обед, лишний слой абстракции существует. Но в 9 случаях из 10 реальная причина замедления после переезда в Docker — не сам факт контейнеризации, а конкретная неоптимальная настройка: сеть через NAT там, где не нужно, забытый лимит памяти, раздутый образ. Разберём по слоям, что на самом деле происходит внутри контейнера и где миф ошибается.
Содержание
Что не виртуализируется: CPU и память
Первое, что нужно понять: Linux-контейнер — это не виртуальная машина. VMware, KVM или Hyper-V эмулируют железо (виртуальный CPU, виртуальную память, виртуальный диск), поверх которого крутится отдельное гостевое ядро. Это реальная накладная плата — гипервизор перехватывает привилегированные инструкции, транслирует адреса памяти через дополнительный уровень таблиц страниц, и хотя технологии вроде Intel VT-x/AMD-V и EPT/NPT снизили эти издержки почти до нуля для большинства операций, VM всё равно исполняет код на другом, гостевом ядре.
Docker-контейнер устроен принципиально иначе. Это обычный Linux-процесс, которому через namespaces показали урезанную картину системы (свой PID-namespace, свой network-namespace, свой mount-namespace), а через cgroups — ограничили доступный набор ресурсов. Ключевое слово — «показали» и «ограничили», а не «эмулировали». Процесс внутри контейнера:
- выполняет ровно те же машинные инструкции на том же физическом CPU, что и процесс на хосте;
- делает ровно те же системные вызовы (
read,write,mmap,futex...) напрямую в то же самое ядро хоста — без прослойки гостевой ОС; - обращается к той же физической RAM без дополнительной трансляции адресов (в отличие от EPT в виртуализации, здесь просто применяется обычный Linux MMU, как для любого процесса).
Практически проверить это легко: возьмите CPU-bound задачу (например, sysbench cpu --threads=4 run или собственный бенчмарк на перемножение матриц) и прогоните её сначала напрямую на хосте, потом в docker run --rm my-image. Разница по времени выполнения на современном ядре укладывается в статистический шум запуска — единицы процентов туда-сюда, объясняемые скорее вариативностью самого измерения, чем контейнером. Точные цифры зависят от вашего железа и версии ядра, поэтому не полагайтесь на чужие бенчмарки — прогоните свой тест на своём сервере, благо это пять минут работы.
Отдельно стоит упомянуть seccomp и AppArmor-профили, которые Docker навешивает по умолчанию — они фильтруют системные вызовы через BPF. Формально это добавляет проверку на каждый syscall, но она настолько дешёвая (десятки-сотни наносекунд), что для большинства приложений не измерима на фоне реальной работы I/O или сети.
Почему синтетические тесты вводят в заблуждение
Часть репутации мифа держится на бенчмарках, которые сравнивают «голый бинарник» с «контейнером» некорректно. Типичные ошибки в таких сравнениях:
- Разный лимит ресурсов по умолчанию. Хост-процесс не ограничен ничем, а контейнер запущен с
--memory=512m --cpus=1— сравниваются не Docker с не-Docker, а урезанные ресурсы с полными. - Холодный старт против прогретого процесса. Первый запуск контейнера с образом, не закэшированным в page cache хоста, будет медленнее — но это разовая стоимость распаковки слоёв, а не постоянный оверхед в работе.
- Разные версии рантайма внутри и снаружи контейнера. В образе нередко оказывается другая сборка Python/Node.js с другими флагами компиляции — разница списывается на Docker, хотя причина в другом артефакте.
- Тестирование через сеть там, где сравнение шло локально. Хост-версия слушает
localhost, контейнерная — проходит через bridge и NAT: сравнивается не «Docker vs без Docker», а «loopback vs сеть с NAT» — тут разница действительно может быть измеримой (см. следующий раздел).
Чтобы протестировать честно у себя — зафиксируйте одинаковые лимиты CPU/RAM для обоих сценариев, прогревайте кэш перед замером, используйте одинаковый сетевой путь и повторяйте измерение минимум 5-10 раз, отбрасывая выброс на холодном старте.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать VPSСеть: где разница действительно есть
Это первое место, где миф о медленном Docker имеет под собой реальную техническую почву. По умолчанию Docker создаёт контейнер в режиме bridge — контейнер получает виртуальный сетевой интерфейс, подключённый к виртуальному мосту docker0 на хосте, а весь входящий/исходящий трафик через проброшенные порты проходит через iptables-правила DNAT/MASQUERADE. Проверить их можно так:
iptables -t nat -L DOCKER -n -v
Каждый пакет на проброшенный порт (например, -p 8080:80) проходит через таблицу nat, где ядро подменяет адрес назначения, и дальше через conntrack для отслеживания состояния соединения. Для типичного веб-приложения с REST API это лишние микросекунды на пакет — незаметно на фоне сетевой задержки клиента и времени обработки запроса. Но для сценариев с очень высокой частотой пакетов и минимальной задержкой — игровых серверов с UDP на десятках тысяч пакетов в секунду, высоконагруженных reverse-proxy с миллионами RPS на инстанс — накладные расходы NAT и conntrack-таблицы становятся измеримыми, вплоть до роста задержки при исчерпании ephemeral-портов или размера conntrack-таблицы.
Для таких случаев у Docker есть режим --network=host, где контейнер вообще не получает отдельный network-namespace, а слушает порты напрямую на хосте — ровно так же, как обычный процесс:
services:
api:
image: my-api:latest
network_mode: host
Издержки NAT здесь исчезают полностью, поскольку сетевой стек больше не виртуальный. Обратная сторона — теряется изоляция портов между контейнерами (два контейнера с host-сетью не могут слушать один порт) и удобство service discovery по имени. Для одиночного высоконагруженного сервиса это разумный компромисс. Есть и промежуточный вариант — macvlan, отдельный MAC и IP в локальной сети без NAT хоста, но со своими нюансами настройки.
Разобраться в том, какой режим когда уместен, подробнее можно в статье про типы docker-сетей bridge, host и overlay — там же разбор типичных проблем с проброшенными портами.
Диск: storage-драйверы и когда они важны
Второе место, где миф частично оправдан — дисковый I/O, и снова не потому, что «Docker» как факт, а из-за конкретной конфигурации storage-драйвера. По умолчанию современный Docker использует overlay2 — объединённую файловую систему, которая накладывает read-write слой контейнера поверх read-only слоёв образа через copy-on-write.
Для чтения файлов, которые не менялись, overlay2 практически не добавляет накладных расходов — ядро читает их напрямую из нижних слоёв. А вот интенсивная запись прямо в writable-слой контейнера (например, если приложение пишет логи или временные файлы прямо в /app/tmp, а не в volume) может быть заметно медленнее прямой записи на файловую систему хоста — особенно при большом числе мелких файлов или частой перезаписи существующих, из-за механики copy-on-write (файл сначала полностью копируется из read-only слоя в writable, потом уже правится).
Практическое решение здесь простое и общеизвестное: не писать данные, которые важны для производительности или долговечности, в writable-слой контейнера. Вместо этого — использовать volume:
docker run -v pgdata:/var/lib/postgresql/data postgres:16
Именованные volumes монтируются напрямую в файловую систему хоста (по умолчанию /var/lib/docker/volumes/), минуя overlay2 и его copy-on-write — для баз данных и любого дискового I/O это стандартная практика, а не оптимизация «для продвинутых». Bind-mount (-v /host/path:/container/path) работает аналогично.
Отдельно стоит проверить, какой storage-драйвер вообще используется: docker info | grep "Storage Driver". Если вы видите что-то отличное от overlay2 на современном дистрибутиве (например, устаревший devicemapper в loop-lvm-режиме, годами известный медлительностью и нестабильностью) — вот это уже реальная и системная проблема, но она решается миграцией на overlay2, а не отказом от Docker целиком. С типами volumes и тем, когда какой использовать, подробнее — в статье про типы docker volumes.
cgroups: как лимиты превращаются в самострел
Вот где чаще всего рождается реальное, измеримое замедление — но виноват в нём не Docker, а тот, кто настраивал лимиты. Cgroups (control groups) — механизм ядра, которым Docker ограничивает CPU, память и I/O для контейнера. Настроенные бездумно, «на всякий случай», по шаблону из интернета, они создают проблемы, которых на голом хосте просто не существовало бы.
Классический пример — CPU throttling. Если задать --cpus=1.5 контейнеру, который в моменте пытается использовать 3 ядра под всплеск нагрузки, ядро включает CFS bandwidth control — процесс получает квоту процессорного времени за период (по умолчанию 100мс) и принудительно снимается с CPU после её исчерпания, даже если другие ядра простаивают. Снаружи это выглядит как необъяснимые просадки latency, хотя top может показывать, что CPU в целом не загружен. Проверить троттлинг: cat /sys/fs/cgroup/system.slice/docker-<container_id>.scope/cpu.stat — растущая строка nr_throttled подтверждает диагноз. Решение — поднять лимит с запасом под пиковую, а не среднюю нагрузку, либо использовать cpu.weight вместо жёсткой квоты.
Второй классический случай — лимит памяти без запаса. Если задать --memory=512m, а приложению в пике нужно 600 МБ (например, при GC-паузе в JVM/Node.js, временно раздувающей heap), контейнер не «подтормаживает» — его убивает OOM killer ядра, и вы получаете Exit Code 137 в логах. Это выглядит как «Docker нестабилен», хотя то же приложение с тем же лимитом памяти через ulimit на голом хосте вело бы себя идентично — просто на голом хосте лимит часто никто явно не ставит, вот и «Docker виноват».
Подробный разбор того, как cgroups технически ограничивают контейнер и что при этом реально происходит с ядром, — в статье как cgroups ограничивают контейнер, а конкретные значения для типовых сценариев — в статье про лимиты CPU и памяти для Docker.
Раздутый образ: где на самом деле теряется время
Отдельная категория жалоб на «медленный Docker» на деле относится не к рантайму, а к процессу деплоя и холодному старту. Образ, собранный без multi-stage build, тащит в продакшен весь набор инструментов сборки — компиляторы, dev-зависимости, кэш пакетного менеджера, исходники тестов. Это не замедляет работающее приложение ни на микросекунду (лишние файлы в образе не читаются во время работы), но напрямую увеличивает:
- время
docker pullпри деплое на новый сервер или при масштабировании — больше слоёв и больше суммарный размер значит дольше скачивание; - время холодного старта в оркестраторах, которые тянут образ перед запуском (Kubernetes, Swarm) — задержка перед первым запросом растёт;
- занимаемое место на диске и, как следствие, риск исчерпания диска логами и слоями старых образов одновременно.
Сравните два подхода к одному и тому же Node.js-приложению:
# Плохо: один stage, всё тащится в финальный образ
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
# Лучше: multi-stage, в финальном образе только рантайм и прод-зависимости
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]
Второй вариант обычно даёт образ в разы меньше по размеру (конкретная цифра зависит от приложения и зависимостей — не полагайтесь на чужие проценты, сравните docker images до и после у себя). Меньше слоёв — меньше поверхность для инвалидации кэша сборки при пересборке после мелких правок кода, что ускоряет CI/CD, хоть и не сам рантайм. Подробно тема разобрана в статье про multi-stage build и оптимизацию образа.
Важно отделить это от рантайм-производительности: контейнер из раздутого образа работает с той же скоростью, что и из компактного — файловая система образа не сканируется целиком при каждом запросе. Раздутый образ — это про деплой, диск и CI, не про CPU и латентность работающего процесса.
Как проверить у себя, а не верить на слово
Если приложение после переезда на Docker реально стало медленнее — диагностируйте по порядку, а не списывайте на контейнеризацию:
| Шаг | Что проверить | Команда | |
|---|---|---|---|
| 1 | Не троттлится ли CPU по cgroup-лимиту | cat .../cpu.stat, смотреть nr_throttled | |
| 2 | Не близко ли приложение к лимиту памяти (и не свопит ли) | docker stats, vmstat 1 | |
| 3 | Какой сетевой режим используется | `docker inspect <container> \ | grep NetworkMode` |
| 4 | Какой storage-драйвер и куда пишутся данные | `docker info \ | grep "Storage Driver"`, проверить volumes |
| 5 | Не идёт ли лишний трафик через NAT там, где можно host-сеть | iptables -t nat -L DOCKER -n -v | |
| 6 | Размер и число слоёв образа | docker images, docker history <image> |
В большинстве случаев один из этих шести пунктов даёт ответ. Если разница всё ещё измерима и стабильно воспроизводится — повод смотреть глубже (версия ядра, драйвер CNI при оркестрации, специфика приложения), но такие случаи — редкое исключение, а не типичная картина для веб-приложения на VPS.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать VPSНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Правда ли, что Kubernetes и оркестрация добавляют больше оверхеда, чем сам Docker?
Да, оверхед оркестрации (service mesh, CNI-плагины, sidecar-контейнеры, лишние слои маршрутизации между подами) обычно заметнее оверхеда самого контейнера. Это отдельный вопрос от «Docker vs без Docker» — сравнивать нужно архитектуру деплоя, а не факт контейнеризации.
А как же виртуализация внутри Docker Desktop на macOS и Windows — там же есть VM?
Да, и это важное исключение: там Docker Desktop реально гоняет Linux-контейнеры внутри лёгкой VM (Hyperkit/WSL2), потому что ядро Linux нужно эмулировать целиком. Оверхед там выше, особенно на файловый I/O через смонтированные тома. На проде, где Docker работает нативно на Linux-сервере (то есть на большинстве арендованных VPS), этого слоя просто нет.
Нужно ли всегда использовать --network=host для скорости?
Нет, только когда вы упёрлись в измеримые издержки NAT для конкретного высоконагруженного сценария. Для типичного веб-бэкенда разница не будет заметна, а вы потеряете изоляцию портов между контейнерами.
Стоит ли вообще отказываться от Docker ради максимальной производительности?
Для 95% типичных задач (веб-бэкенд, API, база данных, очередь) — нет: выгоды контейнеризации перевешивают недоказанную «медленность». Отказ оправдан в узких high-performance нишах с латентностью в микросекундах, и там решение обычно точечное (host-сеть), а не полный отказ от контейнеров.
Как быстро проверить, тормозит ли именно Docker, а не само приложение?
Прогоните один и тот же запрос через ab или wrk сначала к процессу напрямую на хосте, потом к тому же процессу в контейнере с теми же лимитами и тем же сетевым режимом. Если разница в пределах погрешности — дело не в Docker.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →