---
title: "Контейнеры: namespaces и cgroups"
description: "Частое представление о контейнере: маленькая виртуальная машина, внутри которой крутится свой мини-Linux со своим ядром ОС. Из-за этой картинки потом непонятно, почему контейнер…"
canonical: https://graphlms.ru/os/book/containers-namespaces
course: "Операционные системы"
updated: 2026-10-12
lang: ru
publisher: GraphLMS
---

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

> Источник: https://graphlms.ru/os/book/containers-namespaces — курс «Операционные системы», GraphLMS.

## Контейнер — это не виртуальная машина

Частое представление о контейнере: маленькая виртуальная машина, внутри
которой крутится свой мини-Linux со своим ядром ОС. Из-за этой картинки потом
непонятно, почему контейнер внезапно убит, почему сервис тормозит при
свободных ядрах процессора и почему уязвимость в ядре хоста опасна для всех
контейнеров сразу.

На самом деле **контейнер — это обычные процессы Linux** на том же ядре ОС, что
и всё остальное на хосте. Их видно в `ps` на хосте. Отличаются они тем, что ядро
ОС применяет к ним два механизма:

- **namespaces** (пространства имён) ограничивают, что процесс **видит**;
- **cgroups** (control groups) ограничивают, сколько ресурсов процесс может
  **потребить**.

```text
   виртуальная машина                  контейнер
   ┌──────────────────┐                ┌──────────────────┐
   │ приложение       │                │ приложение       │
   ├──────────────────┤                ├──────────────────┤
   │ гостевая ОС      │                │ (своего ядра ОС  │
   │ со своим ядром   │                │  нет)            │
   ├──────────────────┤                ├──────────────────┤
   │ гипервизор       │                │ namespaces +     │
   │                  │                │ cgroups          │
   ├──────────────────┤                ├──────────────────┤
   │ ядро ОС хоста    │                │ ядро ОС хоста —  │
   │                  │                │ одно на всех     │
   ├──────────────────┤                ├──────────────────┤
   │ железо           │                │ железо           │
   └──────────────────┘                └──────────────────┘
   старт: секунды                      старт: миллисекунды
```

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

Механизмы независимы: можно использовать namespaces без лимитов cgroups и
наоборот. Docker, containerd, Kubernetes используют оба.

## namespaces: что процесс видит

**Namespace** даёт группе процессов собственный, отдельный вид на один тип
системных ресурсов. Нагляднее всего **PID namespace**. На хосте все процессы
видят друг друга, и корень дерева — процесс с PID 1 (`systemd`). Внутри нового
PID namespace первый процесс получает PID 1 и не видит ничего снаружи.

```text
   хост (корневой 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-машине:

```bash
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](/os/book/proportional-scheduling).

- **Вес** (`cpu.weight`, в cgroups v1 — `cpu.shares`). Если у группы A вес 100,
  а у B — 200, то при борьбе за CPU B получит вдвое больше. Когда процессор
  свободен, вес ничего не ограничивает.
- **Квота** (`cpu.max`). Жёсткий потолок: не больше столько-то времени CPU за
  период. `100000 100000` — ровно одно ядро процессора в среднем, `200000
  100000` — два.

Когда группа израсходовала квоту до конца периода, планировщик снимает все её
потоки с процессора до начала следующего периода. Это **троттлинг**
(throttling).

Важная деталь: квота считается суммарно по всем потокам группы на всех
ядрах процессора. Пусть квота 50 мс на 100 мс (половина ядра), а в сервисе
четыре потока, которые одновременно заняты обработкой запросов на четырёх
ядрах.

