Раздел 2 · Linux и контейнеры — глава 2.2

Контейнеры: namespaces, cgroups и образы

Контейнер часто объясняют словом «лёгкая виртуалка». Это удобная, но вредная метафора: она скрывает и устройство контейнера, и большинство его поломок. Контейнер — обычный процесс Linux. Ядро просто ограничило, что он видит, сколько ресурсов может потратить и из какого дерева файлов стартует.

§1Процесс, которому ограничили мир

Виртуальная машина получает виртуальное железо и загружает собственное ядро. Контейнер использует ядро хоста: его read(), connect() и остальные системные вызовы обрабатывает то же ядро, что обслуживает все соседние контейнеры.

Виртуальная машина Контейнер ────────────────────────────── ────────────────────────────── приложение приложение — обычный процесс гостевые библиотеки rootfs с библиотеками гостевое ядро namespaces + cgroups виртуальное железо общее ядро хоста гипервизор физическое железо ядро хоста физическое железо

Отсюда три практических вывода. Контейнер стартует быстро, потому что не загружает ОС. Образ не обязан содержать ядро, systemd и драйверы — обычно там только приложение и его зависимости. И граница изоляции слабее, чем у VM: ошибка в общем ядре потенциально затрагивает хост.

Спросят

«Можно ли запустить Windows-контейнер на Linux?» — Не напрямую: пользовательское пространство ожидает системные вызовы Windows, а хост предоставляет ядро Linux. Нужна виртуальная машина с подходящим ядром. По той же причине контейнер не может загрузить собственный модуль ядра независимо от хоста.

§2Namespaces: что именно контейнеру показывают отдельно

Namespace не ограничивает ресурс — он меняет видимость. Процесс внутри получает собственное представление отдельных частей системы.

NamespaceЧто изолируетЧто видно в контейнере
pidНомера и дерево процессовСвой процесс как PID 1 и только процессы своего пространства
netИнтерфейсы, маршруты, порты, conntrackСвой eth0, lo, таблица маршрутов и свободный порт 8080
mntТочки монтированияСобственное дерево файлов, собранное из слоёв образа и volumes
utsHostname и domain nameСобственное имя хоста
ipcОчереди сообщений, shared memory, семафорыСобственные IPC-объекты
userUID и GIDUID 0 внутри может отображаться в непривилегированный UID снаружи
cgroupВидимость дерева cgroupСвою точку в иерархии ограничений
timeСмещения системных часовОтдельные значения монотонного и boot-time clock offset

Namespaces можно увидеть без Docker. lsns показывает пространства имён в системе, readlink /proc/PID/ns/net — сетевое пространство конкретного процесса, а nsenter -t PID -n запускает команду внутри него. Именно этим приёмом удобно смотреть сеть контейнера, когда внутри образа нет диагностических утилит.

Namespace не является стеной сам по себе. Если дать контейнеру --network host, он разделит сетевое пространство хоста; с --pid host увидит все процессы. Контейнер остаётся контейнером, но часть изоляции сознательно выключена.

§3Cgroups: сколько контейнеру разрешено потратить

Cgroups отвечают уже не за видимость, а за учёт и ограничения ресурсов. Через них Docker и Kubernetes ограничивают CPU, память, ввод-вывод и количество процессов.

CPU и память ведут себя принципиально по-разному. CPU можно выдавать порциями: если процесс исчерпал квоту, планировщик приостановит его до следующего периода. Это throttling — приложение медленнее, но живо. Память нельзя «замедлить»: если cgroup пересекла жёсткий предел и ядру нечего освободить, OOM killer убивает процесс. В Kubernetes это видно как OOMKilled, часто с кодом 137.

# к какой cgroup относится процесс
$ cat /proc/4242/cgroup
0::/system.slice/docker-a82f....scope

# cgroup v2: лимит и текущее потребление памяти
$ cat /sys/fs/cgroup/system.slice/docker-a82f....scope/memory.max
536870912
$ cat /sys/fs/cgroup/system.slice/docker-a82f....scope/memory.current
311246848

# накопленное время CPU throttling
$ cat /sys/fs/cgroup/system.slice/docker-a82f....scope/cpu.stat
usage_usec 9281451
nr_throttled 184
throttled_usec 2118043

Грабли

