
Привет, Хабр! Меня зовут Олег, я работаю в VK Cloud в команде Key Management Service. Есть у меня такая привычка: когда пользуюсь каким-то инструментом изо дня в день, то рано или поздно хочется залезть внутрь и посмотреть, как оно там устроено. С контейнерами так и вышло: docker run, docker build — всё это прекрасно работает, но что именно происходит, когда мы «запускаем контейнер»?
В этой статье разберём контейнеры не на уровне «вот вам YAML, отправляйте в прод», а чуть глубже — на уровне системных вызовов Linux. По ходу дела напишем свой примитивный контейнер на Go, используя буквально четыре syscall'а, а в конце посмотрим, куда эта история развивалась дальше (сети, файловые системы) и почему виртуальные машины всё ещё живы.
Что вообще умеет Docker-контейнер
Прежде чем писать своё, давайте посмотрим на поведение обычного Docker-контейнера. Запустим Alpine и потыкаем его палочкой.

Изоляция файловой системы

На хосте каталог /opt заполнен всяким добром: containerd, google, Insomnia, Lens, openbao, zoom и прочим. Заходим в контейнер, смотрим в тот же /opt — пусто. Абсолютно.

Контейнер живёт в своей файловой системе и понятия не имеет о том, что лежит на хосте за пределами его корня. Казалось бы, очевидная ситуация, но именно с этого начинается вся магия изоляции.
Сеть хоста ≠ сеть контейнера
Запускаем на хосте Nginx с пробросом 80-го порта:

С хоста curl localhost возвращает знакомую страницу «Welcome to nginx!». Теперь заходим в другой контейнер, пробуем тот же curl localhost и получаем ошибку «Failed to connect to localhost port 80».


Тут важно понять вот что: localhost внутри контейнера — это не тот же localhost, что на хосте. Сетевой стек у контейнера собственный. Это не баг, а фича, причём одна из ключевых.
Ограничение ресурсов
Возьмём простой bash-скрипт, который в бесконечном цикле наращивает строку и печатает её. Этакий генератор «GNU Not Unix → GNU stands for GNU Not Unix → ...» — бессмысленный, но жадный до CPU.
#!/usr/bin/sh str="GNU Not Unix" while true; do echo $str str="GNU stands for ${str}" done
Запускаем на хосте и видим в ps aux, что процесс съедает около 92 % ядра.

Теперь запускаем тот же скрипт в контейнере с флагом --cpus=0.5, и потребление падает примерно до 50 %. Docker честно не даёт процессу вылезти за установленный лимит.

Изоляция процессов
На хосте ps aux выдаёт длинную простыню процессов. В контейнере — обычно два: shell с PID 1 и сам ps.

Но самое интересное — асимметрия. Запустим в контейнере что-нибудь долгоживущее:

Внутри контейнера этот процесс виден, допустим, как PID 7.
А теперь смотрим с хоста (ps aux | grep alive) и находим тот же процесс, но уже с PID 272688 (или каким-то другим большим числом). Один и тот же процесс, два разных идентификатора.

