Проект ЦИТадель
Контейнеры стали обычным способом упаковки и запуска серверных приложений, но между приложением и ядром появилось много инструментов и терминов. Эта статья разбирает не команды Docker, а устройство контейнера Linux. В основе находятся обычные процессы, для которых ядро ограничивает видимость ресурсов с помощью пространств имён, учитывает и ограничивает потребление через cgroups и предоставляет отдельное дерево файловой системы. Исполнитель также настраивает права, системные вызовы, сеть и другие границы. Docker, Podman, containerd, Kubernetes и реестры образов организуют эти механизмы и стандартизованные артефакты в удобный рабочий процесс. В практикуме мы соберём упрощённый контейнер средствами Linux и отдельно отметим, чего ему не хватает до промышленного исполнения.
Пространство имён (namespace) изолирует представление некоторого глобального ресурса ядра. Процессы в одном пространстве видят согласованный экземпляр этого ресурса, а изменения в других пространствах обычно для них невидимы. В современном Linux есть восемь типов:
CLOCK_REALTIME не виртуализируется.Основные интерфейсы — системные вызовы clone и unshare для создания пространств и setns для присоединения к существующему. Утилиты unshare и nsenter позволяют исследовать их без собственной программы:
$ sudo unshare --uts --pid --fork --mount-proc bash
# hostname inside
# hostname
inside
# ps -o pid,ppid,comm
PID PPID COMMAND
1 0 bash
2 1 ps
Параметр --mount-proc создаёт отдельное пространство монтирования и подключает в нём /proc, соответствующий новому PID-пространству. Без отдельного пространства монтирования попытка заменить /proc могла бы затронуть другие процессы. После выхода из оболочки имя хоста снаружи останется прежним.
Пространства имён меняют видимость, но сами по себе не мешают процессу занять всю память или процессорное время. Cgroups объединяют процессы в иерархию, учитывают их потребление и применяют ограничения. В cgroup v2 контроллеры находятся в единой иерархии, обычно смонтированной в /sys/fs/cgroup. Пример файлов группы с квотой в половину одного CPU, пределом памяти 256 МиБ и не более чем 128 процессов и потоков выглядит так:
$ sudo mkdir /sys/fs/cgroup/citadel-demo $ echo "50000 100000" | sudo tee /sys/fs/cgroup/citadel-demo/cpu.max $ echo 268435456 | sudo tee /sys/fs/cgroup/citadel-demo/memory.max $ echo 128 | sudo tee /sys/fs/cgroup/citadel-demo/pids.max
Процесс помещают в группу записью его PID в cgroup.procs; потомки наследуют принадлежность. Контроллер CPU ограничивает время выполнения, а достижение memory.max вызывает освобождение памяти и при необходимости OOM-убийство одного или нескольких процессов группы. Контроллер io ограничивает ввод-вывод, а pids — число задач, включая потоки. На системе под управлением systemd иерархию следует менять через делегированный подкаталог или свойства unit, а не создавать произвольные группы рядом со служебными. Docker, Podman и Kubernetes передают лимиты исполнителю и менеджеру cgroups; итоговые значения можно увидеть в тех же файлах ядра.
Следующий компонент — отдельное дерево файловой системы. OCI-образ содержит конфигурацию и упорядоченный набор неизменяемых слоёв; слой представляет изменения относительно предыдущего. Хранилище загружает одинаковые слои один раз и повторно использует их для разных образов. При запуске исполнитель создаёт доступный для записи снимок поверх образа. В Linux распространён вариант с OverlayFS: каталоги lowerdir доступны для чтения, изменения попадают в upperdir, а merged показывает их объединение. При первом изменении файла из нижнего слоя выполняется copy-up:
$ mkdir lower upper work merged && echo base > lower/a.txt $ sudo mount -t overlay overlay -o lowerdir=lower,upperdir=upper,workdir=work merged $ echo changed > merged/a.txt # copy-on-write: изменение легло в upper $ cat lower/a.txt upper/a.txt base changed $ sudo umount merged
OverlayFS — частая, но не единственная реализация: исполнители могут использовать другие файловые системы и snapshotter-механизмы. Записываемый слой также не обязан оставаться небольшим, поэтому данные приложения выносят в тома прежде всего ради управляемого жизненного цикла и подходящей семантики хранения. Для смены корня применяют pivot_root, chroot и операции монтирования. Сам по себе chroot не является границей безопасности; промышленный исполнитель сочетает отдельное пространство монтирования со сменой корня, отключением старого дерева и ограничением привилегий.
OCI (Open Container Initiative) поддерживает три связанные спецификации. Image Specification задаёт манифест, конфигурацию и слои образа. Runtime Specification описывает bundle: корневую файловую систему и config.json с процессом, пространствами имён, монтированиями и ограничениями. Distribution Specification определяет API передачи манифестов и содержимого через реестр. runc — распространённая реализация OCI Runtime Specification для Linux: она читает bundle и настраивает механизмы ядра, разобранные выше.
Стандарт даёт переносимый формат артефакта, но не обещает запуск любого образа на любой машине. Должны совпадать операционная система и архитектура, а исполнителю нужны использованные возможности ядра и расширения. Многоархитектурный индекс позволяет одному имени образа указывать на варианты для разных платформ. При выполнении этих условий один OCI-образ обычно можно передавать между Docker, Podman, Kubernetes и облачными сервисами без пересборки.
Инструменты экосистемы решают разные части общего процесса:
dockerd. В типичной установке демон поручает хранение образов и жизненный цикл контейнеров containerd, а тот запускает OCI runtime, например runc.conmon следит за контейнером, а rootless-режим использует пользовательские пространства имён и другие ограничения непривилегированного запуска.Виртуальная машина исполняет собственное ядро на виртуальном оборудовании, а обычные контейнеры Linux делят ядро хоста. Уязвимость в доступном системном вызове, ошибочная выдача capability или небезопасное монтирование могут открыть путь из контейнера к хосту. Поэтому стандартный контейнер обычно даёт более слабую границу доверия, чем отдельная ВМ:
Для вычислений и обычного доступа к памяти контейнерный процесс обычно близок к процессу на хосте: эмуляции процессора нет. Заметные издержки могут появляться в многослойной файловой системе, сетевой обработке, шифровании, журналах, средствах безопасности и при конкуренции за ресурсы. Том или другой сетевой режим меняет свойства и риски, поэтому производительность проверяют на характерной нагрузке, а не выводят только из факта контейнеризации.
Соберём упрощённый контейнер стандартными утилитами, без Docker. Выполняйте опыт в отдельной Linux-ВМ с cgroup v2 и правами root: команды намеренно меняют пространства имён и лабораторную ветвь cgroups. Для другой архитектуры замените x86_64 подходящим вариантом из каталога Alpine.
Шаг 1. Подготовьте корневую файловую систему. Выберите актуальный minirootfs в каталоге latest-stable, скачайте архив вместе с опубликованной контрольной суммой и проверьте его до распаковки. На момент подготовки статьи актуален следующий файл; для новой ветви одновременно замените версию в адресе каталога и имени архива:
$ ALPINE_BASE=https://dl-cdn.alpinelinux.org/alpine/v3.24/releases/x86_64 $ ALPINE_ARCHIVE=alpine-minirootfs-3.24.1-x86_64.tar.gz $ curl -fLO "$ALPINE_BASE/$ALPINE_ARCHIVE" $ curl -fLO "$ALPINE_BASE/$ALPINE_ARCHIVE.sha256" $ sha256sum -c "$ALPINE_ARCHIVE.sha256" $ mkdir rootfs $ sudo tar --numeric-owner -xzf "$ALPINE_ARCHIVE" -C rootfs
Проверка суммы важна: в следующей команде содержимое архива будет исполняться с правами root. Экспорт файловой системы уже созданного контейнера тоже подходит, но docker export принимает имя или ID контейнера, а не образ, и не сохраняет конфигурацию OCI-образа.
Шаг 2. Создайте лабораторную cgroup. Убедитесь, что используется cgroup v2, и включите нужные контроллеры в корне лабораторной ВМ, если они ещё не включены:
$ test -f /sys/fs/cgroup/cgroup.controllers $ echo "+cpu +memory +pids" | sudo tee /sys/fs/cgroup/cgroup.subtree_control $ sudo mkdir /sys/fs/cgroup/citadel-demo $ echo "50000 100000" | sudo tee /sys/fs/cgroup/citadel-demo/cpu.max $ echo 268435456 | sudo tee /sys/fs/cgroup/citadel-demo/memory.max $ echo 64 | sudo tee /sys/fs/cgroup/citadel-demo/pids.max $ echo 1 | sudo tee /sys/fs/cgroup/citadel-demo/memory.oom.group
Если нужных файлов контроллеров нет, родительская группа не делегировала их. На рабочем сервере не перестраивайте ради опыта иерархию systemd: используйте отдельную ВМ или создайте ограниченную unit через systemd-run.
Шаг 3. Запустите процесс. Промежуточная оболочка сначала помещает себя в cgroup; запущенные после неё unshare и процессы нового корня наследуют это ограничение:
$ sudo sh -c 'echo $$ > /sys/fs/cgroup/citadel-demo/cgroup.procs exec unshare --uts --ipc --net --pid --mount --fork --propagation private \ chroot rootfs /bin/sh -c \ "mount -t proc proc /proc; hostname box; exec /bin/sh"'
Шаг 4. Сравните вид изнутри и снаружи. Внутри выполните hostname, ps -o pid,ppid,comm, ip link и mount. Оболочка имеет PID 1, а loopback-интерфейс существует, но по умолчанию выключен. Во втором терминале прочитайте /sys/fs/cgroup/citadel-demo/cgroup.procs, выведите найденные процессы через ps и для PID внутренней оболочки выполните sudo lsns -p <PID>. Один и тот же процесс имеет обычный PID на хосте и PID 1 внутри.
Шаг 5. Проверьте лимит. Внутри контейнера создайте tmpfs больше ограничения и заполните его:
# mount -t tmpfs -o size=300m tmpfs /tmp # dd if=/dev/zero of=/tmp/blob bs=1M count=300
Ожидаемый результат — OOM-убийство процессов лабораторной группы и завершение оболочки. После этого снаружи сравните счётчики memory.events, особенно oom, oom_kill и oom_group_kill, затем удалите пустую группу командой sudo rmdir /sys/fs/cgroup/citadel-demo.
Шаг 6. Зафиксируйте границы опыта. Мы использовали пространства имён, cgroup и отдельный корень, но не создали user namespace и veth-сеть, не удалили capabilities, не применили seccomp и LSM-профиль, не сделали корень только для чтения и оставили chroot вместо более полного набора операций с корнем. Поэтому это модель основных механизмов, а не безопасная замена OCI runtime.