[Перевод] Как собрать Linux‑контейнер с нуля без Docker: unshare, pivot_root и rootfs

В этой статье разберёмся, как Linux‑контейнеры устроены изнутри.
Начнём с пространства имён монтирования и корневой файловой системы, а затем посмотрим, почему за ними естественным образом следуют пространства имён PID, cgroup, UTS и сети. Эта база заодно помогает гораздо проще понять bind‑монтирования, тома и хранение данных в Docker и Kubernetes.
Одна из главных возможностей контейнеров — изолированное представление файловой системы. Изнутри контейнера она может выглядеть как полноценный Linux‑дистрибутив, причём нередко совсем не тот, что установлен на хосте.
Запустите docker run nginx, и Nginx окажется в привычном пользовательском пространстве Debian независимо от того, какой Linux работает на хостовой системе. Но как создаётся эта иллюзия?
В этой статье мы соберём небольшой, но вполне реалистичный Docker‑подобный контейнер, используя только штатные инструменты Linux:
unshare,mountиpivot_root.
Никакой магии среды выполнения контейнеров и почти никаких упрощений. Заодно станет понятно, почему именно пространство имён монтирования лежит в основе изоляции контейнеров, а остальные пространства имён — PID, cgroup, UTS и даже сетевое — скорее дополняют его.
К концу статьи вы сможете запускать полноценные контейнеры в стиле Docker, используя только стандартные команды Linux. Мечта любого начинающего гуру контейнеров.
Что понадобится
Базовое знакомство с контейнерами Docker, Podman или аналогичными инструментами
Базовые знания Linux: shell‑скрипты и общее понимание пространств имён
Понимание основ файловой системы: единой иерархии каталогов, таблицы монтирования, bind‑монтирований и так далее
Если хотите заодно проверить, насколько уверенно ориентируетесь в Linux и какие темы стоит подтянуть, попробуйте короткий вступительный тест по продвинутому администрированию Linux.
Каким должен получиться результат
На схеме ниже показано, как выглядит изоляция файловой системы при создании нового контейнера Docker. Если сейчас схема кажется перегруженной, это нормально. Практические упражнения из этой статьи помогут выстроить цельную ментальную модель работы контейнеров, и когда в заключительном разделе мы вернёмся к этой схеме, разобраться в ней будет гораздо проще.

Что именно изолирует пространство имён монтирования?
Проведём небольшой эксперимент. В Терминале 1 запустим новый сеанс shell в отдельном пространстве имён монтирования:
sudo unshare --mount bashТеперь в Терминале 2 создадим файл где‑нибудь в файловой системе хоста:
echo "Hello from host's mount namespace" | sudo tee /opt/marker.txtКак ни странно — а может, как раз вполне ожидаемо, — если попытаться найти этот файл в только что созданном пространстве имён монтирования через Терминал 1, он окажется на месте:
cat /opt/marker.txtТак что же именно мы изолировали с помощью unshare --mount?
Ответ — таблицу монтирования. Проверить это просто. В Терминале 1 смонтируем что‑нибудь:
sudo mount --bind /tmp /mntПримечание
Для простоты здесь используется bind‑монтирование, но обычное монтирование, например блочного устройства, сработало бы точно так же.
Теперь, если посмотреть содержимое каталога /mnt в Терминале 1, мы увидим файлы из /tmp:
ls -l /mnttotal 12
drwx------ 3 root root 4096 Sep 11 14:16 file1
drwx------ 3 root root 4096 Sep 11 14:16 file2
...При этом в пространстве имён монтирования хоста каталог /mnt по‑прежнему останется пустым. Если выполнить ту же команду ls в Терминале 2, файлов там не будет:
ls -lah /mnttotal 0Вот теперь представления файловой системы в двух пространствах имён действительно начали различаться. Но добиться этого удалось только после создания новой точки монтирования.

