Утоньшение образа: multi-stage, distroless и слои, которые тянутся за секунды
Образ 1.2 ГБ замедляет каждый pull, деплой и scale-up и тащит shell как attack surface. Multi-stage сборка плюс distroless или scratch финал режут его до ~25 МБ, ужимают число CVE и упорядочивают слои так, что правка кода пересобирается за секунды.
Автоскейлер был узким местом, которое никто не измерял. Трафик скакнул, оркестратор запланировал новые поды, и они сидели в ContainerCreating 90 секунд — таща образ 1.2 ГБ на холодные узлы — пока очередь копилась, а существующие поды падали под нагрузкой, которую масштабирование должно было разгрузить. Образ был Go-сервисом: единственный статический бинарник 15 МБ, погребённый под полной базой Ubuntu, всем тулчейном Go, кэшами сборки, apt-списками и компилятором. Переписали в multi-stage Dockerfile — собрать в тулчейн-стадии, скопировать только бинарник в distroless-базу — и образ ушёл с 1.2 ГБ до 23 МБ. Холодные pull’ы упали с 90 секунд до менее 2. Бонусом security-скан, что раньше флагал 180 CVE в OS-пакетах, флагнул ноль, ведь OS-пакетов не осталось: ни shell, ни менеджера пакетов, ни утилит libc для пивота атакующего. Жирный образ был тремя проблемами в одном пальто — медленные деплои, медленное масштабирование и широкая attack surface.
Multi-stage: собирай тяжело, отгружай легко
Ключевая техника — multi-stage сборка: одна стадия держит тяжёлый тулчейн, финальная — только артефакт. Образом становится лишь последняя стадия; всё в стадии сборки — компиляторы, кэши, dev-зависимости — отбрасывается.
# ---- стадия сборки: тяжёлая, никогда не отгружается ----
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download # кэшируется, пока go.mod/go.sum не изменятся
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server
# ---- финальная стадия: крошечная, отгружается ----
FROM gcr.io/distroless/static-debian12 # база ~2 МБ: ни shell, ни менеджера пакетов
COPY --from=build /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]Финальный образ — это distroless-база (минимальный образ от Google без shell, пакетного менеджера и libc-утилит, ~2 МБ) плюс бинарник — для статического Go-бинарника ~25 МБ всего против 1.2 ГБ наивной одностадийной сборки на golang:1.23. Этот размер — не метрика тщеславия; он возвращается на каждой операции. Pull на холодном узле падает с ~90 с до ~2 с, что напрямую сокращает время реакции автоскейлера и длительность раската. Реестр хранит и передаёт долю байт. И attack surface схлопывается: distroless не тащит ни shell, ни менеджера пакетов, ни OS-утилит, так что скомпрометированный процесс не может sh, curl, apt-get или пивотить через libc-инструменты — а CVE-сканер рапортует почти ноль находок, ведь OS-пакетов, у которых были бы уязвимости, почти нет. Для по-настоящему статического бинарника можно даже FROM scratch (пустая база), отгружая буквально один бинарник; distroless — прагматичная середина, ведь добавляет CA-сертификаты, /etc/passwd и данные таймзон, которых нет у scratch.
Помимо экономии хранилища реестра, какова самая прямая продовая цена отгрузки образа 1.2 ГБ вместо 25 МБ?
Упорядочивание слоёв: кэшируй медленные шаги
Финальный образ уже 25 МБ — но если порядок инструкций Dockerfile неверный, каждая правка кода всё равно запускает многоминутную переустановку зависимостей. Размер образа платишь на pull; порядок слоёв платишь на каждом коммите.
Слои образа контент-адресуемы и кэшируются: слой переиспользуется лишь если его инструкция и все предшествующие слои не изменились. Упорядочь инструкции от наименее- к наиболее-часто-меняющимся, чтобы однострочная правка кода не сбивала слой установки зависимостей. Единственное правило наибольшего рычага — копируй и устанавливай зависимости до копирования исходного кода.
# ХОРОШО — слой зависимостей переживает правки исходника
COPY package.json package-lock.json ./
RUN npm ci # кэшируется, пока package-файлы не изменятся
COPY . . # правки исходника инвалидируют только отсюда вниз
RUN npm run build
# ПЛОХО — каждая правка исходника перезапускает npm ci (минуты)
COPY . .
RUN npm ci # любая правка файла сбивает это; полная переустановка каждой сборкиВ плохом порядке COPY . . идёт до npm ci, так что правка одной строки исходника меняет контекст сборки, инвалидирует тот COPY-слой и форсит полную переустановку зависимостей — превращая 5-секундную инкрементальную сборку в многоминутную. В хорошем порядке установка зависимостей сидит на стабильном слое, ключованном лишь lock-файлами, так что переиспользуется на каждой правке только-исходника. Ещё две дисциплины слоёв важны: .dockerignore, исключающий node_modules, .git и тест-фикстуры, держит мусор вне контекста сборки (меньше, быстрее, и не даёт устаревшему локальному node_modules протечь); и объединение связанных RUN-шагов с очисткой в том же слое (apt-get install ... && rm -rf /var/lib/apt/lists/* ) важно, ведь удаление файлов в более позднем слое не ужимает образ — байты уже закоммичены в раннем слое и лишь маскируются, не удаляются. Число слоёв тоже реальное число: каждый слой — отдельный tar, тянущийся и распаковывающийся, так что десятки крошечных RUN-строк замедляют pull’ы; консолидируй, но не так агрессивно, чтобы уничтожить переиспользование кэша.
Dockerfile ставит пакет в одном RUN-слое и удаляет кэш в более позднем RUN-слое, но образ не меньше. Почему?
- 01Объясни, как multi-stage сборка с distroless финальной стадией берёт образ 1.2 ГБ до ~25 МБ, и назови три продовые цены, которые утоньшение возвращает.
- 02Почему копирование исходника до установки зависимостей рушит время сборки, и почему удаление файлов в позднем слое не ужимает образ?
Жирный образ — налог, платимый на каждой операции, а не разовая цена хранения. Образ 1.2 ГБ тянется ~90 секунд на холодный узел, так что удлиняет реакцию автоскейлера и раскат ровно когда нужно масштабироваться, жжёт трафик реестра на каждой передаче и тащит полную OS — shell, менеджер пакетов, утилиты libc — как attack surface, через которую пивотит скомпрометированный процесс. Лекарство — multi-stage сборка: тяжёлая стадия компилирует артефакт, а финальная копирует лишь этот артефакт в крошечную базу. Поскольку образом становится лишь последняя стадия, тулчейн и кэши отбрасываются, беря статический Go-бинарник с 1.2 ГБ до ~25 МБ на distroless-базе, роняя холодные pull’ы до ~2 с и число CVE OS-пакетов почти до нуля, ведь distroless не тащит shell или менеджер пакетов — FROM scratch идёт дальше для полностью статического бинарника, а distroless — прагматичная середина, добавляющая CA-сертификаты, /etc/passwd и данные таймзон. Упорядочивание слоёв — другая половина: слои контент-адресуемы и кэшируются, переиспользуются лишь если инструкция и все предшествующие слои не изменились, так что копируй и ставь зависимости до исходника — иначе однострочная правка инвалидирует COPY-слой и форсит полную переустановку каждой сборки. .dockerignore держит мусор вроде node_modules и .git вне контекста, а очистка должна быть в том же RUN, что создал файлы, ведь слои аддитивны: позднее удаление лишь маскирует байты, уже закоммиченные в раннем слое, оно их не убирает. Тонкие образы одновременно быстрее деплоятся, быстрее масштабируются, дешевле хранятся и меньше для атаки. Теперь, когда ты видишь в новом проекте Dockerfile с COPY . . до RUN npm install — ты знаешь, что сейчас случится со временем инкрементальной сборки каждого разработчика, и двустрочный фикс у тебя уже готов.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.