open atlas
↑ К треку
Docker: контейнеры как система DOCK · 01 · 04

Механика Dockerfile: какие инструкции становятся слоями, как кеш ключует каждый шаг и почему важен порядок

Каждая инструкция Dockerfile — это либо слой ФС (RUN, COPY, ADD), либо нулевая мутация конфига (ENV, WORKDIR, CMD, …). Ключ кеша на каждом шаге = состояние родителя плюс текст плюс, для COPY/ADD, чексумма файлов. Первый изменённый шаг сбрасывает все слои ниже.

DOCK Senior ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior

Дашборд CI показывал, что Node-сервис пересобирается 94 секунды после правки одной строки в обработчике маршрута. Никто не трогал package.json. Никто не добавлял зависимость. И всё же каждый пуш заново гонял npm ci с нуля, вытягивая 1100 пакетов, каждый раз. Джуниор задал очевидный вопрос: «почему правка одного .ts-файла переустанавливает node_modules?» Ответ был в Dockerfile, и это был порядок инструкций. Файл делал COPY . ., а затем RUN npm ci. Поскольку COPY . . копирует всё рабочее дерево, его ключ кеша включает чексумму каждого файла — так что как только менялся один байт исходника, ключ этого слоя менялся, и инвалидация кеша каскадом шла вниз: каждая инструкция ниже COPY, включая npm ci, была вынуждена пересобраться. Фикс — три строки и ноль новых инструментов: сначала скопировать package.json и лок-файл, запустить установку, потом скопировать исходник. После этого правка только кода каждый раз попадала в кеш установки. Пересборка упала с 94 секунд примерно до 3. Урок был не «npm медленный». Урок был в том, что Dockerfile — это упорядоченный кеш, и куда ты ставишь COPY — это решение о производительности.

Читай Dockerfile как упорядоченный, контентно-адресуемый кеш, а не как скрипт, запускаемый однажды, — и каждый медленный пересбор объяснится ещё до того, как ты откроешь таймер.

Два вида инструкций: слои против конфига

Пройди по Dockerfile сверху вниз — и каждая инструкция делает одно из двух. Несколько из них — RUN, COPY, ADD — исполняются против файловой системы и порождают новый read-only слой: tarball ровно тех файлов, что изменились, названный контентным дайджестом. Всё остальное — ENV, WORKDIR, EXPOSE, ENTRYPOINT, CMD, LABEL, USER, ARG — не трогает файловую систему вовсе. Оно пишет поле в JSON-конфиг образа и записывает нулевую history-запись. Запусти docker history — и разделение видно напрямую:

$ docker history myapp:1 --format '{{.Size}}\t{{.CreatedBy}}'
0B        CMD ["node" "server.js"]
0B        EXPOSE 3000
0B        ENV NODE_ENV=production
0B        WORKDIR /app
12.4MB    COPY . . # исходники
108MB     RUN npm ci
1.1kB     COPY package.json package-lock.json ./
0B        WORKDIR /app
77.8MB    RUN apt-get install ...

Строки 0B — это мутации конфига: они меняют образ (и, значит, image ID), но не добавляют байтов слоёв. Строки с размером — настоящие слои ФС. Это не мелочь — это говорит тебе ровно, какие строки дороги для пересборки, а какие бесплатны.

Викторина

Dockerfile заканчивается четырьмя строками: WORKDIR /app, ENV NODE_ENV=production, EXPOSE 3000 и CMD, запускающий node server.js. Сколько слоёв ФС добавляют эти четыре строки и что они меняют?

Ключ кеша одного шага

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

# ключ каждого шага включает всё над ним
FROM node:20-slim          # родительская база
WORKDIR /app               # только конфиг, бесплатно
COPY package.json package-lock.json ./   # ключ = checksum(эти 2 файла)
RUN npm ci                 # ключ = текст "RUN npm ci" + состояние выше
COPY . .                   # ключ = checksum(всё дерево исходника)
CMD ["node","server.js"]   # только конфиг, бесплатно

Следствие — односторонний каскад: первый шаг, чей ключ изменился, инвалидирует каждый шаг ниже. Кеш — это префиксное совпадение сверху. Если COPY package.json всё ещё совпадает, а COPY . . нет, пересобираются лишь COPY . . и то, что за ним — npm ci выше остаётся в кеше. Поменяй порядок так, чтобы COPY . . шёл первым, и каскад дотягивается до npm ci при каждой правке кода. Это вся военная история в одном правиле.

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

Почему COPY получает контентную чексумму, а RUN нет? Потому что Docker не может знать, что произведёт команда RUN — он не может дёшево её выполнить, чтобы выяснить, — так что он консервативно доверяет тексту инструкции: тот же текст, тот же родитель — предполагаем тот же результат. COPY иной: его входы — файлы в твоём build-контексте, а их дёшево чексуммировать, так что BuildKit может быть точным. Эта асимметрия — и классический футган. RUN apt-get update, ключуемый лишь по тексту, спокойно отдаст из кеша индекс пакетов месячной давности, потому что текст не менялся, хоть апстрим-репозиторий и менялся. Текстовый кеш — фича для воспроизводимости и ловушка для свежести, и знание, какие инструкции ключуются по тексту, говорит тебе ровно, где может укусить устаревший кеш.

