Cgroups и namespaces: понимаем основы контейнеров на уровне ядра Linux

Задача клиента: команда хочет понять, что под капотом Docker

DevOps-команда «ДевКоманда» — аутсорсинговая группа из восьми инженеров, обслуживающая инфраструктуру десяти заказчиков. Команда активно использовала Docker и Kubernetes, но при диагностике проблем упиралась в непонимание низкоуровневых механизмов: почему контейнер не видит хостовую сеть, как ограничения памяти работают на уровне ядра, почему процесс внутри контейнера может «сбежать» при неправильной конфигурации.

Руководитель команды обратился к специалистам itfresh.ru с запросом на практический воркшоп: построить контейнер с нуля, используя только системные вызовы Linux, без Docker. Цель — чтобы каждый инженер понимал, что стоит за строкой docker run.

Мы разработали программу, которую описываем в этой статье: от отдельных namespaces до полноценного изолированного окружения с собственной сетью, файловой системой и ресурсными ограничениями.

Linux namespaces: шесть видов изоляции

Namespaces — механизм ядра Linux, который создаёт иллюзию отдельной системы для процесса. Каждый тип namespace изолирует свой аспект:

  • PID namespace — изоляция дерева процессов. Процесс внутри видит себя как PID 1.
  • Network namespace — собственный сетевой стек: интерфейсы, маршруты, iptables.
  • Mount namespace — отдельная таблица монтирования.
  • UTS namespace — собственное имя хоста.
  • IPC namespace — изоляция System V IPC и POSIX очередей сообщений.
  • User namespace — маппинг UID/GID: root внутри контейнера ≠ root на хосте.
# Просмотр namespaces текущего процесса
$ ls -la /proc/self/ns/
lrwxrwxrwx 1 root root 0 Apr  5 10:00 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0 Apr  5 10:00 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 root root 0 Apr  5 10:00 mnt -> 'mnt:[4026531841]'
lrwxrwxrwx 1 root root 0 Apr  5 10:00 net -> 'net:[4026531840]'
lrwxrwxrwx 1 root root 0 Apr  5 10:00 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0 Apr  5 10:00 user -> 'user:[4026531837]'
lrwxrwxrwx 1 root root 0 Apr  5 10:00 uts -> 'uts:[4026531838]'

# Создание нового PID namespace с помощью unshare
$ unshare --pid --fork --mount-proc bash
$ ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.0  0.0   9688  5312 pts/0    S    10:01   0:00 bash
root         2  0.0  0.0  12136  3456 pts/0    R+   10:01   0:00 ps aux

# Процесс видит только себя! На хосте он имеет обычный PID

Каждый namespace создаётся независимо. Docker комбинирует все шесть, но можно использовать их по отдельности — например, только network namespace для тестирования сетевых конфигураций.

Cgroups v1 vs v2: управление ресурсами

Cgroups (control groups) — второй столп контейнеризации после namespaces. Если namespaces отвечают за изоляцию (что процесс видит), то cgroups — за ограничение ресурсов (сколько процесс может потребить).

Cgroups v1 использовали отдельную иерархию для каждого ресурса (cpu, memory, blkio). Это создавало проблемы: процесс мог находиться в разных cgroups для CPU и памяти, что усложняло управление. Cgroups v2 — единая иерархия с контроллерами.

# Проверка версии cgroups
$ stat -fc %T /sys/fs/cgroup/
cgroup2fs   # v2
tmpfs       # v1

# Структура cgroups v2
$ ls /sys/fs/cgroup/
cgroup.controllers  cgroup.max.depth  cgroup.stat       cpu.stat
cgroup.events       cgroup.procs      cgroup.subtree_control  io.stat
cgroup.max.descendants  cgroup.threads  cgroup.type        memory.stat

# Создание cgroup вручную
$ mkdir /sys/fs/cgroup/mycontainer

# Включение контроллеров для дочерних cgroups
$ echo '+cpu +memory +io +pids' > /sys/fs/cgroup/cgroup.subtree_control

