GraphLMS

ОС
Начать

ОС · ОС в проде · 15 мин

Контейнеры: namespaces и cgroups

Контейнер — это не виртуалка

Когда джуниор первый раз слышит слово «контейнер», в голове часто рисуется маленькая виртуальная машина: будто внутри Docker крутится отдельный мини-Linux со своим ядром. Это неверно, и из-за этого заблуждения люди потом не понимают, почему контейнер «внезапно умер» или «тормозит».

Запомните главное:

Контейнер — это обычные процессы Linux на вашем хосте. Те же самые процессы, что вы видите в ps, на том же самом ядре. Просто им «подкрутили» два механизма ядра: namespaces (что процесс видит) и cgroups (сколько ресурсов процесс может съесть).

Сравним с виртуальной машиной — это сразу всё расставляет по местам.

   ВИРТУАЛКА (VM)                     КОНТЕЙНЕР
 ┌──────────────────┐            ┌──────────────────┐
 │ Приложение       │            │ Приложение       │
 ├──────────────────┤            ├──────────────────┤
 │ Гостевая ОС      │            │  (нет гостевой    │
 │ + СВОЁ ЯДРО      │            │   ОС и ядра!)     │
 ├──────────────────┤            ├──────────────────┤
 │ Гипервизор       │            │ namespaces+cgroups│
 ├──────────────────┤            ├──────────────────┤
 │ ЯДРО ХОСТА       │            │ ЯДРО ХОСТА (одно  │
 │                  │            │ на всех!)        │
 ├──────────────────┤            ├──────────────────┤
 │ Железо           │            │ Железо           │
 └──────────────────┘            └──────────────────┘
   тяжело, секунды старта         легко, миллисекунды старта

Что нарисовано: слева виртуалка тащит с собой целую гостевую ОС и отдельное ядро — поэтому она весит гигабайты и стартует секунды. Справа контейнер — это просто процессы прямо на ядре хоста; никакого второго ядра нет. Отсюда и скорость старта, и лёгкость, и — как мы увидим в конце — главная дыра в безопасности.

Два механизма: видимость и ресурсы

Всё «волшебство» контейнеров держится на двух ортогональных вещах:

Механизм Что делает Бытовая аналогия
namespaces изоляция видимости стены и шторы — ты не видишь соседей
cgroups изоляция/лимит ресурсов счётчик и автомат на электричество и воду

namespaces отвечают на вопрос «что процесс вообще может разглядеть вокруг себя»: какие другие процессы, какую файловую систему, какую сеть. cgroups отвечают на вопрос «сколько процессор-времени и памяти процессу разрешено сожрать».

Это два независимых инструмента. Можно навесить namespaces без cgroups (изоляция есть, лимитов нет) и наоборот. Docker просто использует оба сразу.

namespaces — изоляция видимости

namespace (пространство имён) — это механизм ядра, который даёт группе процессов свой собственный, отдельный взгляд на какой-то один вид системных ресурсов. Виды независимы: для процессов отдельно, для сети отдельно, для файловой системы отдельно.

Самый наглядный — PID namespace (пространство имён процессов). Обычно в Linux все процессы видят друг друга, и есть один PID 1 (init/systemd) — корень дерева. Внутри pid namespace процесс думает, что он — PID 1, и не видит ни одного процесса снаружи.

        ХОСТ (корневой pid namespace)
   PID 1   init
   PID 842 dockerd
   PID 991 nginx ────────┐
   PID 992 worker        │  эти два процесса засунули
   ...                   │  в отдельный pid namespace

              ┌───────────────────────────┐
              │  pid namespace контейнера  │
              │   PID 1  nginx   (это 991  │
              │          снаружи!)         │
              │   PID 7  worker  (это 992) │
              │                            │
              │  ps внутри видит ТОЛЬКО    │
              │  эти два процесса          │
              └───────────────────────────┘

Что нарисовано: один и тот же процесс nginx имеет два номера — снаружи он PID 991, а внутри своего namespace он PID 1. Процесс внутри буквально не способен увидеть или послать сигнал процессам хоста — для него их не существует. Поэтому ps aux внутри контейнера показывает три строчки, а не триста.

Видов namespaces несколько, и каждый изолирует свой кусок мира:

namespace Что изолирует Что это значит на практике
pid дерево процессов внутри ты PID 1, чужих процессов не видно
mount точки монтирования / файловую систему своя /, свой /etc, не видно файлов хоста
net сетевой стек: интерфейсы, порты, маршруты свой eth0, свой localhost, свои порты
uts hostname и domainname свой hostname (отсюда красивый id в shell)
ipc System V IPC, очереди сообщений, разделяемую память не достучаться до чужих IPC-объектов
user таблицу uid/gid root (uid 0) внутри = непривилегированный снаружи

