Перейти к содержанию

Docker

Главная идея:

Контейнер — это не виртуалка. Это обычный процесс на хосте, которому ядро Linux наврало про мир вокруг: своя файловая система, свой список процессов, своя сеть, свои лимиты. Два механизма ядра делают всю работу — namespaces (что процесс видит) и cgroups (сколько ресурсов ему дают). Всё остальное в Docker — образы, слои, registry — это про то, как удобно собрать и доставить эту упаковку.

Что такое контейнер

Контейнер — это процесс (или дерево процессов) хоста, изолированный через namespaces и ограниченный через cgroups, с собственным корнем файловой системы.

Ключевое, что надо принять сразу: внутри контейнера нет отдельного ядра. Процесс работает на том же kernel, что и хост. Когда ты делаешь docker run nginx, на хосте стартует обычный nginx, просто он не видит остальную систему и думает, что он один.

Три кита, на которых стоит контейнер: - namespaces — изолируют то, что процесс видит: процессы, сеть, монтирования, hostname. - cgroups — ограничивают то, что процесс может потратить: CPU, RAM, I/O, число процессов. - union filesystem — собирает корень / из слоёв образа, не копируя их физически.

Никакой магии в Docker сверх этого нет. Docker — это удобная обёртка над фичами ядра, которые существовали и до него (namespaces с 2002, cgroups с 2008). Его реальная заслуга — формат образа и tooling вокруг доставки, а не сама изоляция.

Note

Поэтому Docker нативно работает только на Linux. На macOS и Windows он крутит лёгкую Linux-VM (через Docker Desktop / WSL2) и запускает контейнеры уже внутри неё. «Docker на маке» — это Docker внутри виртуалки.


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

Разница в том, на каком уровне идёт изоляция. VM виртуализирует железо и тащит своё ядро. Контейнер делит ядро хоста и изолируется на уровне процессов.

Свойство Виртуальная машина Контейнер
Ядро своё, гостевое общее с хостом
Изоляция на уровне железа (hypervisor) на уровне процессов (namespaces)
Старт секунды-минуты (грузится ОС) миллисекунды (просто fork/exec)
Накладные расходы гостевая ОС целиком: гигабайты RAM, диск почти ноль сверх самого процесса
Граница безопасности сильная (отдельное ядро) слабее (общий kernel — см. ниже)
Плотность на хосте десятки сотни-тысячи

Грубо говоря: VM — это «отдельный компьютер», контейнер — «процесс в костюме отдельного компьютера». Контейнер легче и быстрее, но платит за это тем, что делит ядро — а значит и поверхность атаки ядра.


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

Namespace — это механизм ядра, который даёт процессу собственную изолированную копию какого-то глобального ресурса системы.

Идея простая: ядро ведёт не один глобальный список (процессов, сетевых интерфейсов, монтирований), а несколько. Процесс видит только тот список, к которому привязан его namespace. Соседний контейнер живёт в других namespaces и тех же объектов не видит.

Виды namespaces, которые собирают контейнер: - PID — свой список процессов. Внутри контейнера главный процесс получает PID 1, а процессов хоста не видно. На хосте этот же процесс имеет обычный большой PID. - MNT (mount) — своя таблица монтирований и свой корень /. Основа изоляции файловой системы. - NET (network) — свой сетевой стек: интерфейсы, IP, таблицы маршрутизации, порты. У контейнера свой lo и обычно свой eth0. - UTS — свои hostname и domainname. Поэтому hostname внутри контейнера — это его id, а не имя хоста. - IPC — своя System V IPC и POSIX message queues; разделяемая память не течёт между контейнерами. - USER — своё отображение UID/GID. Позволяет быть root (UID 0) внутри контейнера, оставаясь непривилегированным пользователем на хосте. Ключевая фича для безопасности, но включена не всегда. - CGROUP — изолирует видимость самой cgroup-иерархии.

Процесс создаётся в namespaces системными вызовами clone(), unshare(), setns(). Docker сам их не изобретает — он просит ядро создать процесс с нужным набором флагов.

Note

Можно посмотреть namespaces процесса глазами: ls -l /proc/<pid>/ns/. Каждый namespace — это inode; если у двух процессов одинаковый inode для net, они в одной сети.


Cgroups — сколько процесс может потратить

Cgroups (control groups) — это механизм ядра для учёта и ограничения ресурсов группы процессов.