Из man‑страницы пространства имён монтирования:
Пространства имён монтирования изолируют список монтирований, видимых процессам в каждом экземпляре пространства имён. Поэтому процессы в разных пространствах имён монтирования видят разные единые иерархии каталогов.
Сравните таблицы монтирования, выполнив findmnt в Терминале 1 и Терминале 2:
TARGET SOURCE FSTYPE OPTIONS
/ /dev/vda ext4 rw,...
├─/dev devtmpfs devtmpfs rw,...
│ ├─/dev/shm tmpfs tmpfs rw,...
│ ├─/dev/pts devpts devpts rw,...
│ └─/dev/mqueue mqueue mqueue rw,...
├─/proc proc proc rw,...
├─/sys sysfs sysfs rw,...
│ ├─/sys/kernel/security securityfs securityfs rw,...
│ ├─/sys/fs/cgroup cgroup2 cgroup2 rw,...
│ ...
└─/run tmpfs tmpfs rw,...
├─/run/lock tmpfs tmpfs rw,...
└─/run/user/1001 tmpfs tmpfs rw,...Если посмотреть на всё это задним числом, логика вполне очевидна: мы всё‑таки работаем с пространством имён монтирования. Пространств имён файловой системы в Linux, хорошо это или плохо, просто не существует.
Примечание
Любопытный факт: пространства имён монтирования стали первым типом пространств имён, добавленным в Linux. Они появились ещё в Linux 2.4, примерно в 2002 году.
Совет
Быстро проверить текущее пространство имён монтирования процесса можно такой командой:
readlink /proc/$PID/ns/mntРазные номера inode в выводе будут означать разные пространства имён. Попробуйте выполнить readlink /proc/self/ns/mnt в Терминале 1 и Терминале 2.
Что вообще такое распространение монтирований?
Прежде чем разбираться, как именно Docker и OCI‑среда выполнения контейнеров, например runc, используют пространства имён монтирования для создания контейнеров, нужно познакомиться ещё с одним важным и тесно связанным понятием — распространением монтирований.
Важно
Прежде чем выполнять команды из этого раздела, обязательно выйдите из shell в отдельном пространстве имён в Терминале 1.
Если повторить эксперимент из предыдущего раздела с помощью системного вызова unshare() вместо CLI‑команды unshare, результат может оказаться другим.
unshare_lite.go
package main
import "os"
import "os/exec"
import "syscall"
func main() {
if err := syscall.Unshare(syscall.CLONE_NEWNS); err != nil {
panic(err)
}
cmd := exec.Command("bash")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = os.Environ()
cmd.Run()
}Соберите импровизированную программу unshare_lite:
go build -o unshare_lite unshare_lite.goИ запустите её в Терминале 1:
sudo ./unshare_liteЗатем смонтируйте что‑нибудь:
mount --bind /tmp /mntНа этот раз вывод ls -l /mnt в Терминале 1 и Терминале 2 будет одинаковым. Получается, одного пространства имён монтирования недостаточно, чтобы гарантировать изоляцию таблицы монтирования.
Если сравнить таблицы монтирования, выполнив findmnt в Терминале 1 и Терминале 2, они тоже будут выглядеть одинаково:
TARGET SOURCE FSTYPE OPTIONS
/ /dev/vda ext4 rw,...
├─/dev devtmpfs devtmpfs rw,...
│ ├─/dev/shm tmpfs tmpfs rw,...
│ ├─/dev/pts devpts devpts rw,...
│ └─/dev/mqueue mqueue mqueue rw,...
├─/proc proc proc rw,...
├─/sys sysfs sysfs rw,...
│ ├─/sys/kernel/security securityfs securityfs rw,...
│ ├─/sys/fs/cgroup cgroup2 cgroup2 rw,...
│ ...
├─/run tmpfs tmpfs rw,...
│ ├─/run/lock tmpfs tmpfs rw,...
│ └─/run/user/1001 tmpfs tmpfs rw,...
└─/mnt /dev/vda[/tmp] ext4 rw,...Когда создаётся новое пространство имён монтирования с помощью unshare, оно получает полную копию таблицы монтирования вызывающего процесса. Однако изменения в таблице монтирования вызывающего процесса могут распространяться в новую таблицу и наоборот.
Но почему?
Сегодня контейнеры, пожалуй, главный «потребитель» пространств имён монтирования. Но их применение контейнеризацией не ограничивается. Например, с их помощью можно создавать отдельное представление файловой системы для каждого пользователя.
Первая реализация пространств имён монтирования оказалась слишком жёсткой и заставляла системных администраторов снова и снова выполнять одни и те же действия. Чтобы решить эту проблему, в ядро добавили механизм разделяемых поддеревьев (shared subtrees), который, в частности, позволил распространять события монтирования между группами одноранговых точек монтирования.