Особо отметим user namespace: он позволяет процессу быть root-ом внутри контейнера, оставаясь обычным безправным пользователем снаружи. Это важнейший кирпич безопасности: даже если внутри тебя «root», на хосте ты никто.

Технически это всё — три системных вызова: clone() (создать процесс сразу в новых namespaces), unshare() (отделить текущий процесс) и setns() (войти в существующий namespace). Docker за вас просто дёргает их в нужном порядке.

cgroups — изоляция ресурсов

namespaces ничего не говорят про количество. Процесс в своём уютном namespace может спокойно сожрать всю память хоста и положить соседей. Чтобы этого не случилось, есть cgroups (control groups, группы контроля) — механизм ядра, который вешает на группу процессов лимиты и счётчики по CPU, памяти, диску, сети.

Представляйте cgroup как стенки загона: процессы внутри могут бегать, но за стенку (лимит) не выпрыгнут.

                ЯДРО + ЖЕЛЕЗО (CPU, RAM)
 ┌──────────────────────────────────────────────────┐
 │                                                    │
 │  cgroup A                    cgroup B              │
 │  ┌───────────────┐          ┌───────────────┐     │
 │  │ CPU:  0.5 ядра│          │ CPU:  2 ядра  │     │
 │  │ MEM:  256 МБ  │          │ MEM:  1 ГБ    │     │
 │  │               │          │               │     │
 │  │  nginx        │          │  postgres     │     │
 │  │  worker       │          │               │     │
 │  └───────────────┘          └───────────────┘     │
 │   упрётся в стенку →          свои лимиты повыше   │
 │   throttle / OOM                                   │
 └──────────────────────────────────────────────────┘

Что нарисовано: два контейнера на одном ядре, каждый в своей cgroup со своими «стенками». Если процессы в cgroup A попытаются выйти за CPU-лимит — их притормозят; за память — придёт убийца (см. ниже). Соседняя cgroup B этого даже не заметит.

CPU-лимит — это привет планировщику

cgroup ограничивает CPU двумя способами, и оба — прямое продолжение пропорционального планирования и CFS:

  • доля (cpu.weight / shares) — относительный вес. Если у A вес 100, у B вес 200, то при конкуренции B получит вдвое больше процессора. Это мягкий механизм: пока процессор свободен, бери сколько хочешь.
  • квота (cpu.max — quota/period) — жёсткий потолок. Например «100 мс CPU на каждые 100 мс реального времени» = ровно 1 ядро, хоть тресни.

Когда процесс выбирает свою квоту досрочно, планировщик притормаживает (throttling) его до начала следующего периода. Со стороны приложения это выглядит как «сервис необъяснимо тормозит, хотя CPU на хосте свободен».

 период = 100 мс, квота = 50 мс  (полъядра)
 |####работаем####|------ throttled (спим) ------|####работаем####|
 0               50ms                          100ms            150ms

          квоту выбрали — насильно усыпили до нового периода

Что нарисовано: процесс отработал свои 50 мс и был принудительно остановлен до конца 100-мс окна. Это и есть CPU throttling — частая причина «латенси прыгает, а нагрузки вроде нет». Лечится повышением квоты или избеганием слишком агрессивных лимитов.

Go-специфика: рантайм по умолчанию ставит GOMAXPROCS по числу ядер хоста, а не по cpu-квоте cgroup. На 64-ядерном хосте с квотой в 2 ядра Go наплодит 64 исполнителя и будет постоянно упираться в throttle. Подробнее про работу планировщика Go — в главе scheduler-deep.

Memory-лимит — это привет OOM killer

Память cgroup ограничивает жёстко: memory.max = «больше этого не дам». Когда процессы в cgroup пытаются перешагнуть лимит и память некуда вытеснять, срабатывает OOM killer (Out-Of-Memory killer) — ядро выбирает жертву в этой cgroup и убивает её сигналом, без шанса на обработку.

Это ровно тот механизм, что мы разбирали в памяти в проде: процесс падает мгновенно, без Go-паники, без стектрейса. В Kubernetes/Docker вы увидите загадочное:

   контейнер ест память ──→ упёрся в memory.max


                    cgroup OOM killer


         процесс убит SIGKILL ──→ exit code 137
                                   (128 + 9, где 9 = SIGKILL)

Что нарисовано: путь от «съели слишком много» до знаменитого exit code 137. Когда видите 137 — почти всегда это cgroup memory limit + OOM, а не баг в коде и не «сеть моргнула». Лечится увеличением лимита памяти или починкой утечки.

Как из этого складывается Docker