Namespaces отвечают на вопрос «что видно», cgroups — на вопрос «сколько дадут». Без cgroups один контейнер сожрал бы всю память или CPU и положил соседей. С ними каждому выставлен потолок.

Что ограничивают контроллеры: - memory — лимит RAM. При превышении срабатывает OOM killer и процесс убивают (в логах — OOMKilled). Это самая частая причина внезапной смерти контейнера. - cpu — доля процессорного времени: --cpus, --cpu-shares (вес при конкуренции), квоты. - io (blkio) — пропускная способность диска. - pids — максимум процессов; защита от fork-бомбы.

Note

Есть две версии: cgroups v1 (раздельные иерархии на каждый контроллер) и cgroups v2 (единая унифицированная иерархия). Современные дистрибутивы и Docker давно на v2. Лимиты те же, устройство чище.

Грабли: приложение внутри контейнера часто не знает о cgroup-лимите и читает «железные» цифры хоста. JVM до версии 10 видела всю память хоста, а не лимит контейнера, и ловила OOM на ровном месте. Современные рантаймы (JVM, Go, Node) умеют читать cgroup-лимиты, но проверять это надо.


Union filesystem и слои

Union filesystem — это файловая система, которая показывает несколько каталогов (слоёв) как один объединённый, не копируя их физически.

Docker по умолчанию использует драйвер overlay2 поверх OverlayFS. Корень / контейнера — это не копия образа, а виртуальное объединение его слоёв плюс один тонкий писчий слой сверху.

Как устроен OverlayFS: - lowerdir — слои образа, все read-only. Их может быть много, они складываются стопкой. - upperdir — единственный writable-слой контейнера. Всё, что контейнер пишет, идёт сюда. - merged — то, что процесс видит как /: объединение lower + upper. - workdir — служебный каталог OverlayFS для атомарных операций.

                 merged  (то, что видит контейнер как /)
   ┌───────────────┴───────────────┐
   │  upperdir   (writable, RW)     │  ← изменения контейнера
   ├────────────────────────────────┤
   │  lowerN     (layer, RO)        │  ┐
   │  ...                           │  ├ слои образа, read-only, шарятся
   │  lower0     (base image, RO)   │  ┘   между всеми контейнерами
   └────────────────────────────────┘

Copy-on-write (COW) — ключевой приём. Пока контейнер только читает файл, он берётся из read-only слоя напрямую. Как только контейнер пишет в файл — файл целиком копируется из нижнего слоя в upperdir, и дальше правки идут уже в копии. Нижний слой не меняется.

Что из этого следует: - Десять контейнеров из одного образа делят одни и те же read-only слои на диске. Образ скачан и лежит один раз. Поэтому контейнеры дешёвые. - Запись в большой файл из нижнего слоя дорогая — сначала полное копирование (copy-up). Это плохо для нагруженного I/O; такие данные выносят в volume (см. ниже). - Writable-слой живёт ровно столько, сколько контейнер. docker rm — и все изменения внутри / исчезли. Это не баг, это дизайн: контейнер эфемерен.


Образ

Образ (image) — это иммутабельный артефакт: стопка read-only слоёв плюс манифест и config, описывающие, как из них собрать и запустить контейнер.

Образ — не один файл. Это набор объектов, адресуемых по содержимому: - Слои (layers) — tar-архивы с разницей файловой системы. Каждый слой адресуется хешем своего содержимого (sha256). Одинаковые слои в разных образах хранятся один раз. - Config — JSON: какой ENTRYPOINT/CMD, ENV, WORKDIR, какие слои в каком порядке (rootfs.diff_ids), история сборки. - Manifest — JSON, который связывает config и список слоёв в один образ под конкретную платформу.

Digest — это sha256 от содержимого. Контейнерный мир content-addressable: один и тот же digest гарантирует байт-в-байт тот же объект. Поэтому в проде надёжнее ссылаться на image@sha256:abc..., а не на тег.

Tag vs digest — это разные вещи: - Тег (nginx:1.25) — изменяемый указатель. Сегодня он на один digest, завтра мейнтейнер перетегал — на другой. latest особенно коварен. - Digest (nginx@sha256:...) — неизменяемая ссылка на конкретные биты.

Note