Метрика CPU внутри контейнера без учёта cgroup может вводить в заблуждение: приложение видит много процессоров хоста, хотя квота разрешает долю одного ядра. Диагностируя задержки, проверяй throttling и лимиты, а не только общий CPU узла.

§4Что происходит от docker run до процесса

Docker — не особый тип контейнера. Это удобный API и набор компонентов поверх стандартной модели OCI. Упрощённая цепочка выглядит так:

docker run nginx │ ▼ dockerd — API, образы, сети, volumes │ ▼ containerd — жизненный цикл контейнеров и образов │ ▼ containerd-shim — остаётся рядом с запущенным контейнером │ ▼ runc — создаёт namespaces/cgroups, монтирует rootfs, запускает процесс │ ▼ nginx — обычный процесс Linux

runc получает OCI-конфигурацию: какой rootfs смонтировать, какую команду запустить, какие namespaces создать, какие capabilities и лимиты оставить. После запуска главный долгоживущий компонент — само приложение; runc завершается. В Kubernetes место Docker Engine обычно занимают containerd или CRI-O, с которыми kubelet разговаривает через CRI.

На собеседовании полезно разделять интерфейсы: OCI стандартизует формат образа и запуск контейнера, CRI — разговор kubelet с runtime, CNI — подключение сети, CSI — подключение хранилища. Похожие аббревиатуры решают разные задачи.

§5Образ, слои и writable layer

Образ — неизменяемый набор слоёв плюс конфигурация запуска. Каждый слой хранит разницу относительно предыдущего. При старте runtime объединяет слои в одно дерево и сверху добавляет тонкий записываемый слой контейнера.

контейнер ┌──────────────────────────────┐ │ writable layer: /tmp, логи │ ← исчезнет вместе с контейнером ├──────────────────────────────┤ │ COPY app /app │ ├──────────────────────────────┤ │ RUN install dependencies │ ├──────────────────────────────┤ │ базовый образ │ ← общие immutable-слои └──────────────────────────────┘

При изменении файла из нижнего слоя объединяющая файловая система сначала копирует его наверх — copy-up — и меняет уже копию. Поэтому активная база данных в writable layer плоха не только из-за потери данных: такой паттерн добавляет лишнюю работу файловой системе.

Удаление в следующем слое не стирает байты из предыдущего. Если скопировать секрет, а позже сделать rm, итоговый вид файловой системы будет чистым, но секрет останется внутри старого слоя. Секреты сборки передают через механизм BuildKit secret mounts, а утёкший секрет всё равно отзывают.

§6Dockerfile: кеш, воспроизводимость и маленький финальный образ

Кеш сборки переиспользует результат шага, пока не изменились инструкция и её входы. Поэтому сначала копируют файлы зависимостей, устанавливают пакеты и только затем копируют часто меняющийся код.

# syntax=docker/dockerfile:1
FROM golang:1.25 AS build
WORKDIR /src

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 go build -trimpath -o /out/app ./cmd/app

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]

Многоступенчатая сборка оставляет компилятор и исходники в стадии build, а в финальный образ переносит только артефакт. Это сокращает размер и площадь атаки. Но минимальность не самоцель: если образ невозможно диагностировать, в Kubernetes нужны ephemeral containers или отдельный отладочный образ.

Тег удобен человеку, но изменяем: сегодня app:prod может указывать на один образ, завтра — на другой. Digest вида sha256:... идентифицирует содержимое. Для воспроизводимого продакшена собирают артефакт один раз, продвигают между окружениями тот же digest и не пересобирают его отдельно для prod.

§7Где живут данные: writable layer, bind mount и volume

МеханизмКому принадлежитКогда использовать
Writable layerКонкретному контейнеруВременные файлы и кеш, который можно потерять
Bind mountПути на хостеРазработка, конфиги или осознанная привязка к устройству хоста
Named volumeRuntimeДанные между пересозданиями контейнера без жёсткого пути на хосте
TmpfsПамятиЧувствительные или быстрые временные данные без записи на диск

Volume делает данные долговечнее контейнера, но не превращает их в бэкап. Удаление данных приложением, повреждение файловой системы и ошибка оператора доберутся до volume так же быстро. Бэкап должен жить независимо и регулярно проверяться восстановлением.

§8PID 1, сигналы и корректная остановка