Например, если несколько пользователей в системе работают в отдельных пространствах имён монтирования, чтобы изолировать свои корневые файловые системы, то без распространения событий монтирования подключение нового общего тома потребует выполнить N операций монтирования, где N — число пользователей.
А с распространением событий монтирования администратору достаточно смонтировать том один раз: изменение будет автоматически воспроизведено во всех группах одноранговых точек монтирования, даже если они находятся в разных пространствах имён.
Примечание
Ни документация ядра, ни man‑страница пространства имён монтирования не используют термин mount propagation. Вместо него там говорится о типе распространения (propagation type) точки монтирования. При этом в индустрии термин mount propagation используется повсеместно, в том числе в документации Docker и Kubernetes.
Распространение событий монтирования — это ровно то, что мы только что наблюдали, когда напрямую вызвали unshare из программы на Go. После bind‑монтирования /tmp в /mnt в новом пространстве имён исходное пространство получило событие монтирования и воспроизвело это изменение, создав аналогичное монтирование /tmp:/mnt.
Хм… А почему этого не происходило, когда мы использовали стандартную утилиту unshare?
CLI‑утилита unshare делает немного больше, чем просто вызывает системный вызов unshare(). Заглянуть ей под капот можно с помощью strace — выполните в новом терминале:
sudo strace unshare --mount bashЕсли отбросить лишний шум трассировки, можно заметить три важных системных вызова, выполненных подряд:
...
unshare(CLONE_NEWNS) = 0
mount("none", "/", NULL, MS_REC|MS_PRIVATE, NULL) = 0
execve("/usr/bin/bash", ["bash"], 0x7fff03d0e038 /* 19 vars */) = 0
...Сразу после создания нового пространства имён монтирования и перед запуском bash команда unshare также меняет тип распространения корневой точки монтирования. Вызов mount() выше эквивалентен следующей команде:
mount --make-rprivate /Это означает, что в новом пространстве имён корневое монтирование и все вложенные в него монтирования (MS_REC и r в rprivate означают рекурсивное применение) полностью изолируются от внешнего мира. Новые монтирования внутри этого пространства имён не будут видны в пространстве имён вызывающего процесса, то есть в нашем случае на хосте, и наоборот.
Примечание
Тип распространения — это свойство точки монтирования. Поскольку каждая точка монтирования относится к определённому пространству имён монтирования, тип распространения тоже зависит от конкретного пространства имён. Например, корневое монтирование
/в одном пространстве имён может иметь типshared, а в другом —private.
Никакого распространения событий монтирования между пространствами имён:
sudo unshare --mount --propagation private
findmnt -o TARGET,SOURCE,FSTYPE,PROPAGATIONTARGET SOURCE FSTYPE PROPAGATION
/ /dev/vda ext4 private
├─/dev devtmpfs devtmpfs private
│ ├─/dev/shm tmpfs tmpfs private
│ ├─/dev/pts devpts devpts private
│ └─/dev/mqueue mqueue mqueue private
├─/proc proc proc private
│ └─/proc/sys/fs/binfmt_misc systemd-1 autofs private
│ └─/proc/sys/fs/binfmt_misc binfmt_misc binfmt_misc private
├─/sys sysfs sysfs private
│ ├─/sys/kernel/security securityfs securityfs private
│ ├─/sys/fs/selinux selinuxfs selinuxfs private
│ ...
└─/run tmpfs tmpfs private
├─/run/lock tmpfs tmpfs private
└─/run/user/1001 tmpfs tmpfs privateПочему распространение монтирований вообще важно для нас? Причины две:
pivot_root, современная альтернативаchroot, на которую опирается большинство сред выполнения контейнеров, предъявляет свои требования к типу распространения задействованных точек монтирования. Мы разберём это в следующем разделе.Некоторым приложениям, работающим в контейнере, может понадобиться монтировать файловые системы на хосте. Другим, наоборот, нужно видеть новые монтирования, которые появляются на хосте или в соседних контейнерах во время работы. Например, именно для этого в Kubernetes существуют режимы распространения монтирований
HostToContainerиBidirectional. К этому мы ещё вернёмся.
Простейшая попытка изолировать файловую систему контейнера
Пространства имён монтирования и механизм распространения монтирований — это хорошо, но как всё это используется в контейнерах? Давайте соберём простой контейнер и посмотрим на этот механизм в деле.
Важно
Прежде чем выполнять команды из этого раздела, обязательно выйдите из shell в отдельном пространстве имён в Терминале 1.
Подготавливаем rootfs контейнера
Для начала нужно подготовить будущую корневую файловую систему. С точки зрения хоста rootfs каждого контейнера — это обычный каталог с набором файлов внутри:
sudo mkdir -p /opt/container-1/rootfsДля нашего эксперимента можно «позаимствовать» файловую систему Alpine, распаковав образ alpine:3 в только что созданный каталог:
crane export alpine:3 | sudo tar -xvC /opt/container-1/rootfsЕсли сравнить содержимое /opt/container-1/rootfs и корневого каталога / на хосте, они окажутся на удивление похожими:
tree -L 1 /opt/container-1/rootfs/opt/container-1/rootfs/
├── bin
├── dev
├── etc
├── home
├── lib
...
├── tmp
├── usr
└── var
18 directories, 0 filesНо если присмотреться, станет понятно, что перед нами два разных Linux‑дистрибутива:
cat /opt/container-1/rootfs/etc/os-releaseNAME="Alpine Linux"
ID=alpine
VERSION_ID=3.22.1
PRETTY_NAME="Alpine Linux v3.22"
HOME_URL="https://alpinelinux.org/"
BUG_REPORT_URL="https://gitlab.alpinelinux.org/alpine/aports/-/issues"Переключаемся на новый rootfs с помощью pivot_root
Системный вызов pivot_root(new_root, put_old) меняет корневое монтирование в пространстве имён вызывающего процесса. Точнее, он перемещает текущее корневое монтирование вызывающего процесса в каталог put_old, а new_root делает новым корневым монтированием.
На практике это означает, что если вызвать pivot_root("/opt/container-1/rootfs") в новом пространстве имён монтирования, мы переключимся на новую корневую файловую систему.