Docker (и любой container runtime) — это удобная обёртка. Он сам ничего не изолирует; он комбинирует кирпичи ядра плюс образ.

  ┌─────────────────────────────────────────────┐
  │             Образ (image)                     │  ← неизменяемый шаблон:
  │   слой 3: ваше приложение                     │    бинарь, библиотеки,
  │   слой 2: зависимости (apt-get ...)           │    конфиги. Слои переиспользуются
  │   слой 1: базовый rootfs (debian, alpine)     │    между образами.
  └─────────────────────────────────────────────┘
                       │ docker run

  ┌─────────────────────────────────────────────┐
  │            Контейнер (running)                │
  │  overlayfs: слои образа (RO) + writable слой  │  ← пишем поверх, образ не трогаем
  ├─────────────────────────────────────────────┤
  │  namespaces: pid / mount / net / uts / ipc    │  ← изоляция видимости
  ├─────────────────────────────────────────────┤
  │  cgroups: CPU-квота + memory.max              │  ← лимиты ресурсов
  ├─────────────────────────────────────────────┤
  │             ЯДРО ХОСТА                         │  ← общее, одно на всех
  └─────────────────────────────────────────────┘

Что нарисовано: контейнер = образ (слои) + overlayfs + namespaces + cgroups поверх общего ядра. overlayfs — это файловая система слоёв: слои образа read-only, а сверху добавляется тонкий writable-слой, куда уходят все изменения. Поэтому сто контейнеров из одного образа не копируют гигабайты — они делят read-only слои и хранят только свои отличия. Образ — это «фото» (шаблон), контейнер — «запущенный экземпляр» этого фото.

Чего контейнер НЕ изолирует

Вернёмся к самому первому рисунку: ядро одно на всех. Из этого следует неприятное:

   контейнер A        контейнер B        контейнер C
       │                  │                  │
       └──────────────────┼──────────────────┘

                   ОДНО ЯДРО ХОСТА
              (syscalls, драйверы, /proc,
               баги ядра — общие!)

Что нарисовано: все контейнеры ходят в одно и то же ядро через системные вызовы. Значит:

  • Уязвимость в ядре = побег из контейнера. Если в каком-то системном вызове баг, процесс из контейнера может его проэксплуатировать и получить хост целиком (container escape). У виртуалки между гостем и хостом стоит ещё гипервизор и отдельное ядро — там сбежать на порядок сложнее.
  • Общие ресурсы ядра — версия ядра, sysctl-параметры, время — у всех одни. Нельзя «в одном контейнере ядро 5.10, в другом 6.1».
  • Изоляция настолько хороша, насколько правильно навешаны namespaces и cgroups. Забыл namespace — дыра. Поэтому в проде добавляют слои защиты: seccomp (фильтр системных вызовов), AppArmor/SELinux, отказ от root, user namespaces.

Вывод: контейнеры дают изоляцию ради удобства и плотности, а не максимальную изоляцию ради безопасности. Где нужна жёсткая граница (чужой недоверенный код) — берут виртуалки или лёгкие микро-VM (gVisor, Firecracker, Kata).

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

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

За что отвечает PID namespace?

Какие из перечисленных задач решают cgroups (а не namespaces)?

Контейнер упал с exit code 137. Что это почти всегда означает?

Сколько ядер ОС работает под десятью контейнерами на одном хосте?

Почему Go-сервис в контейнере с CPU-квотой может зря жечь CPU и упираться в throttling?

Что спрашивают на собесе

  • Чем контейнер отличается от виртуалки? Контейнер — это процессы на общем ядре хоста с namespaces+cgroups; у VM своё ядро поверх гипервизора. Отсюда лёгкость/скорость контейнеров и более слабая изоляция (общее ядро → container escape при багах ядра).
  • За что отвечают namespaces, а за что cgroups? namespaces — изоляция видимости (pid, mount, net, uts, ipc, user); cgroups — лимиты ресурсов (CPU доля/квота, memory.max). Это два независимых механизма.
  • Что значит exit code 137 у контейнера? 128 + 9 (SIGKILL) — почти всегда OOM killer по cgroup memory limit. Падение мгновенное, без стектрейса; см. memory-cloud.
  • Что такое CPU throttling и откуда он? cgroup-квота (cpu.max): процесс выбрал квоту за период — планировщик усыпляет его до следующего периода. Латенси растёт при «свободном» CPU. Связано с пропорциональным планированием.
  • Почему Go-сервис в контейнере жжёт CPU зря? GOMAXPROCS по числу ядер хоста, а не cgroup-квоты → лишние исполнители упираются в throttle. Ставьте GOMAXPROCS по лимиту (или automaxprocs).
  • Что контейнер НЕ изолирует? Ядро общее: версия ядра, баги ядра, sysctl. Поэтому в проде добавляют seccomp, user namespaces, отказ от root.
Цена системного вызова и переключения контекстаОС глазами бэкендера: собираем всё вместе