MAATRIX / Блог / Миф: Docker всегда медленнее, чем без него

Миф: Docker всегда медленнее, чем без него

MAATRIX

«Мы гоняли приложение без контейнеров, потому что 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 или сети.

Почему синтетические тесты вводят в заблуждение

Часть репутации мифа держится на бенчмарках, которые сравнивают «голый бинарник» с «контейнером» некорректно. Типичные ошибки в таких сравнениях:

  1. Разный лимит ресурсов по умолчанию. Хост-процесс не ограничен ничем, а контейнер запущен с --memory=512m --cpus=1 — сравниваются не Docker с не-Docker, а урезанные ресурсы с полными.
  2. Холодный старт против прогретого процесса. Первый запуск контейнера с образом, не закэшированным в page cache хоста, будет медленнее — но это разовая стоимость распаковки слоёв, а не постоянный оверхед в работе.
  3. Разные версии рантайма внутри и снаружи контейнера. В образе нередко оказывается другая сборка Python/Node.js с другими флагами компиляции — разница списывается на Docker, хотя причина в другом артефакте.
  4. Тестирование через сеть там, где сравнение шло локально. Хост-версия слушает 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 ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

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