Различай diff_id и digest слоя. diff_id — хеш от распакованного (uncompressed) tar-слоя, используется в config. digest слоя — хеш от сжатого (gzip) блоба, используется в манифесте и при передаче по сети. Один слой — два разных хеша для двух представлений.


Dockerfile и сборка образа

Dockerfile — это рецепт сборки образа: список инструкций, каждая из которых даёт новый слой.

Каждая инструкция, меняющая файловую систему (RUN, COPY, ADD), создаёт новый read-only слой поверх предыдущего. Метаданные (ENV, WORKDIR, CMD, EXPOSE) слой не плодят — они правят config.

Полный список инструкций с короткими пояснениями — в Dockerfile.

Build cache

Билд кешируется послойно. Перед выполнением инструкции Docker проверяет: входные данные те же — берём готовый слой из кеша, не выполняем заново.

Как инвалидируется кеш: - RUN кешируется по тексту команды. Поменял строку — слой и все следующие за ним пересобираются. - COPY/ADD кешируются по контрольной сумме копируемых файлов. Изменился файл — кеш сброшен. - Сброс кеша на слое N автоматически сбрасывает кеш на всех слоях N+1, N+2... Порядок инструкций определяет, как часто ты пересобираешь.

Отсюда главное практическое правило сборки:

Клади редко меняющееся выше, часто меняющееся ниже.

Сначала ставь зависимости (COPY package.json + RUN npm ci), потом копируй исходники (COPY . .). Тогда правка кода не пересобирает слой с зависимостями — он остаётся в кеше.

Multi-stage build

Multi-stage build — это сборка в несколько стадий FROM, где финальный образ забирает из предыдущих стадий только нужные артефакты.

Зачем: на прод не должны ехать компилятор, исходники, кеши сборки. Собираешь в «толстой» стадии с тулчейном, а в финальную копируешь только бинарник.

# стадия сборки — тут есть весь Go toolchain
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server

# финальная стадия — почти пустая
FROM gcr.io/distroless/static
COPY --from=build /app /app
ENTRYPOINT ["/app"]

Результат: образ на десятки мегабайт вместо сотен, без лишней поверхности атаки. distroless/scratch базы вообще не содержат ОС в привычном смысле — ни shell, ни пакетного менеджера, только твой статический бинарник и сертификаты.


Registry

Registry — это хранилище образов с протоколом для push/pull (например Docker Hub, GitLab Registry, Harbor, ECR).

Передаётся не «файл образа», а его объекты по digest: сначала манифест, потом те слои, которых ещё нет локально. Если слой с таким digest уже есть — он не качается повторно. Поэтому образы с общей базой тянутся быстро.

Поток: - docker build -t registry/app:1.0 . — собрал, навесил тег. - docker push registry/app:1.0 — залил манифест и недостающие слои. - docker pull registry/app:1.0 — на другой ноде стянул то, чего нет локально.

В CI/CD один и тот же digest проходит весь путь: собрался в CI → протестирован → тот же digest едет в прод. Исчезает класс багов «в CI прошло, на проде упало из-за окружения».


Что под капотом: dockerd, containerd, runc

docker в терминале — это всего лишь CLI. За ним стоит цепочка демонов и стандартов. Понимать её полезно: половина проблем в проде — это «кто из них лёг».

docker (CLI)
   │  REST API через unix socket
dockerd              высокоуровневый демон: API, образы, сети, volumes
   │  gRPC
containerd           container runtime: управляет жизненным циклом, pull образов
   │  создаёт по одному shim на контейнер
containerd-shim      переживает рестарт containerd, держит stdio и PID контейнера
   │  вызывает
runc                 low-level OCI runtime: настраивает namespaces+cgroups, делает exec
[ процесс контейнера ]   обычный процесс на ядре хоста

Кто за что отвечает: - dockerd — то, с чем говорит CLI. Образы, сети, volumes, build, высокоуровневое API. - containerd — собственно container runtime. Тянет образы, разворачивает слои, управляет жизненным циклом. Может работать и без Docker (его напрямую использует Kubernetes). - containerd-shim — по одному на контейнер. Главное: он остаётся родителем процесса контейнера, поэтому dockerd/containerd можно перезапустить, а контейнеры продолжают жить. - runc — эталонная реализация OCI runtime. Делает грязную работу: clone() с нужными namespaces, настройка cgroups, pivot_root, и exec твоего процесса. Сделав своё, runc завершается — он не висит рядом с контейнером.

