ОС в проде · 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 aux

unshare запускает 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 и работающее в пространстве пользователя, которое перехватывает системные вызовы контейнера).

Проверь себя· Контейнеры: namespaces и cgroups

Чем контейнер принципиально отличается от виртуальной машины?

За что отвечает 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() внутри контейнера.

Проверьте себя

  1. Какой механизм ядра ОС отвечает за то, что ps в контейнере показывает три процесса, а какой — за то, что контейнер убили при 512 МБ?
  2. cpu.max = 100000 100000, в сервисе 8 потоков постоянно заняты работой. Сколько миллисекунд из каждых 100 сервис будет работать и сколько стоять?
  3. Go 1.25, go 1.25 в go.mod, хост на 32 ядра, лимит cpu.max = 150000 100000. Чему будет равен GOMAXPROCS по умолчанию?
  4. Почему 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.

Продолжить