Хост видит всё, что происходит в контейнере, а контейнер хост не видит. Это не какая-то виртуализация на уровне железа, это просто хитрая изоляция на уровне ядра.
Так что же такое контейнер?
После всех этих экспериментов напрашивается определение: контейнер — это обычный процесс в Linux, но с ограничениями. Ограничен он по контексту (видит не всю файловую систему, не все процессы, имеет свой сетевой стек), ограничен по ресурсам (процессор, память, дисковый I/O) и запускается в отдельном userspace. Никакой магии, никакой аппаратной виртуализации. Просто процесс с обвязкой.
Давайте напишем свой.
Пишем свой контейнер
Проект будет на Go — простой CLI, который умеет выполнять container run какой-либо команды. Код открыт, ссылка в конце статьи. Здесь разберём ключевые моменты.
Syscall #1: exec — подм��на бинаря
Первый кирпичик — системный вызов exec. Его суть в том, что он берёт текущий процесс и подменяет в нём исполняемый файл. Не создаёт новый процесс, а именно заменяет содержимое текущего.
В Go это выглядит примерно так: создаём команду через exec.Command с аргументами, которые нам передал пользователь, прокидываем stdin/stdout/stderr и вызываем Run(). Получается что-то вроде docker run: передаём путь к бинарю, и он запускается.
func run(argv []string) error { cmd := exec.Command(argv[0], argv[1:]...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr err = cmd.Run() if err != nil { return err } return nil }

Проваливаемся в shell. Работает. Но пока это просто запуск процесса, никакой изоляции нет.
Syscall #2: chroot — изоляция файловой системы
Прежде чем лезть в код, немного истории. В 1979 году в Unix System 7 появился chroot — буквально «change root», смена корневой директории. Это один из старейших механизмов изоляции, и он до сих пор лежит в основе того, как работают контейнеры. Идея простая: создаём где-нибудь каталог, который станет корнем («/») для нашего процесса, а всё, что за пределами этого каталога, для процесса не существует.
Дальше по таймлайну: FreeBSD jails (1999), Linux namespaces (2002), cgroups (2006), LXC (2008), Docker (2013), Kubernetes (2014). Каждый следующий шаг добавлял новые возможности, но chroot — это та самая отправная точка, с которой всё началось.
Пробуем использовать в нашем коде syscall.Chroot(path) — и при попытке запустить shell получаем панику: «no such file or directory». Внутри нового корня пусто, там нет ни shell'а, ни библиотек, от которых он зависит.
func changeDirectory(path string) error { err = syscall.Chroot(path) if err != nil { return err } return nil }

Зачем нужен userspace?
Чтобы понять, что пошло не так, нужно вспомнить, как устроена операционная система. Есть kernel space — это само ядро Linux, которое отвечает за файловую систему, драйверы, работу с памятью и планирование процессов. И есть user space — всё то, что приносят дистрибутивы: библиотеки (libc и компания), утилиты командной строки, shell'ы и пользовательские программы.

Когда мы исполняем chroot, ядро у нас остаётся общим с хостом, а вот userspace нужно создать внутри контейнера, иначе там просто нечему работать. Самый простой способ — скопировать из хоста директории /usr (там библиотеки и бинари), /lib и /lib64 (обычно это симлинки на /usr/lib). По сути, мы сейчас руками делаем то, что делает Docker, когда собирает образ: наполняем контейнер необходимым для работы.
func setupUserspace(path string) error { err := syscall.Mkdir(path, 0o777) if err != nil { fmt.Println("mkdir:", err) } for _, deps := range []string{"/usr", "/lib", "/lib64", "./scripts/benchmark.sh"} { cmd := exec.Command("cp", "-r", deps, path) err = cmd.Run() if err != nil { return err } } return nil }

Копировать всё подряд расточительно. Если нужна только конкретная утилита, то можно через ldd посмотреть её зависимости и скопировать только их. Например, для /usr/bin/ls это будет libselinux.so.1, libc.so.6, libpcre2-8.so.0, ld-linux-x86-64.so.2 и ещё несколько библиотек.

Вторая попытка: chroot + chdir + PATH
Чтобы chroot заработал корректно, перед ним нужно выполнить cd: перейти в тот каталог, который станет корнем, иначе получаем ошибку «getcwd: cannot access parent directories». Такова специфика реализации: текущая рабочая директория должна находиться внутри нового корня.
После chroot задаём переменную окружения PATH, чтобы система знала, где искать бинари:
func changeDirectory(path string) error { err := syscall.Chdir(path) if err != nil { return err } err = syscall.Chroot(path) if err != nil { return err } err = os.Setenv("PATH", "/usr/bin") if err != nil { return err } return nil }
Теперь всё работает: ls показывает lib, lib64 и usr, команда pwd возвращает «/». Файловая система изолирована.

Syscall #3: namespaces — изоляция сущностей ОС
В 2002 году в Linux появились namespaces. В ядре каждый процесс (или поток — в Linux они представлены одинаково) описывается структурой task_struct. Это монструозная сущность на полторы тысячи строк, но нас интересует поле ns_proxy: оно содержит информацию о namespaces, которые назначены процессу. Через них можно изолировать имя хоста и системное время, IPC (механизмы межпроцессного взаимодействия), точки монтирования, процессы, информацию о пользователях, сеть.
struct task_struct { … struct nsproxy *nsproxy; … }; struct nsproxy { refcount_t count; struct uts_namespace *uts_ns; struct ipc_namespace *ipc_ns; struct mnt_namespace *mnt_ns; struct pid_namespace *pid_ns_for_children; struct net *net_ns; struct time_namespace *time_ns; struct time_namespace *time_ns_for_children; struct cgroup_namespace *cgroup_ns; };
Важное пояснение про файловую систему в Linux: это интерфейс, а не просто хранилище файлов. Не всё, что мы видим в дереве директорий, является файлами на диске. /proc, /dev, /sys — эти директории не содержат файлов в привычном смысле. Под капотом там системные вызовы, которые отдают информацию в виде файлоподобного API.
Для изоляции PID добавляем флаг при создании процесса:
func setNamespace(cmd *exec.Cmd) error { cmd.SysProcAttr = &syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWPID, } return nil }
Таких флагов много — для хоста, пользователей, сети и так далее. Мы пока смотрим на процессы.
Проверяем изоляцию процессов
На хосте запускаем фоновый процесс:

