Контейнеры и виртуализация

Что на самом деле запускает docker run, чем контейнер отличается от виртуальной машины, откуда берутся OOMKilled, медленные сборки, steal time и тормоза Docker на Mac.

Финал серии «Как работает компьютер». Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки.

ВМ и контейнер: что общее

Виртуальная машина изображает целый компьютер: у каждой своё ядро, своя ОС, свои драйверы. Контейнер — обычный процесс хоста, которому ядро показывает урезанный мир. Ядро одно на всех.

  • Отсюда и цифры: ВМ грузится десятки секунд и платит сотни мегабайт за гостевую ОС, контейнер стартует за доли секунды почти без накладных.
  • И граница безопасности: у ВМ — гипервизор с узким интерфейсом, у контейнера — всё ядро Linux с сотнями системных вызовов.

Переключайте вид и двигайте число экземпляров. Сервер — 64 ГБ RAM, приложению нужно 200 МБ.

Namespaces: что видит процесс

Namespace подменяет для процесса одну глобальную таблицу ядра своей копией. Их восемь типов, контейнер — процесс, у которого почти все свои. Создаются флагами clone() и unshare(), вход в чужие — setns().

Выключите namespace — увидите, что процесс в контейнере видит вместо своей копии. Это ровно то, что делают --pid=host, --network=host и подобные флаги.

PID namespace: два номера у процесса

PID-namespace вложенные: процесс виден в своём и во всех родительских, и в каждом у него свой номер. Хост видит всё, контейнер — только своё поддерево. Оба номера — в строке NSpid файла /proc/PID/status.

  • Первый процесс в namespace получает PID 1 и роль init: ему перепадают осиротевшие процессы, а когда он умирает, ядро убивает всех остальных.
  • docker exec не создаёт контейнер: он входит в существующие namespaces через setns().

Нажимайте кнопки и следите за обеими колонками.

cgroups v2: что бывает на лимите

Namespaces решают, что процесс видит, cgroups — сколько он потребляет. Контейнер — это каталог в /sys/fs/cgroup, лимиты — файлы в нём. У каждого контроллера своя реакция на превышение.

  • CPU и диск лимит не убивает, а замедляет. Память и PID — убивает или ломает.
  • Счётчики событий — в cpu.stat, memory.events, pids.events, давление — в *.pressure (PSI).

Выберите контроллер и двигайте нагрузку. Основы cpu.max — в «Процессах и потоках», memory.max — в «Виртуальной памяти».

Проверьте себя

Контейнер запущен с --memory=512m --cpus=2 на сервере с 64 ГБ и 32 ядрами. Внутри выполнили free -m и nproc. Что они покажут?

free читает /proc/meminfo, а он не виртуализирован: это память хоста. nproc смотрит маску sched_getaffinity, а --cpus — это квота в cpu.max, не привязка к ядрам. Поэтому и os.cpu_count(), и runtime.NumCPU() видят 32. Правда — в /sys/fs/cgroup/memory.max и cpu.max; JVM и .NET читают их сами, Go с 1.25 — только cpu.max для GOMAXPROCS; остальным передайте размеры пулов явно.

Образ — стопка слоёв, overlayfs их склеивает

Каждая инструкция Dockerfile, меняющая файлы, даёт слой — tar-архив с разницей. При запуске слои становятся read-only каталогами lowerdir, сверху кладётся пустой upperdir контейнера, а процесс видит их склейку — merged.

  • Чтение — из самого верхнего слоя, где файл есть.
  • Первая запись в файл из нижнего слоя копирует его целиком наверх (copy-up).
  • Удаление кладёт наверх whiteout — «файла нет». Байты в нижнем слое остаются.

Выберите файл в merged и действие.

Порядок в Dockerfile и кэш сборки

Шаг берётся из кэша, если не изменились сама инструкция, родительский слой и — для COPY — контрольные суммы копируемых файлов. Первый промах инвалидирует все шаги ниже.

  • Редко меняющееся — наверх, код — в самый низ.
  • .dockerignore обязателен: без него COPY . . зависит и от .git, и от логов.
  • BuildKit умеет кэш между сборками: RUN --mount=type=cache,target=/root/.cache/pip.

Поменяйте файл, соберите, затем переключите порядок.

Размер образа и distroless

Размер — это время скачивания на каждом новом узле при автоскейлинге, а каждый лишний пакет — ещё строки в отчёте сканера уязвимостей. Слои с одинаковым хешем скачиваются один раз и делятся между образами.

  • Multi-stage: собираем в толстом образе, в финальный копируем только результат.
  • Alpine маленький, но на musl: колёса manylinux не встают, пакеты собираются из исходников, другой malloc и DNS-резолвер.
  • В distroless нет shell — отлаживают через kubectl debug с эфемерным контейнером.
python:3.12~1 ГБ
python:3.12-slim~130 МБ
python:3.12-alpine~50 МБ
distroless/python3~50 МБ
golang:1.23~820 МБ
distroless/static + Go~15 МБ

FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/api

FROM gcr.io/distroless/static:nonroot
COPY --from=build /app /app
ENTRYPOINT ["/app"]       # ~15 МБ вместо ~850

