2026 г.

Контейнеры: устройство и экосистема

Проект ЦИТадель

Контейнеры стали обычным способом упаковки и запуска серверных приложений, но между приложением и ядром появилось много инструментов и терминов. Эта статья разбирает не команды Docker, а устройство контейнера Linux. В основе находятся обычные процессы, для которых ядро ограничивает видимость ресурсов с помощью пространств имён, учитывает и ограничивает потребление через cgroups и предоставляет отдельное дерево файловой системы. Исполнитель также настраивает права, системные вызовы, сеть и другие границы. Docker, Podman, containerd, Kubernetes и реестры образов организуют эти механизмы и стандартизованные артефакты в удобный рабочий процесс. В практикуме мы соберём упрощённый контейнер средствами Linux и отдельно отметим, чего ему не хватает до промышленного исполнения.

1. Пространства имён: изоляция видимости

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

  • mnt — набор точек монтирования и отдельное дерево файловых систем;
  • uts — имя хоста и доменное имя NIS;
  • ipc — объекты System V IPC и очереди сообщений POSIX;
  • pid — нумерация процессов. Один процесс может иметь разные PID во вложенных пространствах. Процесс с PID 1 внутри пространства принимает осиротевших потомков и имеет особые правила обработки сигналов, поэтому приложение, не пожинающее дочерние процессы, оставляет зомби;
  • net — сетевые интерфейсы, адреса, маршруты, правила фильтрации и порты. Два контейнера в разных сетевых пространствах могут одновременно слушать порт 80. Связь часто строят через пару veth и мост, маршрутизацию или сетевой плагин;
  • user — идентификаторы пользователей и групп, а также связанные с ними capabilities. UID 0 внутри можно отобразить в непривилегированный UID хоста; на этом основана значительная часть rootless-режима;
  • cgroup — представление положения процесса в иерархии cgroups, но не сами ресурсные лимиты;
  • time — смещения монотонных часов и времени с момента загрузки. Реальное календарное время 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 могла бы затронуть другие процессы. После выхода из оболочки имя хоста снаружи останется прежним.

2. Контрольные группы: учёт и ограничение ресурсов

Пространства имён меняют видимость, но сами по себе не мешают процессу занять всю память или процессорное время. 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; итоговые значения можно увидеть в тех же файлах ядра.

3. Файловая система: образы и слои

Следующий компонент — отдельное дерево файловой системы. 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 не является границей безопасности; промышленный исполнитель сочетает отдельное пространство монтирования со сменой корня, отключением старого дерева и ограничением привилегий.

4. Стандарт OCI: возможности и границы совместимости

OCI (Open Container Initiative) поддерживает три связанные спецификации. Image Specification задаёт манифест, конфигурацию и слои образа. Runtime Specification описывает bundle: корневую файловую систему и config.json с процессом, пространствами имён, монтированиями и ограничениями. Distribution Specification определяет API передачи манифестов и содержимого через реестр. runc — распространённая реализация OCI Runtime Specification для Linux: она читает bundle и настраивает механизмы ядра, разобранные выше.

Стандарт даёт переносимый формат артефакта, но не обещает запуск любого образа на любой машине. Должны совпадать операционная система и архитектура, а исполнителю нужны использованные возможности ядра и расширения. Многоархитектурный индекс позволяет одному имени образа указывать на варианты для разных платформ. При выполнении этих условий один OCI-образ обычно можно передавать между Docker, Podman, Kubernetes и облачными сервисами без пересборки.

5. Экосистема: кто есть кто

