Перейти к содержимому

Docker под капотом: namespaces, слои и BuildKit

Docker кажется магией одной команды, но под ним — три примитивных механизма ядра Linux, которые существовали задолго до Docker. Когда контейнер ведёт себя странно — «видит» чужие процессы, съедает всю память, файл «удалён», но секрет утёк, — ответ всегда лежит в этих механизмах. В краткой версии ты видел слои и кэш; здесь разбираем всё до уровня системных вызовов.

Namespaces отвечают на вопрос «что процесс видит». Когда docker run стартует контейнер, ядро создаёт новые namespace’ы:

Namespace Изолирует Эффект в контейнере
pid процессы PID 1 — твой ENTRYPOINT, остальные процессы хоста невидимы
net сеть свой loopback, свой список интерфейсов и портов
mnt точки монтирования свой корень / — образ + слои
uts hostname контейнер может называться как угодно, хост не пострадает
ipc разделяемую память/семафоры два контейнера не видят shm друг друга
user UID/GID root (uid 0) внутри может быть uid 100000 снаружи

Проверь сам: docker run --rm alpine cat /proc/1/status | grep NSpid — увидишь одно значение в контейнере, а docker inspect -f '{{.State.Pid}}' покажет реальный PID на хосте.

cgroups (control groups) v2 отвечают на вопрос «сколько процесс может съесть». Флаги --memory, --cpus, --pids-limit — это записи в /sys/fs/cgroup/docker/<id>/:

Окно терминала
docker run -d --memory=256m --cpus=0.5 --pids-limit=100 nginx
# Docker создал cgroup: /sys/fs/cgroup/system.slice/docker-<id>.scope/
cat /sys/fs/cgroup/system.slice/docker-<id>.scope/memory.max # 268435456
cat /sys/fs/cgroup/system.slice/docker-<id>.scope/cpu.max # 50000 100000

Когда приложение лезет за лимит памяти, ядро вызывает OOM-killer — и он убивает процесс внутри контейнера. docker inspect покажет OOMKilled: true — вот и весь диагноз. Контейнер при этом может «успешно» перезапуститься по политике restart, маскируя утечку.

UnionFS (overlay2) — файловая система слоёв. Каждый слой образа — каталог с файлами; overlay2 накладывает их друг на друга: нижние слои — только чтение, верхний — writable container layer. Удаление файла не стирает его из нижнего слоя — создаётся «whiteout»-маркер, который скрывает файл сверху (механика подробно разобрана в документации overlay2). Отсюда главное следствие безопасности: удаление секрета в следующем слое не удаляет секрет — он остаётся в нижнем слое и доступен любому, кто сделает docker create от промежуточного образа.

Окно терминала
docker run -d --name app -p 8080:3000 -e NODE_ENV=prod myapp:1.4.2
  1. Парсинг и defaults. Docker CLI шлёт запрос демону. Имя app резервируется.
  2. Образ. Если myapp:1.4.2 нет локально — pull: скачиваются только отсутствующие слои (каждый идентифицируется по sha256 содержимого).
  3. Контейнер-слой. Создаётся тонкий writable-слой поверх образа — в нём будут все изменения процесса.
  4. Namespaces + cgroups. Демон просит ядро создать namespace’ы (см. таблицу) и cgroup с лимитами из флагов.
  5. Сеть. Создаётся veth-пара: один конец в bridge docker0 (или пользовательском bridge), второй — «внутри» контейнера как eth0. Правило -p 8080:3000 — это DNAT-правило iptables/nftables на хосте: 8080 → контейнерный_IP:3000.
  6. Монтирования. tmpfs на /run/secrets, bind-mounts из -v, ro-маски из --read-only.
  7. Exec. Запускается ENTRYPOINT+CMD через runccontainerd → execve внутри нового PID namespace. Процесс становится PID 1.
  8. Захват потоков. stdout/stderr процесса подключаются к log-драйверу (json-file/journald).