Что происходит при docker run nginx: 1. CLI шлёт запрос в dockerd. 2. dockerd просит containerd создать контейнер; нужный образ при отсутствии тянется из registry. 3. containerd разворачивает слои в OverlayFS, готовит спецификацию OCI и поднимает shim. 4. shim зовёт runc; тот создаёт namespaces и cgroups, делает корнем merged-каталог и запускает процесс nginx. 5. runc уходит, shim остаётся родителем nginx.


Жизненный цикл контейнера и PID 1

Контейнер проходит понятные состояния:

created ──start──▶ running ──stop──▶ exited ──rm──▶ (удалён)
                     │  ▲                │
                  pause│ │unpause        │
                     ▼  │                │
                   paused             (restart policy может поднять заново)
  • created — контейнер создан (docker create), но процесс ещё не запущен.
  • running — главный процесс работает.
  • paused — процессы заморожены через cgroup freezer, память на месте.
  • exited — процесс завершился (сам или по сигналу), writable-слой ещё цел.

PID 1 — главная грабля

Главный процесс контейнера получает PID 1, и это не просто номер. В Linux у PID 1 особая роль, о которой обычные приложения не знают.

Две проблемы: - Сигналы. Ядро не применяет к PID 1 дефолтную обработку сигналов. Если приложение не ставит свой обработчик SIGTERM, то docker stop ему ничего не сделает — и через --time (по умолчанию 10 секунд) контейнер прибьют SIGKILL. Отсюда долгие остановки и потерянные graceful shutdown. - Зомби. PID 1 обязан «пожинать» (reap) осиротевших детей-зомби. Обычное приложение этого не делает — зомби копятся в таблице процессов.

Решения: - Запускать процесс через exec (форма ENTRYPOINT ["app"], не ENTRYPOINT app), чтобы он реально стал PID 1, а не висел под /bin/sh -c, который сигналы не пробрасывает. - Если процесс плодит детей — добавить мини-init: флаг docker run --init (подкладывает tini) или явный init в образе.

Note

Форма ENTRYPOINT app (shell form) запускает приложение как /bin/sh -c "app". Тогда PID 1 — это sh, а твоё приложение — его ребёнок, и SIGTERM до него не доходит. Почти всегда нужна exec-форма с JSON-массивом.


Volumes и данные

Writable-слой контейнера эфемерен: умер контейнер — данные ушли. Для состояния, которое должно пережить контейнер, есть отдельные механизмы.

  • Named volumeтом, которым управляет Docker (/var/lib/docker/volumes/...). Это рекомендованный способ хранить состояние. Переживает docker rm, легко переносится между контейнерами, не зависит от структуры хоста.
  • Bind mountпроброс конкретного пути хоста внутрь контейнера. Удобно в разработке (код с хоста внутрь), но жёстко привязывает контейнер к файловой системе хоста.
  • tmpfs mountданные в RAM, на диск не пишутся. Для секретов и временных файлов, которые не должны переживать контейнер.

Зачем вообще volume, а не writable-слой: - Данные переживают пересоздание контейнера — основа атомарного деплоя (поднял новый образ, том тот же). - Запись идёт мимо OverlayFS напрямую в файловую систему хоста — без дорогого copy-up, нормальный I/O для баз.

Грабли stateful: именно поэтому БД в контейнерах — спорная история. Появляются вопросы с производительностью диска, fsync, бэкапами тома. Многие осознанно держат БД на bare metal или managed, а в контейнерах гоняют только stateless-сервисы. Подробнее — в Why Docker.


Networking

У контейнера свой network namespace: свой lo, свои интерфейсы, свои порты. Связь с хостом и другими контейнерами Docker строит сам. Режим задаётся драйвером сети.

  • bridge (по умолчанию) — Docker поднимает виртуальный мост docker0 на хосте. Каждый контейнер получает veth-пару: один конец внутри контейнера как eth0, другой воткнут в мост. Контейнеры в одной bridge-сети видят друг друга по внутренним IP.
  • host — контейнер делит network namespace с хостом. Своего сетевого стека нет, порты публикуются напрямую. Быстрее (нет NAT), но нет изоляции и конфликтуют порты.
  • none — только lo, наружу никак. Полная сетевая изоляция.
  • overlay — сеть поверх нескольких хостов (для Swarm/кластера); контейнеры на разных нодах в одной L2-подсети.
        ┌─────────── host ───────────┐
        │   eth0 (внешний IP)         │
        │     │  iptables NAT/DNAT    │
        │   docker0  10.0.0.1         │   bridge
        │    │        │               │
        │  veth     veth              │
        │   │         │               │
        │ ┌─┴───┐  ┌──┴──┐            │
        │ │ctr A│  │ctr B│            │
        │ │eth0 │  │eth0 │            │
        │ │.0.2 │  │.0.3 │            │
        │ └─────┘  └─────┘            │
        └─────────────────────────────┘