Цепочки RUN и ловушка rm-в-позднем-слое

Поскольку каждый RUN — это собственный слой, файл, который ты создал в одном RUN и удалил в позднем RUN, не уменьшает образ. Слои наслоены и неизменяемы: нижний слой по-прежнему несёт байты, а удаление лишь пишет whiteout в верхнем слое, который скрывает файл на рантайме. Байты всё ещё тянутся, всё ещё хранятся, всё ещё извлекаемы.

# НЕВЕРНО: два слоя — clean происходит слишком поздно, чтобы что-то уменьшить
RUN apt-get update && apt-get install -y build-essential
RUN apt-get clean && rm -rf /var/lib/apt/lists/*   # только whiteout

# ВЕРНО: один слой — индекс и кеши никогда не остаются в слое
RUN apt-get update \
 && apt-get install -y --no-install-recommends build-essential \
 && rm -rf /var/lib/apt/lists/*

Сцепить установку и очистку в один RUN значит, что индекс пакетов apt (часто 40–60 МБ) и скачанные кеши .deb удаляются в пределах того же слоя, до того как его tarball финализируется, так что они вообще не попадают в образ. На типичной Debian-сборке одно это изменение экономит примерно 40–100 МБ, а --no-install-recommends срезает ещё транш необязательных пакетов. Правило обобщается: всё, что ты создаёшь лишь затем, чтобы удалить, должно создаваться и удаляться внутри одного RUN.

Викторина

Dockerfile скачивает 200-МБ инструмент сборки в одном RUN, использует его, затем удаляет в отдельном позднем RUN. Каков эффект для размера образа?

Сбрасывать кеш, когда так и задумано

Иногда текстовый кеш слишком липкий и нужен намеренный промах — например, чтобы форсировать свежий git clone или повторный fetch. Чистый рычаг — ARG, чьё значение ты передаёшь во время сборки: ссылка на него внутри RUN вкладывает его значение в ключ кеша шага, так что смена арга сбрасывает кеш с этой точки вниз. Контентная чексумма (передача известного хеша файла как build-арг) делает то же точечно. Грубый инструмент — docker build --no-cache, пропускающий кеш для каждого шага. А чтобы делить кеш между машинами или свежим CI-раннером, docker build --cache-from затравливает локальный кеш из запушенного образа, так что холодный раннер всё равно попадает в тёплые слои. Ещё одна вещь на одну строку: BuildKit теперь билдер по умолчанию, и он параллелит независимые стадии multi-stage-сборки, а не гонит их строго сверху вниз — глубокий разбор multi-stage — следующий юнит.

Вспомните перед уходом
  1. 01
    Какие инструкции создают слой ФС, а какие лишь мутируют конфиг, и каков ключ кеша каждого шага?
  2. 02
    Объясни каскад инвалидации кеша и как он делает порядок инструкций контрактом производительности, плюс ловушку rm-в-позднем-слое.
Итог

Dockerfile — не скрипт, исполняемый однажды; это упорядоченный, контентно-адресуемый кеш, и чтение его так объясняет каждый сюрприз пересборки. Каждая инструкция делает одно из двух. RUN, COPY и ADD исполняются против файловой системы и замораживают новый read-only слой — tarball, названный контентным дайджестом. Всё остальное — ENV, WORKDIR, EXPOSE, ENTRYPOINT, CMD, LABEL, USER, ARG — пишет поле в JSON-конфиг и записывает нулевую history-запись; оно меняет image ID, но байтов слоёв не добавляет, что docker history показывает напрямую как колонку строк 0B над строками с размером. Кеш решает твоё время пересборки. Ключ шага — состояние родителя плюс текст инструкции, и для COPY и ADD дополнительно чексумма копируемых файлов; RUN ключуется лишь по тексту, так что текстово-идентичный RUN apt-get update может вручить тебе устаревший индекс пакетов. Поскольку кеш — префиксное совпадение, первый шаг, чей ключ изменился, инвалидирует каждый шаг ниже — вот почему порядок есть контракт производительности. Канонический фикс — копировать лок-файл и ставить зависимости низко, затем копировать исходник высоко: правка только кода тогда промахивается на верхнем COPY, но держит npm ci в кеше, превращая 94-секундную переустановку примерно в 3 секунды и поднимая долю попаданий кеша установки с почти нуля до почти единицы. Ещё два правила следуют из неизменяемости: файл, удалённый в позднем RUN, всё ещё доставляется в нижнем слое под whiteout, так что сцепляй создание-и-очистку в один RUN, чтобы реально сэкономить ~40-100 МБ; а когда тебе действительно нужен свежий результат, сбрасывай текстовый кеш намеренно через ARG или чексумму, обнуляй его через —no-cache или грей холодный раннер через —cache-from. BuildKit, билдер по умолчанию, также параллелит независимые стадии — но это следующий юнит. Держи модель: слои — это байты, конфиг — это идентичность, кеш — это префикс, а порядок — это рычаг. Теперь, когда увидишь CI-пересборку на 90 секунд после правки лишь кода, первое, что проверяешь — где стоит COPY . . относительно RUN npm ci: порядок почти всегда и есть причина.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.