Перейти к содержанию

Linux namespaces: фундамент контейнеров

Namespaces — механизм ядра Linux, изолирующий то, что процесс видит: свой список mount points, свою таблицу PID, свой сетевой стек, свой hostname. Снаружи это всё ещё одно ядро и одна машина, но изнутри процесс верит, что он запущен в собственной системе.

Без namespaces классический chroot(2) защищает только файловую систему: процесс, запертый в chroot-jail, всё равно видит PID-ы соседей, может им сигналить, использует общий network stack и общий список UID-ов с host'ом. На namespaces построены все контейнеры в Linux: Docker, LXC, Podman, systemd-nspawn, runc, containerd. Когда runc «создаёт контейнер» — это последовательность clone() с правильными CLONE_NEW* флагами и mount()/pivot_root() поверх.

Восемь типов namespaces

Namespace Флаг (clone/unshare/setns) Изолирует Появился в ядре
mount CLONE_NEWNS список mount points, propagation 2.4.19 (2002)
UTS CLONE_NEWUTS hostname, domainname 2.6.19
IPC CLONE_NEWIPC System V IPC, POSIX message queues 2.6.19
PID CLONE_NEWPID таблица PID, init процесса (PID 1) 2.6.24
network CLONE_NEWNET интерфейсы, IP, routes, iptables, sockets, /proc/net 2.6.29
user CLONE_NEWUSER UID/GID mapping, capabilities 3.8
cgroup CLONE_NEWCGROUP корень иерархии cgroup 4.6
time CLONE_NEWTIME монотонные часы CLOCK_MONOTONIC, CLOCK_BOOTTIME 5.6

Флаг CLONE_NEWNS назван так, потому что это был самый первый namespace в Linux (mount) — тогда слово «namespace» ещё не было общим термином. Все остальные флаги начинаются с CLONE_NEW<имя>.

graph TB
    subgraph K["один Linux kernel, одна машина"]
        subgraph A["контейнер A"]
            A1["mnt: своё /<br/>pid: init=1<br/>net: eth0=10...<br/>uts: app-1<br/>user: root↔1000<br/>ipc: своя SysV<br/>cgrp: /<br/>time: своё mono"]
        end
        subgraph B["контейнер B"]
            B1["mnt: своё /<br/>pid: init=1<br/>net: eth0=10...<br/>uts: db-2<br/>user: root↔1001<br/>ipc: своя SysV<br/>cgrp: /<br/>time: своё mono"]
        end
        subgraph H["host"]
            H1["mnt: /<br/>pid: systemd<br/>net: eth0, wl<br/>uts: workstation<br/>user: real<br/>ipc: общий<br/>cgrp: реальный<br/>time: реальное"]
        end
    end

Namespaces ортогональны cgroups. Namespaces отвечают на вопрос «что видит процесс», cgroups — «сколько ресурсов ему позволено потребить». Контейнер — это процесс с собственными namespaces, помещённый в cgroup с лимитами.

Три способа создать/войти в namespace

clone(CLONE_NEW*)

clone(2) создаёт новый процесс. Если передать флаги CLONE_NEW*, ребёнок появится сразу в новых namespaces, родитель остаётся в старых.

#define _GNU_SOURCE
#include <sched.h>
#include <stdlib.h>
#include <unistd.h>

static char stack[1 << 20];

static int child(void *arg) {
    sethostname("container", 9);
    execl("/bin/sh", "sh", NULL);
    return 1;
}

int main(void) {
    int flags = CLONE_NEWUTS | CLONE_NEWPID | CLONE_NEWNS | SIGCHLD;
    pid_t pid = clone(child, stack + sizeof stack, flags, NULL);
    waitpid(pid, NULL, 0);
    return 0;
}

unshare(CLONE_NEW*)

unshare(2) отделяет текущий процесс в новые namespaces, не порождая ребёнка. Удобно для shell-сессий и интерактивных экспериментов.

# войти в новый UTS namespace и сменить hostname только для себя
sudo unshare --uts /bin/bash
hostname isolated
hostname            # → isolated (только в этом bash)
# в другом терминале — старый hostname