# Установка лимита памяти: 256 МБ
$ echo 268435456 > /sys/fs/cgroup/mycontainer/memory.max

# Установка мягкого лимита памяти: 200 МБ
$ echo 209715200 > /sys/fs/cgroup/mycontainer/memory.high

# Лимит CPU: 50% одного ядра (50000 из 100000 микросекунд)
$ echo '50000 100000' > /sys/fs/cgroup/mycontainer/cpu.max

# Лимит на количество процессов
$ echo 20 > /sys/fs/cgroup/mycontainer/pids.max

# Помещение процесса в cgroup
$ echo $$ > /sys/fs/cgroup/mycontainer/cgroup.procs

# Проверка текущего потребления
$ cat /sys/fs/cgroup/mycontainer/memory.current
8192000
$ cat /sys/fs/cgroup/mycontainer/cpu.stat
usage_usec 1234567
user_usec 1000000
system_usec 234567

В Docker контроллерами cgroups управляет runtime (runc). Флаги docker run --memory 256m --cpus 0.5 транслируются в записи файлов cgroups v2.

Строим контейнер с нуля: unshare + chroot + cgroups

Теперь объединяем все механизмы и строим минимальный контейнер вручную. Для корневой файловой системы используем Alpine Linux — она весит всего 3 МБ.

# Подготовка rootfs
$ mkdir -p /srv/container/rootfs
$ cd /srv/container
$ wget https://dl-cdn.alpinelinux.org/alpine/v3.19/releases/x86_64/alpine-minirootfs-3.19.1-x86_64.tar.gz
$ tar xzf alpine-minirootfs-3.19.1-x86_64.tar.gz -C rootfs/

# Создание cgroup для контейнера
$ mkdir /sys/fs/cgroup/container01
$ echo 268435456 > /sys/fs/cgroup/container01/memory.max
$ echo '100000 100000' > /sys/fs/cgroup/container01/cpu.max
$ echo 50 > /sys/fs/cgroup/container01/pids.max

# Запуск процесса во всех namespaces
$ unshare --pid --net --mount --uts --ipc --fork \
    --map-root-user --mount-proc=/srv/container/rootfs/proc \
    chroot /srv/container/rootfs /bin/sh -c '
        # Устанавливаем hostname
        hostname container01

        # Монтируем необходимые файловые системы
        mount -t sysfs sysfs /sys
        mount -t tmpfs tmpfs /tmp
        mount -t tmpfs tmpfs /run

        # Запускаем shell
        exec /bin/sh
    '

Внутри этого «контейнера» процесс видит собственное дерево процессов (PID 1), отдельный hostname, изолированную файловую систему. Добавляем процесс в cgroup для ограничения ресурсов:

# На хосте, в другом терминале: находим PID контейнерного процесса
$ CONTAINER_PID=$(pgrep -f 'chroot /srv/container')

# Помещаем в cgroup
$ echo $CONTAINER_PID > /sys/fs/cgroup/container01/cgroup.procs

# Проверяем: процесс ограничен 256 МБ RAM
$ cat /sys/fs/cgroup/container01/memory.current
4096000

# Подключаемся к namespaces работающего контейнера
$ nsenter --target $CONTAINER_PID --pid --net --mount --uts --ipc /bin/sh

Команда nsenter — это аналог docker exec: она подключается к существующим namespaces процесса и запускает в них новую команду.

Overlay filesystem: слоистые образы

Docker-образы состоят из слоёв (layers), и каждый слой — read-only. Запись выполняется в верхний слой с помощью OverlayFS — файловой системы, которая «накладывает» несколько директорий друг на друга.

# Структура слоёв
$ mkdir -p /srv/overlay/{lower1,lower2,upper,work,merged}

