сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Docker

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. #dvo_docker_build1 / 5
    Образ с компилируемым сервисом весит 1.2 ГБ: внутри тулчейн, исходники, кэш пакетов. Как ужать по-честному?
    A)Собрать с флагом сжатия слоёв и включить squash
    B)Выгрузить образ через save и загрузить обратно
    C)Многостадийная сборка: в финал копируем только артефакт
    D)Смонтировать тулчейн томом вместо установки в образ
    показать ответ и разбор
    +C)Многостадийная сборка: в финал копируем только артефакт

    // разбор: Компилятор, заголовки и кэш пакетов нужны только на стадии сборки. Многостадийный Dockerfile собирает артефакт в тяжёлой стадии, а в финальную (slim, distroless, alpine) копирует бинарь и его данные — остальное остаётся в промежуточном образе и в реестр не едет. Побочный выигрыш: в проде нет компилятора и лишних пакетов, значит меньше уязвимостей.

  2. #dvo_docker_build2 / 5
    В Dockerfile две инструкции: RUN apt-get update и отдельно RUN apt-get install pkg. Иногда ставится устаревший пакет. Почему?
    A)apt-get install ставит версию из образа, минуя обновлённый индекс
    B)Между ними обязательно нужно добавить ещё apt-get upgrade
    C)update и install кэшируются порознь — объединяют в один RUN
    D)Пакет закреплён по версии где-то в базовом образе
    показать ответ и разбор
    +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/*, чтобы индекс и установка кэшировались вместе.

  3. #dvo_docker_build3 / 5
    CMD задан строкой: CMD app --serve. Контейнер не реагирует на docker stop и умирает по таймауту. При чём тут форма записи CMD?
    A)Shell-форма гонит app через /bin/sh -c; SIGTERM ловит sh
    B)Строковая форма запрещает контейнеру принимать сигналы извне
    C)CMD не получает сигналы — их принимает только ENTRYPOINT
    D)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 приходит ему.

  4. #dvo_docker_build4 / 5
    Приватный токен нужен только на этапе сборки (скачать зависимости), но не должен остаться в образе. Как правильно в 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.

  5. #dvo_docker_build5 / 5
    Что такое Dockerfile?
    A)Файл с настройками запущенного контейнера
    B)Инструкции, по которым собирают образ
    C)Архив с готовым образом для переноса
    D)Журнал работы контейнера
    показать ответ и разбор
    +B)Инструкции, по которым собирают образ

    // разбор: Каждая инструкция Dockerfile добавляет слой к образу: взять базовый образ, скопировать файлы, поставить зависимости, задать команду запуска. Файл держат в репозитории рядом с кодом — образ должен собираться воспроизводимо.

дальше

Теорию прочитали. Навык ставится повторением

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