CLONE_NEWPID через unshare устроен особенно: текущий процесс не становится PID 1, в новый PID ns попадают только его потомки (нужен fork сразу после). Поэтому unshare --pid --fork идёт почти всегда вместе.

setns(fd, type)

setns(2) присоединяет текущий процесс к существующему namespace, открытому через файловый дескриптор. Дескриптор получается из /proc/PID/ns/<тип>.

int fd = open("/proc/1234/ns/net", O_RDONLY);
setns(fd, CLONE_NEWNET);   // теперь у нас network ns процесса 1234

Это позволяет «зайти внутрь» контейнера и видеть его сеть/файлы.

Shell-инструменты

# создать новый ns и сразу выполнить команду
unshare --net --pid --fork --mount-proc /bin/bash

# войти в namespaces работающего контейнера
nsenter --target $(pgrep -n nginx) --mount --uts --ipc --net --pid /bin/sh

# заглянуть в network ns конкретного контейнера Docker
PID=$(docker inspect -f '{{.State.Pid}}' my-app)
nsenter -t $PID -n ip addr

/proc/PID/ns: namespace как файл

Каждому namespace ядро даёт уникальный inode. Symlink в /proc/PID/ns/<type> указывает на «магический» файл вида mnt:[4026531840] — это inode mount namespace, в котором живёт процесс.

$ ls -l /proc/self/ns/
lrwxrwxrwx mnt    -> 'mnt:[4026531840]'
lrwxrwxrwx pid    -> 'pid:[4026531836]'
lrwxrwxrwx net    -> 'net:[4026531992]'
lrwxrwxrwx user   -> 'user:[4026531837]'
lrwxrwxrwx uts    -> 'uts:[4026531838]'
lrwxrwxrwx ipc    -> 'ipc:[4026531839]'
lrwxrwxrwx cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx time   -> 'time:[4026531834]'

Если у двух процессов одинаковый inode для конкретного namespace — они в одном и том же namespace.

host shell (PID 2001)             container init (PID 5000)
  /proc/2001/ns/                    /proc/5000/ns/
  ├── mnt:[4026531840]              ├── mnt:[4026532418]   ◀── разные
  ├── net:[4026531992]              ├── net:[4026532555]   ◀── разные
  ├── pid:[4026531836]              ├── pid:[4026532500]   ◀── разные
  └── user:[4026531837] ──┬──────── └── user:[4026531837]  ◀── одинаковые!
                          └─── оба процесса в одном user ns
                               (контейнер без user remapping)

Namespace продолжает существовать, пока на него ссылается хотя бы один файловый дескриптор или живёт процесс внутри. Это даёт трюк «удержать» пустой ns:

# bind-mount файла ns куда-нибудь — ns не исчезнет даже без процессов
mount --bind /proc/$$/ns/net /var/run/netns/myns
ip netns exec myns ip addr     # ip netns под капотом так и работает

Mount namespace

Mount ns изолирует список mount points, видимый процессу. При создании нового mount ns ядро копирует текущий список, дальше изменения распространяются по правилам propagation.

Propagation types

Каждой точке монтирования назначен один из четырёх propagation типов. Они определяют, как mount/umount события передаются между mount namespaces.

Тип Флаг Что делает
MS_SHARED --make-shared mount/umount распространяются в обе стороны
MS_PRIVATE --make-private полная изоляция
MS_SLAVE --make-slave принимает события от master, свои не отдаёт
MS_UNBINDABLE --make-unbindable как private + нельзя bind-mount'ить
flowchart LR
    subgraph SH["shared propagation (default в systemd)"]
        SH_H["host mount ns:<br/>/, /home, /mnt/usb (new)"]
        SH_C["container mount ns:<br/>/, /home, /mnt/usb (auto)"]
        SH_H <-->|"mount событие в обе стороны"| SH_C
    end
    subgraph SL["slave propagation (для контейнеров)"]
        SL_H["host mount ns:<br/>/, /home, /mnt/usb (new)"]
        SL_C["container mount ns:<br/>/, /home (copy), /mnt/usb (auto),<br/>/private (new) — не уйдёт в host"]
        SL_H -->|"host → container: да"| SL_C
        SL_C -.->|"container → host: нет"| SL_H
    end
    subgraph PR["private propagation"]
        PR_H["host mount ns:<br/>/, /mnt/usb (new)"]
        PR_C["container mount ns:<br/>/, /private (new)"]
        PR_H -.- PR_C
    end

