Namespaces: вид ядра, который контейнеру дозволено видеть
Контейнер — обычный процесс, чей вид со стороны ядра огорожен namespaces. Типов 8; runc разворачивает их флагами clone(). PID, mount и network делают видимую изоляцию — а забытый PID namespace означает, что зомби некому пожинать.
Пейджер дежурного сообщил: на ноде кончились PID. В worker-поде крутился shell-скрипт, который тысячи раз в час дёргал curl, и каждый curl оставлял зомби: записи <defunct> копились в ps, таблица PID ядра заполнялась, новые fork падали с EAGAIN. Автор клялся, что скрипт пожинает своих детей. Так и было — в версии, что запускалась под systemd. В контейнере скрипт работал как PID 1. А у PID 1 есть особая роль ядра, унаследованная от загрузочного init: он обязан делать wait() на осиротевших процессах, иначе их записи никогда не очищаются. Обычный shell этого не делает. Фикс — три байта в Dockerfile: добавить --init, чтобы крошечный reaper работал как PID 1. Урок под этим: у контейнера был PID namespace, namespace перенумеровал скрипт в 1, а PID 1 несёт обязанности ядра, на которые автор не подписывался.
Восемь видов, один процесс
Зачем инженеру знать все восемь? Потому что когда что-то ломается — копятся зомби, пропадает сетевой интерфейс, root-процесс не может смонтировать — тип namespace сразу говорит, какая граница дала сбой и где искать. Это полная карта поверхности контейнера.
Namespace оборачивает один глобальный ресурс ядра так, что процессы внутри видят свой экземпляр этого ресурса. На современном ядре 8 типов namespace, и контейнер — это просто процесс, помещённый в свежий набор из них:
- PID — идентификаторы процессов; первый процесс становится PID 1.
- mount (
mnt) — дерево файловой системы; именно это делает/контейнера его собственным корнем. - network (
net) — интерфейсы, таблицы маршрутизации, loopback, iptables. - UTS — hostname и domainname (так
hostnameвнутри отличается от хостового). - IPC — System V IPC и POSIX-очереди сообщений.
- user — карты UID/GID; позволяет root-в-контейнере (UID 0) отобразиться в непривилегированный хостовый UID.
- cgroup — какой вид дерева cgroup видит процесс.
- time — смещения boot/monotonic-часов (добавлен в ядре 5.6).
Вместе эти восемь типов покрывают полный вид ядра, который процесс может запросить: кто я, какие файлы существуют, что в сети, как меня зовут, с кем я общаюсь через IPC, что я вижу в дереве cgroup и который час. Упусти любой из них — и контейнер протечёт в глобальный хостовый вид по этому ресурсу, а это ровно тот класс ошибок, что выглядит как «локально работало, в поде нет».
Ключевой сдвиг в голове: процесс-контейнер — объект того же рода, что любой хостовый процесс. ps на хосте показывает его напрямую, его планирует тот же планировщик, в том же ядре. Нет гостевого ядра, нет ловушки гипервизора. Изоляция — это лишь: когда процесс спрашивает «какие PID существуют?», ядро отвечает из его PID namespace, а не из глобального.
// Примерно то, что рантайм делает, создавая первый процесс контейнера.
// CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWUTS |
// CLONE_NEWIPC | CLONE_NEWUSER | CLONE_NEWCGROUP — по флагу на namespace.
int flags = CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET |
CLONE_NEWUTS | CLONE_NEWIPC | CLONE_NEWUSER | CLONE_NEWCGROUP;
pid_t pid = clone(child_fn, stack_top, flags | SIGCHLD, arg);
// child_fn теперь работает как PID 1 в новеньком наборе namespace.runc — OCI-рантайм, который вызывают Docker и containerd, — читает массив linux.namespaces из config.json контейнера и делает unshare(2) / clone(2) именно для них. Namespaces ортогональны: можно взять сетевой namespace, но разделить хостовый PID namespace — именно это делает docker run --pid=host, и именно это позволяет отладочному sidecar выполнить ps целевого контейнера.
Главный процесс контейнера завершается чисто, но worker-под медленно копит <defunct> (зомби), пока fork не падают с EAGAIN. Образ запускает shell-скрипт как entrypoint. В чём корневая причина?
▸Почему это работает
Почему PID 1 несёт эту обязанность? Когда родитель процесса умирает, сирота переусыновляется — в PID namespace к PID 1 этого namespace, а не к хостовому init. Ядро отдаёт труп каждого сироты PID 1 и ждёт, что PID 1 сделает wait(). Хостовый /sbin/init написан, чтобы это делать; ваш entrypoint.sh — нет. docker run --init (или entrypoint tini/dumb-init) вставляет 10-КБ reaper как PID 1, который пробрасывает сигналы и пожинает сирот, восстанавливая контракт, который namespace тихо навязал.
Mount и network: видимая изоляция
Два namespace делают бо́льшую часть того, что вы видите. Mount namespace даёт контейнеру своё дерево файловой системы: рантайм делает pivot_root в распакованный образ, так что / контейнера — корень образа, а хостовые пути просто не существуют в его виде, если только не примонтированы через bind. Network namespace даёт ему свой loopback, свои интерфейсы, свои таблицы маршрутизации и фаервола — поэтому у свежего контейнера есть только lo, пока рантайм не переместит один конец пары veth (виртуальный Ethernet-кабель: два конца, каждый в своём namespace) внутрь, а другой не подключит к мосту на хосте. Namespace живёт, пока в нём есть процесс или пока его пинит файловый дескриптор либо bind-mount; так ip netns и docker exec повторно входят в namespaces существующего контейнера через setns(2), а не создают новые.
User namespace — краеугольный камень безопасности и тот, что чаще всего забывают: с userns-remap UID 0 внутри контейнера отображается в непривилегированный хостовый UID (скажем, 100000), так что «root» контейнера, сбежавший из mount, имеет на хосте привилегии nobody. Без него root-в-контейнере становится root-на-хосте в тот миг, как любой другой барьер (плохой bind-mount, забытая невыброшенная capability) поддастся.
Почему отладочный sidecar, запущенный с --pid=host, может выполнить ps и увидеть процессы целевого контейнера, хотя у того свои mount и network namespaces?
- 01Назови 8 типов namespace и для PID, mount, network и user скажи, какой глобальный ресурс каждый огораживает и одно конкретное следствие этого.
- 02Объясни, почему ортогональность namespaces важна операционно, на примерах --pid=host и setns.
Контейнер — не маленькая виртуальная машина; это обычный процесс, который ядро планирует как любой другой, отличающийся лишь тем, какие namespaced-виды он держит. Namespace оборачивает один глобальный ресурс так, что его члены видят приватный экземпляр, и современное ядро предлагает 8: PID, mount, network, UTS, IPC, user, cgroup и time. OCI-рантайм runc читает массив namespaces из config.json и применяет соответствующие флаги CLONE_NEW* через clone() или unshare(). PID namespace перенумеровывает entrypoint в 1, что тихо передаёт ему работу init по пожинанию осиротевших детей — пропусти reaper вроде —init или tini, и зомби копятся, пока таблица PID не исчерпается и fork не вернёт EAGAIN. Mount namespace делает pivot_root в распакованный образ, так что корень контейнера — образ, а хостовые пути исчезают из вида. Network namespace стартует только с loopback, пока пара veth не соединит его с хостом. User namespace — краеугольный камень безопасности: ремаппинг root контейнера в непривилегированный хостовый UID превращает побег в не-событие. Поскольку namespaces ортогональны, каждый разделяется или изолируется отдельно — —pid=host разделяет ровно один, чтобы sidecar мог сделать ps целевого, а setns позволяет exec войти в запиненный namespace (файловый дескриптор или bind-mount, закрепляющий namespace), а не строить новый. Теперь, когда в контейнере что-то ломается — копятся зомби, пропадает интерфейс, root не может смонтировать — первый вопрос: какой из восьми namespace задет и он изолирован или разделён?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.