Контейнер живёт, пока жив его главный процесс. Если он ушёл в фон и родитель завершился, runtime считает контейнер остановленным. Поэтому демоны внутри контейнера запускают в foreground-режиме.

Главный процесс получает PID 1 внутри своего namespace. У него две особенности: он должен собирать завершившихся детей через wait(), и для некоторых сигналов ядро не применяет обычное действие по умолчанию. Если приложение не обрабатывает SIGTERM, штатная остановка легко заканчивается SIGKILL после grace period.

Shell-форма CMD ./app запускает /bin/sh -c как PID 1, а приложение становится ребёнком и может не получить сигнал. Exec-форма CMD ["./app"] запускает приложение напрямую. Если обёртка нужна, последняя команда в ней — exec "$@". Чужому приложению, которое не умеет быть init, помогает tini или Docker-флаг --init.

В бою

Проверка остановки входит в тест контейнера: запусти его, отправь SIGTERM, убедись, что приложение перестало принимать новые запросы, завершило текущие и вышло до таймаута. Работоспособный health check не доказывает корректный shutdown.

§9Контейнер — граница удобства, а не абсолютной безопасности

Базовый принцип — убрать всё, что процессу не нужно:

  • запускать не от root и по возможности использовать user namespace или rootless runtime;
  • оставлять минимальный набор Linux capabilities, а не выдавать --privileged;
  • включать seccomp/AppArmor/SELinux и read-only root filesystem там, где приложение это позволяет;
  • не монтировать Docker socket: доступ к нему практически равен root-доступу к хосту;
  • не хранить секреты в образе и не передавать их через аргументы командной строки;
  • фиксировать базовые образы, регулярно пересобирать и сканировать итоговый артефакт.

root внутри обычного контейнера часто остаётся root на хосте с тем же числовым UID; namespaces скрывают объекты, но не отменяют полномочия ядра. User namespaces меняют это отображение: UID 0 внутри соответствует непривилегированному UID снаружи. Rootless-режим идёт дальше и запускает без root ещё и сам daemon.

§10Диагностика: идти от состояния процесса наружу

# состояние, код возврата, PID и лимиты
$ docker inspect app --format \
  'status={{.State.Status}} exit={{.State.ExitCode}} pid={{.State.Pid}} oom={{.State.OOMKilled}}'

# логи и потребление ресурсов
$ docker logs --tail 100 app
$ docker stats app

# процессы и файловая система
$ docker top app
$ docker diff app

# сеть без установки утилит в контейнер
$ PID=$(docker inspect -f '{{.State.Pid}}' app)
$ sudo nsenter -t "$PID" -n ip route
$ sudo nsenter -t "$PID" -n ss -lntp

# конфигурация образа и история слоёв
$ docker image inspect app:1.7
$ docker history --no-trunc app:1.7

Удобный порядок: контейнер вообще запущен → чем завершился → что пишет главный процесс → не упёрся ли он в лимит → слушает ли ожидаемый порт → какой маршрут и DNS видны внутри namespace → что изменилось в writable layer. Это быстрее, чем начинать с перезапуска.

§11Как это звучит в ответе

«Контейнер — обычный процесс на ядре хоста. Namespaces дают ему отдельную видимость процессов, сети, mount points и пользователей, cgroups учитывают и ограничивают CPU, память и I/O, а capabilities и seccomp сужают доступ к операциям ядра. Runtime монтирует immutable-слои образа, добавляет writable layer и запускает главный процесс по OCI-конфигурации.

От VM контейнер отличается отсутствием собственного ядра: поэтому он быстрее и компактнее, но делит границу безопасности с хостом. В проде я запускаю приложение не от root, в exec-форме, с обработкой SIGTERM, фиксированным digest образа и минимальными правами. Данные, которые должны пережить контейнер, выношу в volume, но volume не считаю бэкапом. При диагностике сначала смотрю exit code, OOMKilled и логи, затем лимиты, процессы и namespace сети.»

Проверь себя

  1. Почему два контейнера могут одновременно слушать порт 8080?
  2. Почему CPU limit замедляет, а memory limit убивает?
  3. Почему удалённый в следующем слое секрет всё ещё считается утёкшим?
  4. Что произойдёт при shell-форме ENTRYPOINT во время остановки?
  5. Чем OCI отличается от CRI, CNI и CSI?