Шаг 5 объясняет классическую путаницу: «я слушаю 0.0.0.0:3000 в контейнере, а снаружи не доступен». Необходимо ДВА условия: приложение слушает 0.0.0.0 (а не 127.0.0.1 — внутри net namespace 127.0.0.1 это ЛОКАЛЬНЫЙ интерфейс контейнера!) и опубликован порт -p.

Каждая инструкция FROM, RUN, COPY, ADD — новый слой. Кэш срабатывает, если не изменились: сама инструкция И все слои выше. Отсюда закон: неизменное сверху, изменчивое снизу.

# Плохо: любая правка в src/ пересобирает npm ci — сборка 3 минуты
FROM node:22-alpine
COPY . /app
WORKDIR /app
RUN npm ci
# Хорошо: правка src/ пересобирает только последние два слоя — сборка 15 секунд
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src ./src

package-lock.json копируется отдельно не случайно: lock-файл меняется реже исходников, но чаще, чем base-образ. Три строки COPY вместо одной — типичная плата за точный кэш.

BuildKit — билдер по умолчанию с Docker 23. Что он даёт:

  • Сборка слоёв независимо и лениво: только нужные для текущего target ветки.
  • Cache mounts: кэши инструментов живут между сборками вне слоёв.
  • Secret mounts: секреты монтируются на время сборки и не попадают в слои.
  • SBOM и provenance из коробки.
# syntax=docker/dockerfile:1
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
# Кэш npm между сборками — слой не инвалидируется
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
# Секрет для приватного пакета: монтируется, не запекается
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm run build

Обрати внимание на директиву # syntax первой строкой — без неё новые mount-инструкции не работают. Секрет попадает в билд через docker build --secret id=npmrc,src=$HOME/.npmrc ., в слоях его нет — проверяется через docker history.

Полноценный production-Dockerfile, который собирает TS-приложение и оставляет минимум:

# syntax=docker/dockerfile:1
# --- Стадия deps: только production-зависимости ---
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
# --- Стадия build: компиляция TS ---
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build && npm prune --omit=dev
# --- Финальный образ: рантайм ---
FROM node:22-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
# Системные зависимости одним слоем + очистка кэша пакетного менеджера
RUN apk add --no-cache tini curl \
&& addgroup -S app && adduser -S app -G app
COPY --from=deps --chown=app:app /app/node_modules ./node_modules
COPY --from=build --chown=app:app /app/dist ./dist
COPY --chown=app:app package.json ./
USER app
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD curl -sf http://localhost:3000/health || exit 1
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/server.js"]

Разбор приёмов:

  • Две сборочные стадии (deps/build) — финальный образ не знает ни о devDependencies, ни о tsconfig, ни об исходниках. npm prune --omit=dev в build-стадии ужимает node_modules до prod-набора ещё до копирования.
  • tini — init-процесс: node не умеет быть PID 1 корректно (игнорирует сигналы, не реапит зомби). Tini принимает SIGTERM и передаёт детям, собирает зомби. Без него docker stop ждёт 10 секунд таймаута и убивает SIGKILL.
  • HEALTHCHECK — встроенная проверка; docker ps покажет (healthy), а оркестраторы смогут на неё опираться.
  • addgroup/adduser — не root внутри контейнера. Даже если процесс скомпрометирован, uid 100 в контейнере — это uid 100 на хосте, без root-привилегий.
  • Один RUN с apk — каждый RUN — слой; кэш apk останется в слое, если не удалить его в той же инструкции. --no-cache у apk решает это на стороне apk.
.git
.gitignore
node_modules
dist
coverage
*.log
.env
.env.*
!.env.example
Dockerfile
docker-compose*.yml
docs
tests

.dockerignore работает как .gitignore для контекста сборки: COPY . . не увидит исключённого. Три причины держать его строгим: скорость (контекст не тащит гигабайты), кэш (изменение мусорных файлов инвалидирует слои), безопасность (.env и id_rsa не попадут в слой).