До ядра 2.6.15 любой mount в новом mount ns был приватным, и Docker bind-mount'ы из host'а внутрь контейнера не обновлялись бы при изменениях на хосте. Сейчас по умолчанию systemd ставит / в MS_SHARED, и контейнер-рантаймы явно делают mount --make-rslave / при старте контейнера.

Пример: приватный /tmp для одного процесса

# tmpfs виден только в этом unshare'нутом bash
sudo unshare --mount --propagation private /bin/bash
mount -t tmpfs none /tmp
echo secret > /tmp/file
ls /tmp                  # → file

# в другом терминале — старый /tmp без secret

chroot vs mount namespace vs pivot_root

chroot(2) меняет «корень» процессу, но не трогает mount table. Процесс всё ещё видит mount points host'а через /proc/mounts, может выйти за chroot, имея CAP_SYS_CHROOT.

pivot_root(2) атомарно меняет местами старый и новый корни всей mount table. Используется в контейнерах: образ монтируется в новый mount ns, делается pivot_root в него, старый корень отмонтируется. После этого процесс физически не может «вылезти» — старого корня уже нет в mount table.

flowchart TB
    R1["1. unshare(CLONE_NEWNS) — свой mount ns"]
    R2["2. mount('rootfs.ext4', '/var/run/runc/abc/rootfs')<br/>— образ как новый /"]
    R3["3. mount --rbind /proc, /sys, /dev, /tmp — заполнить"]
    R4["4. pivot_root('/var/run/runc/abc/rootfs', '.old')<br/>— атомарный swap"]
    R5["5. umount('.old', MNT_DETACH) — отрезать прошлое"]
    R6["6. chdir('/') — корень установлен"]
    R1 --> R2 --> R3 --> R4 --> R5 --> R6

PID namespace

Внутри PID ns нумерация процессов начинается с 1. Первый процесс ns становится init процессом этого ns: он получает PID 1, наследует обязанности reaper'а зомби и обработки сигналов от ядра.

graph LR
    subgraph H["host PID namespace"]
        H1["PID 1 systemd"]
        H2["PID 832 dockerd"]
        H3["PID 2001 containerd-shim"]
        H4["PID 2050 nginx"]
        H5["PID 2052 worker1"]
        H6["PID 2053 worker2"]
    end
    subgraph C["container PID namespace"]
        C1["PID 1 nginx"]
        C2["PID 2 worker1"]
        C3["PID 3 worker2"]
    end
    H4 -->|тот же процесс| C1
    H5 -->|тот же процесс| C2
    H6 -->|тот же процесс| C3

Один и тот же процесс имеет разные PID в разных namespaces — это PID translation, его делает ядро при чтении /proc. getpid(2) внутри процесса всегда возвращает PID в текущем ns. Чтобы узнать «настоящий» PID на хосте, надо прочитать /proc/PID/status, поле NSpid: — там список PID-ов во всех вложенных ns.

Свойства init процесса (PID 1)

  • Сигналы, для которых нет установленного handler'а, игнорируются — кроме SIGKILL и SIGSTOP. Это означает, что kill 1 из контейнера не убьёт его init, если в коде нет signal(SIGTERM, ...).
  • Когда init процесс умирает, ядро посылает SIGKILL всем остальным процессам этого ns и сам ns уничтожается.
  • Любой осиротевший процесс в ns reparent'ится на init этого ns (а не на init хоста, как было бы без PID ns).

Это причина, по которой простой CMD ["python", "app.py"] в Docker — плохая идея: Python становится PID 1, не умеет reap'ать зомби и плохо обрабатывает сигналы. Решения: tini, dumb-init, или флаг docker run --init.