# lower1 — базовый слой (Alpine rootfs)
$ cp -a /srv/container/rootfs/* /srv/overlay/lower1/

# lower2 — слой с приложением
$ mkdir -p /srv/overlay/lower2/app
$ echo '#!/bin/sh' > /srv/overlay/lower2/app/server.sh
$ echo 'echo "Hello from container"' >> /srv/overlay/lower2/app/server.sh
$ chmod +x /srv/overlay/lower2/app/server.sh

# upper — слой для записи (аналог контейнерного слоя)
# work — служебная директория OverlayFS

# Монтирование overlay
$ mount -t overlay overlay \
    -o lowerdir=/srv/overlay/lower2:/srv/overlay/lower1,\
       upperdir=/srv/overlay/upper,\
       workdir=/srv/overlay/work \
    /srv/overlay/merged

# Теперь в merged видна объединённая файловая система
$ ls /srv/overlay/merged/app/
server.sh

# Запись идёт в upper — lower остаётся нетронутым
$ echo 'data' > /srv/overlay/merged/tmp/test.txt
$ ls /srv/overlay/upper/tmp/
test.txt
$ ls /srv/overlay/lower1/tmp/
# пусто — lower не изменён

Это именно тот механизм, который Docker использует для слоёв образов. Когда вы пишете FROM alpine + RUN apk add nginx, каждая инструкция создаёт новый lower-слой. При запуске контейнера создаётся upper-слой для записи. При удалении контейнера удаляется только upper — образ остаётся нетронутым.

Сетевая изоляция: veth pairs и network namespaces

Network namespace создаёт полностью изолированный сетевой стек. Для связи между namespace хоста и контейнера используются veth pairs — виртуальные сетевые кабели с двумя концами.

# Создание network namespace
$ ip netns add container01

# Просмотр сетевых интерфейсов внутри (только loopback)
$ ip netns exec container01 ip link
1: lo:  mtu 65536 qdisc noop state DOWN

# Поднимаем loopback
$ ip netns exec container01 ip link set lo up

# Создание veth pair: veth-host <-> veth-cont
$ ip link add veth-host type veth peer name veth-cont

# Перемещаем один конец в namespace контейнера
$ ip link set veth-cont netns container01

# Настраиваем IP на стороне хоста
$ ip addr add 10.200.0.1/24 dev veth-host
$ ip link set veth-host up

# Настраиваем IP на стороне контейнера
$ ip netns exec container01 ip addr add 10.200.0.2/24 dev veth-cont
$ ip netns exec container01 ip link set veth-cont up
$ ip netns exec container01 ip route add default via 10.200.0.1

# Проверяем связь
$ ping -c 2 10.200.0.2
PING 10.200.0.2 (10.200.0.2) 56(84) bytes of data.
64 bytes from 10.200.0.2: icmp_seq=1 ttl=64 time=0.042 ms

# Доступ в интернет для контейнера (NAT через хост)
$ iptables -t nat -A POSTROUTING -s 10.200.0.0/24 -o eth0 -j MASQUERADE
$ echo 1 > /proc/sys/net/ipv4/ip_forward

# Теперь из namespace контейнера доступен интернет
$ ip netns exec container01 ping -c 1 8.8.8.8
64 bytes from 8.8.8.8: icmp_seq=1 ttl=115 time=2.34 ms

Docker bridge network (docker0) работает по такому же принципу: каждый контейнер получает veth pair, хостовый конец подключён к bridge-интерфейсу, NAT обеспечивает доступ в интернет.

Seccomp и capabilities: финальные уровни защиты

Namespaces и cgroups обеспечивают изоляцию и ограничение ресурсов, но не защищают от вредоносных системных вызовов. Для этого используются seccomp и capabilities.

Capabilities разбивают монолитные привилегии root на гранулярные разрешения:

# Просмотр capabilities текущего процесса
$ getpcaps $$
1234: cap_chown,cap_dac_override,cap_fowner,...=ep

# Docker по умолчанию выдаёт ограниченный набор capabilities:
# CAP_CHOWN, CAP_DAC_OVERRIDE, CAP_FSETID, CAP_FOWNER,
# CAP_MKNOD, CAP_NET_RAW, CAP_SETGID, CAP_SETUID,
# CAP_SETFCAP, CAP_SETPCAP, CAP_NET_BIND_SERVICE,
# CAP_SYS_CHROOT, CAP_KILL, CAP_AUDIT_WRITE

# Убираем все capabilities и добавляем только нужные
$ docker run --cap-drop ALL --cap-add NET_BIND_SERVICE nginx

# Проверка capabilities процесса внутри контейнера
$ docker exec container01 getpcaps 1

Seccomp (Secure Computing) фильтрует системные вызовы на уровне ядра:

# Профиль seccomp для Docker (JSON)
{
    "defaultAction": "SCMP_ACT_ERRNO",
    "archMap": [
        { "architecture": "SCMP_ARCH_X86_64", "subArchitectures": ["SCMP_ARCH_X86"] }
    ],
    "syscalls": [
        {
            "names": [
                "accept", "accept4", "bind", "brk", "close",
                "connect", "dup", "dup2", "dup3", "epoll_create",
                "epoll_create1", "epoll_ctl", "epoll_wait",
                "execve", "exit", "exit_group", "fcntl",
                "fstat", "futex", "getcwd", "getdents64",
                "getpid", "getppid", "ioctl", "listen",
                "lseek", "mmap", "mprotect", "munmap",
                "nanosleep", "open", "openat", "pipe", "pipe2",
                "poll", "read", "readlink", "recvfrom", "recvmsg",
                "rename", "rt_sigaction", "rt_sigprocmask",
                "sendmsg", "sendto", "set_robust_list",
                "setsockopt", "socket", "stat", "uname",
                "write", "writev"
            ],
            "action": "SCMP_ACT_ALLOW"
        }
    ]
}

# Запуск контейнера с кастомным профилем
$ docker run --security-opt seccomp=profile.json myapp

По умолчанию Docker блокирует ~44 системных вызова, включая reboot, mount, swapon, init_module. Кастомный профиль позволяет ещё больше сузить поверхность атаки.

Результаты воркшопа для «ДевКоманда»:

НавыкДоПосле
Диагностика сетевых проблем контейнеровМетод проб и ошибокip netns exec + tcpdump за 5 минут
Анализ OOM в контейнерах«Что-то упало»cgroup memory.events + dmesg
Отладка прав доступа--privileged на всёТочные capabilities + seccomp
Понимание слоёв образовЧёрный ящикOverlayFS: inspect + оптимизация

Если вашей команде нужен глубокий практический воркшоп по контейнеризации, Linux-изоляции или Kubernetes internals — обращайтесь к специалистам itfresh.ru.

Часто задаваемые вопросы

Docker добавляет поверх системных примитивов: управление образами (слои, registry), декларативный формат (Dockerfile), сетевые драйверы (bridge, overlay, macvlan), оркестрацию (Compose, Swarm), логирование и мониторинг. Ядерные механизмы те же самые: namespaces, cgroups, overlayfs, seccomp.
User namespace маппит UID/GID: root (UID 0) внутри контейнера может соответствовать непривилегированному пользователю (например, UID 100000) на хосте. Это значит, что даже если процесс «сбежит» из контейнера, он не получит root-привилегии на хосте. В Docker включается флагом --userns-remap.
Cgroups v2 используют единую иерархию (процесс в одном месте, все контроллеры применяются к нему), имеют чистый интерфейс файловой системы, поддерживают PSI (Pressure Stall Information) для мониторинга давления на ресурсы и корректно наследуют лимиты от родительских cgroups.
User namespace — единственный, который можно создать без root. Все остальные namespaces требуют CAP_SYS_ADMIN. Однако через user namespace можно получить «виртуальный root» внутри и затем создать остальные namespaces — именно так работают rootless-контейнеры (Podman, rootless Docker).
Влияние минимально — около 1-2% на микробенчмарках системных вызовов. Seccomp-BPF компилируется ядром в эффективный фильтр, который проверяет номер системного вызова за O(1). На реальных приложениях разница в производительности не измерима.

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#cgroups#namespaces#linux контейнеры#unshare#nsenter#overlay filesystem#veth pair#seccomp
Комментарии 0

Оставить комментарий

загрузка...