Dockerfile: кэш, ENTRYPOINT, multi-stage
Взял одно и то же приложение на Python и собрал двумя способами: в первом сначала копируется весь проект, потом ставятся зависимости, во втором наоборот. Холодная сборка заняла примерно одинаково: 8164 и 7530 миллисекунд. А потом я поменял одну строку в коде и пересобрал: первый вариант - 6869 миллисекунд, второй - 1594. Разница в четыре раза выросла из порядка двух строк.
Стержень: кэш живёт по слоям и рвётся на первом изменившемся шаге; форма записи команды решает, кто станет первым процессом; инструменты сборки не должны доезжать до прода.
// Формулировки: «почему зависимости ставят до копирования кода?», «чем ENTRYPOINT отличается от CMD?», «как уменьшить образ?»
Кэш слоёв и порядок строк
Каждая строка файла сборки добавляет слой - неизменяемый набор изменений в файлах. Слой берётся из кэша, пока не изменилось то, из чего он сделан. Как только один шаг изменился, все шаги ниже по файлу пересчитываются заново - кэш рвётся вниз. Поэтому порядок строк и решает: тяжёлое и редко меняющееся ставят выше, лёгкое и постоянно меняющееся - ниже.
На моём замере это выглядит так. Плохой порядок: сначала COPY всего проекта, потом установка зависимостей. Правка любой строки кода меняет слой копирования, и установка едет заново - 6869 миллисекунд каждый раз. Хороший порядок: сначала копируем только файл со списком зависимостей, ставим их, и лишь потом копируем код. Правка кода трогает лишь последний лёгкий слой - 1594 миллисекунды. Когда я поменял сам список зависимостей, установка честно пошла заново и заняла 7722 миллисекунды, и это правильно: вход изменился.
// Рядом живёт вторая потеря времени - контекст сборки. Перед сборкой команда docker упаковывает каталог проекта и отправляет его демону: так называют постоянно работающую службу docker на машине, которая и делает всю настоящую работу. В моём проекте лежали служебный каталог .git на 41 мегабайт и node_modules на 31: отправка заняла 73,41 мегабайта и 6137 миллисекунд. Файл .dockerignore с двумя строками сократил отправку до 4,096 килобайта и 1301 миллисекунды. Заодно в образ перестают случайно попадать локальные файлы с паролями.
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt . # сначала только список зависимостей
RUN pip install --no-cache-dir -r requirements.txt
COPY . . # код последним: правки трогают лишь этот слой
CMD ["python", "main.py"]- кэш слоя
- переиспользование готового шага, пока его вход не изменился
- контекст сборки
- каталог проекта, который клиент отправляет демону перед сборкой
- .dockerignore
- список того, что не отправлять в контекст сборки
Что именно выполняется: ENTRYPOINT, CMD и форма записи
ENTRYPOINT задаёт саму команду, CMD - аргументы по умолчанию. Всё, что дописано после имени образа в docker run, подставляется ВМЕСТО CMD, а ENTRYPOINT остаётся. Я прогнал это руками. Образ только с CMD ["echo", "это CMD"] без аргументов печатает «это CMD», а с дописанным «echo другое» печатает «другое» - строка CMD заменилась целиком. Образ с ENTRYPOINT ["echo", "это ENTRYPOINT:"] и CMD ["значение по умолчанию"] без аргументов печатает «это ENTRYPOINT: значение по умолчанию», а с аргументом «свой» - «это ENTRYPOINT: свой». Подменить сам ENTRYPOINT можно единственным способом - флагом --entrypoint.
Форма записи важнее, чем кажется. Список в квадратных скобках запускает программу напрямую, и она становится первым процессом контейнера. Строка без скобок запускается через оболочку sh - программу, которая разбирает текст команды и запускает её, - и первым процессом остаётся сама оболочка. Замер: приложение, которое умеет ловить сигнал на остановку. Сигнал - короткое уведомление процессу от ядра, здесь оно означает «пора завершаться». Записал строкой - первым процессом стал «/bin/sh -c python /srv.py», команда docker stop ждала 10170 миллисекунд и завершила контейнер кодом 137, то есть добила его принудительно. Записал списком - первым процессом стало само приложение, остановка заняла 168 миллисекунд и код выхода 0.
// Оболочка не передаёт сигнал остановки дочерней программе, поэтому та ничего не узнаёт и не успевает закрыть соединения. Лечится тремя способами: писать списком; писать строкой, но со словом exec впереди (замер дал 158 миллисекунд и код 0, потому что exec заменяет оболочку собой); либо флагом --init, который ставит первым процессом крошечного управляющего и передаёт сигналы дальше - 148 миллисекунд и код 0.
// Ещё одна ловушка формы: ENTRYPOINT, записанный строкой, проглатывает и CMD, и аргументы запуска. У меня он напечатал «это ENTRYPOINT строкой» и в пустом запуске, и когда я дописал аргумент.
- форма списком
- запись команды массивом; программа стартует напрямую и становится первым процессом
- форма строкой
- запись команды строкой; её запускает оболочка sh и остаётся первым процессом
- первый процесс
- процесс с номером 1 внутри контейнера; сигналы приходят ему
Размер: чистить надо в той же строке
Слой неизменяем, поэтому удаление в следующей строке ничего не возвращает: файлы уже записаны этажом ниже, а сверху лежит пометка об их отсутствии. Замер на debian: поставил лишний пакет и почистил следующей строкой - образ 281 мегабайт. Поставил только нужное и почистил в той же строке - 130 мегабайт. Сама база, то есть образ, с которого начинается сборка, без всего весит 116. То есть «уборка» отдельной строкой не убрала ничего, она добавила ещё один слой.
Радикальнее работает многостадийная сборка: первая стадия собирает, вторая берёт из неё готовый файл. Замер на программе из четырёх строк на Go. Одностадийный образ с компилятором внутри - 1,27 гигабайта. Многостадийный на базе alpine - 15,5 мегабайта. Многостадийный на пустой базе scratch - 3,44 мегабайта, и внутри ровно один файл на 2,1 мегабайта. Все три печатают одно и то же.
// Выигрыш двойной: меньше качать и меньше поверхности для дыр. В первом образе внутри лежит рабочий компилятор - программа, превращающая исходный код в исполняемый файл (проверил: «go version go1.23.12»), во втором есть оболочка для входа внутрь, а в третьем нет ничего - попытка зайти внутрь даёт «exec: "sh": executable file not found in $PATH». Пустая база безопаснее, но отлаживаться в ней придётся снаружи или временно подменяя образ.
FROM golang:1.23 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app .
FROM alpine:3.20 # 15,5 МБ против 1,27 ГБ у одностадийной
COPY --from=build /app /app
CMD ["/app"]- многостадийная сборка
- сборка в одной стадии и копирование готового файла в другую
- scratch
- пустая база: ни оболочки, ни пакетов, только положенный туда файл
Как отвечать: «Почему зависимости ставят отдельным шагом до копирования кода?»
Из-за кэша слоёв. Слой переиспользуется, пока не изменился его вход, а как только шаг изменился, всё ниже пересобирается заново. Если сначала скопировать весь проект, то правка одной строки кода ломает слой копирования и тянет за собой полную переустановку зависимостей. Я замерял на обычном приложении: плохой порядок давал около семи секунд на каждую пересборку, правильный - полторы. Порядок такой: копируем список зависимостей, ставим их, код копируем последним. И рядом всегда кладу .dockerignore: без него в контекст сборки уезжают служебные каталоги, у меня это было семьдесят с лишним мегабайт вместо четырёх килобайт, плюс риск занести в образ локальные файлы с паролями.
Ответ объясняет механику, даёт порядок величин из личного замера и добавляет второй приём. Это звучит как опыт, а не как пересказ документации.
На чём валятся
- −Копируют весь проект до установки зависимостей: кэш рвётся от любой правки.
- −Пишут команду запуска строкой и теряют корректную остановку: сигнал получает оболочка, а не приложение.
- −Ждут, что аргументы docker run допишутся к CMD. Они его заменяют целиком.
- −Чистят образ отдельной строкой в конце и удивляются, что размер не изменился.
- −Забывают .dockerignore и гоняют десятки мегабайт лишнего на каждой сборке.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 8, остальные разбираются в тренажёре.
- Образ с компилируемым сервисом весит 1.2 ГБ: внутри тулчейн, исходники, кэш пакетов. Как ужать по-честному?A)Собрать с флагом сжатия слоёв и включить squashB)Выгрузить образ через save и загрузить обратноC)Многостадийная сборка: в финал копируем только артефактD)Смонтировать тулчейн томом вместо установки в образ
показать ответ и разбор
+C)Многостадийная сборка: в финал копируем только артефакт// разбор: Компилятор, заголовки и кэш пакетов нужны только на стадии сборки. Многостадийный Dockerfile собирает артефакт в тяжёлой стадии, а в финальную (slim, distroless, alpine) копирует бинарь и его данные — остальное остаётся в промежуточном образе и в реестр не едет. Побочный выигрыш: в проде нет компилятора и лишних пакетов, значит меньше уязвимостей.
- В Dockerfile две инструкции: RUN apt-get update и отдельно RUN apt-get install pkg. Иногда ставится устаревший пакет. Почему?A)apt-get install ставит версию из образа, минуя обновлённый индексB)Между ними обязательно нужно добавить ещё apt-get upgradeC)update и install кэшируются порознь — объединяют в один RUND)Пакет закреплён по версии где-то в базовом образе
показать ответ и разбор
+C)update и install кэшируются порознь — объединяют в один RUN// разбор: Каждый RUN — отдельный кэшируемый слой. Если update и install разнесены, Docker может переиспользовать старый закэшированный слой update, а install поставит пакеты по устаревшему индексу — отсюда старые версии. Канон: объединять в один слой
RUN apt-get update && apt-get install -y --no-install-recommends pkg && rm -rf /var/lib/apt/lists/*, чтобы индекс и установка кэшировались вместе. - CMD задан строкой: CMD app --serve. Контейнер не реагирует на docker stop и умирает по таймауту. При чём тут форма записи CMD?A)Shell-форма гонит app через /bin/sh -c; SIGTERM ловит shB)Строковая форма запрещает контейнеру принимать сигналы извнеC)CMD не получает сигналы — их принимает только ENTRYPOINTD)app сам игнорирует SIGTERM из-за отсутствия обработчика
показать ответ и разбор
+A)Shell-форма гонит app через /bin/sh -c; SIGTERM ловит sh// разбор: Shell-форма (CMD app --serve) исполняется как /bin/sh -c "app --serve": PID 1 становится sh, а приложение — его дочерним процессом. docker stop шлёт SIGTERM в PID 1, то есть sh, который его не пробрасывает, — приложение сигнала не видит и через таймаут получает SIGKILL. Exec-форма CMD ["app","--serve"] запускает приложение напрямую как PID 1, и SIGTERM приходит ему.
- Приватный токен нужен только на этапе сборки (скачать зависимости), но не должен остаться в образе. Как правильно в BuildKit?A)Передать токен через ARG и удалить его следующей инструкциейB)Положить токен в ENV: переменные окружения в слои не пишутсяC)Скопировать файл с токеном, использовать и удалить в концеD)Секрет через RUN --mount=type=secret — в слой он не попадёт
показать ответ и разбор
+D)Секрет через RUN --mount=type=secret — в слой он не попадёт// разбор: Секрет на этапе сборки нельзя оставлять в слоях: и ARG, и ENV, и COPY-с-последующим-rm сохраняют его в истории образа — достаётся docker history/распаковкой слоёв. BuildKit даёт
RUN --mount=type=secret,id=token …: файл секрета монтируется только на время этой RUN-инструкции, команда его читает, но в слой он не записывается. Передают через docker build --secret id=token,src=./token. - Что такое Dockerfile?A)Файл с настройками запущенного контейнераB)Инструкции, по которым собирают образC)Архив с готовым образом для переносаD)Журнал работы контейнера
показать ответ и разбор
+B)Инструкции, по которым собирают образ// разбор: Каждая инструкция Dockerfile добавляет слой к образу: взять базовый образ, скопировать файлы, поставить зависимости, задать команду запуска. Файл держат в репозитории рядом с кодом — образ должен собираться воспроизводимо.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.