Nested PID namespaces

PID ns могут быть вложенными. Процесс в N-ом вложенном ns имеет N+1 PID: по одному в каждом ns выше плюс свой собственный.

graph TB
    H["host ns<br/>PID 3000 — видно везде выше"]
    L1["container ns (level 1)<br/>PID 42 — видно в L1 и host"]
    L2["sandbox ns (level 2, внутри L1)<br/>PID 1 — 'настоящий' PID для процесса"]
    H --> L1 --> L2
    NS["один процесс — три разных PID:<br/>NSpid: 3000 42 1"]

Процесс из родительского ns может слать сигналы процессам в дочернем (по их PID в родительском ns), но не наоборот.

User namespace

Самый молодой и сложный namespace. Изолирует UID/GID — внутри ns пользователь может быть root (UID 0), а снаружи это обычный непривилегированный пользователь.

UID/GID mapping

Mapping задаётся через файлы /proc/PID/uid_map и /proc/PID/gid_map. Каждая строка: inside outside count — непрерывный диапазон.

$ cat /proc/$$/uid_map
         0       1000          1     ← root внутри ns ↔ UID 1000 снаружи
         1     100000      65536     ← UID 1..65536 внутри ↔ 100000..165535 снаружи

Преобразование двустороннее. Когда процесс из user ns создаёт файл, на диск пишется его снаружный UID. Когда читает ownership файла — ядро транслирует обратно.

graph LR
    subgraph IN["inside user ns"]
        I1["UID 0 (root)"]
        I2["UID 1 (daemon)"]
        I3["UID 1000 (app)"]
    end
    subgraph OUT["outside (host)"]
        O1["UID 1000 (egor)"]
        O2["UID 100001"]
        O3["UID 101000"]
    end
    I1 -->|mapping| O1
    I2 -->|mapping| O2
    I3 -->|mapping| O3
    N["touch /tmp/file → owner: root (UID 0) внутри<br/>ls -l /tmp/file → owner: egor (UID 1000) снаружи"]

Capabilities в user ns

Внутри user ns процесс получает полный набор capabilities относительно ресурсов, созданных внутри ns. Это позволяет обычному пользователю настраивать сеть, монтировать tmpfs/proc, запускать chroot — всё внутри своего ns, без угрозы хосту.

Однако capabilities не дают магических прав на ресурсы родительского ns. Root в дочернем user ns не может прочитать /etc/shadow хоста, потому что для VFS он всё ещё UID 1000 (после translation).

Rootless containers

User ns — основа rootless контейнеров: Podman, rootless Docker, Buildah. Обычный пользователь запускает контейнер, внутри которого видит root, но на хосте контейнер бежит под его UID. Ему не нужен Docker daemon с правами root, не нужен SUID, не нужен sudo.

# создать user ns с маппингом root↔себя
unshare -U -r /bin/bash
id              # uid=0(root) gid=0(root) внутри
mount -t tmpfs none /mnt   # работает! привилегии есть внутри ns

Флаг -r (--map-root-user) — сокращение для маппинга 0 <real-uid> 1. Для полноценного маппинга диапазона (subuid/subgid) Podman читает /etc/subuid, /etc/subgid.

Безопасность

User namespaces исторически были источником большого количества CVE (CVE-2018-18955, CVE-2022-0185, CVE-2022-34918, CVE-2023-32233 и другие): они открывают непривилегированным пользователям доступ к коду ядра, который раньше был достижим только из root. Многие дистрибутивы (Debian, Ubuntu) ставят:

# полностью запретить непривилегированный clone user ns
sysctl kernel.unprivileged_userns_clone=0

# Ubuntu 24+ — apparmor profile вместо полного запрета
sysctl kernel.apparmor_restrict_unprivileged_userns=1

Network namespace

Network ns — полностью изолированный сетевой стек: свои interfaces, IP-адреса, routing table, iptables/nftables rules, /proc/net, sockets. Сокет, открытый в одном netns, недоступен из другого даже по тому же порту.

