Namespaces: почему контейнер думает, что он один на сервере
Заходите в контейнер, набираете ps aux — и видите только пару процессов, а ваш процесс внутри имеет PID 1. Смотрите ip addr — там свой eth0 с отдельным IP, как будто это отдельная машина. При этом контейнер запущен на том же хосте, что и десяток соседей, и делит с ними одно ядро Linux. Разбираем по механизму, а не по рекламным слоганам «полная изоляция как в VM», где эта граница на самом деле проходит.
Содержание
- Что вообще такое namespace в Linux
- PID namespace: свой PID 1 и невидимость соседей
- Network namespace: свой сетевой стек с нуля
- Mount namespace: своя файловая система
- UTS и IPC namespaces: то, что редко обсуждают, но тоже важно
- User namespace: самая недооценённая изоляция
- Почему это не то же самое, что виртуальная машина
- Практика: посмотреть и зайти в namespace процесса
Что вообще такое namespace в Linux
Namespace (пространство имён) — это механизм ядра Linux, который позволяет процессу видеть свою собственную, урезанную версию какого-то глобального ресурса системы: списка процессов, сетевых интерфейсов, точек монтирования и так далее. Ключевое слово — «видеть»: ресурс физически один, ядро одно, но группе процессов показывается изолированный срез этого ресурса.
Технически это делается через системные вызовы clone(), unshare() и setns(). Всего в ядре сейчас восемь типов namespace: PID, network (CLONE_NEWNET), mount (CLONE_NEWNS), UTS (CLONE_NEWUTS), IPC (CLONE_NEWIPC), user (CLONE_NEWUSER), cgroup (CLONE_NEWCGROUP) и time (CLONE_NEWTIME, начиная с ядра 5.6).
Docker, Podman, containerd не изобретают свою виртуализацию — они дёргают эти же системные вызовы ядра и упаковывают результат в удобный CLI. Контейнер — это обычный процесс Linux, которому включили несколько namespace одновременно плюс ограничили ресурсы через cgroups — это отдельный механизм, не namespace, и про лимиты CPU и памяти в Docker у нас есть отдельный разбор.
PID namespace: свой PID 1 и невидимость соседей
Самое заметное для пользователя — PID namespace. Внутри контейнера первый запущенный процесс всегда получает PID 1, независимо от реального PID на хосте: так работает нумерация — каждое новое PID-пространство начинает счёт с единицы. Проверить это можно прямо изнутри контейнера:
# внутри контейнера
$ ps aux
PID USER COMMAND
1 root nginx: master process
7 root nginx: worker process
А теперь с хоста посмотрите на реальные PID тех же процессов:
# на хосте
$ ps aux | grep nginx
root 48213 nginx: master process
root 48221 nginx: worker process
Процесс с PID 1 внутри контейнера снаружи имеет обычный «взрослый» PID, как любой другой процесс на хосте — ядро ведёт две параллельные нумерации для одного и того же процесса. Отсюда известная проблема: PID 1 в Linux обязан пожинать (reap) зомби-процессы и корректно обрабатывать сигналы завершения. Если образ запускает приложение напрямую как PID 1, а оно не умеет вести себя как init-процесс, получаются зависшие зомби или контейнер, который не реагирует на docker stop. Отсюда практика ставить внутрь лёгкий init вроде tini или флаг --init в Docker. Сигналы, разосланные всем процессам внутри контейнера, тоже видят только процессы этого же PID namespace — сосед их не почувствует.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверNetwork namespace: свой сетевой стек с нуля
Network namespace даёт процессу собственный набор сетевых интерфейсов, таблицу маршрутизации и правила iptables/nftables. Именно поэтому два контейнера на одном хосте могут оба слушать порт 80 «внутри себя» без конфликта — с точки зрения ядра это два разных сетевых стека.
Новый network namespace по умолчанию содержит только выключенный loopback lo. Связь с внешним миром обеспечивается парой veth (virtual ethernet pair) — один конец живёт внутри namespace контейнера, второй подключается к мосту (bridge) на хосте, обычно docker0 или аналогу CNI-плагина в Kubernetes. Дальше NAT и правила iptables на хосте решают, как трафик из моста попадает наружу.
Команда ip netns list для просмотра namespace на хосте для контейнеров Docker часто ничего не покажет — Docker не всегда регистрирует namespace в стандартной директории /var/run/netns, которую использует iproute2. Надёжнее зайти через nsenter (см. ниже) или посмотреть символическую ссылку /proc/<PID>/ns/net.
Отдельный нюанс: если контейнер работает в режиме --network host, network namespace не создаётся вообще — процесс использует сетевой стек хоста напрямую. Это быстрее (меньше слоёв NAT), но контейнер видит все интерфейсы хоста и конкурирует за порты с остальными процессами. Если у вас были проблемы именно с недоступностью сети из контейнера в bridge-режиме — это отдельная тема, разобранная в статье про то, когда у контейнера нет доступа к сети.
Mount namespace: своя файловая система
Mount namespace изолирует дерево точек монтирования. Внутри контейнера вы видите /, /etc, /var — но это не директории хоста, а объединение слоёв образа (обычно через OverlayFS) плюс смонтированные volume. С точки зрения процесса — это полноценная, самодостаточная файловая система, будто он единственный на диске.
Технически используется pivot_root в паре с mount namespace: процесс меняет корень файловой системы на подготовленную директорию с распакованными слоями образа и дальше не может «выйти» за пределы этого корня обычными путями — разве что найдётся дыра вроде примонтированного хостового каталога с широкими правами.
Важная деталь: mount namespace изолирует точки монтирования, а не содержимое файлов. Если вы примонтировали хостовый каталог через -v /data:/data, изменения видны в обе стороны — это и есть смысл volume, а не баг изоляции. Посмотреть точки монтирования конкретного процесса:
$ cat /proc/<PID>/mountinfo
Это полезно, когда контейнер «не видит» файл, который вы туда вроде бы положили — часто причина в том, что монтирование указывает не туда, куда вы думаете, либо перекрыто более поздним mount поверх той же точки.
UTS и IPC namespaces: то, что редко обсуждают, но тоже важно
UTS namespace (название историческое, от Unix Time-Sharing) изолирует hostname и NIS domain name — именно благодаря ему у контейнера свой hostname вроде случайного хеша a3f9c21b4e10, видный в приглашении командной строки внутри.
IPC namespace изолирует объекты межпроцессного взаимодействия: очереди сообщений System V, семафоры, разделяемую память. Без изоляции два несвязанных контейнера теоретически могли бы столкнуться с одинаковыми ключами IPC и получить доступ к чужой shared memory — namespace делает идентификаторы уникальными для каждого контейнера.
Оба namespace редко становятся источником проблем, но объясняют, почему приложения на POSIX/System V IPC (некоторые СУБД, старые Java-приложения с shared memory) в контейнерах иногда ведут себя иначе, чем на голом хосте. Если два контейнера из одного docker-compose должны шарить IPC — есть опция --ipc=container:<name> или --ipc=host.
User namespace: самая недооценённая изоляция
User namespace позволяет процессу внутри контейнера думать, что он root (UID 0), при этом на хосте этот же процесс выполняется от имени непривилегированного пользователя с высоким UID (например, 100000) — через отображение (mapping) UID/GID между namespace и хостом.
Это единственный namespace из перечисленных, который напрямую влияет на модель безопасности, а не только на удобство изоляции. Если контейнер скомпрометирован и атакующий получил root внутри, но remapping включён — на хосте этот «root» на самом деле обычный пользователь без привилегий. Без remapping root в контейнере — это буквально root на хосте, ограниченный только другими namespace и capabilities.
По умолчанию в классическом Docker remapping выключен — компромисс ради совместимости (часть образов плохо работает с ним, плюс исторически была путаница с правами на volume). Включается через /etc/docker/daemon.json:
{
"userns-remap": "default"
}
После включения Docker создаёт пользователя dockremap и мапит UID контейнеров на диапазон непривилегированных UID хоста. Нюанс: могут понадобиться правки для существующих volume (владелец файлов меняется) — включать стоит осознанно, не «на живую» на проде.
Почему это не то же самое, что виртуальная машина
Маркетинг вокруг контейнеров годами смазывает эту границу, но она принципиальна. Виртуальная машина через гипервизор (KVM, Xen) эмулирует отдельное аппаратное окружение и запускает своё собственное ядро. Контейнер с namespaces — это процесс, который делит одно физическое ядро хоста со всеми остальными контейнерами. Namespaces меняют то, что процесс *видит*, а не то, на чём он *выполняется*.
| Виртуальная машина | Контейнер (namespaces + cgroups) | |
|---|---|---|
| Ядро | Своё отдельное | Общее с хостом |
| Уровень изоляции | Аппаратный (гипервизор) | Программный (namespaces, cgroups, capabilities) |
| Что происходит при уязвимости в ядре | Не затрагивает хост напрямую | Потенциально затрагивает весь хост |
| Время старта | Секунды-минуты | Обычно доли секунды |
| Накладные расходы | Заметные (эмуляция устройств, память под гостевую ОС) | Минимальные |
Если в ядре найдена уязвимость, позволяющая экранироваться из namespace (container escape), она потенциально даёт доступ ко всему хосту и всем соседним контейнерам — ядро одно на всех. С виртуальной машиной такой сценарий требует уязвимости уже в самом гипервизоре — принципиально другой и обычно более узкий класс атак.
Практический вывод: для по-настоящему недоверенных нагрузок (например, вы даёте доступ к среде исполнения клиентам, которым не доверяете) одних namespaces и стандартного Docker может быть недостаточно — стоит смотреть в сторону gVisor, Kata Containers (лёгкие VM вместо процессов) или разносить такие нагрузки по отдельным VM. Для внутренних сервисов одной команды на своём VPS стандартной изоляции через namespaces обычно достаточно — про модель угроз есть отдельный разбор изоляции сервисов через Docker для безопасности.
Практика: посмотреть и зайти в namespace процесса
Каждый процесс в Linux хранит ссылки на свои namespace в /proc/<PID>/ns/. Посмотреть, в каких namespace живёт процесс:
$ ls -la /proc/<PID>/ns/
lrwxrwxrwx 1 root root 0 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0 ipc -> 'ipc:[4026532341]'
lrwxrwxrwx 1 root root 0 mnt -> 'mnt:[4026532339]'
lrwxrwxrwx 1 root root 0 net -> 'net:[4026532344]'
lrwxrwxrwx 1 root root 0 pid -> 'pid:[4026532342]'
lrwxrwxrwx 1 root root 0 user -> 'user:[4026531837]'
lrwxrwxrwx 1 root root 0 uts -> 'uts:[4026532340]'
Число в скобках — это inode namespace, уникальный идентификатор. Если у двух процессов совпадает число для, скажем, net (проверить: readlink /proc/<PID>/ns/net), значит они делят один network namespace — как обычно происходит с процессами внутри одного docker-контейнера (все процессы контейнера обычно делят PID, network, mount, UTS, IPC namespace, но могут иметь и собственные, если это явно настроено через unshare).
Самая полезная команда на практике — nsenter. Она позволяет зайти в namespace процесса снаружи, без docker exec, что незаменимо для диагностики, когда контейнер настолько сломан, что в него не зайти штатным способом (сломан entrypoint или в образе нет шелла):
# найти PID главного процесса контейнера
$ docker inspect --format '{{.State.Pid}}' <container_id>
48213
# зайти во все namespace этого процесса
$ nsenter -t 48213 -a /bin/bash
# зайти только в network namespace (диагностика сети без входа в контейнер целиком)
$ nsenter -t 48213 -n ip addr
Флаги: -t — целевой PID, -a — все namespace, -n — только network, -m — только mount, -p — PID, -u — UTS, -i — IPC, -U — user. Это ровно тот механизм, которым docker exec пользуется под капотом — nsenter даёт доступ к нему напрямую, в обход Docker daemon, что важно, когда сам daemon недоступен или тормозит. Для обзора всех namespace в системе с числом процессов в каждом пригодится lsns (например, lsns -t net).
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Можно ли создать namespace без Docker?
Да, через unshare из util-linux: unshare --pid --fork --mount-proc /bin/bash создаст новый PID namespace и запустит в нём bash с собственной нумерацией процессов. Docker — удобная обёртка вокруг тех же вызовов плюс управление образами.
Почему docker stop иногда ждёт таймаут и убивает процесс силой?
Обычно потому, что PID 1 внутри не обрабатывает SIGTERM правильно — либо приложение не подписано на сигнал, либо порождает дочерние процессы и не пересылает им сигнал. Решение — init-обёртка (tini, флаг --init) или явная обработка SIGTERM.
Если ядро хоста скомпрометировано, все контейнеры под угрозой?
Да, ядро общее — в этом ключевое отличие от виртуальных машин, где хостовая уязвимость эксплуатируется уже через гипервизор, более узкий вектор атаки.
Зачем нужен user namespace, если Docker и без него работает?
Без remapping root внутри контейнера — это буквально root на хосте, ограниченный только другими механизмами. User namespace добавляет слой: получив root внутри, атакующий не получает реальных привилегий на хосте.
Как понять, что два контейнера случайно делят один namespace?
Сравните числа namespace-идентификаторов через readlink /proc/<PID>/ns/net (или pid, mnt) для их главных процессов — совпадение означает общий namespace.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →