Раздел 2 · Linux и контейнеры — глава 2.2
Контейнеры: namespaces, cgroups и образы
Контейнер часто объясняют словом «лёгкая виртуалка». Это удобная, но вредная метафора: она скрывает и устройство контейнера, и большинство его поломок. Контейнер — обычный процесс Linux. Ядро просто ограничило, что он видит, сколько ресурсов может потратить и из какого дерева файлов стартует.
§1Процесс, которому ограничили мир
Виртуальная машина получает виртуальное железо и загружает собственное ядро. Контейнер использует ядро хоста: его read(), connect() и остальные системные вызовы обрабатывает то же ядро, что обслуживает все соседние контейнеры.
Отсюда три практических вывода. Контейнер стартует быстро, потому что не загружает ОС. Образ не обязан содержать ядро, systemd и драйверы — обычно там только приложение и его зависимости. И граница изоляции слабее, чем у VM: ошибка в общем ядре потенциально затрагивает хост.
Спросят
«Можно ли запустить Windows-контейнер на Linux?» — Не напрямую: пользовательское пространство ожидает системные вызовы Windows, а хост предоставляет ядро Linux. Нужна виртуальная машина с подходящим ядром. По той же причине контейнер не может загрузить собственный модуль ядра независимо от хоста.
§2Namespaces: что именно контейнеру показывают отдельно
Namespace не ограничивает ресурс — он меняет видимость. Процесс внутри получает собственное представление отдельных частей системы.
| Namespace | Что изолирует | Что видно в контейнере |
|---|---|---|
pid | Номера и дерево процессов | Свой процесс как PID 1 и только процессы своего пространства |
net | Интерфейсы, маршруты, порты, conntrack | Свой eth0, lo, таблица маршрутов и свободный порт 8080 |
mnt | Точки монтирования | Собственное дерево файлов, собранное из слоёв образа и volumes |
uts | Hostname и domain name | Собственное имя хоста |
ipc | Очереди сообщений, shared memory, семафоры | Собственные IPC-объекты |
user | UID и GID | UID 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. Упрощённая цепочка выглядит так:
runc получает OCI-конфигурацию: какой rootfs смонтировать, какую команду запустить, какие namespaces создать, какие capabilities и лимиты оставить. После запуска главный долгоживущий компонент — само приложение; runc завершается. В Kubernetes место Docker Engine обычно занимают containerd или CRI-O, с которыми kubelet разговаривает через CRI.
На собеседовании полезно разделять интерфейсы: OCI стандартизует формат образа и запуск контейнера, CRI — разговор kubelet с runtime, CNI — подключение сети, CSI — подключение хранилища. Похожие аббревиатуры решают разные задачи.
§5Образ, слои и writable layer
Образ — неизменяемый набор слоёв плюс конфигурация запуска. Каждый слой хранит разницу относительно предыдущего. При старте runtime объединяет слои в одно дерево и сверху добавляет тонкий записываемый слой контейнера.
При изменении файла из нижнего слоя объединяющая файловая система сначала копирует его наверх — 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 volume | Runtime | Данные между пересозданиями контейнера без жёсткого пути на хосте |
| 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 сети.»
Проверь себя
- Почему два контейнера могут одновременно слушать порт 8080?
- Почему CPU limit замедляет, а memory limit убивает?
- Почему удалённый в следующем слое секрет всё ещё считается утёкшим?
- Что произойдёт при shell-форме ENTRYPOINT во время остановки?
- Чем OCI отличается от CRI, CNI и CSI?