Если говорить упрощённо, pivot_root — это более безопасный вариант chroot: эффект похожий, но без риска выбраться наружу через забытые символические ссылки на старую корневую файловую систему или с помощью трюка с двойным chroot.
У вызова pivot_root() есть несколько ограничений. В частности:
Путь
new_rootдолжен быть точкой монтирования, но не может быть/— впрочем, попытка выполнитьpivot_root("/")всё равно не имела бы особого смысла.Тип распространения родительского монтирования для
new_rootи родительского монтирования текущего корневого каталога не должен бытьshared.Если
put_oldуже является точкой монтирования, её тип распространения тоже не должен бытьshared.
Разумеется, такую радикальную операцию имеет смысл выполнять только в отдельном пространстве имён монтирования — иначе мы повредим хостовую систему.
Последние два ограничения как раз гарантируют, что pivot_root не распространит изменения таблицы монтирования в другое пространство имён:
sudo unshare --mount bashТеперь попробуем выполнить требования pivot_root. Тип распространения монтирования /, то есть родительского монтирования текущего корневого каталога, не должен быть shared. Команда unshare выше, скорее всего, уже выставила для него private, но лучше указать это явно:
mount --make-rprivate /Примечание
Любопытный факт:
runcиспользуетrslaveвместоrprivate, ссылаясь на возможное состояние гонки. Но для нашего демонстрационного примера подойдёт любой из этих вариантов.
В нашем случае каталог /opt/container-1/rootfs не является точкой монтирования — это обычный каталог в файловой системе хоста. Но его легко превратить в точку монтирования, выполнив bind‑монтирование каталога на самого себя. Используем рекурсивное bind‑монтирование, поскольку теоретически внутри rootfs контейнера уже могут находиться вложенные монтирования:
mount --rbind /opt/container-1/rootfs /opt/container-1/rootfsОстаётся убедиться, что тип распространения самого new_root тоже не shared:
mount --make-rprivate /opt/container-1/rootfsТеперь всё готово к переключению корневой файловой системы:
cd /opt/container-1/rootfs
mkdir .oldrootpivot_root . .oldrootСразу после этого переключимся на shell из нового rootfs. Текущий процесс bash после pivot_root в совершенно другой Linux‑дистрибутив может начать вести себя непредсказуемо. Это нужно только в нашем демонстрационном примере: реальные среды выполнения контейнеров обычно с такой проблемой не сталкиваются, потому что работают с ядром напрямую через системные вызовы, а не через shell‑команды:
exec /bin/shИнтересно, что после pivot_root среда выполнения контейнеров может установить для новой корневой файловой системы практически любой тип распространения: shared, slave, private и даже unbindable:
mount --make-rslave /Примечание
Тип распространения корневой файловой системы контейнера не следует путать с типом распространения bind‑монтирований и томов в Docker и Kubernetes соответственно (см. ниже). Это продвинутая настройка, которую высокоуровневые среды выполнения контейнеров зачастую вообще не выставляют через пользовательские API. Самый типичный сценарий её применения — вложенные контейнеры, например Docker‑in‑Docker (DinD).
Наконец, поскольку исходная корневая файловая система, скорее всего, не должна оставаться доступной внутри контейнера, каталог .oldroot можно и нужно удалить сразу после вызова pivot_root:
umount -l .oldroot # -l stands for "lazy" because the fs can be busy
rm -rf .oldrootУра! Мы только что переключились в новый контейнер. Давайте осмотримся:
ls -lah /ls -l /
total 68
drwxr-xr-x 2 root root 4096 Jul 15 10:42 bin
drwxr-xr-x 2 root root 4096 Sep 7 12:40 dev
drwxr-xr-x 17 root root 4096 Jul 15 10:42 etc
drwxr-xr-x 2 root root 4096 Jul 15 10:42 home
...
drwxr-xr-x 11 root root 4096 Jul 15 10:42 varcat /etc/os-releaseNAME="Alpine Linux"
ID=alpine
VERSION_ID=3.22.1
PRETTY_NAME="Alpine Linux v3.22"
HOME_URL="https://alpinelinux.org/"
BUG_REPORT_URL="https://gitlab.alpinelinux.org/alpine/aports/-/issues"Пока всё отлично. Но если попробовать вывести список процессов, результат окажется пустым, хотя такого, конечно, быть не может:
ps auxPID USER TIME COMMANDКоманда df тоже, похоже, не работает:
df -ahFilesystem Size Used Available Use% Mounted on
df: /proc/mounts: No such file or directoryПодготавливаем полноценную файловую систему контейнера
Сообщение об ошибке от df подсказывает, в чём дело: каталог /proc в новом пространстве имён пуст:
ls -l /proctotal 0Хм… Как так получилось?
Оказывается, не вся корневая файловая система контейнера берётся из его образа.
Как и на хосте, где /proc заполняется соответствующей псевдофайловой системой ядра, внутри контейнера /proc нужно настраивать отдельно. То же самое относится к виртуальным файловым системам /dev и /sys.
Кроме того, некоторые специальные файлы, например /etc/hosts, /etc/hostname и /etc/resolv.conf, нужно создавать отдельно для каждого контейнера. Файлы из образа, если они там вообще есть, могут содержать только общие значения вроде localhost. Docker же обычно задаёт контейнеру hostname на основе префикса его случайного ID, а resolv.conf формирует на основе одноимённого файла на хосте.
Заполняем псевдофайловую систему /proc
Подключить псевдофайловую систему /proc совсем несложно:
mount -t proc proc /procПримечание
На практике среды выполнения контейнеров обычно монтируют
/procещё до вызоваpivot_root, поэтому команда выглядела бы так:mount -t proc proc $ROOTFS/proc.
Но если прямо сейчас выполнить команду выше, файловая система /proc внутри контейнера будет выглядеть точно так же, как на хосте. В частности, ps начнёт показывать полный список процессов на сервере, что для контейнера обычно совсем нежелательно.
Именно здесь нам понадобится пространство имён PID. Нужно немного вернуться назад и изменить команду unshare, чтобы она создавала не только пространство имён монтирования, но и новое пространство имён PID. Тогда самый верхний процесс контейнера получит PID 1, и иерархия процессов внутри контейнера будет начинаться с него:
# НЕ ЗАПУСКАТЬ
sudo unshare --mount --pid --fork bashНо пока выполнять эту команду не будем…
Примечание
Дополнительный флаг
--forkсам по себе не создаёт новых пространств имён. Он заставляетunshareпородить новый процесс вместо прямого запускаbashчерезexec. Это необходимо, чтобы флаг--pidдействительно повлиял на запускаемую команду: в новое пространство имён PID попадает первый дочерний процесс, а не сам процесс, который вызвалunshare(CLONE_NEWPID).
Заполняем псевдофайловую систему /dev
Ещё один особый каталог — /dev. На хосте он обычно предоставляется файловой системой devtmpfs и несколькими вложенными виртуальными файловыми системами. Проверим это из нового терминала:
findmntTARGET SOURCE FSTYPE OPTIONS
/ /dev/vda ext4 rw,relatime,stripe=4
├─/dev devtmpfs devtmpfs rw,relatime,size=4068368k,nr_inodes=1017092,mode=755
│ ├─/dev/shm tmpfs tmpfs rw,nosuid,nodev
│ ├─/dev/pts devpts devpts rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=000
│ ├─/dev/mqueue mqueue mqueue rw,nosuid,nodev,noexec,relatime
...Однако в контейнерах обычно используется более ограниченная версия /dev, размещённая на обычной tmpfs. Вот как её можно подготовить из нового пространства имён, вернувшись в Терминал 1:
mkdir -p /dev
mount -t tmpfs -o nosuid,strictatime,mode=0755,size=65536k tmpfs /devНа практике среды выполнения контейнеров обычно подготавливают файловую систему /dev ещё до вызова pivot_root, поэтому команда выглядела бы примерно так: mount -t tmpfs ... $ROOTFS/dev.
После монтирования tmpfs в /dev нужно создать специальные символьные устройства, например /dev/null, /dev/zero, /dev/random и другие. Сделать это можно с помощью команды mknod:
mknod -m 666 /dev/null c 1 3
chown root:root /dev/null
mknod -m 666 /dev/zero c 1 5
chown root:root /dev/zero
# и т. д.Затем нужно смонтировать вложенные файловые системы /dev/shm, /dev/pts и /dev/mqueue:
mkdir -p /dev/shm
mount -t tmpfs -o nosuid,nodev,noexec,mode=1777,size=67108864 tmpfs /dev/shmmkdir -p /dev/pts
mount -t devpts -o newinstance,ptmxmode=0666,mode=0620 devpts /dev/ptsmkdir -p /dev/mqueue
mount -t mqueue -o nosuid,nodev,noexec mqueue /dev/mqueueИ наконец, создать несколько стандартных символических ссылок:
ln -sf /proc/self/fd /dev/fd
ln -sf /proc/self/fd/0 /dev/stdin
ln -sf /proc/self/fd/1 /dev/stdout
ln -sf /proc/self/fd/2 /dev/stderr
ln -sf /proc/kcore /dev/coreЗаполняем псевдофайловую систему /sys
Пожалуй, самая ограниченная из псевдофайловых систем контейнера — /sys. Обычно она монтируется только для чтения и содержит лишь небольшую часть доступных узлов:
mount -t sysfs -o ro,nosuid,nodev,noexec sysfs /sysПримечание
На практике среды выполнения контейнеров обычно подготавливают файловую систему
/sysещё до вызоваpivot_root, поэтому команда выглядела бы примерно так:mount -t sysfs ... $ROOTFS/sys.
Важная часть /sys — виртуальная файловая система cgroup. Уже несколько лет Docker и другие популярные среды выполнения контейнеров по умолчанию полностью изолируют иерархию cgroup контейнера. Здесь ситуация похожа на /proc, который лучше всего работает вместе с отдельным пространством имён PID: новое пространство имён cgroup позволяет сделать так, чтобы монтирование cgroup2 начиналось с того узла хостовой cgroupfs, который соответствует самому верхнему процессу контейнера. Поэтому к команде unshare нужно добавить ещё один флаг — --cgroup:
# НЕ ЗАПУСКАТЬ
sudo unshare --mount --pid --fork --cgroup bashСмонтировать файловую систему cgroup2 можно так:
mkdir -p /sys/fs/cgroup
mount -t cgroup2 -o ro,nosuid,nodev,noexec cgroup2 /sys/fs/cgroupУсиливаем защиту псевдофайловых систем
Для нашего демонстрационного примера это необязательно, но в реальных контейнерах корневая файловая система обычно проходит ещё один этап усиления защиты. Например, Docker по умолчанию делает некоторые части /proc доступными только для чтения, а другие маскирует, полностью закрывая к ним доступ приложению в контейнере.
Вот как можно получить список чувствительных путей, которые Docker делает доступными только для чтения. Выполните команду в новом терминале:
docker container inspect \
$(docker run --rm -d alpine:3 sleep 5) \
--format '{{join .HostConfig.ReadonlyPaths "\n"}}'/proc/bus
/proc/fs
/proc/irq
/proc/sys
/proc/sysrq-triggerЛюбой файл или каталог можно сделать доступным только для чтения, выполнив bind‑монтирование на него самого, а затем перемонтировав с опцией ro:
RO_PATH=/proc/bus # или /proc/fs, /proc/irq и т. д.
if [ -e "$RO_PATH" ]; then
mount --bind "$RO_PATH" "$RO_PATH"
mount -o remount,bind,ro "$RO_PATH"
fiАналогично можно получить список путей, которые обычно полностью скрываются от приложения в контейнере с помощью маскирования:
docker container inspect \
$(docker run --rm -d alpine:3 sleep 5) \
--format '{{join .HostConfig.MaskedPaths "\n"}}'/proc/asound
/proc/acpi
/proc/interrupts
/proc/kcore
/proc/keys
/proc/latency_stats
/proc/timer_list
/proc/timer_stats
/proc/sched_debug
/proc/scsi
/sys/firmware
/sys/devices/virtual/powercapКаталоги и обычные файлы маскируются по‑разному. Чтобы скрыть каталог, поверх него можно смонтировать tmpfs только для чтения. А чтобы скрыть обычный файл, достаточно выполнить bind‑монтирование /dev/null на его путь:
MASKED_FILE=/proc/asound # или /proc/interrupts, /proc/kcore и т. д.
mount --bind /dev/null $MASKED_FILE
MASKED_DIR=/proc/acpi # или /proc/scsi и т. д.
mount -t tmpfs -o ro tmpfs $MASKED_DIRПримечание
Перечисленные выше пути только для чтения и маскируемые пути — это значения Docker по умолчанию для непривилегированных контейнеров. Спецификация OCI Runtime определяет сам механизм усиления защиты, но не задаёт конкретный список путей.
Подготавливаем специальные файлы в /etc
Некоторые обычные файлы в rootfs контейнера тоже требуют особого подхода:
/etc/hosts/etc/hostname/etc/resolv.conf
Если посмотреть на эти файлы в каталоге /opt/container-1/rootfs сразу после распаковки туда rootfs Alpine, станет понятно почему:
cat /opt/container-1/rootfs/etc/{hosts,hostname,resolv.conf}# -- /opt/container-1/rootfs/etc/hosts
127.0.0.1 localhost localhost.localdomain
::1 localhost localhost.localdomain
# -- /opt/container-1/rootfs/etc/hostname
localhost
# -- /opt/container-1/rootfs/etc/resolv.conf
cat: /opt/container-1/rootfs/resolv.conf: No such file or directoryЭто всего лишь общие значения из образа alpine:3, которые мало подходят конкретному контейнеру. А вот внутри запущенного контейнера alpine:3 эти файлы выглядят совсем иначе:
docker run --rm alpine:3 cat /etc/{hosts,hostname,resolv.conf}# -- /etc/hosts
127.0.0.1 localhost
::1 localhost ip6-localhost ip6-loopback
172.17.0.2 2f26e97ae70c
# -- /etc/hostname
2f26e97ae70c
# -- /etc/resolv.conf
# Сгенерировано Docker Engine.
# Этот файл можно редактировать; после изменения Docker Engine больше не будет
# вносить в него изменения.
nameserver 168.119.149.157
nameserver 8.8.8.8
nameserver 1.1.1.1
# На основе файла хоста: '/etc/resolv.conf' (legacy)
# Переопределения: []То есть Docker или одна из лежащих под ним сред выполнения заменяет общие /etc/hosts, /etc/hostname и /etc/resolv.conf из образа вариантами, подготовленными специально для конкретного контейнера.
Мы можем сделать то же самое. У нашего контейнера пока нет сетевых интерфейсов, кроме loopback, но ему всё равно можно задать нормальный hostname. Выполним это из терминала хоста:
cat <<EOF | sudo tee /opt/container-1/hosts
127.0.0.1 localhost container-1
::1 localhost ip6-localhost ip6-loopback
EOFcat | sudo tee /opt/container-1/hostname <<EOF
container-1
EOFsudo cp /etc/resolv.conf /opt/container-1/resolv.confПримечание
Файл
/etc/resolv.confобычно формируется на основе хостового/etc/resolv.conf, а затем при необходимости адаптируется под нужды контейнера.
Самое интересное — как именно эти файлы попадают в rootfs контейнера. Вместо того чтобы просто перезаписывать файлы из образа, среды выполнения контейнеров обычно монтируют поверх них версии, подготовленные специально для этого контейнера, фактически маскируя исходные файлы:
sudo mount --bind /opt/container-1/hosts /opt/container-1/rootfs/etc/hosts
sudo mount --bind /opt/container-1/hostname /opt/container-1/rootfs/etc/hostname
sudo mount --bind /opt/container-1/resolv.conf /opt/container-1/rootfs/etc/resolv.confИ последнее: чтобы у контейнера был собственный hostname, ему нужны отдельные пространства имён сети и UTS. Поэтому в команду unshare придётся добавить ещё два флага — --uts и --net:
# НЕ ЗАПУСКАТЬ
sudo unshare --mount --pid --fork --cgroup --uts --net bashПримечание
Если забыть создать новое пространство имён UTS, изменение hostname внутри нового контейнера перезапишет hostname хоста, а нам этого точно не нужно. Без отдельного сетевого пространства имён контейнер тоже не сможет иметь собственный hostname, поскольку технически будет использовать тот же сетевой стек, что и хост, а hostname входит в его состав.
Теперь у нас наконец есть всё необходимое, чтобы подготовить полностью изолированную файловую систему контейнера.
Создаём контейнер с нуля: полный пример
Теперь, когда мы разобрали все основные детали, попробуем создать второй контейнер — на этот раз сразу с нужными пространствами имён и правильной подготовкой rootfs.
Примечание
Команды ниже основаны на реальных шагах, которые выполняет среда
runcпри подготовке контейнера. Получить их удалось с помощью такого трюка соstrace:
# Терминал 1
sudo strace -f -qqq -e \
trace=/clone,/exec,/unshare,/mount,/mknod,/mkdir,/link,/chdir,/root \
-p $(pgrep containerd)# Терминал 2
docker run alpine:3 sleep 9999Шаг 1. Подготавливаем файлы rootfs
Важно
Прежде чем выполнять команды из этого раздела, обязательно выйдите из shell в отдельном пространстве имён в Терминале 1.
Второй контейнер будем хранить в каталоге /opt/container-2:
CONTAINER_DIR=/opt/container-2
ROOTFS_DIR=${CONTAINER_DIR}/rootfsКак и в случае с первым контейнером, «позаимствуем» файлы rootfs из образа alpine:3:
sudo mkdir -p $ROOTFS_DIR
crane export alpine:3 | sudo tar -xvC $ROOTFS_DIRНа этот раз заранее подготовим файлы /etc/hosts, /etc/hostname и /etc/resolv.conf, но пока сохраним их за пределами каталога rootfs:
cat <<EOF | sudo tee $CONTAINER_DIR/hosts
127.0.0.1 localhost container-2
::1 localhost ip6-localhost ip6-loopback
EOFcat <<EOF | sudo tee $CONTAINER_DIR/hostname
container-2
EOFsudo cp /etc/resolv.conf $CONTAINER_DIR/resolv.confШаг 2. Создаём пространства имён
С помощью unshare создадим все необходимые пространства имён: монтирования, PID, cgroup, UTS и сети:
sudo unshare --mount --pid --fork --cgroup --uts --net bashПримечание
Есть и другие пространства имён:
ipc— не влияет на создание rootfs, поэтому для краткости его пропустим;
time— необязательное пространство имён; Docker и другие популярные среды выполнения контейнеров пока его не используют;
user— тоже необязательное; rootless‑контейнеры — более сложная тема, заслуживающая отдельного руководства.
Шаг 3. Изолируем новое пространство имён монтирования
Важно
Начиная с этого момента все команды выполняются от
rootи внутри новых пространств имён, поэтомуsudoдальше опускаем. ПеременныеCONTAINER_DIRиROOTFS_DIR, возможно, придётся задать заново:
CONTAINER_DIR=/opt/container-2
ROOTFS_DIR=${CONTAINER_DIR}/rootfsСначала нужно убедиться, что события монтирования не будут распространяться обратно в пространство имён монтирования хоста:
mount --make-rslave /Затем нужно сделать саму корневую файловую систему точкой монтирования:
mount --rbind $ROOTFS_DIR $ROOTFS_DIR…и убедиться, что её тип распространения не shared:
mount --make-private $ROOTFS_DIRШаг 4. Подготавливаем псевдофайловую систему /proc
Монтируем псевдофайловую систему /proc:
mkdir -p $ROOTFS_DIR/proc
mount -t proc proc $ROOTFS_DIR/procВажно
Оговорка по безопасности: если rootfs нельзя считать доверенной,
$ROOTFS_DIR/<path>может оказаться символической ссылкой, ведущей за пределы$ROOTFS_DIR. Из‑за этого приведённая выше и многие последующие операции могут повредить хостовую систему.
В реальных средах выполнения контейнеров обычно сначала открывают целевой файл или каталог через системный вызов openat2() с флагом RESOLVE_NO_SYMLINKS, тем самым убеждаясь, что это не символическая ссылка. После этого mount и другие операции с файловой системой выполняются через открытый файловый дескриптор, а не по строковому пути. Это также помогает избежать уязвимостей TOCTTOU, если содержимое $ROOTFS_DIR меняется прямо во время создания контейнера.
Но для демонстрационного примера работать с обычными путями относительно безопасно. Поэтому ради краткости пойдём более простым путём.
Шаг 5. Подготавливаем псевдофайловую систему /dev
Монтируем /dev как обычную tmpfs:
mount -t tmpfs \
-o nosuid,strictatime,mode=0755,size=65536k tmpfs \
$ROOTFS_DIR/devСоздаём стандартные символьные устройства (/dev/null, /dev/zero, /dev/random и так далее):
mknod -m 666 "$ROOTFS_DIR/dev/null" c 1 3
mknod -m 666 "$ROOTFS_DIR/dev/zero" c 1 5
mknod -m 666 "$ROOTFS_DIR/dev/full" c 1 7
mknod -m 666 "$ROOTFS_DIR/dev/random" c 1 8
mknod -m 666 "$ROOTFS_DIR/dev/urandom" c 1 9
mknod -m 666 "$ROOTFS_DIR/dev/tty" c 5 0
chown root:root "$ROOTFS_DIR/dev/"{null,zero,full,random,urandom,tty}Создаём стандартные символические ссылки:
ln -sf /proc/self/fd "$ROOTFS_DIR/dev/fd"
ln -sf /proc/self/fd/0 "$ROOTFS_DIR/dev/stdin"
ln -sf /proc/self/fd/1 "$ROOTFS_DIR/dev/stdout"
ln -sf /proc/self/fd/2 "$ROOTFS_DIR/dev/stderr"
ln -sf /proc/kcore "$ROOTFS_DIR/dev/core"Создаём вложенные файловые системы (/dev/pts, /dev/shm, /dev/mqueue):
mkdir -p "$ROOTFS_DIR/dev/pts"
mount -t devpts \
-o newinstance,ptmxmode=0666,mode=0620 devpts \
$ROOTFS_DIR/dev/pts
ln -sf /dev/pts/ptmx "$ROOTFS_DIR/dev/ptmx"mkdir -p "$ROOTFS_DIR/dev/mqueue"
mount -t mqueue \
-o nosuid,nodev,noexec mqueue \
$ROOTFS_DIR/dev/mqueuemkdir -p "$ROOTFS_DIR/dev/shm"
mount -t tmpfs \
-o nosuid,nodev,noexec,mode=1777,size=67108864 tmpfs \
$ROOTFS_DIR/dev/shmШаг 6. Подготавливаем псевдофайловую систему /sys
Монтируем псевдофайловую систему /sys только для чтения:
mkdir -p "$ROOTFS_DIR/sys"
mount -t sysfs \
-o ro,nosuid,nodev,noexec sysfs \
$ROOTFS_DIR/sysМонтируем вложенную файловую систему cgroup2 в /sys/fs/cgroup:
mkdir -p "$ROOTFS_DIR/sys/fs/cgroup"
mount -t cgroup2 \
-o ro,nosuid,nodev,noexec cgroup2 \
$ROOTFS_DIR/sys/fs/cgroupШаг 7. Подключаем hostname, hosts и resolv.conf через bind‑монтирование
Подключаем подготовленные для контейнера файлы hostname, hosts и resolv.conf из /opt/container-2, маскируя исходные файлы в каталоге /etc внутри rootfs:
for p in hostname hosts resolv.conf
do
touch $ROOTFS_DIR/etc/$p
mount --bind "$CONTAINER_DIR/$p" $ROOTFS_DIR/etc/$p
doneШаг 8. Переключаемся на новый rootfs
Наконец, переключаемся на полностью подготовленную корневую файловую систему:
cd $ROOTFS_DIR
mkdir -p .oldroot
pivot_root . .oldrootНастоящая среда выполнения контейнеров так обычно не делает, но поскольку мы работаем через shell, после вызова pivot_root лучше как можно скорее запустить shell целевого контейнера:
exec /bin/shНастроим тип распространения корневой файловой системы контейнера. Здесь для примера зададим slave, хотя спецификация OCI Runtime поддерживает также private и даже shared:
mount --make-rslave /И наконец, уберём ссылку на старую корневую файловую систему:
umount -l .oldroot
rmdir .oldrootУстановим hostname контейнера, взяв значение из файла /etc/hostname внутри контейнера:
hostname $(cat /etc/hostname)Шаг 9. Усиливаем защиту файловой системы контейнера
Сделаем значительную часть файловой системы /proc доступной только для чтения:
for d in bus fs irq sys sysrq-trigger
do
if [ -e "/proc/$d" ]; then
mount --bind "/proc/$d" "/proc/$d"
mount -o remount,bind,ro "/proc/$d"
fi
doneЗамаскируем чувствительные пути в файловых системах /proc и /sys:
for p in \
/proc/asound \
/proc/interrupts \
/proc/kcore \
/proc/keys \
/proc/latency_stats \
/proc/timer_list \
/proc/timer_stats \
/proc/sched_debug \
/proc/acpi \
/proc/scsi \
/sys/firmware
do
if [ -d "$p" ]; then
# Маскируем каталог
mount -t tmpfs -o ro tmpfs $p
elif [ -f "$p" ]; then
# Маскируем обычный файл
mount --bind /dev/null $p
fi
doneШаг 10. Запускаем целевое приложение
Теперь контейнерное окружение готово к работе. Можно осмотреться с помощью ps, ls, mount, df, hostname и любых других команд, которые придут в голову, а затем запустить приложение внутри контейнера:
APP=${APP:-/bin/sh}
exec $APPБонус: передаём файлы и каталоги с хоста в контейнер
Один из самых распространённых сценариев использования Docker, особенно при локальной разработке, — передавать файлы и каталоги с хоста в контейнер через bind‑монтирования:
# Традиционный флаг -v|--volume
docker run -v ./data:/data redis
# Более современная, но эквивалентная форма с --mount
docker run --mount type='bind,src=./data,dst=/data' redisВ предыдущих разделах мы уже видели, что обычные файлы на хосте можно через bind‑монтирование подключить к будущей корневой файловой системе контейнера. Именно так Docker, runc и другие подобные среды выполнения контейнеров подставляют подготовленные для конкретного контейнера файлы /etc/hosts, /etc/hostname и /etc/resolv.conf.
Точно тем же способом в контейнер можно подключить любые другие файлы и каталоги с хоста.
Команда strace -p $(pgrep containerd), которую мы использовали, чтобы разобраться в процессе подготовки rootfs, показывает, что bind‑монтирования из флага -v|--volume создаются сразу после подготовки псевдофайловых систем и непосредственно перед монтированием /etc/hosts, /etc/hostname и /etc/resolv.conf.
И не зря мы потратили время на механизм распространения событий монтирования: Docker позволяет настраивать тип распространения для bind‑монтирований, поэтому следующая команда теперь уже не должна выглядеть как магическое заклинание:
docker run -v .:/project:ro,rshared ...В этом примере, если приложение внутри контейнера смонтирует что‑нибудь во вложенный каталог внутри /project, это монтирование станет видно и на хосте — и наоборот. Однако по умолчанию Docker использует для bind‑монтирований тип распространения rprivate, поэтому не удивляйтесь, если вложенные монтирования наружу не появятся.
Бонус: добавляем поддержку томов данных
Хотя в документации Docker тома представлены как отдельная концепция, внутри они тоже реализованы через bind‑монтирования, просто с дополнительными возможностями: именованием, управлением жизненным циклом и поддержкой различных драйверов источников данных.
# Традиционный флаг -v|--volume
docker run --volume redis-data:/data redis
# Более современная, но эквивалентная форма с --mount
docker run --mount type='volume,src=redis-data,dst=/data' redisВ отличие от произвольных каталогов на хосте, данные томов всегда хранятся в /var/lib/docker/volumes/CONTAINER_ID/_data. Существующие тома можно посмотреть командой docker volume ls, новые — создать через docker volume create, а удалить — с помощью docker volume rm. Но в конечном счёте все эти операции сводятся к просмотру, созданию и удалению каталогов _data внутри /var/lib/docker/volumes.
Любопытно, что Docker всегда задаёт для томов тип распространения rprivate, тогда как для bind‑монтирований его можно менять. Kubernetes при этом, хотя под капотом использует тот же runc или аналогичную среду выполнения, позволяет гибче настраивать распространение монтирований, например через HostToContainer и Bidirectional.
Так что в Docker различие между bind‑монтированиями и томами скорее семантическое, дополненное искусственными ограничениями на расположение данных и тип распространения, чем действительно техническое.
Где здесь появляются union‑файловые системы?
Одна из тем, которую мы в этой статье не затрагивали, — union‑файловые системы вроде overlayfs. Причина проста: вопреки распространённому мнению, для работы контейнеров они вовсе не обязательны.
Как мы только что показали на практике, полноценный контейнер можно создать вообще без union‑файловой системы. Docker использует драйвер хранилища overlay2 или один из альтернативных вариантов, чтобы разворачивать многослойные образы контейнеров в обычные локальные каталоги. Но это всего лишь оптимизация, прежде всего с точки зрения экономии дискового пространства. Как мы уже видели, файловую систему контейнерного образа можно просто распаковать в обычный каталог с помощью crane export или аналогичной команды, и среда выполнения контейнеров, например runc, без проблем использует его как корневую файловую систему.