Проверьте себя

В Dockerfile: COPY id_rsa /root/.ssh/, затем RUN git clone …, затем RUN rm /root/.ssh/id_rsa. Образ опубликовали. Ключ в безопасности?

Слои неизменяемы, rm лишь добавляет whiteout в новый слой. docker save или crane export — и ключ на руках. То же с ENV TOKEN=… и ARG: они видны в docker history. Секреты при сборке передают через RUN --mount=type=secret или --mount=type=ssh — они не попадают ни в один слой.

Что делает docker run

Docker — это цепочка: CLI просит демона, демон — containerd, containerd запускает shim и runc. Сам контейнер создаёт маленькая программа runc за несколько системных вызовов и уходит. В Kubernetes вместо dockerd — kubelet, говорящий с containerd через CRI.

Нажимайте «Дальше» — шаг за шагом от команды до вашего PID 1.

Сеть контейнера: veth, мост, NAT

У контейнера свой сетевой namespace с одним eth0. Это конец veth-пары — виртуального кабеля, второй конец которого воткнут в мост docker0 на хосте. Наружу — через NAT, внутрь — через DNAT опубликованного порта.

Выберите сценарий и проведите пакет по шагам. Как работает сам NAT — в «Сетях».

Pod в Kubernetes — общие namespaces

Pod — это не контейнер, а группа контейнеров с общей сетью. Первым запускается крошечный pause: он держит net-, ipc- и uts-namespace. Остальные контейнеры входят в них через setns().

  • Один IP на pod, контейнеры ходят друг к другу по localhost — так работают sidecar-прокси вроде Envoy в Istio.
  • Пространство портов тоже общее.
  • Файловые системы у контейнеров свои, PID-namespace — тоже, пока не включён shareProcessNamespace.
  • Сеть pod настраивает CNI-плагин: veth, мост или маршруты, оверлей между узлами.

$ crictl pods
POD ID         NAME              STATE
7f3c1a9e2b10   api-7d9f8-xk2lp   Ready

$ crictl ps --pod 7f3c1a9e2b10
CONTAINER      IMAGE    NAME
a41d…          api      api
9be0…          envoy    istio-proxy

$ lsns -t net
        NS TYPE NPROCS   PID COMMAND
4026532871 net       9 51207 /pause
# api и envoy — в том же namespace,
# что и pause: 9 процессов на один IP

$ nsenter -t 51207 -n ss -ltn
LISTEN 0.0.0.0:8080     # api
LISTEN 0.0.0.0:15001    # envoy

Проверьте себя

В pod два контейнера: основное приложение и sidecar с метриками. Оба по умолчанию слушают 0.0.0.0:8080. Что будет при запуске?

Сетевой namespace у контейнеров pod один, значит и таблица сокетов одна. Кто первым сделал bind(), тот и занял порт; второй контейнер уйдёт в CrashLoopBackOff. Поле containerPort в манифесте ничего не настраивает — это документация. Порт меняют в конфигурации самого приложения.

Безопасность: ядро одно на всех

  • root в контейнере без user namespace — это настоящий UID 0 хоста, только с урезанными capabilities: из ~40 Docker оставляет 14.
  • seccomp запрещает около 44 системных вызовов, которые нужны только для администрирования: mount, reboot, init_module, kexec_load…
  • Rootless и user namespace: UID 0 внутри — это, например, UID 100000 снаружи. Выбрались из контейнера — оказались никем.
  • --privileged снимает всё сразу. Это уже не изоляция, а root на хосте.

Выберите режим запуска и посмотрите, что получится у процесса с UID 0 внутри.

Гипервизор: trap-and-emulate

Гостевой код исполняется прямо на процессоре — никакой эмуляции. VT-x и AMD-V добавили режим «гость»: ядро гостя работает в своём кольце 0, а чувствительные инструкции вызывают VM exit — выход в гипервизор, который их эмулирует и возвращает управление.

  • Type 1 (ESXi, Xen, Hyper-V) работает прямо на железе. Type 2 — программа в ОС (VirtualBox). KVM — модуль ядра Linux, так что Linux сам становится гипервизором.
  • Дорого не исполнение, а выходы. Поэтому устройства паравиртуальные — virtio: гость знает, что он в ВМ, и сдаёт работу пачками.

Запускайте гостевые операции и смотрите на счётчик выходов.

Вложенные таблицы страниц: EPT и NPT

В ВМ адрес переводится дважды: виртуальный гостя → физический гостя по таблицам гостевого ядра, затем → физический хоста по таблицам гипервизора (EPT у Intel, NPT у AMD). Без них гипервизор вёл бы теневые таблицы и ловил каждую запись в таблицы гостя.

Цена — промах TLB: каждый адрес гостевой таблицы тоже надо перевести. Выберите размеры страниц и нажмите «Пройти». Про таблицы страниц и TLB — в «Виртуальной памяти».

Шумные соседи и steal time