Получаем его PID, допустим, 40728.

Теперь из контейнера пробуем:

Ошибка: «No such process». Контейнер не видит процессы хоста, изоляция работает.
Чтобы ps внутри контейнера показывал правильную картину, нужно примонтировать /proc. Это псевдофайловая система, и её нельзя просто скопировать, нужен mount:

Теперь ps aux показывает только процессы контейнера: shell с PID 1 и сам ps. Изоляция процессов работает.
Syscall #4: mount + cgroups — ограничение ресурсов
Cgroups (control groups) появились в 2006 году, во многом благодаря контрибьюторам из Google, которым нужно было как-то управлять ресурсами на своих серверах. Идея cgroups: мы можем объединить процессы в группу и задать для неё лимиты — по процессору, памяти, дисковому вводу-выводу. Причём ограничивается именно группа целиком, а не каждый процесс по отдельности. Если в группе два процесса и лимит по процессору 50 %, то они вдвоём не смогут потребить больше половины ядра.
Принцип работы простой: за определённый промежуток времени ядро отслеживает, сколько ресурсов потратила группа, и не даёт превысить квоту.
Для использования cgroups v2 монтируем /sys/fs/cgroup и создаём в ней каталог для нашей группы:
func setResourceLimits() error { err := os.MkdirAll("/sys/fs/cgroup", 0o777) if err != nil { return err } err = syscall.Mount("/sys/fs/cgroup", "/sys/fs/cgroup", "cgroup2", 0, "") if err != nil { return err } path := "/sys/fs/cgroup/container" err = os.MkdirAll(path, 0o777) if err != nil { return err } … return nil }
Чтобы ограничить процессор до половины ядра, пишем в файл cpu.max:
func setResourceLimits() error { … err = os.WriteFile(filepath.Join(path, "cpu.max"), []byte("50000 100000"), 0o777) if err != nil { return err } err = os.WriteFile(filepath.Join(path, "cgroup.procs"), []byte(fmt.Sprintf("%d", os.Getpid())), 0o777) if err != nil { return err } return nil }
Это значит, что из каждых 100 000 микросекунд процессу доступны только 50 000 — половина ядра.
Дальше добавляем PID текущего процесса в cgroup.procs, и ограничение начинает действовать на него и все его потомки.

Запускаем бенчмарк: без ограничений он съедает около 70 % ядра, с ограничением — стабильно около 50 %. Работает.

Что получилось
Четыре системных вызова — и у нас есть примитивный, но работающий контейнер: exec для запуска нужного бинаря, chroot для изоляции файлово�� системы, namespaces (через clone-флаги) для изоляции процессов и других сущностей, cgroups для ограничения ресурсов.
После того как эти механизмы появились в ядре, создали LXC (Linux Containers) — первую по-настоящему удобную среду исполнения контейнеров. В 2013 году Docker взял эти идеи и упаковал их в продукт, который «взлетел». В 2014-м появился Kubernetes и вывел оркестрацию на новый уровень.
Дальнейшее развитие
Мы разобрали основы, но до настоящих контейнеров ещё далеко. Поговорим о том, что мы пока не затронули.
Сети в контейнерах
Базовые понятия: eth — сетевой интерфейс (физический или виртуальный), veth — виртуальный Ethernet-интерфейс, который обычно создаётся парами и используется для связи между namespace'ами.
Когда приложение в контейнере отправляет пакет, тот проходит длинный путь: сначала в iptables (правила фильтрации и NAT), потом в подсистему маршрутизации ядра, снова в iptables на исходящее направление, и только потом на сетевой интерфейс.

