ОС в проде · 14 мин
Контент v0.12.0Контейнеры: namespaces и cgroups
Контейнер — это не виртуальная машина
Частое представление о контейнере: маленькая виртуальная машина, внутри которой крутится свой мини-Linux со своим ядром ОС. Из-за этой картинки потом непонятно, почему контейнер внезапно убит, почему сервис тормозит при свободных ядрах процессора и почему уязвимость в ядре хоста опасна для всех контейнеров сразу.
На самом деле контейнер — это обычные процессы Linux на том же ядре ОС, что
и всё остальное на хосте. Их видно в ps на хосте. Отличаются они тем, что ядро
ОС применяет к ним два механизма:
- namespaces (пространства имён) ограничивают, что процесс видит;
- cgroups (control groups) ограничивают, сколько ресурсов процесс может потребить.
виртуальная машина контейнер
┌──────────────────┐ ┌──────────────────┐
│ приложение │ │ приложение │
├──────────────────┤ ├──────────────────┤
│ гостевая ОС │ │ (своего ядра ОС │
│ со своим ядром │ │ нет) │
├──────────────────┤ ├──────────────────┤
│ гипервизор │ │ namespaces + │
│ │ │ cgroups │
├──────────────────┤ ├──────────────────┤
│ ядро ОС хоста │ │ ядро ОС хоста — │
│ │ │ одно на всех │
├──────────────────┤ ├──────────────────┤
│ железо │ │ железо │
└──────────────────┘ └──────────────────┘
старт: секунды старт: миллисекундыВиртуальная машина везёт с собой целую гостевую ОС и запускает её ядро. Контейнер — это процессы, запущенные с особыми настройками; второго ядра ОС нет. Отсюда и скорость старта, и плотность, и главная слабость в безопасности.
Механизмы независимы: можно использовать namespaces без лимитов cgroups и наоборот. Docker, containerd, Kubernetes используют оба.
namespaces: что процесс видит
Namespace даёт группе процессов собственный, отдельный вид на один тип
системных ресурсов. Нагляднее всего PID namespace. На хосте все процессы
видят друг друга, и корень дерева — процесс с PID 1 (systemd). Внутри нового
PID namespace первый процесс получает PID 1 и не видит ничего снаружи.
хост (корневой PID namespace)
PID 1 systemd
PID 842 containerd
PID 991 ./api ─────────────┐ эти процессы запущены
PID 992 ./api (дочерний) │ в отдельном PID namespace
▼
┌────────────────────────────┐
│ PID namespace контейнера │
│ PID 1 ./api (на хосте 991)│
│ PID 7 ./api (на хосте 992)│
│ ps видит только эти два │
└────────────────────────────┘У одного процесса два номера: 991 для хоста и 1 внутри. Изнутри нельзя увидеть процессы хоста или послать им сигнал — для процесса их не существует.
Попробовать без Docker можно на любой Linux-машине:
sudo unshare --pid --fork --mount-proc bash
ps auxunshare запускает bash в новом PID namespace (--fork нужен, чтобы bash
стал первым процессом в нём, а --mount-proc монтирует свой /proc, иначе ps
прочитает процессы хоста). ps aux покажет две строки: bash с PID 1 и сам
ps.
Виды namespaces в Linux:
| namespace | Что изолирует | Что видно изнутри |
|---|---|---|
| pid | номера процессов | своё дерево процессов, свой PID 1 |
| mnt | точки монтирования | свой корень /, свои /etc, /tmp |
| net | сетевой стек | свои интерфейсы, свой localhost, свои порты и правила iptables |
| uts | имя хоста | свой hostname |
| ipc | System V IPC, POSIX-очереди | только свои объекты IPC |
| user | uid и gid | root внутри может быть обычным пользователем снаружи |
| cgroup | путь в дереве cgroups | свою cgroup как корень |
| time | часы CLOCK_MONOTONIC и CLOCK_BOOTTIME |
своё время с момента загрузки (нужно при миграции контейнеров) |
Отдельно про user namespace: процесс может быть root (uid 0) внутри и
непривилегированным пользователем (скажем, uid 100000) на хосте. Даже если код
сбежит из контейнера, на хосте у него не будет прав root. Это один из главных
механизмов защиты, но по умолчанию Docker его не включает (режим rootless или
опция userns-remap).
Всё это делается тремя системными вызовами: clone (создать процесс сразу в
новых namespaces), unshare (перевести текущий процесс в новые) и setns
(войти в существующий — так работает docker exec). Namespaces процесса видны
как ссылки в /proc/<pid>/ns/.
cgroups: сколько процесс может потребить
Namespaces ничего не говорят о количестве. Процесс в своём namespace может спокойно съесть всю память хоста. Лимиты задают cgroups: процессы объединяются в группу, ядро ОС считает потребление группы и не даёт выйти за пределы.
Современные дистрибутивы используют cgroups v2: единое дерево групп,
смонтированное в /sys/fs/cgroup. Каждая группа — каталог, настройки и
статистика — файлы в нём. Узнать свою группу можно из /proc/self/cgroup, а
внутри контейнера обычно видна именно своя группа как корень.
| Файл | Что значит |
|---|---|
cpu.max |
квота CPU: 50000 100000 — 50 мс времени CPU на каждые 100 мс |
cpu.weight |
относительный вес при конкуренции, по умолчанию 100 |
cpu.stat |
статистика, в том числе nr_throttled и throttled_usec |
memory.max |
жёсткий лимит памяти; выше — OOM |
memory.high |
мягкий порог: выше него ядро ОС активно отбирает память и притормаживает группу |
memory.current |
сколько память группа использует сейчас (вместе с page cache) |
memory.events |
счётчики событий, в том числе oom_kill |
pids.max |
сколько процессов и потоков можно создать |
В Kubernetes resources.limits.cpu превращается в cpu.max, requests.cpu — в
cpu.weight, limits.memory — в memory.max.
CPU: вес и квота
CPU в cgroups ограничивают двумя способами. Оба — продолжение пропорционального планирования и CFS.
- Вес (
cpu.weight, в cgroups v1 —cpu.shares). Если у группы A вес 100, а у B — 200, то при борьбе за CPU B получит вдвое больше. Когда процессор свободен, вес ничего не ограничивает. - Квота (
cpu.max). Жёсткий потолок: не больше столько-то времени CPU за период.100000 100000— ровно одно ядро процессора в среднем,200000 100000— два.
Когда группа израсходовала квоту до конца периода, планировщик снимает все её потоки с процессора до начала следующего периода. Это троттлинг (throttling).
Важная деталь: квота считается суммарно по всем потокам группы на всех ядрах процессора. Пусть квота 50 мс на 100 мс (половина ядра), а в сервисе четыре потока, которые одновременно заняты обработкой запросов на четырёх ядрах.
период 100 мс, квота 50 мс, 4 потока работают параллельно
ядро 1 |████| |████|
ядро 2 |████| |████|
ядро 3 |████| |████|
ядро 4 |████| |████|
0 12,5 мс 100 мс
└─── троттлинг 87,5 мс: никто не работает ──┘
4 потока × 12,5 мс = 50 мс квоты израсходованы за 12,5 мсЗапрос, пришедший на 13-й миллисекунде, будет ждать почти 90 мс, хотя ядра
процессора на хосте свободны. Так выглядит классический симптом: средняя
загрузка CPU низкая, а p99 задержки — десятки миллисекунд. Подтвердить
троттлинг можно по cpu.stat: если nr_throttled растёт, а throttled_usec
велико, группа упирается в квоту.
Лечат по-разному: увеличить лимит, убрать CPU-лимит и оставить только
requests (вес), уменьшить число одновременно работающих потоков.
Что с этим делает Go
Для Go-сервиса число потоков, одновременно исполняющих Go-код, задаёт
GOMAXPROCS. До Go 1.25 он по умолчанию равнялся числу ядер процессора,
доступных процессу, и про квоту cgroup ничего не знал. На хосте с 64 ядрами и
лимитом в 2 ядра сервис запускал 64 рабочих потока, они вместе выбирали квоту
за пару миллисекунд и весь остаток периода стояли. Лечили явной переменной
окружения GOMAXPROCS или библиотекой go.uber.org/automaxprocs.
С Go 1.25 рантайм на Linux сам читает CPU-лимит cgroup: GOMAXPROCS по
умолчанию равен меньшему из числа доступных ядер процессора и квоты, округлённой
вверх, но не меньше 2. Для лимита 2,5 ядра получится 3, для лимита 0,5 — 2.
Рантайм также периодически перечитывает лимит и подстраивается, если его
изменили. Оговорки:
- поведение включается, только если в
go.modуказана версияgo 1.25или новее; у старых модулей его можно включить черезGODEBUG=containermaxprocs=1(и выключить через=0); - учитывается только квота (
cpu.max), вес (requests) — нет; - явная переменная
GOMAXPROCSпо-прежнему имеет приоритет.
Подробнее о том, как GOMAXPROCS влияет на планировщик, — в Go-курсе:
планировщик.
Память: лимит и OOM
С памятью ядро ОС поступает жёстче. Когда группа упирается в memory.max,
ядро сначала пытается освободить память внутри группы: выкидывает чистые
страницы page cache, вытесняет то, что можно. Если не получилось, срабатывает
OOM killer этой группы и убивает процесс в ней сигналом SIGKILL. Код
выхода такого процесса — 137 (128 + номер сигнала 9). Подробно механизм, роль
page cache в лимите и GOMEMLIMIT разобраны в главе
о памяти в проде.
Если 137 — проверьте, действительно ли это OOM: memory.events (счётчик
oom_kill), kubectl describe pod (причина OOMKilled) или docker inspect
(OOMKilled: true). Тот же код выхода даст любой SIGKILL, например от
оркестратора, который не дождался завершения.
В отличие от CPU, превышение памяти не тормозит, а убивает. Поэтому для памяти
полезен memory.high ниже memory.max: группа начинает тормозить на освобождении
памяти раньше, чем дойдёт до убийства.
PID 1 внутри контейнера
Процесс с PID 1 в PID namespace особенный, и это часто кусает.
Сигналы. Ядро ОС не доставляет процессу PID 1 сигналы, для которых тот не
установил обработчик (кроме SIGKILL и SIGSTOP из родительского
namespace). docker stop посылает SIGTERM, ждёт 10 секунд и шлёт
SIGKILL. Если PID 1 — шелл, который запустил приложение дочерним процессом
(скрипт-обёртка entrypoint.sh, последней строкой которого стоит ./api без
exec, или shell-форма CMD со сложной командой), то шелл обработчика
SIGTERM не ставит и ничего не передаёт приложению. Итог: каждая остановка
контейнера длится 10 секунд и заканчивается убийством без graceful shutdown.
Лечение — exec-форма CMD ["./api"] или exec ./api в конце скрипта: тогда
PID 1 — ваше приложение.
Сам Go-бинарь в роли PID 1 на SIGTERM завершится (рантайм ставит обработчики
сигналов), но завершится сразу, не закрыв соединения. Для корректной остановки
нужен свой обработчик: signal.NotifyContext(ctx, syscall.SIGTERM) и
server.Shutdown.
Зомби. PID 1 усыновляет все осиротевшие процессы своего namespace и должен
забирать их статус (см. зомби и сироты). Если приложение
запускает дочерние процессы, а само wait за чужими сиротами не делает,
таблица процессов постепенно заполняется зомби. Для этого есть крошечные
init-процессы: docker run --init (внутри — tini) или shareProcessNamespace
в Kubernetes.
Из чего складывается Docker
Контейнерный runtime сам ничего не изолирует, он собирает кирпичи ядра ОС вместе с образом.
образ (image) неизменяемые слои:
слой 3: ваш бинарь у сотни контейнеров из одного
слой 2: зависимости образа они общие
слой 1: базовый rootfs (debian, alpine, distroless)
│ docker run
▼
контейнер
overlayfs: слои образа (только чтение) + свой слой для записи
namespaces: pid, mnt, net, uts, ipc (+ user, если включён)
cgroups: cpu.max, memory.max, pids.max
seccomp, capabilities, AppArmor/SELinux: ограничение системных вызовов и прав
─────────────────────────────────────────
ядро ОС хоста — общееoverlayfs — файловая система, которая накладывает слои друг на друга: нижние слои образа только для чтения, сверху тонкий слой для записи. Изменённый файл копируется в верхний слой при первой записи (copy-on-write). Поэтому сотня контейнеров из одного образа не копирует гигабайты. И поэтому не стоит писать базу данных в файловую систему контейнера: копирование при записи и слои делают это медленно, а данные исчезнут вместе с контейнером. Для данных монтируют тома.
Чего контейнер не изолирует
Ядро ОС у всех контейнеров одно. Отсюда:
- Уязвимость в ядре ОС — побег из контейнера. Если в каком-то системном вызове ошибка, процесс в контейнере может её использовать и получить хост (container escape). Поэтому дополнительно ограничивают сами системные вызовы: профиль seccomp по умолчанию в Docker запрещает несколько десятков опасных вызовов, а capabilities отбирают у root внутри контейнера большую часть привилегий.
- Общие настройки ядра ОС. Версия ядра, большинство sysctl, часы реального времени — одни на всех. Нельзя запустить в одном контейнере ядро 5.10, а в другом 6.1.
- Что видит процесс про ресурсы.
/proc/meminfoиnprocв контейнере показывают память и ядра процессора хоста, а не лимиты группы. Программы, которые выбирают размер кэша или пула по этим числам, ошибаются.
Контейнеры дают изоляцию ради удобства и плотности, а не максимальную изоляцию ради безопасности. Для недоверенного кода используют виртуальные машины или песочницы: microVM вроде Firecracker и Kata Containers (свой маленький гипервизор и ядро на каждую нагрузку) или gVisor (ядро ОС, написанное на Go и работающее в пространстве пользователя, которое перехватывает системные вызовы контейнера).
Чем контейнер принципиально отличается от виртуальной машины?
За что отвечает PID namespace?
Какие из перечисленных задач решают cgroups (а не namespaces)?
Контейнер упал с exit code 137. Какая причина самая частая?
Сколько экземпляров ядра ОС работает под десятью контейнерами на одном хосте?
Go-сервис работает в контейнере с CPU-квотой 2 ядра на хосте с 64 ядрами. Что произойдёт на Go старше 1.25?
Частые ошибки
- Считать контейнер виртуальной машиной со своим ядром ОС.
- Смотреть на загрузку CPU и не смотреть на троттлинг. Средняя загрузка может
быть 30%, а p99 испорчен квотой; проверяйте
cpu.stat. - Ставить CPU-лимит в долях ядра многопоточному сервису и удивляться паузам на десятки миллисекунд.
- Думать, что Go до версии 1.25 или модуль с
go 1.24вgo.modсам учтёт CPU-лимит. - Принимать каждый код 137 за OOM, не проверив
oom_killилиOOMKilled. - Запускать приложение из скрипта-обёртки без
execи получать 10-секундную остановку сSIGKILL. - Определять размер пулов и кэшей по
/proc/meminfoилиruntime.NumCPU()внутри контейнера.
Проверьте себя
- Какой механизм ядра ОС отвечает за то, что
psв контейнере показывает три процесса, а какой — за то, что контейнер убили при 512 МБ? cpu.max=100000 100000, в сервисе 8 потоков постоянно заняты работой. Сколько миллисекунд из каждых 100 сервис будет работать и сколько стоять?- Go 1.25,
go 1.25вgo.mod, хост на 32 ядра, лимитcpu.max=150000 100000. Чему будет равенGOMAXPROCSпо умолчанию? - Почему
docker stopдля контейнера, гдеENTRYPOINT ["/entrypoint.sh"], а скрипт заканчивается строкой./api, всегда длится 10 секунд?
Показать ответы
Ответы. (1) PID namespace и cgroup (лимит памяти memory.max, OOM killer
группы). (2) Квота — 100 мс CPU за период; 8 потоков выберут её за 12,5 мс и
будут стоять около 87,5 мс. (3) 2: квота 1,5 ядра округляется вверх до 2.
(4) PID 1 — шелл, который не ставит обработчик SIGTERM и не передаёт его
приложению; через 10 секунд приходит SIGKILL.
Что спрашивают на собесе
- Чем контейнер отличается от виртуальной машины? Контейнер — процессы на общем ядре ОС хоста с namespaces и cgroups; у виртуальной машины своё ядро поверх гипервизора. Контейнеры легче, изоляция слабее.
- За что отвечают namespaces, а за что cgroups? namespaces — что процесс видит (pid, mnt, net, uts, ipc, user, cgroup, time). cgroups — сколько ресурсов потребляет (CPU, память, число процессов, ввод-вывод).
- Что такое CPU throttling и как его найти? Группа израсходовала квоту
cpu.maxдо конца периода и стоит до следующего. Видно поnr_throttledиthrottled_usecвcpu.stat. - Почему Go-сервис в контейнере страдает от троттлинга? До Go 1.25
GOMAXPROCSравнялся числу ядер хоста. С 1.25 рантайм учитывает квоту cgroup, если модуль собран сgo 1.25. - Что значит exit code 137? Процесс убит
SIGKILL; чаще всего — OOM killer поmemory.max. Подтверждаетсяoom_killвmemory.eventsилиOOMKilled. - Что контейнер не изолирует? Ядро ОС: его уязвимости, версию, большинство sysctl. Поэтому добавляют seccomp, capabilities, user namespaces, а для недоверенного кода — microVM или gVisor.