Слои, кэш сборки и 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 и очистка кэша пакетного менеджера в том же слое, где он появился — удалять его следующей строкой бесполезно, байты останутся в образе.»