Слои, кэш сборки и multi-stage build

Практический вопрос, где кандидату дают «плохой» Dockerfile и просят объяснить, что с ним не так.

Вопрос: «Сборка образа занимает 8 минут при изменении одной строчки кода. Итоговый образ весит 1.4 ГБ. Что вы измените?»

Что на самом деле проверяет интервьюер

Это вопрос про модель слоёв. Без неё невозможно объяснить ни поведение кэша, ни размер образа, ни то, почему удалённый в следующем слое файл всё равно занимает место. Кандидат, который понимает слои, обычно сам предлагает multi-stage build и правильный порядок инструкций — и это ровно то, чего ждут.

Развёрнутый ответ: что такое слой

Каждая инструкция RUN, COPY и ADD создаёт новый слой — набор изменений файловой системы относительно предыдущего состояния. Слои неизменяемы и адресуются по хэшу содержимого. Инструкции вроде ENV, WORKDIR, CMD меняют только метаданные и слой не добавляют.

Из неизменяемости следует главное правило: удаление файла в следующем слое не уменьшает образ. Слой с файлом уже зафиксирован, поверх лишь ставится «маркер удаления» (whiteout), а байты остаются в образе — и их сможет достать любой, кто скачает образ. Именно так утекают секреты, «удалённые» следующей строкой.

# ПЛОХО: ключ навсегда останется в первом слое
COPY id_rsa /root/.ssh/id_rsa
RUN git clone git@github.com:acme/app.git
RUN rm /root/.ssh/id_rsa

# ПЛОХО: apt-кэш попал в слой и остался там
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# ХОРОШО: очистка в том же слое, где появился мусор
RUN apt-get update \
 && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*

Как работает кэш сборки

Перед выполнением инструкции Docker проверяет, есть ли уже слой, собранный из того же родительского слоя той же инструкцией. Для COPY и ADD дополнительно сравнивается контрольная сумма копируемых файлов. Если совпало — слой берётся из кэша. Как только одна инструкция не попала в кэш, все последующие пересобираются заново: их родитель изменился.

Отсюда — главный приём: от редко меняющегося к часто меняющемуся. Зависимости меняются раз в неделю, код — двадцать раз в день, значит установка зависимостей должна идти раньше копирования кода.

# ПЛОХО: любая правка кода инвалидирует npm ci (это и есть 8 минут)
FROM node:20
WORKDIR /app
COPY . .
RUN npm ci
CMD ["node", "server.js"]

# ХОРОШО: слой с зависимостями переиспользуется, пока не менялся package-lock.json
FROM node:20
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
CMD ["node", "server.js"]

Второй обязательный элемент — .dockerignore. Без него в контекст сборки уезжают .git, node_modules и локальные артефакты: контекст раздувается, а COPY . . ломает кэш при любом изменении любого мусорного файла.

.git
node_modules
dist
*.log
.env

Multi-stage build: как уменьшить образ в десять раз

Для сборки нужны компилятор, заголовочные файлы, dev-зависимости и тесты. Для работы приложения — только бинарник или собранные ассеты. Multi-stage build позволяет собрать в одном образе, а в финальный перенести только результат: всё, что осталось в предыдущих стадиях, в итоговый образ не попадает.

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 /out/app ./cmd/api

FROM gcr.io/distroless/static:nonroot
COPY --from=build /out/app /app
USER nonroot
EXPOSE 8080
ENTRYPOINT ["/app"]

Первая стадия весит около 900 МБ, финальный образ — порядка 15 МБ. Кроме размера это даёт и безопасность: в distroless нет shell, пакетного менеджера и утилит, а значит и почти нет поверхности атаки, и сканеры уязвимостей находят на порядок меньше CVE.

Тот же приём для фронтенда: собираем в node, отдаём из nginx.

FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

Чек-лист ответа на исходный вопрос

  • Разнести COPY манифестов зависимостей и COPY кода — так кэш зависимостей переживает правки кода.
  • Добавить .dockerignore, чтобы контекст сборки не тянул .git и node_modules.
  • Перевести сборку на multi-stage и брать в финальный образ минимальную базу (alpine, slim, distroless).
  • Объединять установку и очистку в одну инструкцию RUN.
  • Фиксировать теги базовых образов (node:20.11-alpine, а не node:latest) — иначе сборка невоспроизводима.
  • В CI подключить кэш сборщика (--cache-from или BuildKit cache), потому что у runner-а кэша нет по умолчанию.

Типичные ошибки кандидатов

  • Уверены, что rm в следующем слое уменьшает образ. Это самая частая ошибка, и на ней же строят вопрос про утечку секретов.
  • Ставят COPY . . в начало Dockerfile и жалуются на медленную сборку.
  • Считают, что multi-stage нужен только «для красоты», не связывая его с безопасностью и скоростью загрузки образа.
  • Забывают про .dockerignore и удивляются гигабайтному контексту.
  • Путают кэш сборки (слои на машине сборщика) и слои в registry.

Как ответить кратко (20–30 секунд)

«Каждая RUN и COPY создаёт неизменяемый слой, а кэш инвалидируется начиная с первой изменившейся инструкции. Поэтому сначала копирую только манифесты зависимостей и ставлю их, а код копирую после — тогда правка кода не пересобирает зависимости. Размер режу multi-stage-сборкой: собираю в толстом образе, а в финальный копирую только бинарник поверх alpine или distroless. Плюс .dockerignore и очистка кэша пакетного менеджера в том же слое, где он появился — удалять его следующей строкой бесполезно, байты останутся в образе.»

Проверьте себя
1. В Dockerfile идут подряд: COPY secret.key /tmp/, RUN use-key, RUN rm /tmp/secret.key. Насколько это безопасно?
AБезопасно: файл удалён, в образе его нет
BНебезопасно: слой с ключом остался в образе, и его можно извлечь из истории слоёв
CБезопасно, если добавить --squash при docker build
DНебезопасно только при использовании ADD вместо COPY
2. Почему COPY package.json перед npm ci, а COPY . . после, ускоряет сборку?
AПотому что npm ci работает быстрее с меньшим количеством файлов
BПотому что слой с установленными зависимостями берётся из кэша, пока не изменился package.json — правки кода его не инвалидируют
CПотому что Docker выполняет такие инструкции параллельно
DПотому что так уменьшается размер контекста сборки
3. Что попадает в финальный образ при multi-stage build?
AВсе слои всех стадий
BТолько слои последней стадии плюс явно скопированное через COPY --from
CТолько слои первой стадии
DСтадии объединяются в один слой автоматически