три примитива ядра и то, как ими пользуются напрямую и через Dockerthree kernel primitives, and how to use them directly or through Docker
Контейнер — не отдельная сущность ядра, а обычный Linux-процесс с ограниченной видимостью (namespaces) и ограниченными ресурсами (cgroups), чья файловая система обычно собрана слоями (OverlayFS). Docker, containerd и Kubernetes — это разные уровни удобной обвязки поверх этих трёх примитивов, а не что-то принципиально иное на уровне ядра.A container isn't a separate kernel entity — it's an ordinary Linux process with restricted visibility (namespaces) and restricted resources (cgroups), whose filesystem is usually assembled in layers (OverlayFS). Docker, containerd, and Kubernetes are different levels of convenient tooling built on top of these three primitives, not something fundamentally different at the kernel level.
Три независимых механизма ядра, которые вместе дают то, что называют «контейнером»:Three independent kernel mechanisms that together give you what's called a "container":
| примитивprimitive | что делаетwhat it does | чем управляетwhat it controls |
|---|---|---|
| namespaces | изоляция видимости — процесс видит только «свою» версию ресурсаvisibility isolation — a process sees only "its own" version of a resource | что процесс видит: PID, сеть, точки монтирования, hostname, IPC, UID/GIDwhat the process sees: PIDs, network, mount points, hostname, IPC, UID/GID |
| cgroups | ограничение и учёт потребления ресурсовlimiting and accounting for resource consumption | сколько процесс может потребить: CPU, память, дисковый I/O, число процессовhow much the process can consume: CPU, memory, disk I/O, process count |
| OverlayFS | сборка файловой системы из слоёвassembling a filesystem out of layers | откуда процесс читает файлы и куда пишет: неизменный образ снизу, записываемый слой сверхуwhere the process reads files from and writes files to: an immutable image below, a writable layer on top |
Namespaces и cgroups решают разные задачи и не заменяют друг друга: namespaces контролируют, что процесс видит, cgroups — сколько он может потребить. Процесс может быть полностью изолирован по namespaces и при этом не иметь вообще никаких лимитов cgroups (тогда он теоретически способен исчерпать все ресурсы хоста) — или наоборот, иметь жёсткие cgroup-лимиты, но не быть изолированным по namespaces (тогда он видит все остальные процессы системы). «Настоящий» контейнер в духе Docker использует оба механизма одновременно, плюс отдельную файловую систему через OverlayFS или аналог.Namespaces and cgroups solve different problems and don't substitute for each other: namespaces control what a process sees, cgroups control how much it can consume. A process can be fully isolated via namespaces while having no cgroup limits at all (in which case it can theoretically exhaust every host resource) — or, conversely, have hard cgroup limits without namespace isolation (in which case it sees every other process on the system). A "real" Docker-style container uses both mechanisms at once, plus a separate filesystem via OverlayFS or an equivalent.
В современном ядре восемь видов namespace, каждый изолирует свой ресурс: PID (список процессов — внутри контейнера процесс видит себя как PID 1), net (сетевые интерфейсы, таблицы маршрутизации, порты), mnt (точки монтирования файловой системы), uts (hostname и domain name), ipc (System V IPC, очереди сообщений), user (соответствие UID/GID внутри namespace и на хосте — основа rootless-контейнеров), cgroup (изоляция вида дерева cgroup изнутри процесса) и time (независимые системные часы, самый новый вид). Процесс, у которого не переопределён конкретный namespace, просто наследует его от родителя — то есть «контейнеризация» на практике означает выбор, какие именно из этих ресурсов заменить на новые, а какие оставить общими с хостом.The modern kernel has eight namespace types, each isolating one resource: PID (the process list — inside a container a process sees itself as PID 1), net (network interfaces, routing tables, ports), mnt (filesystem mount points), uts (hostname and domain name), ipc (System V IPC, message queues), user (UID/GID mapping between the namespace and the host — the basis of rootless containers), cgroup (isolating the view of the cgroup tree from inside the process), and time (independent system clocks, the newest type). A process that doesn't override a given namespace simply inherits it from its parent — so "containerizing" in practice means choosing which of these resources to replace with new ones and which to leave shared with the host.
# PID + mount namespace: процесс видит себя как PID 1, своя /proc
sudo unshare --fork --pid --mount-proc /bin/bash
# net namespace: только loopback, до ip link set lo up ничего не работает
sudo unshare --net /bin/bash
ip link set lo up
# проверка: разные namespace = разные inode-идентификаторы
readlink /proc/$$/ns/pid
readlink /proc/$$/ns/net
cgroups v2 (единая иерархия, дефолт в современных дистрибутивах) представлена в виде псевдо-файловой системы, обычно смонтированной в /sys/fs/cgroup. Каждая cgroup — это каталог с файлами-контроллерами: cpu.max (лимит CPU-времени), memory.max (жёсткий лимит памяти), memory.current (текущее потребление), pids.max (лимит числа процессов), io.max (лимит дискового I/O). Процесс попадает в cgroup записью его PID в файл cgroup.procs этого каталога — с этого момента к нему применяются все лимиты, заданные в файлах-контроллерах этой cgroup. В cgroups v2 процесс может состоять только в «листовой» cgroup — группе без дочерних групп — что убирает саму возможность двусмысленности, когда неясно, чьи лимиты (родителя или потомка) реально действуют.cgroups v2 (a single unified hierarchy, the default on modern distros) is exposed as a pseudo-filesystem, typically mounted at /sys/fs/cgroup. Each cgroup is a directory with controller files: cpu.max (a CPU-time limit), memory.max (a hard memory limit), memory.current (current usage), pids.max (a process-count limit), io.max (a disk-I/O limit). A process joins a cgroup by writing its PID into that directory's cgroup.procs file — from that point on, every limit set in that cgroup's controller files applies to it. In cgroups v2, a process can only belong to a "leaf" cgroup — one with no child groups — which removes the very possibility of ambiguity over whose limits (the parent's or the child's) actually apply.
# половина ядра CPU, максимум 512 МБ памяти
sudo systemd-run --scope -p CPUQuota=50% -p MemoryMax=512M ./my-script.sh
# посмотреть реальные лимиты, выставленные ядру
cat /sys/fs/cgroup/system.slice/run-*.scope/cpu.max
cat /sys/fs/cgroup/system.slice/run-*.scope/memory.max
OverlayFS объединяет несколько каталогов в одно видимое дерево: lowerdir (один или несколько неизменных слоёв, обычно слои образа), upperdir (единственный записываемый слой, куда попадают все изменения работающего контейнера) и workdir (служебный каталог самой файловой системы, не используется напрямую). При чтении файла ядро сначала проверяет upperdir, и только если файла там нет — спускается в lowerdir. При записи или удалении файла, физически лежащего в lowerdir, срабатывает copy-on-write: файл копируется в upperdir и там же изменяется, оригинал в lowerdir остаётся нетронутым. Именно поэтому удаление контейнера — это просто удаление upperdir: образ (lowerdir) остаётся неизменным и готовым для следующего контейнера.OverlayFS merges several directories into one visible tree: lowerdir (one or more immutable layers, typically the image's layers), upperdir (a single writable layer that captures every change a running container makes), and workdir (an internal scratch directory the filesystem needs, not used directly). On a read, the kernel checks upperdir first, and only falls through to lowerdir if the file isn't there. On a write or delete of a file that physically lives in lowerdir, copy-on-write kicks in: the file gets copied into upperdir and modified there, leaving the lowerdir original untouched. This is exactly why removing a container is just deleting upperdir: the image (lowerdir) stays unchanged and ready for the next container.
mkdir -p upper work merged
sudo mount -t overlay overlay \
-o lowerdir=/path/to/image,upperdir=upper,workdir=work \
merged
# всё, что пишется в merged/, реально попадает в upper/
# image/ остаётся нетронутым
Docker и containerd не делают ничего магического — они автоматизируют именно ту последовательность действий, что описана выше, и добавляют удобный формат образа и реестр для его распространения. Типичные команды и что за ними физически стоит:Docker and containerd don't do anything magical — they automate exactly the sequence of steps described above, and add a convenient image format plus a registry to distribute it. Typical commands and what physically happens behind them:
| командаcommand | что она делает под капотомwhat it does under the hood |
|---|---|
| docker run --cpus=0.5 --memory=256m nginx | containerd передаёт запрос runc → runc вызывает clone() с флагами всех стандартных namespaces, создаёт cgroup с cpu.max/memory.max, монтирует OverlayFS из слоёв образа nginxcontainerd hands the request to runc → runc calls clone() with all the standard namespace flags, creates a cgroup with cpu.max/memory.max, mounts OverlayFS from the nginx image's layers |
| docker inspect --format {{.State.Pid}} имяname | PID процесса на хосте — контейнер не отдельный «объект ядра», это конкретный обычный процесс, видимый и снаружи через ps aux на хостеthe process's PID on the host — a container isn't a separate "kernel object," it's a concrete ordinary process, visible from outside via ps aux on the host too |
| docker exec -it имяname sh | запускает новый процесс, присоединяя его к уже существующим namespaces контейнера (через setns()) — не создаёт новый контейнерlaunches a new process, attaching it to the container's already-existing namespaces (via setns()) — doesn't create a new container |
| kubectl describe poddescribe pod | kubelet обращается к containerd через CRI (gRPC), тот делегирует runc — тот же путь, что и docker run, просто инициирован оркестратором, а не человеком с терминалаkubelet talks to containerd via CRI (gRPC), which delegates to runc — the same path as docker run, just initiated by an orchestrator instead of a human at a terminal |
Практическое следствие этой механики: почти всё, что видно снаружи контейнера (через ps, top, cgroup-файлы в /sys/fs/cgroup на хосте), доступно и без Docker — инструменты вроде nsenter позволяют зайти в namespaces уже работающего контейнера напрямую, минуя docker exec, что полезно при отладке, когда сам демон Docker недоступен или отвечает медленно.A practical consequence of this mechanics: almost everything visible from outside a container (via ps, top, the cgroup files in /sys/fs/cgroup on the host) is accessible without Docker too — tools like nsenter let you enter an already-running container's namespaces directly, bypassing docker exec, which is useful for debugging when the Docker daemon itself is unavailable or slow to respond.
У VM есть собственное ядро под гипервизором, у контейнера — нет, он использует ядро хоста напрямую. Это не количественная разница («контейнер легче VM»), а качественная: все контейнеры на машине делят одно ядро, поэтому уязвимость в самом ядре хоста потенциально затрагивает их все разом, а container escape через баг ядра — атака, невозможная для VM с отдельным ядром и аппаратной изоляцией гипервизора.A VM has its own kernel under a hypervisor, a container doesn't — it uses the host kernel directly. This isn't a difference of degree ("a container is lighter than a VM") but of kind: every container on a machine shares one kernel, so a vulnerability in the host kernel itself potentially affects all of them at once, and a container escape through a kernel bug is an attack that isn't possible against a VM with its own kernel and hardware hypervisor isolation.
Namespaces сами по себе ничего не ограничивают — они только скрывают. Контейнер, запущенный без --memory (или с явно завышенным лимитом), способен потребить всю доступную RAM хоста и спровоцировать OOM killer, который может убить совершенно посторонний процесс на этой же машине. Изоляция видимости и ограничение потребления — независимые настройки, которые нужно задавать обе.Namespaces by themselves don't limit anything — they only hide. A container started without --memory (or with an overly generous limit) can consume all the host's available RAM and trigger the OOM killer, which may kill a completely unrelated process on that same machine. Visibility isolation and resource limiting are independent settings, and both need to be set.
CRI (Container Runtime Interface) отвязал kubelet от жёстко зашитого рантайма — типичный узел Kubernetes сегодня использует containerd или CRI-O напрямую, без Docker Engine в качестве промежуточного слоя. Путать «контейнер» с «Docker-контейнером» — устаревшее допущение: то же самое OCI-совместимое изображение можно запустить через podman, containerd или runc напрямую, минуя Docker целиком.CRI (Container Runtime Interface) decoupled kubelet from a hardcoded runtime — a typical Kubernetes node today uses containerd or CRI-O directly, with no Docker Engine as a middle layer. Conflating "a container" with "a Docker container" is an outdated assumption: the same OCI-compatible image can be run via podman, containerd, or runc directly, bypassing Docker entirely.