ESPNSource: Lamar has realistic chance to return for Week 6 game vs. BrownsThe Jerusalem PostThree seriously injured in suspected gas cylinder explosion at Intel chip plant in southern IsraelESPN Deportes¿Cómo al cumplir 18 años cambian los permisos para Gilberto Mora?RTP DesportoMaria Martins conquista medalha de bronzeBollywood HungamaKriti Sanon to headline out-and-out horror project backed by Maddock Films, marking their NINTH collaborationInquirerSpying not ruled out at suspected Pogo hub near military campZDF heuteEntdecken Sie das ZDF-NachrichtenstudioThe IndependentRoyal Mail on ‘brink of collapse’ as 400 staff quitting each week, union warnsSportstarRanji Trophy 2026/27: Assam determinedly holds off Punjab push for outright winRMF24Kreml: Dostawy rosyjskiego diesla do USA otworzą drogę do kolejnych porozumieńtazNach Starlink-Anschlag in Polen: Brand an Starlink-Antennen auch in BulgarienDeadlineMongolian-German Feature ‘Zogsool’ Wins Big At Busan Film Festival’s Vision Awards
The Daily Newsstand · Free, Always
Wednesday, October 14, 2026

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

Translate

В этой статье разберёмся, как 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 /mnt
total 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 /mnt
total 0

Вот теперь представления файловой системы в двух пространствах имён действительно начали различаться. Но добиться этого удалось только после создания новой точки монтирования.

Linux mount namespaces isolate the list of mount points (mount table) seen by the processes in each namespace.

Linux mount namespaces isolate the list of mount points (mount table) seen by the processes in each namespace.

Из 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,PROPAGATION
TARGET                         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-release
NAME="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 .oldroot
pivot_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 var
cat /etc/os-release
NAME="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 aux
PID   USER     TIME  COMMAND

Команда df тоже, похоже, не работает:

df -ah
Filesystem                Size      Used Available Use% Mounted on
df: /proc/mounts: No such file or directory

Подготавливаем полноценную файловую систему контейнера

Сообщение об ошибке от df подсказывает, в чём дело: каталог /proc в новом пространстве имён пуст:

ls -l /proc
total 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 и несколькими вложенными виртуальными файловыми системами. Проверим это из нового терминала:

findmnt
TARGET            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/shm
mkdir -p /dev/pts
mount -t devpts -o newinstance,ptmxmode=0666,mode=0620 devpts /dev/pts
mkdir -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
EOF
cat | sudo tee /opt/container-1/hostname <<EOF
container-1
EOF
sudo 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
EOF
cat <<EOF | sudo tee $CONTAINER_DIR/hostname
container-2
EOF
sudo 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/mqueue
mkdir -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, без проблем использует его как корневую файловую систему.

Объединение слоёв образа контейнера через OverlayFS в единую корневую файловую систему, пригодную для запуска контейнера.

Объединение слоёв образа контейнера через OverlayFS в единую корневую файловую систему, пригодную для запуска контейнера.

Подведём итоги

В основе контейнеров лежит пространство имён монтирования. И это не случайность: в Linux файловая система давно служит одним из главных интерфейсов для работы с процессами, устройствами и ресурсами.

Стоит начать собирать корневую файловую систему контейнера, и быстро становится понятно, что пространства имён PID, cgroup, UTS и сети тесно связаны между собой и без них полноценный контейнер не получится.

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

И раз уж вы дочитали до этого места, ещё раз взгляните на схему из начала статьи — теперь она должна выглядеть куда понятнее.

оригинал изображения доступен по ссылке

оригинал изображения доступен по ссылке

Мы разобрались, как устроены контейнеры на уровне Linux. А как с контейнерными средами выполнения взаимодействует Kubernetes?

Об этом поговорим 10 ноября в 20:00 на бесплатном вебинаре «CRI — обзор контейнерных runtime». Записаться на урок.

Больше технических разборов и практических встреч — в дайджесте OTUS.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.