По умолчанию свежесозданный netns содержит только lo (loopback), и тот в state DOWN.

sudo ip netns add ns1
sudo ip netns exec ns1 ip link
# 1: lo: <LOOPBACK> mtu 65536 state DOWN

veth pair

Чтобы соединить netns с внешним миром, ядро предоставляет veth (virtual ethernet) — пару виртуальных интерфейсов, соединённых трубой: всё, что записано на один конец, выходит на другом.

graph LR
    subgraph H["host netns"]
        V0["veth0<br/>192.168.1.1/24"]
    end
    subgraph C["container netns"]
        V1["veth1<br/>192.168.1.2/24"]
    end
    V0 <-->|"виртуальная труба"| V1
    P["ping 192.168.1.2 → пакет → veth0 → veth1 → доставка"]

Bridge

Когда контейнеров много, у каждого свой veth pair, и все нужно объединить в подсеть. Решение — bridge (виртуальный коммутатор) на host'е. Docker создаёт bridge docker0, и host-конец каждого veth подключается к нему.

graph TB
    subgraph H["host netns"]
        BR["bridge docker0 (10.0.0.1)"]
        V0a[veth0a]
        V0b[veth0b]
        V0c[veth0c]
        BR --- V0a
        BR --- V0b
        BR --- V0c
    end
    subgraph C1["container1"]
        V1["veth1<br/>10.0.0.2"]
    end
    subgraph C2["container2"]
        V2["veth2<br/>10.0.0.3"]
    end
    subgraph C3["container3"]
        V3["veth3<br/>10.0.0.4"]
    end
    V0a <-->|veth pair| V1
    V0b <-->|veth pair| V2
    V0c <-->|veth pair| V3
    N["bridge коммутирует L2-фреймы между всеми contained'ами + uplink через NAT"]

Пример: ручная сборка netns с veth

# создать namespace
sudo ip netns add ns1

# создать veth pair
sudo ip link add veth0 type veth peer name veth1

# один конец оставить на host, второй — в ns1
sudo ip link set veth1 netns ns1

# поднять и настроить host-конец
sudo ip addr add 192.168.1.1/24 dev veth0
sudo ip link set veth0 up

# поднять и настроить container-конец
sudo ip netns exec ns1 ip addr add 192.168.1.2/24 dev veth1
sudo ip netns exec ns1 ip link set veth1 up
sudo ip netns exec ns1 ip link set lo up

# проверка
sudo ip netns exec ns1 ping -c 2 192.168.1.1

Команды

ip netns add ns1                          # создать
ip netns list                             # список
ip netns exec ns1 <cmd>                   # выполнить в ns
ip netns delete ns1                       # удалить
ip -n ns1 addr                            # сокращение для exec ns1 ip addr

ip netns под капотом bind-mount'ит /proc/PID/ns/net в /var/run/netns/<name> — благодаря этому ns живёт без процессов внутри.

UTS, IPC, cgroup, time

UTS изолирует hostname (gethostname/sethostname) и NIS domainname. Используется в каждом контейнере, потому что у каждого должен быть свой hostname. Самый дешёвый namespace.

IPC изолирует System V IPC objects (semaphores, shared memory segments, message queues) и POSIX message queues (/dev/mqueue). Без него процессы из разных контейнеров могли бы видеть IPC-объекты друг друга и обмениваться данными через shmget/msgget.

cgroup изолирует видимый корень иерархии cgroup. Процесс не видит, в какой реальный cgroup на хосте он помещён, видит только относительный путь начиная со своего корня. Полезно, чтобы образ контейнера не зависел от структуры cgroup host'а. Часто создаётся последним, уже после pivot_root, чтобы /sys/fs/cgroup уже был смонтирован.

time (самый новый, 5.6+) изолирует смещения для CLOCK_MONOTONIC и CLOCK_BOOTTIME. CLOCK_REALTIME ( системное время) не изолирован — оно общее. Применение: миграция контейнеров (CRIU) с сохранением uptime, тестирование с управляемым «возрастом» системы.