```text
   период 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-курсе:
[планировщик](/go/book/scheduler-deep).

## Память: лимит и OOM

С памятью ядро ОС поступает жёстче. Когда группа упирается в `memory.max`,
ядро сначала пытается освободить память внутри группы: выкидывает чистые
страницы page cache, вытесняет то, что можно. Если не получилось, срабатывает
**OOM killer** этой группы и убивает процесс в ней сигналом `SIGKILL`. Код
выхода такого процесса — 137 (128 + номер сигнала 9). Подробно механизм, роль
page cache в лимите и `GOMEMLIMIT` разобраны в главе
[о памяти в проде](/os/book/memory-cloud).

Если 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 и должен
забирать их статус (см. [зомби и сироты](/os/book/process-api)). Если приложение
запускает дочерние процессы, а само `wait` за чужими сиротами не делает,
таблица процессов постепенно заполняется зомби. Для этого есть крошечные
init-процессы: `docker run --init` (внутри — `tini`) или `shareProcessNamespace`
в Kubernetes.

## Из чего складывается Docker

Контейнерный runtime сам ничего не изолирует, он собирает кирпичи ядра ОС
вместе с образом.

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

> Квиз по разделу: см. вопросы в конце документа или на https://graphlms.ru/os/book/containers-namespaces

## Частые ошибки

- Считать контейнер виртуальной машиной со своим ядром ОС.
- Смотреть на загрузку 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.

## Вопросы для самопроверки

**Квиз «Контейнеры: namespaces и cgroups»**

1. Чем контейнер принципиально отличается от виртуальной машины?
   - В контейнере нельзя запускать root, а в VM можно
   - Контейнер быстрее, потому что написан на C, а VM на Java
   - Контейнер — это процессы на ОБЩЕМ ядре хоста (namespaces+cgroups), а у VM своё ядро поверх гипервизора (верно)
   - Контейнер всегда безопаснее VM, так как полностью изолирован
   - Пояснение: Контейнер — это обычные процессы Linux на ядре хоста, которым навесили namespaces (видимость) и cgroups (ресурсы). У VM отдельное ядро поверх гипервизора — отсюда более тяжёлая, но более сильная изоляция.
2. За что отвечает PID namespace?
   - Ограничивает объём памяти процесса
   - Ограничивает долю CPU
   - Создаёт отдельную сетевую карту
   - Даёт процессу свой взгляд на дерево процессов: внутри он PID 1 и не видит чужих процессов (верно)
   - Пояснение: PID namespace изолирует видимость процессов. Один процесс имеет два номера: PID 1 внутри namespace и, например, 991 снаружи. Чужих процессов изнутри не видно. Память и CPU — это про cgroups, сеть — про net namespace.
3. Какие из перечисленных задач решают cgroups (а не namespaces)?
   - Скрыть от процесса чужие процессы
   - Ограничить долю/квоту CPU (верно)
   - Дать контейнеру свой hostname
   - Ограничить память контейнера (memory.max) (верно)
   - Пояснение: cgroups — это про ЛИМИТЫ ресурсов: память (memory.max) и CPU (доля/квота). Скрытие процессов — pid namespace, свой hostname — uts namespace. Это два независимых механизма.
4. Контейнер упал с exit code 137. Какая причина самая частая?
   - Баг в бизнес-логике с паникой
   - OOM killer убил процесс по достижении cgroup memory limit (128+9=SIGKILL) (верно)
   - Сетевой таймаут
   - Контейнер штатно завершился с кодом 0
   - Пояснение: 137 = 128 + 9, то есть процесс убит сигналом SIGKILL. Чаще всего это OOM killer: процесс упёрся в memory.max своей cgroup, и его убили мгновенно, без стектрейса и без паники Go. Проверьте OOMKilled в docker inspect или kubectl describe pod. Сигнал мог прийти и от docker kill или от оркестратора, поэтому причину стоит подтвердить.
5. Сколько экземпляров ядра ОС работает под десятью контейнерами на одном хосте?
   - Ответ: 1
   - Пояснение: Один. Все контейнеры делят ядро хоста, в этом главное отличие от виртуальных машин, где у каждой своё ядро. Отсюда лёгкость контейнеров и их главный риск: баг в ядре может позволить выбраться из контейнера наружу (container escape).
6. Go-сервис работает в контейнере с CPU-квотой 2 ядра на хосте с 64 ядрами. Что произойдёт на Go старше 1.25?
   - Go откажется стартовать без явного GOMAXPROCS
   - Go автоматически поставит GOMAXPROCS=2
   - Рантайм возьмёт GOMAXPROCS по числу ядер хоста, запустит десятки исполнителей, и cgroup будет их регулярно притормаживать (throttling) (верно)
   - Go создаст ровно одну горутину на ядро хоста
   - Пояснение: До Go 1.25 GOMAXPROCS по умолчанию равен числу ядер хоста и квоту cgroup не видит. Горутины работают на 64 потоках, а квота позволяет суммарно только 2 ядра, поэтому процесс то и дело встаёт на throttling и страдает задержка. Лечили вручную: GOMAXPROCS по лимиту или библиотека automaxprocs. Начиная с Go 1.25 рантайм сам учитывает CPU-лимит cgroup, но на старых версиях образа проблема остаётся.
