1. Необходимый базис Docker
Цель темы: понимать, почему Docker работает именно так: какие механизмы ядра Linux обеспечивают изоляцию и ограничение ресурсов контейнера, как устроена сеть и файловая система, и чем контейнер принципиально отличается от виртуальной машины.
Определения терминов
Контейнер (в контексте Linux/Docker)
Контейнер — процесс (или группа процессов), изолированный от остальной системы с помощью namespaces и ограниченный по ресурсам с помощью cgroups. Контейнер использует то же ядро, что и хост; отдельной ОС внутри контейнера нет. Это отличает контейнер от виртуальной машины (VM), где работает своё ядро в гипервизоре.
Namespaces (пространства имён)
Namespace — механизм ядра Linux, который изолирует определённый вид ресурсов так, что процесс внутри namespace «видит» только свои сущности (свои PID, свою сеть, свои точки монтирования и т.д.). Разные типы namespace изолируют разные аспекты:
- PID — дерево процессов: в контейнере PID 1 — это процесс контейнера, а не хост.
- Network — свои сетевые интерфейсы, сокеты, таблицы маршрутизации.
- Mount — своё дерево монтирований (файловая система).
- UTS — hostname и domainname.
- IPC — объекты межпроцессного взаимодействия (очереди сообщений, семафоры).
- User — изоляция UID/GID (опционально; позволяет маппинг пользователей хост↔контейнер).
Без namespace контейнер был бы просто процессом на хосте без изоляции.
Cgroups (control groups)
Cgroups — механизм ядра для ограничения, учёта и приоритизации использования ресурсов (CPU, память, диск I/O, сеть) группой процессов. Драйвер runc/Docker создаёт для контейнера свою группу и задаёт лимиты (например, 512 MiB памяти, 0.5 CPU). При превышении лимита памятью процесс может быть убит (OOMKilled); CPU ограничивается через квоты или доли.
Основные подсистемы: cpu, memory, blkio (блочные устройства), pids (лимит числа процессов).
OverlayFS (слои файловой системы)
OverlayFS — тип файловой системы в ядре Linux, который объединяет несколько нижних каталогов (layers) и один верхний (upper) в одно видимое дерево. Нижние слои только для чтения; изменения записываются в upper. Docker использует overlay2 (драйвер хранения) поверх слоёв образа и контейнера: образ — нижние слои, слой контейнера — верхний, куда пишутся изменения при работе контейнера.
Bridge (мост), veth (виртуальная пара Ethernet)
Bridge — виртуальный коммутатор в ядре. Контейнеры по умолчанию подключаются к одному bridge (docker0); у каждого контейнера есть пара veth: один конец в namespace контейнера (например, eth0), другой — привязан к bridge на хосте. Трафик между контейнерами и между контейнером и хостом идёт через этот bridge.
NAT и port mapping
NAT (masquerade) — подмена исходящего адреса пакетов контейнера на IP хоста, чтобы ответы возвращались обратно. Port mapping (publish) — проброс порта хоста на порт контейнера: при обращении к host:8080 ядро перенаправляет трафик в контейнер на порт 80. Реализуется через iptables (правила DOCKER, PREROUTING, OUTPUT).
PID 1 в контейнере
В PID namespace контейнера первый запущенный процесс имеет PID 1. К нему предъявляются особые требования: он должен перезапускать дочерние процессы (reap zombies) и корректно обрабатывать сигналы (SIGTERM для graceful shutdown). Если в качестве PID 1 запускают обычное приложение, не предназначенное для этого, возможны «зомби»-процессы и неверная реакция на остановку контейнера.
Контейнер vs виртуальная машина
| Критерий | Контейнер | Виртуальная машина |
|---|---|---|
| Ядро ОС | Общее с хостом | Своё ядро в гостевой ОС |
| Изоляция | Namespaces + cgroups | Гипервизор (аппаратная виртуализация или паравиртуализация) |
| Запуск | Быстрый (процессы) | Дольше (загрузка гостевой ОС) |
| Потребление ресурсов | Обычно меньше (нет второй ОС) | Выше (гостевая ОС + приложения) |
| Безопасность | Изоляция на уровне ядра; общий kernel — общая поверхность атаки | Сильнее изоляция (другое ядро) |
Пример
Контейнер — это изолированный процесс на том же ядре. VM — отдельная машина с собственным ядром. Поэтому «выйти из контейнера на хост» при уязвимости ядра теоретически возможно; из VM «выйти» на хост сложнее, если гипервизор изолирован.
Linux: namespaces
Namespaces создаются при запуске контейнера (через runc/containerd). Типы, важные для понимания Docker:
- PID — процессы в контейнере видят только свои PID; внутри контейнера может быть свой «init» с PID 1.
- Network — у контейнера свои
lo,eth0; нет доступа к сетевым интерфейсам хоста напрямую (если не использован режимhost). - Mount — своё дерево монтирований; корень ФС — слой образа + слой контейнера (overlay).
- UTS — hostname контейнера не совпадает с хостом.
- User — при включённом user namespace UID/GID в контейнере могут мапиться на непривилегированного пользователя на хосте (безопасность).
Проверка namespace процесса (на хосте):
ls -la /proc/<pid>/ns/
Для контейнера <pid> — PID главного процесса контейнера на хосте.
Linux: cgroups
Cgroups ограничивают использование CPU, памяти, I/O. Docker задаёт лимиты через флаги --memory, --cpus, --blkio-weight и т.д.
Пример просмотра cgroup контейнера на хосте (v2):
cat /sys/fs/cgroup/system.slice/docker-<container_id>.scope/memory.max
При исчерпании лимита памяти ядро может завершить процесс в контейнере (OOMKiller); в docker inspect или логах это часто отображается как причина выхода (OOMKilled).
Практика
В production всегда задавайте лимиты памяти (--memory) и при необходимости CPU (--cpus), чтобы один контейнер не мог исчерпать ресурсы хоста.
Файловая система: слои и OverlayFS
Образ Docker состоит из слоёв (read-only). При создании контейнера поверх образа добавляется слой для записи (read-write). При записи в файл, который в нижнем слое, overlay копирует его в верхний слой (copy-on-write) и там вносит изменения.
Драйвер по умолчанию в современных установках — overlay2. Данные контейнера (верхний слой) хранятся в каталоге, привязанном к контейнеру (например, под /var/lib/docker/overlay2/).
Следствия:
- Удаление файла из образа в работающем контейнере — это не физическое удаление с диска, а «whiteout» в верхнем слое; место может не освободиться до удаления контейнера.
- Большие объёмы данных лучше хранить в томах (volumes), а не писать в слой контейнера.
Сеть: bridge, veth, iptables
Bridge и veth
По умолчанию Docker создаёт bridge docker0. Каждый контейнер получает пару veth: один интерфейс внутри контейнера (например, eth0 с IP из подсети bridge), второй — на хосте, привязан к docker0. ARP и маршрутизация между контейнерами идут через bridge.
NAT и port mapping
Исходящий трафик контейнера (в интернет) проходит через NAT на хосте (masquerade). Входящий к опубликованному порту (например, -p 8080:80) обрабатывается правилами iptables: DOCKER цепочки, PREROUTING (внешний трафик), OUTPUT (трафик с самого хоста к контейнеру). Порт на хосте слушает процесс docker-proxy или напрямую правила iptables (в зависимости от версии и настроек).
Проверка правил (на хосте):
sudo iptables -t nat -L -n -v | head -30
Production
Если контейнер «не видит» внешний мир или до него не доходят запросы, проверяют: привязку к bridge, маршруты внутри контейнера, правила iptables на хосте и не блокирует ли трафик firewall (firewalld, ufw).
Процессы: PID 1, сигналы, зомби
PID 1 в контейнере
В контейнере процесс, запущенный по ENTRYPOINT/CMD, получает PID 1 в своём PID namespace. Ядро возлагает на PID 1 особую роль: перехват завершённых дочерних процессов (reap), чтобы они не становились зомби. Обычное приложение (nginx, node) часто не делает wait() для дочерних процессов; тогда зомби накапливаются.
Решение: использовать init-процесс в качестве PID 1, который запускает приложение как дочерний процесс и перезапускает завершённых детей. Популярные варианты: tini, dumb-init. В Docker есть встроенная опция --init, подключающая tini.
docker run --init myimage
В Dockerfile можно установить tini/dumb-init и сделать их ENTRYPOINT, а приложение — аргументом.
Сигналы
При docker stop контейнеру отправляется SIGTERM; через таймаут (по умолчанию 10 с) — SIGKILL. Если PID 1 не обрабатывает SIGTERM (например, не пересылает его приложению), контейнер не сможет корректно завершиться. Поэтому важно, чтобы PID 1 либо сам обрабатывал завершение, либо был init, который пересылает сигнал приложению.
Внимание
Если контейнер «не останавливается» и через 10 секунд принудительно убивается, скорее всего PID 1 не обрабатывает SIGTERM. Добавьте --init или смените ENTRYPOINT на tini/dumb-init.
Паттерны использования
| Паттерн | Описание |
|---|---|
| Задавать лимиты ресурсов | Всегда указывать --memory и при необходимости --cpus для предсказуемости и защиты хоста. |
| Использовать --init для приложений | Если образ не использует своего init (tini/dumb-init), запускать с docker run --init. |
| Данные в томах, не в слое контейнера | Персистентные и большие данные — в volumes; слой контейнера — для временных и небольших изменений. |
| Понимать сеть по умолчанию | Контейнеры в одном bridge видят друг друга по имени (если задан network alias) или по IP; наружу — через NAT. |
Антипаттерны
| Антипаттерн | Почему плохо | Что делать |
|---|---|---|
| Без лимитов памяти/CPU | Один контейнер может исчерпать ресурсы хоста и повалить остальные. | Задавать --memory, --cpus; в оркестраторах — requests/limits. |
| Приложение как PID 1 без init | Зомби-процессы, неверная обработка SIGTERM, контейнер не останавливается gracefully. | Добавить tini/dumb-init или docker run --init. |
| Путать контейнер и VM | Ожидание «полной» изоляции как у VM; недооценка рисков общего ядра. | Считать контейнер изолированным процессом; для жёсткой изоляции использовать VM или изолированные ноды. |
| Хранить большие данные в слое контейнера | Раздувание верхнего слоя, медленная работа, потеря при пересоздании контейнера. | Использовать volumes (или bind mounts) для данных. |
Примеры из production
OOMKilled в логах
Контейнер завершился с кодом 137 или в событиях видно OOMKilled — исчерпан лимит памяти. Реакция: поднять --memory или оптимизировать приложение; проверить утечки памяти.
Контейнер не останавливается по docker stop
Контейнер висит 10 секунд и затем убивается. Причина — PID 1 не обрабатывает SIGTERM. Решение: перейти на образ с tini/dumb-init или запуск с --init; в Dockerfile переписать ENTRYPOINT на init + приложение.
Сеть: контейнер не видит другой контейнер
Проверить, что оба в одной сети (или в одном bridge по умолчанию); что приложение слушает на 0.0.0.0, а не на 127.0.0.1; что нет опечаток в имени сервиса и порту. Для выхода в интернет проверить NAT и DNS (например, dns в docker daemon).