Инструменты экосистемы решают разные части общего процесса:

  • Docker предоставляет клиент, API и постоянно работающий демон dockerd. В типичной установке демон поручает хранение образов и жизненный цикл контейнеров containerd, а тот запускает OCI runtime, например runc.
  • Podman предлагает похожий интерфейс без постоянного центрального демона. Отдельный процесс conmon следит за контейнером, а rootless-режим использует пользовательские пространства имён и другие ограничения непривилегированного запуска.
  • Kubernetes обращается из kubelet к CRI-совместимому исполнителю, обычно containerd или CRI-O. Прямая встроенная интеграция с Docker Engine (dockershim) удалена; при необходимости Docker Engine подключают через отдельный CRI-адаптер.
  • Сборщики читают Dockerfile или Containerfile и создают OCI-образ. Распространены BuildKit и Buildah. Важнее не минимальное число слоёв само по себе, а отсутствие ненужных файлов: удалить кэш в следующем слое недостаточно, потому что байты останутся в предыдущем. Порядок инструкций влияет на повторное использование кэша, а многостадийная сборка не переносит компилятор и исходники в конечный образ.
  • Реестры хранят манифесты и адресуемое по хешу содержимое. Docker Hub, облачные реестры и Harbor совместимы по основному API, но различаются аутентификацией, политиками доступа, квотами, сканированием и поддержкой дополнительных OCI-артефактов.

6. Контейнер — не виртуальная машина

Виртуальная машина исполняет собственное ядро на виртуальном оборудовании, а обычные контейнеры Linux делят ядро хоста. Уязвимость в доступном системном вызове, ошибочная выдача capability или небезопасное монтирование могут открыть путь из контейнера к хосту. Поэтому стандартный контейнер обычно даёт более слабую границу доверия, чем отдельная ВМ:

  • для доверенных приложений разных команд часто достаточно хорошо настроенной контейнерной изоляции; заведомо враждебный код требует отдельной модели угроз и обычно более сильной границы;
  • процессу задают непривилегированный UID, по возможности используют user namespace и rootless-режим, удаляют все capabilities и возвращают только необходимые, запрещают получение новых привилегий, применяют seccomp и SELinux/AppArmor, делают корневую файловую систему доступной только для чтения и не передают сокет исполнителя контейнеров;
  • gVisor реализует значительную часть системного интерфейса гостя в отдельном прикладном ядре. Kata Containers запускает контейнерную нагрузку в лёгких ВМ. Firecracker — монитор микро-ВМ, который становится частью контейнерного решения через дополнительную интеграцию. Такие варианты усиливают изоляцию ценой памяти, задержки запуска, совместимости и сложности.

Для вычислений и обычного доступа к памяти контейнерный процесс обычно близок к процессу на хосте: эмуляции процессора нет. Заметные издержки могут появляться в многослойной файловой системе, сетевой обработке, шифровании, журналах, средствах безопасности и при конкуренции за ресурсы. Том или другой сетевой режим меняет свойства и риски, поэтому производительность проверяют на характерной нагрузке, а не выводят только из факта контейнеризации.

7. Практикум: контейнер голыми руками

Соберём упрощённый контейнер стандартными утилитами, без 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.

Итоги

  • Контейнер Linux — один или несколько обычных процессов с изолированным представлением ресурсов, отдельным деревом файловой системы, ресурсными ограничениями и политикой безопасности.
  • Пространства имён отвечают за видимость, а cgroups — за учёт и ограничения; cgroup namespace сам по себе лимитов не задаёт.
  • OCI стандартизует формат образа, runtime bundle и обмен с реестром. Переносимость требует совместимой ОС, архитектуры и возможностей исполнителя.
  • OverlayFS часто объединяет неизменяемые слои образа с записываемым слоем, но возможны другие snapshotter-механизмы; постоянные данные имеют отдельный жизненный цикл.
  • Обычный контейнер делит ядро хоста и обычно слабее ВМ как граница доверия. Capabilities, seccomp, LSM, user namespaces и запрет лишних монтирований дополняют, а не заменяют друг друга.
  • Практикум воспроизводит основные механизмы, но намеренно не является промышленным и безопасным исполнителем контейнеров.

Литература

  1. Linux manual pages: namespaces(7), а также cgroups(7), unshare(1), setns(2) и pivot_root(2).
  2. Документация ядра Linux: Control Group v2 и Overlay Filesystem.
  3. Open Container Initiative: Image Specification, Runtime Specification и Distribution Specification.
  4. Документация Kubernetes: Container Runtime Interface — граница между kubelet и исполнителем.
  5. M. Kerrisk, "The Linux Programming Interface," No Starch Press, 2010 — процессы, файловые системы, capabilities и пространства имён.
404 Not Found

404 Not Found


nginx/1.24.0 (Ubuntu)

Связь с редакцией