Когда разработчик запускает docker run -it ubuntu bash и оказывается внутри системы без своих процессов, без своих сетевых интерфейсов, без своей файловой системы - он интуитивно ищет объяснение в виртуализации. Но никакого гипервизора нет. Никакого отдельного ядра нет. Контейнер - это просто группа обычных Linux-процессов, живущих в тщательно выстроенной иллюзии. И имя этой иллюзии - Linux namespaces.
Механизм namespaces появился в ядре Linux задолго до Docker. Первые реализации начали появляться в версии 2.4.19 в 2002 году с поддержкой mount namespaces. Полноценная система из семи типов пространств имён сложилась к версии 4.6. Docker, появившийся в 2013 году, не изобрёл ничего нового - он грамотно упаковал уже существующие инструменты ядра в удобный интерфейс. Это честный ответ на вопрос "что такое контейнер": не новая технология, а умная комбинация старых.
Чем контейнер принципиально отличается от виртуальной машины
Классическая виртуальная машина эмулирует железо. Гипервизор создаёт виртуальный процессор, виртуальную память, виртуальный диск. Гостевая ОС загружает собственное ядро и работает так, будто находится на отдельном физическом сервере. Это стоит ресурсов и замедляет старт.
Namespace работает принципиально иначе. Ядро Linux управляет глобальными ресурсами: таблицей процессов, сетевыми интерфейсами, точками монтирования, таблицами пользователей. Namespace - это механизм, который говорит конкретному процессу: "для тебя существует только вот эта часть глобального ресурса, и ты думаешь, что она глобальная". Процесс не подозревает об ограничении. Ядро - одно на всех, железо - одно на всех, а картина мира у каждого контейнера - своя.
Разница в накладных расходах при этом разительная. Виртуальная машина стартует от 10 до 30 секунд и потребляет сотни мегабайт памяти только для гостевого ядра. Контейнер запускается за доли секунды и тратит ровно столько памяти, сколько нужно его процессам - и ни байтом больше.
Когда Docker создаёт контейнер, он вызывает системный вызов clone() с набором флагов:
clone(child_func, stack,
CLONE_NEWPID | /* PID namespace */
CLONE_NEWNET | /* Network namespace */
CLONE_NEWNS | /* Mount namespace */
CLONE_NEWUTS | /* UTS namespace */
CLONE_NEWIPC | /* IPC namespace */
CLONE_NEWUSER, /* User namespace */
&args);
Новый процесс оказывается в изолированной среде ещё до того, как в нём выполнится первая пользовательская инструкция. Каждый из этих флагов - отдельный мир, и каждый заслуживает детального разбора.
PID Namespace - у каждого контейнера свой первый процесс
Каждый Linux-процесс имеет уникальный идентификатор - PID. В обычной системе PID 1 принадлежит init или systemd, и это особый процесс: его гибель означает панику ядра. В PID namespace всё иначе - первый процесс, запущенный внутри, получает PID 1 прямо там, внутри своего пространства имён. Снаружи у него есть настоящий PID в глобальной таблице - скажем, 3847. Но изнутри контейнера он видит себя как PID 1, и это не иллюзия восприятия, а реальная запись в разных таблицах ядра.
Проверить это просто. Можно создать изолированное пространство имён вручную, без Docker:
# Создаём новый PID namespace и запускаем bash в нём
sudo unshare --fork --pid --mount-proc bash
# Внутри нового namespace
echo $$ # → 1 (bash думает, что он PID 1)
ps aux # → видит только свои процессы
А вот что происходит снаружи в это же время:
# В соседнем терминале, на хосте
ps aux | grep bash
# → user 3847 ... bash (настоящий PID виден на хосте)
Это ключевое свойство: изоляция работает в одну сторону. Хост видит всё. Контейнер видит только себя. Именно поэтому docker exec работает - он просто присоединяет новый процесс к уже существующим namespace контейнера через системный вызов setns().
Практическая польза этого механизма выходит за рамки простой изоляции. Если PID 1 внутри контейнера завершается, ядро посылает SIGKILL всем остальным процессам в этом namespace - это корректное завершение контейнера. Никакого мусора, никаких зомби-процессов на хосте.
Network Namespace - собственный сетевой стек для каждого контейнера
Сеть - пожалуй, самый наглядный пример изоляции через namespaces. Каждый network namespace имеет собственные сетевые интерфейсы, собственные таблицы маршрутизации, собственные правила iptables, собственный порт 80. Именно поэтому на одном хосте можно запустить десятки контейнеров, каждый из которых "думает", что слушает порт 8080, - и они не конфликтуют.
Когда Docker запускает контейнер, он создаёт пару виртуальных Ethernet-интерфейсов (veth pair) - своеобразную виртуальную патч-панель. Один конец пары (veth0) помещается в network namespace контейнера и переименовывается в eth0. Второй конец (veth1) остаётся на хосте и подключается к виртуальному бриджу docker0. Именно через этот мост контейнеры общаются друг с другом и с внешним миром.
# Смотрим на сетевые интерфейсы внутри контейнера
docker run --rm alpine ip addr
# 1: lo: <LOOPBACK,UP,LOWER_UP>
# inet 127.0.0.1/8
# 5: eth0@if6: <BROADCAST,MULTICAST,UP,LOWER_UP>
# inet 172.17.0.2/16
# На хосте видим вторую половину veth-пары
ip addr show | grep veth
# 6: veth3a1b2c@if5: ...
Убедиться в полной изоляции сетевого стека можно и вручную, создав новый network namespace:
# Создаём изолированный namespace
sudo ip netns add sandbox
# Внутри него нет ничего, кроме loopback
sudo ip netns exec sandbox ip addr
# → 1: lo: <LOOPBACK> (только loopback, никаких внешних интерфейсов)
# Создаём veth-пару и "перебрасываем" один конец внутрь
sudo ip link add veth-host type veth peer name veth-ns
sudo ip link set veth-ns netns sandbox
sudo ip netns exec sandbox ip addr add 10.0.0.1/24 dev veth-ns
sudo ip netns exec sandbox ip link set veth-ns up
Именно так Docker соединяет контейнеры в сеть. Это не магия и не виртуализация - это стандартные инструменты ядра, собранные в нужном порядке.
Mount Namespace и OverlayFS - файловая система из слоёв
Если network namespace - это про изоляцию сети, то mount namespace - про изоляцию файловой системы. Каждый контейнер получает свою таблицу точек монтирования. Он может монтировать и размонтировать что угодно внутри себя, и хост об этом даже не узнает.
Но здесь Docker добавляет ещё один слой элегантности - OverlayFS. Вместо того чтобы копировать гигабайты образа при каждом запуске контейнера, ядро складывает файловую систему из нескольких слоёв, как слоёный пирог.
Контейнер 1: Контейнер 2:
┌─────────────────┐ ┌─────────────────┐
│ Upper (R/W) │ │ Upper (R/W) │ ← уникально для каждого
├─────────────────┤ ├─────────────────┤
│ Lower: app │ │ Lower: app │ ← слои образа (read-only)
├─────────────────┤ ├─────────────────┤
│ Lower: libs │ │ Lower: libs │ ← общие для всех
└─────────────────┘ └─────────────────┘
Read-only слои (lower layers) - это слои образа Docker. Они общие для всех контейнеров, запущенных из одного образа. Один процесс записи на диск - и десятки контейнеров получают одни и те же данные, занимающие место на диске ровно один раз.
# Посмотреть слои OverlayFS работающего контейнера
docker inspect <container_id> | grep -A 10 "GraphDriver"
# "Data": {
# "LowerDir": "/var/lib/docker/overlay2/abc.../diff:
# /var/lib/docker/overlay2/def.../diff",
# "MergedDir": "/var/lib/docker/overlay2/xyz.../merged",
# "UpperDir": "/var/lib/docker/overlay2/xyz.../diff",
# "WorkDir": "/var/lib/docker/overlay2/xyz.../work"
# }
Когда процесс внутри контейнера изменяет файл, ядро копирует его из read-only слоя в upper layer - и только тогда модифицирует. Это механизм "copy-on-write": пока файл не тронут, он не дублируется. Тронут - копируется ровно один раз. Именно поэтому docker diff <container> показывает только реально изменённые файлы, а не всю файловую систему целиком.
UTS, IPC и User Namespace - три невидимых слоя изоляции
За наиболее очевидными namespace скрываются три более тихих, но не менее важных.
UTS namespace (Unix Time-sharing System) позволяет каждому контейнеру иметь собственное имя хоста. Именно поэтому внутри контейнера hostname возвращает что-то вроде a3f2c1d8b9e4 - случайный идентификатор, а не имя реального сервера. Это критично для приложений, которые регистрируются по имени хоста в service discovery.
IPC namespace изолирует средства межпроцессного взаимодействия - разделяемую память (shared memory), очереди сообщений и семафоры. Процессы в разных контейнерах не могут случайно обменяться данными через System V IPC или POSIX shared memory, даже если запущены на одном ядре.
User namespace - пожалуй, самый интересный с точки зрения безопасности. Он позволяет процессу иметь UID 0 (root) внутри namespace, оставаясь при этом непривилегированным пользователем на хосте. Это отображение идентификаторов выглядит так:
# Запускаем контейнер с remapping пользователей
# В /etc/docker/daemon.json:
# { "userns-remap": "default" }
# Внутри контейнера процесс видит себя как root (UID 0)
docker run --rm alpine id
# → uid=0(root) gid=0(root)
# На хосте этот же процесс выглядит как непривилегированный пользователь
ps aux | grep "alpine"
# → 100000 3847 ... (реальный UID на хосте - 100000)
Это пространство имён закрывает целый класс уязвимостей, связанных с эскалацией привилегий - когда процесс вырывается из контейнера и оказывается root на хосте.
Как nsenter открывает двери в чужой namespace
Понимание namespaces было бы неполным без инструментов для их исследования. nsenter - это команда, позволяющая войти в namespace уже работающего процесса. Именно на этом механизме основан docker exec.
# Находим PID главного процесса контейнера на хосте
CONTAINER_PID=$(docker inspect --format '{{.State.Pid}}' my_container)
echo $CONTAINER_PID # например: 4821
# Заходим в network namespace контейнера
sudo nsenter --target $CONTAINER_PID --net ip addr
# → видим сетевые интерфейсы изнутри контейнера
# Заходим во все namespace сразу - фактически попадаем внутрь контейнера
sudo nsenter --target $CONTAINER_PID \
--mount --uts --ipc --net --pid \
-- /bin/bash
Файловые дескрипторы namespace хранятся в /proc/<pid>/ns/ - это обычные файлы, на которые можно делать hard link, чтобы удержать namespace живым даже после завершения последнего процесса в нём:
# Смотрим namespace процесса
ls -la /proc/$CONTAINER_PID/ns/
# lrwxrwxrwx net -> net:[4026532345]
# lrwxrwxrwx pid -> pid:[4026532346]
# lrwxrwxrwx mnt -> mnt:[4026532347]
# lrwxrwxrwx uts -> uts:[4026532348]
# lrwxrwxrwx ipc -> ipc:[4026532349]
# lrwxrwxrwx user -> user:[4026531837]
# Числа в скобках - это inode namespace.
# Два процесса в одном namespace имеют одинаковый inode.
Это важная деталь для отладки: если два процесса показывают одинаковый inode для net, они делят один сетевой стек. Инструмент для проверки lsns покажет полную картину namespace на хосте в читаемом виде.
Что namespaces не делают и почему cgroups стоят рядом
Честный разговор о namespaces требует признания их границ. Namespaces изолируют видимость ресурсов, но не ограничивают их потребление. Контейнер, изолированный по PID и сети, по-прежнему может съесть всю память хоста или загрузить все ядра CPU - если рядом не стоят cgroups (control groups).
Cgroups - второй фундаментальный механизм контейнеров. Они ограничивают количество ресурсов, тогда как namespaces управляют видимостью. Docker использует оба:
# Запускаем контейнер с ограничениями через cgroups
docker run -d \
--memory="256m" \ # ограничение RAM через cgroup memory
--cpus="0.5" \ # ограничение CPU через cgroup cpu
--pids-limit 100 \ # максимум процессов через cgroup pids
nginx
# Проверяем реальные cgroup-настройки контейнера на хосте
cat /sys/fs/cgroup/memory/docker/<container_id>/memory.limit_in_bytes
# → 268435456 (256 МБ в байтах)
Namespaces без cgroups - это стены без потолка. Cgroups без namespaces - потолок без стен. Контейнер - это комната, у которой есть и то, и другое.
Понимание этого разделения объясняет, почему "контейнерная безопасность" - не бинарное понятие. Можно запустить контейнер с --privileged, который отключает большинство namespace-ограничений. Можно запустить без user namespace remapping. Можно дать контейнеру прямой доступ к host network. Каждое такое решение - осознанный компромисс между изоляцией и удобством, и за ним стоит конкретный механизм ядра.
Чем глубже понимание того, как именно Docker использует namespaces, тем точнее разработчик выбирает нужный уровень изоляции - и тем яснее он видит, где проходит граница между контейнером и хостом, на котором тот живёт.