ARG — переменные времени сборки. Их главные опасности: значение запекается в слой и видно в docker history; и каждое уникальное значение ARG вверху Dockerfile сбрасывает весь кэш ниже.

FROM node:22-alpine AS build
ARG APP_VERSION=dev
# ARG-подстановка происходит ДО выполнения: строка попадёт в историю слоя
RUN echo "export const VERSION = '$APP_VERSION';" > src/version.ts
Окно терминала
docker build \
--build-arg APP_VERSION="1.4.2+$(git rev-parse --short HEAD)" \
-t myapp:1.4.2 .

Таймстемп — отдельная ловушка: RUN echo $(date) > build-time делает слой уникальным при каждой сборке, убивая кэш всего, что ниже. Таймстемп должен приходить снаружи как ARG (и только в финальную стадию), либо не запекаться вовсе — передаваться через environment при запуске.

Dive показывает содержимое каждого слоя и оценивает эффективность:

Окно терминала
dive myapp:1.4.2
# Tab — переключение слоёв/дерева файлов
# Ищи: файлы, которые появились в слое и были удалены выше
# «Wasted space»: кэши apt/apk/npm в слоях, дублирующиеся зависимости

Типовые находки: /root/.npm в слое npm ci (сотни мегабайт), *.log и dist от локалки, скопированные .git (в ней история коммитов, иногда с секретами в прошлом), devDependencies в node_modules финального образа. Правило: эффективность образа (колонка в Dive) ≥ 95 %, кэши пакетных менеджеров удалены в той же инструкции, что и установка.

По убыванию эффекта:

  1. Multi-stage — самый большой выигрыш: -60–80 % объёма (исходники, компиляторы, devDeps остаются в build-стадиях).
  2. Минимальный base: alpine (5 МБ) vs slim (debian без лишнего, ~30 МБ) vs полный образ (~80–1000 МБ). Для node — node:22-alpine. Для go/rust — FROM scratch или gcr.io/distroless.
  3. Один RUN для установки: все пакеты и их кэши в одном слое с очисткой.
  4. Точечный COPY вместо COPY . . — копируй только нужное: COPY src ./src, COPY package*.json ./.
  5. .dockerignore — не тащить мусор в контекст.
  6. dive-аудит после сборки — найти остатки.

Измеряй: docker images до/после, docker history --no-trunc образ для поиска толстых слоёв.

  1. COPY . . до npm install. Каждая правка кода инвалидирует установку зависимостей. Сначала lock-файлы, потом install, потом исходники.
  2. Приложение слушает 127.0.0.1. Внутри контейнера это его собственный loopback — порт не виден снаружи. Всегда 0.0.0.0 внутри контейнера; ограничивать доступ — через сети/фаервол хоста.
  3. Секрет через ARG/COPY. ARG DB_PASSWORD виден в docker history всем, у кого есть образ. Только BuildKit secret mounts или монтирование при запуске.
  4. CMD "node", "server.js" строкой вместо exec-формы. Строковый CMD оборачивает процесс в /bin/sh -c, и сигналы не доходят до node — docker stop ждёт таймаут и убивает SIGKILL. Только exec-форма CMD ["node", "server.js"] + tini для PID 1.
  5. Нет .dockerignore.env в образе. Уже обсудили: слои аддитивны. Один инцидент — и токены утекают всем, кто имеет pull-доступ к registry.
  6. Лимит памяти ставят «по интуиции». OOM-killer убивает процесс молча, restart-политика маскирует утечку неделями. Метод: снять docker stats под реальной нагрузкой, поставить лимит с запасом 30 %, настроить алерт на OOMKilled.

