Перейти к содержанию

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).


Дополнительные материалы