Подведём итоги
В основе контейнеров лежит пространство имён монтирования. И это не случайность: в Linux файловая система давно служит одним из главных интерфейсов для работы с процессами, устройствами и ресурсами.
Стоит начать собирать корневую файловую систему контейнера, и быстро становится понятно, что пространства имён PID, cgroup, UTS и сети тесно связаны между собой и без них полноценный контейнер не получится.
Поэтому упражнение со сборкой rootfs — не просто эффектная низкоуровневая демонстрация для конференционного доклада. Оно помогает выстроить цельную ментальную модель того, как работают контейнеры. А когда такая модель есть, более высокоуровневые концепции вроде bind‑монтирований, томов, распространения монтирований и хранения данных в Docker или Kubernetes перестают выглядеть как набор отдельных исключений и начинают восприниматься как естественное продолжение одной и той же основы.
И раз уж вы дочитали до этого места, ещё раз взгляните на схему из начала статьи — теперь она должна выглядеть куда понятнее.

Мы разобрались, как устроены контейнеры на уровне Linux. А как с контейнерными средами выполнения взаимодействует Kubernetes?
Об этом поговорим 10 ноября в 20:00 на бесплатном вебинаре «CRI — обзор контейнерных runtime». Записаться на урок.
Больше технических разборов и практических встреч — в дайджесте OTUS.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.