Чем контейнер отличается от виртуальной машины? VM эмулирует железо и грузит свое ядро гостевой ОС. Контейнер — процессы хоста, изолированные namespaces (видимость) и ограниченные cgroups (ресурсы), использующие общее ядро хоста. Поэтому контейнер стартует миллисекунды и весит мегабайты, а VM — секунды и гигабайты. Цена — общее ядро: ядро хоста компрометируется — все контейнеры компрометированы.

Объясни жизненный цикл контейнера от docker run до остановки. Демон: скачивает/находит образ, создаёт writable-слой, просит ядро создать namespaces и cgroup с лимитами, настраивает veth-пару в bridge и DNAT для портов, монтирует секреты/тома, запускает ENTRYPOINT через containerd/runc как PID 1 в новом pid namespace, подключает stdout к log-драйверу. При остановке — SIGTERM, пауза (10с по умолчанию), SIGKILL; writable-слой удаляется, если нет --rm-исключений… точнее: слой остаётся до docker rm.

Почему удаление файла в следующем слое не удаляет его из образа? Слои overlay2 аддитивны и read-only нижние. Удаление создаёт whiteout-файл, скрывающий имя сверху; содержимое остаётся в нижнем слое. Отсюда правило: секрет, попавший в слой, из образа не удаляется. Чистота только на этапе сборки.

Что делает BuildKit и чем он лучше классического билдера? Параллельная/ленивая сборка независимых веток, cache mounts (кэши инструментов вне слоёв), secret mounts (секреты не запекаются), SBOM/provenance для цепочки поставок. Старый билдер — последовательный, секреты только через ARG с утечкой в history.

Как работает кэш слоёв и когда он инвалидируется? Кэш адресуется по хешу инструкции + всех родительских слоёв. Любое изменение инструкции или чего-либо выше по Dockerfile делает недействительным кэш этой инструкции и всего ниже. COPY/ADD хэшируют содержимое копируемых файлов. Поэтому порядок — от статичного (base, lock-файлы) к изменчивому (исходники).

Зачем tini в контейнере? PID 1 в Linux имеет особые обязанности: реапить зомби и пробрасывать сигналы. Node/python-сервер этого не умеют. Без tini docker stop шлёт SIGTERM в пустоту, ждёт таймаут, убивает SIGKILL — грязное завершение, битые транзакции. Tini — 20-килобайтный init, решающий обе проблемы.

Как найти, что раздувает образ? dive образ — содержимое по слоям с оценкой эффективности. В дереве файлов ищи: кэши пакетных менеджеров (/root/.npm, /var/cache/apt), .git, devDependencies, скопированные .env/*.log. Плюс docker history --no-trunc для размеров слоёв.

  1. Кэш-эксперимент. Собери Dockerfile с COPY . . до npm ci. Засеки время. Перепиши правильно (lock-файлы → install → исходники), поправь одну строку в src/, пересобери с --progress=plain. Критерий: npm ci взялся из кэша, итоговая сборка < 20 % от первой.
  2. Multi-stage с нуля. Напиши Dockerfile для TS-приложения по образцу из главы, но без подглядывания: три стадии, tini, USER, HEALTHCHECK. Критерий: docker images — финал < 250 МБ; docker exec контейнер id показывает не-root.
  3. Поймай секрет. Создай .env с тестовым токеном, собери образ без .dockerignore, потом добавь RUN rm .env. Докажи, что токен всё ещё в образе: распакуй docker save или используй промежуточный образ. Потом исправь через .dockerignore и повтори проверку — токена нет.
  4. Dive-аудит. Прогони свой образ через Dive, выпиши топ-3 источника мусора, устрани, пересобери. Критерий: эффективность образа ≥ 95 %, размер сократился минимум на 30 %.
  5. OOM-исследование. Запусти контейнер с --memory=64m и заставь его есть память (node-скрипт с массивом). Наблюдай docker inspect после смерти: OOMKilled: true, ExitCode: 137. Зафиксируй связь лимита, кода 137 и journald-хоста.