53. Контейнеры под капотом. namespaces и cgroups
namespaces, cgroups v2 и pivot_root
«Контейнер — это не физическая сущность. Это обычный процесс Linux, надевший тонированные ядром очки».
— Фундаментальная истина контейнеризации
Контейнеры: иллюзия виртуализации или реальная изоляция?
Многие разработчики до сих пор пребывают в заблуждении, считая контейнеры «очень легковесными виртуальными машинами». Это фундаментальная ошибка, мешающая понимать реальное поведение бэкенда в облачной инфраструктуре.
Классическая виртуальная машина эмулирует физическое аппаратное обеспечение (материнскую плату, контроллеры шины, сетевую карту), запускает полноценное гостевое ядро операционной системы и требует постоянного посредничества гипервизора (KVM, Xen, ESXi).
Контейнер — это самый обычный процесс Linux. У него нет собственного ядра, нет эмулированного железа и нет виртуальной памяти, отличной от физической памяти хоста. В исходном коде ядра Linux вы не найдете структуры с именем struct container. Контейнеризация — это элегантная комбинация двух базовых низкоуровневых механизмов ядра:
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph ContainerPillars["Два столпа контейнеризации в Linux"]
NS["1. Namespaces (Пространства имен)
Иллюзия изоляции: процесс видит только свои ресурсы"]:::process
CG["2. cgroups (Контрольные группы)
Диктатура ресурсов: процесс не может превысить лимиты"]:::process
end
subgraph Result["Результат для процесса"]
Proc["Обычный процесс Linux (Go Binary)"]
Perf["Производительность Bare-Metal
(0 оверхеда на трансляцию инструкций CPU)"]
end
NS --> Proc
CG --> Proc
Proc --> Perf
- Namespaces (Пространства имен): Обеспечивают изоляцию видимости. Ядро ограничивает процесс в том, что он может видеть (свои процессы, свои сетевые интерфейсы, свои точки монтирования).
- cgroups (Контрольные группы): Обеспечивают ограничение аппетита. Ядро жестко контролирует, сколько ресурсов процесс может потребить (квоты CPU, потолок памяти RAM, скорость блочного ввода-вывода IOPS).
Когда вы вызываете команду docker run или развертываете под в Kubernetes, вы не создаете новую машину. Вы просто просите ядро Linux породить дочерний процесс с особыми системными флагами изоляции и привязать его к директории в псевдофайловой системе cgroups.
Переключение контекста между процессами разных контейнеров обходится процессору ровно во столько же тактов, сколько переключение между обычными процессами одного пользователя. Нет виртуализации, нет трансляции адресов, нет гипертрейдинга гостевых ОС. Именно поэтому скомпилированный сервис на Go внутри контейнера работает с чистой производительностью bare-metal!
Namespaces: виртуальные окна в системные ресурсы
Namespace — это механизм ядра Linux, который создает для группы процессов изолированное виртуальное «окно» в глобальные системные таблицы. Процесс, помещенный в namespace, не способен увидеть объекты за пределами своего окна, если только ядро явно не предоставит ему этот мост.
В современном ядре Linux существует 8 фундаментальных типов namespaces (к привычным семи добавлен TIME):
| Тип Namespace | Флаг системного вызова clone() |
Что конкретно изолируется от хоста |
|---|---|---|
PID |
CLONE_NEWPID |
Пространство идентификаторов процессов. Внутри контейнера ваш главный процесс получает виртуальный PID 1, при этом на хосте он сохраняет свой реальный глобальный PID (например, 24891). |
NET |
CLONE_NEWNET |
Полный сетевой стек: собственные интерфейсы (eth0, lo), таблицы маршрутизации, цепочки iptables/nftables и изолированное пространство TCP/UDP портов. |
MNT |
CLONE_NEWNS |
Таблица точек монтирования файловой системы. Позволяет подменить корневую файловую систему на контейнерный образ через pivot_root. |
UTS |
CLONE_NEWUTS |
Имя хоста (hostname) и доменное имя NIS. Позволяет контейнеру иметь собственный сетевой идентификатор. |
IPC |
CLONE_NEWIPC |
Межпроцессное взаимодействие System V и очереди POSIX: изолирует семафоры, очереди сообщений и разделяемую память (shared memory). |
USER |
CLONE_NEWUSER |
Таблица соответствия пользователей и групп (UID/GID). Ключевой фундамент rootless-контейнеров (UID 0 внутри контейнера отображается на безопасный UID 1000 на хосте). |
CGROUP |
CLONE_NEWCGROUP |
Видимость виртуальной файловой системы cgroups. Предотвращает возможность контейнера увидеть пути контрольных групп хостовой машины. |
TIME |
CLONE_NEWTIME |
Смещения системных часов (CLOCK_MONOTONIC и CLOCK_BOOTTIME): например, контейнер может видеть собственное «время работы системы». |
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph HostView["Взгляд с хоста (Root Namespace)"]
Init["systemd (PID 1)"]:::process --> Docker["dockerd / containerd (PID 842)"]
Docker --> Shim["containerd-shim (PID 1530)"]
Shim --> GoHost["app-server (Хостовой PID 24891)"]
end
subgraph ContainerView["Взгляд изнутри контейнера (PID Namespace)"]
GoCont["app-server (Виртуальный PID 1)"]
end
GoHost -.->|"Один и тот же процесс в task_struct!"| GoCont
Управление пространствами имен в Linux реализуется тройкой системных вызовов:
clone(): Создание нового дочернего процесса с одновременным выделением указанных namespaces.unshare(): Отсоединение текущего уже работающего процесса от родительских namespaces без создания дочернего процесса.setns(): Присоединение вызывающего процесса к существующему пространству имен другого процесса (именно на этом сисколле построена командаdocker execилиkubectl exec).
Cgroups: жесткие ограничения ресурсов
Если namespaces надевают на процесс «розовые очки», заставляя его верить, что он один на целом сервере, то подсистема cgroups не позволяет этому процессу разрушить сервер, бесконтрольно поглощая физические ресурсы.
Современный стандарт cgroups v2 (ставший стандартом в ядрах Linux 5.3+ и средах Kubernetes 1.25+) полностью устранил хаос раздельных иерархий cgroups v1. Все ресурсы теперь управляются через единое унифицированное дерево каталогов, смонтированное в /sys/fs/cgroup/.
Ключевые контроллеры v2:
cpu.max: Жесткое квотирование процессора через CFS (Completely Fair Scheduler). Формат:quota period. Например, запись100000 100000в контрольный файл выделяет процессу ровно 100 000 мкс за период 100 000 мкс (эквивалент 1 full core CPU). Если процесс расходует квоту раньше, ядро немедленно ставит его потоки на паузу (CPU Throttling).memory.max: Абсолютный жесткий лимит физической памяти (RAM). При попытке превысить этот порог ядро сначала запускает агрессивный сброс файловых кэшей (Page Cache reclaim), а если свободной памяти не находится — активирует OOM Killer и присылает процессу смертельный сигналSIGKILL.memory.high: Мягкий лимит памяти (Soft Limit). При его превышении ядро не убивает процесс, но начинает замедлять его аллокации (throttling) и отправляет асинхронные уведомления в epoll, давая приложению шанс штатно освободить кэши.pids.max: Защита от fork-бомб. Лимитирует суммарное число потоков и подпроцессов внутри группы.io.max: Дросселирование скорости чтения и записи на блочные устройства: задание лимитов на максимальный IOPS и пропускную способность в байтах/сек (throughput).
Как это работает вместе: от docker run до вашего Go-процесса
Когда демон контейнеризации (Docker, containerd, CRI-O) получает команду на запуск контейнера, под капотом разворачивается филигранная цепочка низкоуровневых операций ядра:
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
Host["Демон containerd / runc"]:::process -->|"1. clone(CLONE_NEWPID | CLONE_NEWNET | ...)"| NewProc["Новый процесс (будущий PID 1)"]
NewProc -->|"2. pivot_root(overlay_root, old_root)"| PivotRoot["Подмена корневой ФС (pivot_root)"]
NewProc -->|"3. Запись лимитов в /sys/fs/cgroup/..."| Limits["Конфигурация cgroups (cpu.max, memory.max)"]
PivotRoot --> Exec["4. execve('/app/server')"]
Limits --> Exec
Exec --> Run["Исполнение бинарника Go
в полной изоляции"]
clone()с флагами: Ядро создает новыйtask_struct, связывая его с новым изолированным экземпляромstruct nsproxy.pivot_root(): В отличие от устаревшегоchroot, системный вызовpivot_root()атомарно заменяет корень файловой системы текущего процесса на новый смонтированный каталог (слои образа контейнера OverlayFS), надежно предотвращая возможность побега из песочницы через...- Настройка cgroups: Управляющий рантайм записывает PID процесса в файл
cgroup.procsв/sys/fs/cgroup/docker/<id>/cpu.maxиmemory.max. execve(): Процесс заменяет свой образ исполняемого файла на скомпилированный бинарник Go. Запускается среда исполнения Go: потоки планировщикаruntime.g, сетевой поллерnetpollerи сборщик мусора — абсолютно штатно, но в изолированных границах.
Go и низкоуровневая работа с изоляцией
Хотя стандартная библиотека Go не содержит специализированного пакета container, системный пакет golang.org/x/sys/unix предоставляет исчерпывающий интерфейс ко всем низкоуровневым системным вызовам Linux.
Ниже приведен практический пример на Go, демонстрирующий проверку наличия контрольных групп cgroups v2 и чтение текущих лимитов памяти прямо из псевдофайловой системы:
package main
import (
"fmt"
"os"
"strconv"
"strings"
"golang.org/x/sys/unix"
)
func main() {
// 1. Проверяем тип файловой системы в точке монтирования /sys/fs/cgroup
var stat unix.Statfs_t
if err := unix.Statfs("/sys/fs/cgroup", &stat); err != nil {
fmt.Fprintf(os.Stderr, "Ошибка чтения файловой системы cgroup: %v\n", err)
os.Exit(1)
}
// Магическое число суперблока cgroups v2 (CGROUP2_SUPER_MAGIC = 0x63677270)
const cgroup2SuperMagic = 0x63677270
if stat.Type != cgroup2SuperMagic {
fmt.Println("cgroup v2 не обнаружен: хост использует cgroup v1 или изоляция отключена.")
return
}
fmt.Println("cgroup v2 успешно распознан!")
// 2. Читаем текущее фактическое потребление памяти процессом (memory.current)
currentData, err := os.ReadFile("/sys/fs/cgroup/memory.current")
if err == nil {
currentBytes, _ := strconv.ParseInt(strings.TrimSpace(string(currentData)), 10, 64)
fmt.Printf("Текущее потребление RAM в cgroup: %d байт (%.2f MB)\n",
currentBytes, float64(currentBytes)/1024/1024)
}
// 3. Читаем установленный hard-лимит памяти (memory.max)
maxData, err := os.ReadFile("/sys/fs/cgroup/memory.max")
if err == nil {
rawLimit := strings.TrimSpace(string(maxData))
if rawLimit == "max" {
fmt.Println("Лимит memory.max: БЕЗ ОГРАНИЧЕНИЙ (max)")
} else {
limitBytes, _ := strconv.ParseInt(rawLimit, 10, 64)
fmt.Printf("Установленный лимит memory.max: %d байт (%.2f MB)\n",
limitBytes, float64(limitBytes)/1024/1024)
}
}
}
Ловушки и вопросы с собеседований
1. Почему docker run может работать без прав root?
Благодаря механизму USER namespace. Ядро выполняет трансляцию идентификаторов: виртуальный пользователь UID 0 (root) внутри контейнера маппится на непривилегированного пользователя UID 1000 на физическом хосте. Внутри контейнера процесс считает себя полновластным хозяином, но любая попытка выполнить опасный системный вызов на уровне хостового железа будет мгновенно заблокирована ядром из-за отсутствия реальных capabilities (например, CAP_NET_RAW для создания raw-сокетов утилитой ping).
2. Разница между clone и fork?
Классический системный вызов fork слепо дублирует адресное пространство процесса (через Copy-on-Write) и файловые дескрипторы. Системный вызов clone — это мощнейший параметризуемый супер-инструмент ядра. Он позволяет точечно указать, какие ресурсы родительского процесса будут разделяться (shared), а какие изолироваться: виртуальная память (CLONE_VM), таблица дескрипторов (CLONE_FILES), обработчики сигналов (CLONE_SIGHAND) и все виды namespaces! Именно через clone в Linux создаются и системные потоки ОС, и изолированные контейнеры.
3. Что происходит с горутинами при переключении cgroup CPU?
Если контейнер превысил квоту cpu.max, планировщик ядра CFS просто перестает планировать системные потоки M процесса на ядра процессора. Ядро может вернуть EAGAIN при попытке переключения потока. Для рантайма Go это выглядит как остановка времени: пользовательские горутины (runtime.g) не зависают в deadlock, но общее время ответа сервиса резко подскакивает (CPU throttling).
4. Как связать Go-сервер с сетью конкретного контейнера?
По умолчанию метод net.Listen(":8080") привязывается к адресу 0.0.0.0 текущего NET namespace. Чтобы изолировать трафик или направить его через виртуальный сетевой кабель (veth-пару), контейнерные рантаймы создают пару интерфейсов, один конец которой помещают в NET namespace контейнера, а второй — в виртуальный мост (bridge) хоста, либо привязывают сокет к конкретному интерфейсу опцией SO_BINDTODEVICE через вызов syscall.SetsockoptString.
Итог
- Контейнеры — это не виртуальные машины. Это обычные процессы Linux, исполняющиеся на том же ядре с нулевым оверхедом и скоростью bare-metal.
- Архитектура изоляции держится на двух механизмах:
- Namespaces (
PID,NET,MNT,UTS,IPC,USER,CGROUP) создают виртуальные изолированные проекции системных ресурсов. - cgroups v2 (
cpu.max,memory.max,pids.max,io.max) устанавливают жесткие барьеры потребления вычислительных мощностей.
- Namespaces (
- Безопасная смена корня: Вызов
pivot_root()надежно герметизирует файловую систему контейнера, предотвращая выход в родительские каталоги хоста. - Механическое сочувствие в Go: Управление переменной
GOMEMLIMIT, контроль троттлинга CPU и знание различий между системными ошибкамиENOMEMи казнями OOM Killer позволяют создавать отказоустойчивые микросервисы в облачных средах Kubernetes. - Глубокое понимание низкоуровневых системных вызовов
clone,mountиsyscallкритично для написания кастомных рантаймов, оркестраторов или высоконагруженных сервисов, работающих в изолированных окружениях.
Мы досконально исследовали внутреннюю механику контейнеров. Но как ядро Linux разделяет адресные пространства процессов, почему системный отладчик ptrace не может шпионить за чужим namespace, и как устроена изоляция памяти на уровне ядра? Ответы — в следующей статье: 54. Изоляция процессов в Linux.