Port publishing (-p 8080:80) — это правило DNAT в iptables: трафик на порт 8080 хоста ядро перенаправляет на порт 80 контейнера. Исходящий трафик контейнера наружу маскируется под IP хоста (MASQUERADE). То есть контейнер по умолчанию недоступен снаружи, пока порт явно не опубликован.

DNS и service discovery: в пользовательской bridge-сети Docker поднимает встроенный DNS, и контейнеры резолвят друг друга по имени (db, redis), а не по IP. На дефолтной bridge этого нет — там только IP. Поэтому под compose всегда создаётся отдельная сеть.


Изоляция — это не безопасность

Самое важное честное место. Docker изолирует окружения, но это не граница безопасности уровня VM.

Причина — общий kernel. Namespaces и cgroups скрывают и ограничивают, но все контейнеры делают системные вызовы к одному и тому же ядру хоста. Дыра в ядре = побег из контейнера на хост.

Что ядро и Docker всё же делают для защиты: - Capabilities — root внутри контейнера урезан. По умолчанию выкинуты опасные capabilities (CAP_SYS_ADMIN и прочие), так что «root в контейнере» ≠ «root на хосте». - seccomp — фильтр системных вызовов. Дефолтный профиль Docker блокирует десятки опасных syscall. - user namespace — отображает root контейнера (UID 0) в непривилегированного пользователя хоста. Сильно снижает урон от побега, но включён не везде по умолчанию. - Linux Security Modules — AppArmor / SELinux профили поверх.

Когда этого мало: - Недоверенный код, multi-tenant с разными уровнями доверия → нужна настоящая граница: микро-VM через gVisor (перехватывает syscall в user space) или Kata Containers (каждый контейнер в лёгкой VM).

Не продавай Docker как security-фичу. Для воспроизводимости окружения — отлично. Для изоляции враждебного кода — он не для этого.


OCI — почему всё это совместимо

OCI (Open Container Initiative) — это набор стандартов, благодаря которым образы и рантаймы разных вендоров совместимы между собой.

Три спецификации: - Image spec — формат образа: слои, манифест, config. Образ, собранный Docker, запустит любой OCI-совместимый рантайм. - Runtime spec — как из распакованного образа и JSON-конфига запускать контейнер. Это контракт, который реализует runc (и альтернативы — crun, gVisor, Kata). - Distribution spec — протокол registry для push/pull.

Практический смысл: «Docker-образ» давно не привязан к Docker. Тот же образ тянет и запускает Kubernetes через containerd напрямую, без dockerd вообще. Docker дал формат — экосистема его подхватила и стандартизировала.


Куда дальше: оркестрация

Один Docker на одной машине — это упаковка и запуск. Настоящая отказоустойчивость начинается, когда поверх контейнеров встаёт оркестратор (Kubernetes, Nomad, Swarm). Он умеет то, чего голый Docker не умеет:

  • Self-healing — нода умерла → её контейнеры поднимаются на других. Не прошёл healthcheck → перезапуск.
  • Декларативное желаемое состояние — ты говоришь «хочу 3 реплики всегда», оркестратор постоянно сверяет факт с желаемым и чинит расхождения. Это reconciliation loop — основа всей надёжности.
  • Rolling updates без даунтайма и автоматический rollback, если новая версия не взлетела.
  • Горизонтальное масштабирование и bin-packing по свободным CPU/RAM.

Всё это возможно ровно потому, что контейнер — стандартная единица планирования: любой сервис упакован одинаково (запустить = создать контейнер, проверить = healthcheck, убить = stop). k8s — отдельный топик, своя директория будет рядом.

Главное правило: контейнер — это процесс, которому ядро наврало про мир. namespaces решают, что он видит; cgroups — сколько ему дают; слои — из чего собран его корень. Всё остальное в Docker построено вокруг этих трёх вещей.