awesome-everything EN
↑ Обратно к восхождению

Деплой и инфра

Слои образа и кэш сборки: порядок решает всё

Суть Образ — стек read-only слоёв, каждый шаг Dockerfile кэшируется по инструкции и хэшу входа. Расставь шаги от редко- к часто-меняющимся, иначе каждая правка переустанавливает всё — а удалённый секрет остаётся в истории.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на junior-высоте — поверхность
◷ 16 min

Однострочная правка CSS запускает четырёхминутную CI-сборку. Каждый пуш — то же самое: npm install бежит с нуля, тянет 1200 пакетов по сети, хотя package.json не менялся. Кто-то написал COPY . . перед RUN npm install, поэтому правка любого файла инвалидирует слой установки. Команда платила ~3 минуты за пуш месяцами — кэш, который ни разу не попадал, потому что Dockerfile задавал ему неправильный вопрос первым.

Слой — это diff, а ключ кэша — содержимое

Docker-образ — это не один blob. Это стек read-only слоёв, показанный через union-файловую систему (overlay2 на Linux): запущенный контейнер видит одно слитое дерево, но хранилище — цепочка diff’ов. Каждый RUN, COPY и ADD в Dockerfile порождает один слой — изменения файловой системы, которые сделал этот шаг, и не более.

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

Этот единственный факт двигает каждую оптимизацию в уроке. Твоя задача как автора Dockerfile — расставить инструкции так, чтобы шаги, которые редко меняются, шли в начале (глубоко в префиксе кэша), а шаги, которые меняются на каждый коммит — твой исходный код — шли в конце.

Кардинальное правило: от редко- к часто-меняющимся

Вот баг из хука и его фикс.

ШагПлохой порядок (кэш бьётся на каждой правке)Хороший порядок (установка остаётся в кэше)
1COPY . .COPY package.json package-lock.json ./
2RUN npm ciRUN npm ci
3COPY . .
Эффект правки одного файлаЧек-сумма шага 1 меняется → установка бежит заново (минуты)Промахивается только шаг 3 → установка попадает в кэш (секунды)

В хорошем порядке COPY package.json package-lock.json ./ меняет свою чек-сумму, только когда меняется манифест зависимостей. Правишь компонент — шаги 1 и 2 остаются в кэше; пересобирается лишь финальный COPY . . и то, что следует. Это самое высокорычажное изменение, которое ты можешь внести в Dockerfile: команды регулярно сообщают о падении времени CI-сборки на 70% от одного только кэширования слоёв, потому что многоминутная установка зависимостей превращается в попадание в кэш за доли секунды на подавляющем большинстве коммитов.

Тот же принцип обобщается. Пинни базовый образ и ставь OS-пакеты рано; они меняются максимум раз в месяц. Дальше копируй lock-файлы и ставь зависимости. Исходники и сборку — последними. Градиент частоты — редко-меняющееся внизу, каждый-коммит наверху — это вся игра.

Почему это работает

RUN кэшируется по тексту команды, а не по её результату. RUN apt-get update с радостью переиспользует кэшированный слой многомесячной давности, потому что строка не менялась, — и ты ставишь устаревшие индексы пакетов, а потом apt-get install против них. Фикс — соединить их в одну инструкцию: RUN apt-get update && apt-get install -y curl. Теперь у них общий ключ кэша, и инвалидируются они вместе. Поэтому же важно размещение ARG: build-аргумент меняет ключи кэша ниже по тексту, поэтому объявляй его так поздно, как позволяет сборка.

Multi-stage: компилируй жирно, отгружай тонко

Твоему тулчейну сборки — компиляторам, dev-зависимостям, node_modules, набитому build-time пакетами, кэшам apt — нечего делать в образе, который ты гоняешь в проде. Он раздувает образ, расширяет поверхность атаки и замедляет каждый pull и деплой. Multi-stage сборки решают это: ты пишешь несколько FROM-стейджей в одном Dockerfile, делаешь тяжёлую работу в жирном builder-стейдже, потом через COPY --from=builder переносишь только готовые артефакты в тонкий runtime-стейдж. Опубликованным образом становится только последний стейдж; builder отбрасывается.

# syntax=docker/dockerfile:1
FROM node:22 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM gcr.io/distroless/nodejs22-debian12
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["dist/server.js"]

Числа драматичны. Single-stage Node-сборка обычно весит около 380MB+ (полная база node:22 — ~1.1GB до твоего приложения); multi-stage версия на slim- или distroless-базе падает примерно до 60MB. Для Go-бинарника контраст резче — single-stage сборка около 180MB схлопывается до образа ~3–12MB на scratch или distroless/static, потому что скомпилированному бинарнику вообще не нужен runtime. Один широко цитируемый пример ужал образ с 843MB до 12.1MB — сокращение на 98.6%.