Из чего собирается контейнер

Когда docker run или podman run запускает контейнер, под капотом исполняется примерно такая последовательность. Реальный контейнер собирает runc/crun по OCI runtime spec.

sequenceDiagram
    participant Parent as runc parent
    participant Child as runc child
    Parent->>Child: 1. clone(CLONE_NEWPID|CLONE_NEWNS|CLONE_NEWNET|<br/>CLONE_NEWUTS|CLONE_NEWIPC|CLONE_NEWUSER)<br/>(ребёнок появляется во всех новых namespaces сразу)
    Note over Child: 2a. write /proc/self/uid_map, gid_map<br/>(perm требует setgroups='deny')
    Note over Child: 2b. mount('/', '/', MS_PRIVATE|MS_REC) — отрезать propagation<br/>mount(image_rootfs, '/run/rootfs', BIND) — образ как новый /
    Note over Child: 2c. mount tmpfs, proc, sysfs, devpts, mqueue в новый rootfs<br/>mount --rbind /dev/null /dev/null и т.п.
    Note over Child: 2d. pivot_root('/run/rootfs', '/run/rootfs/.old')<br/>umount('/run/rootfs/.old', MNT_DETACH); chdir('/')
    Note over Child: 2e. unshare(CLONE_NEWCGROUP) — корень cgroup стал контейнерным
    Note over Child: 2f. sethostname('container-id'); set rlimits, oom_score_adj
    Note over Child: 2g. apply seccomp filter; drop capabilities (keep minimal set)
    Note over Child: 2h. setgroups, setgid, setuid в маппированный 'root внутри ns'
    Note over Child: 2i. execve('/entrypoint')

Параллельно cgroup-контроллер на хосте помещает PID контейнера в нужный cgroup с лимитами CPU/memory.

Подводные камни

User ns заблокирован дистрибутивом. Если rootless container не запускается с ошибкой clone: Operation not permitted — проверить sysctl kernel.unprivileged_userns_clone. На Ubuntu 24+ — ещё и AppArmor (dmesg | grep userns).

Mount propagation ломает volume mount. Docker volume должен пробрасываться из host в контейнер. Если корень контейнера в MS_PRIVATE, последующие host mounts не попадают внутрь. Решение — mount --make-rslave / после unshare --mount, как делает runc.

Zombies в PID ns. В контейнере, где init — обычное приложение (Node.js, Python, java), осиротевшие дети не reap'аются: приложение не вызывает wait() по SIGCHLD. Zombies накапливаются, kernel жалуется в dmesg. Использовать tini/dumb-init или docker run --init.

SIGTERM игнорируется в PID ns. Сигналы без handler'а ядро не доставляет PID 1 (защита от случайного kill 1 в обычной системе). Если приложение должно gracefully останавливаться по docker stop, оно обязано установить SIGTERM handler — либо снова, обернуть tini поверх.

Порты не «выставляются» сами. Контейнер в своём netns не доступен снаружи host'а. Нужен либо userspace proxy (docker-proxy), либо NAT через iptables (-A PREROUTING -p tcp --dport 8080 -j DNAT --to 10.0.0.2:80). Docker по умолчанию делает оба варианта.

Network ns теряется, если в нём нет процессов. Если netns создан только через clone, без bind-mount'а в /var/run/netns/, при завершении последнего процесса ns исчезнет вместе с интерфейсами.

Tooling

# список всех namespaces в системе, их типы, владельцы, процессы
lsns
lsns -t net                          # только network ns
lsns -p $$                           # ns, в которых сидит текущий shell

# войти в namespaces работающего процесса
nsenter --target 1234 --all          # все ns процесса 1234
nsenter -t 1234 -n ip addr           # только network ns

# создать свой ns и выполнить
unshare --user --map-root-user --mount --pid --fork --mount-proc /bin/bash

# управление netns
ip netns list / add / delete / exec

# готовый «лёгкий контейнер» из systemd
systemd-nspawn -D /var/lib/machines/debian
machinectl shell <name>

Связанные темы

Источники