На хосте этот путь повторяется: ещё один проход через iptables и маршрутизацию, плюс подмена IP-адреса. Получаем двойные накладные расходы, порядка 10–50 мс на каждый пакет.
eBPF позволяет это оптимизировать. Эта технология позволяет перехватывать пакеты практически в любой точке сетевого стека и направлять их куда нужно, минуя лишние этапы. В Kubernetes-кластерах это активно используется, например, в Cilium.

Итого по сетям: IP контейнера подменяется на выходе (NAT), изоляция достигается за счёт виртуальных интерфейсов и правил файервола, накладные расходы на двойной проход через стек можно снизить с помощью eBPF.
Файловые системы: проблема копирования и OverlayFS
Когда мы создавали userspace для нашего контейнера, мы копировали библиотеки и бинари целиком. На каждый новый контейнер — повторное копирование всего и вся. Это медленно и расточительно.

OverlayFS элегантно решает эту проблему. Файловая система состоит из трёх слоёв: lower (read-only, базовые неизменяемые данные — библиотеки, бинари), diff (слой для записи, где хранятся наши изменения и новые файлы) и merged (результат объединения, который видит пользователь). Принцип работы похож на git: есть базовый коммит, который не меняется, и поверх него накладываются изменения. Когда мы что-то записываем, это попадает в diff-слой. Когда читаем, OverlayFS сначала смотрит в diff, а если там нет нужного файла, то берёт из lower. В результате десять контейнеров на основе одного образа используют один и тот же lower-слой, и каждый имеет только свой небольшой diff. Лишнее копирование исчезает.

Виртуальные машины и контейнеры
Если контейнеры такие замечательные, то зачем до сих пор существуют виртуальные машины? Давайте сравним.
Изоляция. У VM отдельное ядро, отдельная прошивка, отдельное (виртуальное) железо — полная изоляция на всех уровнях. У контейнера ядро общее с хостом, изолирован только userspace и то, что над ним.
Время запуска. VM может стартовать минуты (загрузка ядра, init-система, сервисы). Контейнер — это процесс, он запускается за миллисекунды.
Infrastructure as Code. Для VM часто используют связку Vagrant + Ansible. Для контейнеров — Kubernetes со всей его экосистемой.
Уязвимости. В VM их объективно меньше. Из громких историй — Meltdown, но это уязвимость хитрая, на уровне процессора. В контейнерах поверхность атаки больше: уязвимости в sudo (500 тысяч строк кода в одной утилите для управления привилегиями), уязвимости в самом Docker. Например, в Docker Desktop на Windows находили незащищённый API, через который можно было получить произвольные права.
Поддерживаемые ОС. В VM можно запустить что угодно. Контейнеры — это Linux (есть обходные пути вроде Wine, но это, скорее, костыли).
Накладные расходы. У VM примерно 2–10 % на виртуализацию. У контейнеров — меньше 1 %.
Зачем нужны виртуальные машины
VM используются для самих контейнеров. Docker Desktop на Mac или Windows под капотом запускает виртуалку с Linux, и уже на ней работают контейнеры. Без этого никак — ядро-то нужно Linux'овое.
У VM лучше изоляция. Если нужно запустить что-то, чему вы не доверяете полностью, то VM снижает риски.
Есть сценарии, когда контейнеры подходят хуже: базы данных (долгоживущие процессы с состоянием); экземпляры на ОС, отличных от Linux; legacy-системы, которые требуют специфического окружения.
VM и контейнеры не конкурируют друг с другом, а дополняют друг друга. Использовать их в связке — хорошая практика.
Выводы
Контейнер — это удобная абстракция для разработчика, и при этом просто процесс Linux с ограничениями. Никакой магии, никакой аппаратной виртуализации — четыре системных вызова, которые существуют в ядре десятилетиями, плюс удобная обёртка. У контейнера нет своего ядра, он разделяет его с хостом, что даёт скорость и лёгкость, но означает меньшую изоляцию по сравнению с VM. Полноценные контейнеры существуют только в Linux (FreeBSD развивает свои jails, но основной мир контейнеров — это Linux). И да, контейнеризация не бесплатна: накладные расходы небольшие, но они есть (на сетевой стек, на cgroups, на слои файловой системы).
Однако ничего универсального для абсолютно всех задач, к сожалению, не бывает. Сталкивались ли вы с ситуациями, когда контейнеры не подошли и пришлось возвращаться к VM?
Ссылка на код, использованный в статье.