Distroless vs alpine: трейдофф runtime-базы

Когда у тебя есть тонкий runtime-стейдж, выбор базы — реальное решение. Alpine крошечный, потому что использует musl libc и BusyBox, и поставляет пакетный менеджер — удобно, когда надо добавить инструмент. Подвох: musl отличается от glibc достаточно тонко, чтобы ломать нативные модули и иногда удивлять DNS или производительностью. Distroless поставляет только твоё приложение и его прямые runtime-зависимости — ни шелла, ни пакетного менеджера, ни apt — что отлично для поверхности атаки, но означает, что ты не можешь docker exec шелл внутрь для отладки, и обязан занести каждую runtime-зависимость через COPY.

Выбери лучший вариант

Скомпилированный сервис должен отгружаться как маленький, защищённый прод-образ. Выбери базу финального стейджа.

.dockerignore и секрет, который никогда не умирает

Картину завершают два провала. Первый — build-контекст: когда ты запускаешь docker build ., вся директория упаковывается в tar и шлётся демону. Без .dockerignore туда попадают .git, node_modules, вывод сборки и .env-файлы — замедляя загрузку и рискуя тем, что COPY . . сгребёт секреты и мусор в слой. .dockerignore, перечисляющий node_modules, .git, *.log и .env, держит контекст лёгким, а копирование — чистым.

Второй — ловушка, которая бьёт команды сильнее всего: секрет, добавленный в одном слое и удалённый в более позднем, всё равно живёт в истории образа. Слои — это неизменяемые diff’ы. Если ты COPY id_rsa (или впишешь токен в файл), а потом RUN rm id_rsa через две строки, удаление — это просто новый diff сверху; исходный файл всё ещё восстановим из раннего слоя через docker history или извлечением образа. Удаление — театр. Правильные инструменты — BuildKit secret mounts: RUN --mount=type=secret,id=token ... делает секрет доступным для одной инструкции и пишет его в ноль слоёв, — или, если иначе никак, multi-stage сборка, где секрет живёт только в отбрасываемом builder-стейдже. Никогда не делай rm секрета в надежде, что он исчез.

Викторина

Ты правишь один файл исходника и пересобираешь. npm ci бежит каждый раз. Самая вероятная причина?

Викторина

Токен был COPY-нут в слой, потом удалён через RUN rm в следующей инструкции. Это безопасно?

Расставь шаги по порядку

Расставь шаги Node-Dockerfile для максимального переиспользования кэша (от редко- к часто-меняющимся):

  1. 1 FROM node:22 AS builder — пинни базовый образ (меняется редко)
  2. 2 RUN apt-get update && apt-get install -y <os-deps> — OS-пакеты (максимум раз в месяц)
  3. 3 COPY package.json package-lock.json ./ — манифест зависимостей (меняется при смене зависимостей)
  4. 4 RUN npm ci — установка зависимостей (в кэше, пока не сменится манифест)
  5. 5 COPY . . затем RUN npm run build — исходный код (меняется каждый коммит)
Вспомните перед уходом
  1. 01
    Объясни, почему COPY . . перед RUN npm ci убивает время сборки, и точный реордер, который это чинит.
  2. 02
    Почему удаление секрета в более позднем слое не убирает его, и что делать вместо этого?
Итог

Образ — это стек read-only слоёв над union-файловой системой, и каждая инструкция Dockerfile порождает один слой, ключёванный по тексту инструкции плюс содержимому, которого она касается. Кэш — это совпадение по префиксу, поэтому первая инструкция, чей ключ меняется, пересобирает себя и всё ниже — что делает порядок инструкций самым большим рычагом, который у тебя есть. Ставь редко-меняющиеся шаги первыми: пинни базу, ставь OS-пакеты, копируй lock-файл и ставь зависимости, и только потом копируй исходники и собирай, чтобы повседневная правка кода держала дорогую установку как попадание в кэш. Используй multi-stage сборки, чтобы компилировать в жирном builder’е и через COPY переносить только артефакты в тонкий distroless- или alpine-runtime, роняя образы с сотен MB до десятков. Держи .git, node_modules и .env вне build-контекста через .dockerignore. И помни, что секрет, добавленный и потом rm-нутый, всё ещё живёт в diff’е раннего слоя — тянись к BuildKit secret mounts или отбрасываемому builder-стейджу, потому что в неизменяемых слоях удалить его обратно нельзя.

Продолжить восхождение ↑Image layers: тест с выбором ответа
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.