vCPU — это поток на хосте. Если соседи хотят больше процессора, чем есть физических ядер, ваш vCPU стоит в очереди, хотя готов работать. Гипервизор сообщает гостевому ядру, сколько времени vCPU простоял в очереди, а ядро показывает это как st в top.

  • Steal — время, которое вам должны, но не дали. Ваш код не виноват; лечится переездом на другой хост или выделенными ядрами.
  • У burstable-инстансов (AWS T, доли vCPU 5–50% в других облаках) кончившиеся кредиты тоже видны как steal.

Двигайте нагрузку соседей. Хост — 16 потоков, у вас 4 vCPU.

Посередине: microVM и gVisor

Чужой код в контейнере на общем ядре — риск. Полная ВМ — дорого. Облака выбрали промежуточные варианты.

ГраницаСтарт и накладныеГде
runcобщее ядро хоста, ~350 системных вызовов~0,3 с, единицы МБDocker, Kubernetes по умолчанию
gVisorядро в пространстве пользователя на Go (Sentry) перехватывает системные вызовы; к хосту — несколько десятков разрешённыхбыстрый старт, но системные вызовы и I/O в разы дорожеGKE Sandbox, Cloud Run первого поколения
Kataкаждый pod в лёгкой ВМ со своим ядром~1 с, десятки МБKubernetes через RuntimeClass
FirecrackermicroVM: KVM + минимальный VMM на Rust, только virtio-сеть, -диск и консоль~125 мс до init, ~5 МБ на VMMAWS Lambda, Fargate, Fly.io

Lambda держит тысячи microVM на одном сервере и умеет восстанавливать их из снимков памяти (SnapStart) — отсюда холодный старт в сотни миллисекунд, а не секунды.

Docker на macOS — это Linux в ВМ

Контейнеру нужно ядро Linux, а у macOS его нет. Docker Desktop, OrbStack, Colima запускают Linux в лёгкой ВМ через Virtualization.framework, и контейнеры живут там.

  • Bind mount -v .:/app — это общий каталог через virtiofs. Каждый stat и open пересекает границу ВМ, поэтому npm install и Python-импорты из 30 000 файлов тормозят в разы.
  • Лечение: node_modules и .venv — в именованный том внутри ВМ; синхронизация файлов вместо монтирования.
  • Образ linux/amd64 на Apple Silicon работает через Rosetta или QEMU — медленнее и с сюрпризами. Собирайте linux/arm64.
  • Память под контейнеры — это память ВМ: лимит задаётся в настройках, и docker stats не видит Mac.

$ uname -sm               # на Mac
Darwin arm64
$ docker run --rm alpine uname -sr
Linux 6.10.14-linuxkit    # ядро ВМ

# медленно: node_modules идёт через virtiofs
$ docker run -v .:/app node npm ci

# быстро: node_modules в томе внутри ВМ
$ docker run -v .:/app \
    -v nm:/app/node_modules node npm ci

$ docker run --platform linux/amd64 …
WARNING: The requested image's platform
(linux/amd64) does not match the detected
host platform (linux/arm64/v8)

Как это увидеть у себя

  • Namespaces: lsns, ls -l /proc/PID/ns. Зайти в namespace контейнера с инструментами хоста — nsenter -t PID -n (только сеть: ss, tcpdump внутри distroless).
  • cgroups: systemd-cgls, systemd-cgtop, файлы в /sys/fs/cgroup/…/: cpu.stat, memory.events, *.pressure.
  • Контейнер: docker inspect (PID на хосте, слои overlay, IP), в Kubernetes — crictl inspect, crictl ps.
  • ВМ: st в top и vmstat 1, systemd-detect-virt, на хосте KVM — perf kvm stat покажет VM exits по причинам.

$ PID=$(docker inspect -f '{{.State.Pid}}' api)
$ grep NSpid /proc/$PID/status
NSpid:  48213   1
$ ls -l /proc/$PID/ns | awk '{print $NF}'
cgroup:[4026532880] ipc:[4026532814]
mnt:[4026532812] net:[4026532817] …

$ nsenter -t $PID -n ss -ltnp   # сеть контейнера
$ cat /proc/$PID/cgroup
0::/system.slice/docker-3f9c….scope
$ cd /sys/fs/cgroup/system.slice/docker-3f9c….scope
$ cat cpu.stat | grep throttled
nr_throttled 1840
throttled_usec 92314411
$ cat memory.events pids.events

$ docker inspect -f '{{.GraphDriver.Data.UpperDir}}' api
/var/lib/docker/overlay2/9d1e…/diff

Главное

  • Контейнер — процесс хоста: namespaces решают, что он видит, cgroups — сколько потребляет. Ядро одно на всех.
  • Рантайм внутри не знает лимитов, если не прочитает их из /sys/fs/cgroup: пулы, кучи и воркеры настраивайте явно.
  • CPU и диск на лимите тормозят, память убивает (137), лимит PID ломает fork.
  • Образ — неизменяемые слои: порядок в Dockerfile решает судьбу кэша, удалённый секрет остаётся в слое.
  • Сеть — veth, мост и NAT; pod — общий сетевой namespace.
  • ВМ исполняет код нативно и платит за выходы и двойной перевод адресов. Steal — чужая нагрузка, не ваша.

Контейнер — это не маленькая виртуальная машина, а процесс, которому ядро недоговаривает.