Docker под капотом: namespaces, слои и BuildKit
Docker кажется магией одной команды, но под ним — три примитивных механизма ядра Linux, которые существовали задолго до Docker. Когда контейнер ведёт себя странно — «видит» чужие процессы, съедает всю память, файл «удалён», но секрет утёк, — ответ всегда лежит в этих механизмах. В краткой версии ты видел слои и кэш; здесь разбираем всё до уровня системных вызовов.
Три кита: namespaces, cgroups, UnionFS
Заголовок раздела «Три кита: namespaces, cgroups, UnionFS»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 # 268435456cat /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: по шагам
Заголовок раздела «Что происходит при docker run: по шагам»docker run -d --name app -p 8080:3000 -e NODE_ENV=prod myapp:1.4.2- Парсинг и defaults. Docker CLI шлёт запрос демону. Имя
appрезервируется. - Образ. Если
myapp:1.4.2нет локально — pull: скачиваются только отсутствующие слои (каждый идентифицируется по sha256 содержимого). - Контейнер-слой. Создаётся тонкий writable-слой поверх образа — в нём будут все изменения процесса.
- Namespaces + cgroups. Демон просит ядро создать namespace’ы (см. таблицу) и cgroup с лимитами из флагов.
- Сеть. Создаётся veth-пара: один конец в bridge
docker0(или пользовательском bridge), второй — «внутри» контейнера какeth0. Правило-p 8080:3000— это DNAT-правило iptables/nftables на хосте:8080 → контейнерный_IP:3000. - Монтирования. tmpfs на
/run/secrets, bind-mounts из-v, ro-маски из--read-only. - Exec. Запускается ENTRYPOINT+CMD через
runc→containerd→ execve внутри нового PID namespace. Процесс становится PID 1. - Захват потоков. 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-alpineCOPY . /appWORKDIR /appRUN npm ci
# Хорошо: правка src/ пересобирает только последние два слоя — сборка 15 секундFROM node:22-alpineWORKDIR /appCOPY package.json package-lock.json ./RUN npm ci --omit=devCOPY src ./srcpackage-lock.json копируется отдельно не случайно: lock-файл меняется реже исходников, но чаще, чем base-образ. Три строки COPY вместо одной — типичная плата за точный кэш.
BuildKit: современный билдер
Заголовок раздела «BuildKit: современный билдер»BuildKit — билдер по умолчанию с Docker 23. Что он даёт:
- Сборка слоёв независимо и лениво: только нужные для текущего target ветки.
- Cache mounts: кэши инструментов живут между сборками вне слоёв.
- Secret mounts: секреты монтируются на время сборки и не попадают в слои.
- SBOM и provenance из коробки.
# syntax=docker/dockerfile:1FROM node:22-alpine AS buildWORKDIR /appCOPY package.json package-lock.json ./# Кэш npm между сборками — слой не инвалидируетсяRUN --mount=type=cache,target=/root/.npm \ npm ciCOPY . .# Секрет для приватного пакета: монтируется, не запекаетсяRUN --mount=type=secret,id=npmrc,target=/root/.npmrc \ npm run buildОбрати внимание на директиву # syntax первой строкой — без неё новые mount-инструкции не работают. Секрет попадает в билд через docker build --secret id=npmrc,src=$HOME/.npmrc ., в слоях его нет — проверяется через docker history.
Multi-stage: полный Node.js-образ
Заголовок раздела «Multi-stage: полный Node.js-образ»Полноценный production-Dockerfile, который собирает TS-приложение и оставляет минимум:
# syntax=docker/dockerfile:1
# --- Стадия deps: только production-зависимости ---FROM node:22-alpine AS depsWORKDIR /appCOPY package.json package-lock.json ./RUN --mount=type=cache,target=/root/.npm \ npm ci --omit=dev
# --- Стадия build: компиляция TS ---FROM node:22-alpine AS buildWORKDIR /appCOPY package.json package-lock.json ./RUN --mount=type=cache,target=/root/.npm \ npm ciCOPY tsconfig.json ./COPY src ./srcRUN npm run build && npm prune --omit=dev
# --- Финальный образ: рантайм ---FROM node:22-alpine AS runtimeENV NODE_ENV=productionWORKDIR /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_modulesCOPY --from=build --chown=app:app /app/dist ./distCOPY --chown=app:app package.json ./
USER appEXPOSE 3000HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \ CMD curl -sf http://localhost:3000/health || exit 1ENTRYPOINT ["/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.
.dockerignore и секреты
Заголовок раздела «.dockerignore и секреты».git.gitignorenode_modulesdistcoverage*.log.env.env.*!.env.exampleDockerfiledocker-compose*.ymldocstests.dockerignore работает как .gitignore для контекста сборки: COPY . . не увидит исключённого. Три причины держать его строгим: скорость (контекст не тащит гигабайты), кэш (изменение мусорных файлов инвалидирует слои), безопасность (.env и id_rsa не попадут в слой).
Сборка с аргументами и таймстемпами
Заголовок раздела «Сборка с аргументами и таймстемпами»ARG — переменные времени сборки. Их главные опасности: значение запекается в слой и видно в docker history; и каждое уникальное значение ARG вверху Dockerfile сбрасывает весь кэш ниже.
FROM node:22-alpine AS buildARG APP_VERSION=dev# ARG-подстановка происходит ДО выполнения: строка попадёт в историю слояRUN echo "export const VERSION = '$APP_VERSION';" > src/version.tsdocker 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: рентген образа»Dive показывает содержимое каждого слоя и оценивает эффективность:
dive myapp:1.4.2# Tab — переключение слоёв/дерева файлов# Ищи: файлы, которые появились в слое и были удалены выше# «Wasted space»: кэши apt/apk/npm в слоях, дублирующиеся зависимостиТиповые находки: /root/.npm в слое npm ci (сотни мегабайт), *.log и dist от локалки, скопированные .git (в ней история коммитов, иногда с секретами в прошлом), devDependencies в node_modules финального образа. Правило: эффективность образа (колонка в Dive) ≥ 95 %, кэши пакетных менеджеров удалены в той же инструкции, что и установка.
Уменьшение образа: системный подход
Заголовок раздела «Уменьшение образа: системный подход»По убыванию эффекта:
- Multi-stage — самый большой выигрыш: -60–80 % объёма (исходники, компиляторы, devDeps остаются в build-стадиях).
- Минимальный base:
alpine(5 МБ) vsslim(debian без лишнего, ~30 МБ) vs полный образ (~80–1000 МБ). Для node —node:22-alpine. Для go/rust —FROM scratchилиgcr.io/distroless. - Один RUN для установки: все пакеты и их кэши в одном слое с очисткой.
- Точечный COPY вместо
COPY . .— копируй только нужное:COPY src ./src,COPY package*.json ./. - .dockerignore — не тащить мусор в контекст.
- dive-аудит после сборки — найти остатки.
Измеряй: docker images до/после, docker history --no-trunc образ для поиска толстых слоёв.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»COPY . .доnpm install. Каждая правка кода инвалидирует установку зависимостей. Сначала lock-файлы, потом install, потом исходники.- Приложение слушает
127.0.0.1. Внутри контейнера это его собственный loopback — порт не виден снаружи. Всегда0.0.0.0внутри контейнера; ограничивать доступ — через сети/фаервол хоста. - Секрет через ARG/COPY.
ARG DB_PASSWORDвиден вdocker historyвсем, у кого есть образ. Только BuildKit secret mounts или монтирование при запуске. CMD "node", "server.js"строкой вместо exec-формы. Строковый CMD оборачивает процесс в/bin/sh -c, и сигналы не доходят до node —docker stopждёт таймаут и убивает SIGKILL. Только exec-формаCMD ["node", "server.js"]+ tini для PID 1.- Нет
.dockerignore→.envв образе. Уже обсудили: слои аддитивны. Один инцидент — и токены утекают всем, кто имеет pull-доступ к registry. - Лимит памяти ставят «по интуиции». 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 для размеров слоёв.
Практика
Заголовок раздела «Практика»- Кэш-эксперимент. Собери Dockerfile с
COPY . .доnpm ci. Засеки время. Перепиши правильно (lock-файлы → install → исходники), поправь одну строку вsrc/, пересобери с--progress=plain. Критерий:npm ciвзялся из кэша, итоговая сборка < 20 % от первой. - Multi-stage с нуля. Напиши Dockerfile для TS-приложения по образцу из главы, но без подглядывания: три стадии, tini, USER, HEALTHCHECK. Критерий:
docker images— финал < 250 МБ;docker exec контейнер idпоказывает не-root. - Поймай секрет. Создай
.envс тестовым токеном, собери образ без.dockerignore, потом добавьRUN rm .env. Докажи, что токен всё ещё в образе: распакуйdocker saveили используй промежуточный образ. Потом исправь через.dockerignoreи повтори проверку — токена нет. - Dive-аудит. Прогони свой образ через Dive, выпиши топ-3 источника мусора, устрани, пересобери. Критерий: эффективность образа ≥ 95 %, размер сократился минимум на 30 %.
- OOM-исследование. Запусти контейнер с
--memory=64mи заставь его есть память (node-скрипт с массивом). Наблюдайdocker inspectпосле смерти:OOMKilled: true,ExitCode: 137. Зафиксируй связь лимита, кода 137 и journald-хоста.
Что почитать
Заголовок раздела «Что почитать»- namespaces(7) и cgroups(7) — механизмы ядра, на которых всё стоит
- Dockerfile reference — все инструкции с нюансами кэша
- BuildKit README и dockerfile docs — cache mounts, secret mounts, SBOM
- Dive — README — интерфейс и оценка эффективности
- overlay2 — как Docker хранит слои — whiteout-файлы, copy-up