Мультистадійні збірки Docker: зменшіть образи без порушення виконання

Було корисно?

Нічого так не псувало чисте розгортання, як образ, який спокійно збирається, але потім падає під час виконання, бо ви випадково видалили єдину спільну бібліотеку, яка потрібна вашому бінарнику. Ваш зелений CI-знак стає маленькою брехнею, яку ви собі розповідаєте.

Мультистадійні збірки — це правильний інструмент для зменшення образів без перетворення продакшену на археологічну розкопку. Але це також гострий інструмент. Використаний правильно — обрізає зайве. Використаний необачно — обрізає артерії.

Зміст

Що насправді робить мультистадійна збірка (і чого не робить)

Мультистадійні збірки — це спосіб Docker дозволити вам використовувати один образ для збірки, а інший — для виконання — всередині одного Dockerfile. Ви компілюєте у «товстій» стадії з компіляторами та заголовками, а потім копіюєте результати у «тонку» стадію, що містить лише те, що потрібно додатку під час виконання.

Головне: мультистадійні збірки не перетворюють вашу програму на «мінімально-дружню» автоматично. Вони просто полегшують відділення залежностей під час збірки від залежностей під час виконання. Якщо вашому додатку потрібен glibc під час виконання, а ви відправляєте його у musl-базований Alpine — ви не «оптимізували». Ви заклали часову бомбу.

Чому це подобається операторам

Менші образи швидше завантажуються, дешевше зберігаються, швидше скануються та мають меншу поверхню атаки. Це не теоретично: це менше байтів по мережі у день розгортання і менше пакетів для патчів о 2-й ночі.

Що це змінює на практиці

  • Відтворюваність збірки: ви можете зафіксувати інструментарій збірки без роздування середовища виконання.
  • Позиція з безпеки: менше пакетів у runtime — менше CVE для розбору.
  • Режими помилок: ви почнете бачити відсутні бібліотеки, відсутні CA-сертифікати, відсутні дані часових поясів, відсутність shell, відсутність користувачів — речі, на які ви не підозрювали, що покладаєтеся.

Цитата, яку варто приклеїти на монітор:

«Надія — не стратегія.» — Gordon R. Dickson

Мультистадійні збірки — це спосіб перестати сподіватися, що ваш runtime-образ «ймовірно має все необхідне». Ви це перевіряєте.

Цікаві факти та коротка історія (бо контекст рятує від простоїв)

Кілька коротких, конкретних контекстних моментів, що пояснюють, чому мультистадійні збірки стали стандартною практикою:

  1. Docker додав мультистадійні збірки у 2017 (Docker 17.05). До того люди використовували крихкі «builder контейнері» та ручні кроки копіювання.
  2. Кешування шарів вплинуло на стиль Dockerfile: оптимізація порядку команд для максимального повторного використання кешу стала навичкою, бо перевбудовувати все було надто повільно.
  3. Alpine став популярним через малий розмір, а не через універсальну сумісність. Розбіжність musl vs glibc досі б’є команди, що відправляють попередньо зібрані бінарники.
  4. Distroless-образи (мінімальні runtime без пакетних менеджерів і shell) набрали обертів у міру росту занепокоєнь про ланцюг постачання та поверхню атак.
  5. Формат OCI image стандартизував макет образів контейнерів між рантаймами, що зробило інструменти для інспекції та сканування більш консистентними.
  6. BuildKit змінив правила гри: покращена паралельність, краще кешування, маунти для секретів та просунуті шаблони copy зробили мультистадійні збірки більш підтримуваними.
  7. SBOM став загальноприйнятим, коли організації почали вимагати відповідей на питання «що всередині цього образу?» під час аудитів і інцидентів.
  8. Екосистеми мов відповіли по-різному: Go прийняв статичні бінарники; Node і Python рухаються до тонших баз; Java додала jlink/jdeps для обрізки рантайму.

Основні шаблони, що працюють у продакшені

Шаблон 1: Builder + runtime з явними артефактами

Це канонічний мультистадійний підхід: збирайте в одній стадії, копіюйте бінарник або упакований додаток у стадію виконання.

cr0x@server:~$ cat Dockerfile
# syntax=docker/dockerfile:1

FROM golang:1.22-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/app ./cmd/app

FROM gcr.io/distroless/static-debian12:nonroot AS runtime
COPY --from=build /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]

Думка: якщо ви можете відправити справді статичний бінарник — робіть це. Це найчистіший операційний артефакт. Але тільки якщо ви розумієте, що втрачаєте (функції glibc, нюанси DNS, інструменти на рівні ОС). Статичний — не «кращий», а «інший».

Шаблон 2: «Slim base» runtime (з все ще доступним shell)

Distroless чудовий для загартування. Він також ускладнює життя, коли ви на виклику і потрібно швидко інспектувати контейнер. Іноді правильна відповідь — «slim Debian зі shell», особливо коли готовність організації означає, що ви будете дебажити вживу.

cr0x@server:~$ cat Dockerfile
# syntax=docker/dockerfile:1

FROM node:22-bookworm AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

Якщо ви зменшуєте образи — робіть це, не втрачаючи можливості оперувати сервісом. Shell не є зло; відправляти компілятори і пакетні менеджери в runtime — це зло.

Шаблон 3: «Toolbox stage» для дебагу (не відправляється в продакшен)

Тримайте продакшен-образи мінімальними, але не прикидайтеся, що інструменти не знадобляться. Використовуйте додаткову стадію як toolbox, щоб відтворювати проблеми локально або в контрольованому debug-розгортанні.

cr0x@server:~$ cat Dockerfile
# syntax=docker/dockerfile:1

FROM debian:bookworm AS toolbox
RUN apt-get update && apt-get install -y --no-install-recommends \
    curl ca-certificates iproute2 dnsutils procps strace \
  && rm -rf /var/lib/apt/lists/*

# other stages ...

Ви не відправляєте toolbox-стадію. Ви зберігаєте її, щоб інженери могли прикріпити ті ж інструменти до тієї ж файлової структури при дебагу.

Шаблон 4: Повторне використання артефактів для кількох фінальних образів

Добрий мультистадійний Dockerfile може видавати кілька цілей: runtime-образ, debug-образ, тестовий образ. Такий самий код, інша упаковка.

cr0x@server:~$ docker buildx build --target runtime -t myapp:runtime .
[+] Building 18.7s (16/16) FINISHED
 => exporting to image
 => => naming to docker.io/library/myapp:runtime

Ось як ви зберігаєте паритет, не відправляючи сміття. Та сама збірка, різні цілі.

Жарт #1: Якщо у вашому Dockerfile одна стадія, то він не «простий», а «оптимістичний».

Контракт середовища виконання: що потрібно зберегти

Образ для виконання — це контракт між вашим додатком і userland ОС, який ви відправляєте. Мультистадійні збірки полегшують випадкове порушення цього контракту. Ось що зазвичай зникає:

1) Спільні бібліотеки та динамічний лоадер

Якщо ви компілюєте з увімкненим CGO (поширено для поведінки DNS, биндингів SQLite, обробки зображень тощо), вам потрібні правильні libc і лоадер у стадії виконання. Якщо ви збираєте на Debian, а запускаєте на Alpine, ви можете побачити класичну помилку:

  • exec /app: no such file or directory (хоча файл існує), бо шлях до динамічного лоадера відсутній у runtime.

2) CA-сертифікати

Ваш сервіс спілкується з HTTPS-ендпоінтами. Без CA-сертифікатів TLS не працюватиме. Багато «мінімальних» образів не включають їх за замовчуванням.

3) Дані часових поясів і локалі

UTC-тільки підходить доти, поки не стане проблемою — звіти, відображення часових міток для клієнтів і відповідне логування можуть швидко спотворитися.

4) Користувачі, дозволи та власність файлів

Копіювання між стадіями зберігає власність файлів, якщо ви не вкажете інше. Якщо ви запускаєте як не-root (так треба), перевірте, чи файли читаються/виконуються.

5) Семантика entrypoint

Shell-форма проти exec-форми має значення. Якщо ви покладаєтеся на розгортання shell-розширень, але видалили shell — ви дізнаєтесь під час виконання, а не збірки.

6) Очікування щодо спостережуваності

Якщо ваш playbook для on-call передбачає наявність curl всередині контейнера, distroless вас розчарує. Використовуйте sidecar-і, тимчасові debug-контейнери або окремі debug-цілі. Приймайте рішення навмисно.

Практичні завдання: команди, виходи та рішення (12+)

Це реальні завдання, які ви можете виконати сьогодні. Кожне включає: команду, що означає вивід і яке рішення приймати.

Завдання 1: Порівняйте розміри образів і вирішіть, чи потрібна оптимізація

cr0x@server:~$ docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"
REPOSITORY   TAG       SIZE
myapp        fat       1.12GB
myapp        slim      142MB
myapp        distroless 34.6MB

Значення: у вас є можливість зменшити розмір на порядок.

Рішення: якщо розгортання повільні, зберігання в реєстрі дороге або сканери завалені, використовуйте мультистадійні збірки. Якщо ваш образ уже ~60–150MB і стабільний, віддайте пріоритет коректності перед відтискуванням ще 10MB.

Завдання 2: Проінспектуйте шари, щоб знайти, що роздуте

cr0x@server:~$ docker history --no-trunc myapp:fat | head -n 8
IMAGE          CREATED        CREATED BY                                      SIZE      COMMENT
3f2c...        2 hours ago    /bin/sh -c npm install                          402MB
b18a...        2 hours ago    /bin/sh -c apt-get update && apt-get install   311MB
9c10...        3 hours ago    /bin/sh -c pip install -r requirements.txt     198MB
...

Значення: ви відправляєте залежності збірки й кеші пакетів.

Рішення: перенесіть інструменти компіляції/інсталяції в builder-стадію; переконайтеся, що кеші не копіюються в runtime; використовуйте npm ci --omit=dev або еквівалент.

Завдання 3: Підтвердьте, які стадії існують і як вони названі

cr0x@server:~$ docker buildx bake --print 2>/dev/null | sed -n '1,40p'
{
  "target": {
    "default": {
      "context": ".",
      "dockerfile": "Dockerfile"
    }
  }
}

Значення: ваша конфігурація збірки проста; виявлення стадій відбувається у Dockerfile.

Рішення: явно називайте стадії (AS build, AS runtime), щоб рядки copy не псувалися з часом.

Завдання 4: Зберіть конкретну ціль, щоб перевірити тільки стадію виконання

cr0x@server:~$ docker build --target runtime -t myapp:runtime .
[+] Building 21.3s (12/12) FINISHED
 => exporting to image
 => => naming to docker.io/library/myapp:runtime

Значення: стадія runtime збирається і експортується. Добрий старт.

Рішення: налаштуйте CI так, щоб явно будувати ціль runtime, а не лише дефолтну стадію.

Завдання 5: Запустіть контейнер і перевірте помилки типу «збирається, але не запускається»

cr0x@server:~$ docker run --rm myapp:runtime
standard_init_linux.go:228: exec user process caused: no such file or directory

Значення: зазвичай це невідповідність динамічного лоадера / libc, а не відсутній бінарник.

Рішення: перевірте, чи бінарник динамічно зв’язаний і чи базовий образ має правильний лоадер (glibc vs musl). Виправте вибір бази або параметри компіляції.

Завдання 6: Перевірте, чи бінарник динамічно зв’язаний (інспекція builder-стадії)

cr0x@server:~$ docker run --rm --entrypoint /bin/bash myapp:build -lc "file /out/app && ldd /out/app || true"
/out/app: ELF 64-bit LSB pie executable, x86-64, dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, ...
	linux-vdso.so.1 (0x00007ffd6b3d9000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f9d7c3b4000)
	/lib64/ld-linux-x86-64.so.2 (0x00007f9d7c5b6000)

Значення: він динамічно зв’язаний і очікує шляхи лоадера у стилі Debian.

Рішення: запускайте на Debian/Ubuntu/distroless-glibc сумісній базі, або перебудуйте статично (якщо можливо), або ретельно додайте потрібні бібліотеки в образ.

Завдання 7: Перевірте наявність CA-сертифікатів у runtime-образі

cr0x@server:~$ docker run --rm --entrypoint /bin/sh myapp:runtime -lc "ls -l /etc/ssl/certs/ca-certificates.crt 2>/dev/null || echo missing"
missing

Значення: TLS-виклики не працюватимуть, якщо ваш рантайм не пакує сертифікати (багато рантаймів їх не містять повністю).

Рішення: встановіть ca-certificates у стадії runtime (або скопіюйте з builder), або перейдіть на базу, що їх включає.

Завдання 8: Перевірте вихідний TLS із середини контейнера (коли є інструменти)

cr0x@server:~$ docker run --rm --entrypoint /bin/sh myapp:slim -lc "curl -fsS https://example.com | head"
<!doctype html>
<html>
<head>

Значення: сертифікати і DNS працюють, egress мережі працює, базова працездатність рантайму є.

Рішення: якщо це падає, не гадьте — перевірте сертифікати, DNS, проксі та мережеві політики перед тим, як знову чіпати Dockerfile.

Завдання 9: Підтвердьте, що runtime-образ містить потрібні конфігураційні файли

cr0x@server:~$ docker run --rm --entrypoint /bin/sh myapp:runtime -lc "ls -l /app/config || true"
ls: cannot access '/app/config': No such file or directory

Значення: ви забули скопіювати конфігураційні дефолти, шаблони, міграції або статичні ресурси.

Рішення: явно COPY цих артефактів з контексту збірки або з виводів builder; не покладайтеся на «було в старому образі».

Завдання 10: Підтвердьте користувача і дозволи файлів (не-root runtime)

cr0x@server:~$ docker run --rm --entrypoint /bin/sh myapp:slim -lc "id && ls -l /app && test -x /app/app && echo executable"
uid=1000(node) gid=1000(node) groups=1000(node)
total 18240
-rwxr-xr-x 1 root root 18673664 Jan  2 12:11 app
executable

Значення: бінарник виконується, але належить root.

Рішення: вирішіть, чи важлива власність. Для читання/виконання це ок; для запису логів, тимчасових файлів, кешів — це проблема. Віддавайте перевагу COPY --chown=node:node для директорій додатку, що потребують запису.

Завдання 11: Виміряйте час збірки та ефективність кешу

cr0x@server:~$ DOCKER_BUILDKIT=1 docker build --progress=plain -t myapp:test . | sed -n '1,35p'
#1 [internal] load build definition from Dockerfile
#2 [internal] load metadata for docker.io/library/node:22-bookworm
#3 [build 1/6] WORKDIR /app
#4 [build 2/6] COPY package*.json ./
#5 [build 3/6] RUN npm ci
#5 CACHED
#6 [build 4/6] COPY . .
#7 [build 5/6] RUN npm run build
...

Значення: крок інсталяції залежностей закешований; копіювання коду інвалідовує пізніші шари лише.

Рішення: тримайте дескриптори залежностей скопійованими перед сорсом, щоб невеликі зміни коду не руйнували весь шар залежностей.

Завдання 12: Перевірте, що насправді потрапило у файлову систему runtime-образу

cr0x@server:~$ docker run --rm --entrypoint /bin/sh myapp:slim -lc "du -sh /app/* | sort -h | tail"
4.0K	/app/package.json
16M	/app/node_modules
52M	/app/dist

Значення: node_modules менший за dist; ви відправляєте лише production-залежності (добре).

Рішення: якщо node_modules величезний — ймовірно ви відправили devDependencies або кеш збірки. Виправте команду інсталяції та .dockerignore.

Завдання 13: Виявлення випадкового включення секретів або сміття збірки

cr0x@server:~$ docker run --rm --entrypoint /bin/sh myapp:slim -lc "find /app -maxdepth 2 -name '*.pem' -o -name '.env' -o -name 'id_rsa' 2>/dev/null | head"

Значення: очевидних секретних файлів не знайдено (це не повний аудит, але швидка перевірка здорового глузду).

Рішення: якщо щось знайдеться — зупиніть лінію. Виправте контекст збірки і .dockerignore, потім повертайте секрети, як треба.

Завдання 14: Швидке порівняння CVE-поверхні через список пакетів

cr0x@server:~$ docker run --rm --entrypoint /bin/sh myapp:slim -lc "dpkg -l | wc -l"
196

Значення: встановлено 196 пакетів. Це багато потенційної поверхні для патчів.

Рішення: якщо можна перейти на distroless або тоншу базу, зберігши контракт виконання — ви зменшите обсяг патчів. Якщо ні — принаймні фіксуйте версії і патчте передбачувано.

Завдання 15: Підтвердьте, що entrypoint та cmd такі, як ви думаєте

cr0x@server:~$ docker inspect myapp:runtime --format '{{json .Config.Entrypoint}} {{json .Config.Cmd}}'
["/app"] null

Значення: контейнер використовує exec-form entrypoint; shell не потрібен.

Рішення: залишайте так. Якщо ви бачите ["/bin/sh","-c",...] у мінімальному образі — очікуйте помилок виконання, коли shell відсутній.

План швидкої діагностики

Коли мультистадійна «оптимізація» ламає продакшен, у вас немає часу на контейнерну філософію. Потрібна послідовність, яка швидко знаходить вузьке місце.

По-перше: класифікуйте помилку (запуск vs обслуговування vs зовнішні виклики)

  • Контейнер не стартує: відсутній entrypoint, невідповідність лоадера, дозволи, неправильна архітектура.
  • Стартує, потім падає: відсутні конфіги/артефакти, відсутні змінні оточення, segfault від невідповідності libc, неправильний робочий каталог.
  • Стартує і обслуговує, але функції зламані: відсутні CA-сертифікати, часові пояси, шрифти, кодеки зображень, локалі, відмінності в поведінці DNS.

По-друге: підтвердьте, що ви відправили (реально), а не те, що мали на увазі

  • Інспектуйте entrypoint/cmd (docker inspect).
  • Перелічіть очікувані файли всередині runtime (ls, du).
  • Перевірте режим лінкування бінарника (file, ldd у builder-стадії).

По-третє: валідуйте контракт виконання одним пробним запитом

  • Для HTTPS-клієнтів: перевірте наявність CA-бандлу; протестуйте TLS до відомого ендпоінта (у debug-образі, якщо потрібно).
  • Для додатків, чутливих до DNS: перевірте налаштування резолвера і поведінку; підтвердьте /etc/resolv.conf всередині контейнера.
  • Для запису у файлову систему: перевірте користувача, дозволи та записувані шляхи (/tmp, каталоги кеша додатку).

По-четверте: вирішіть шлях виправлення

  • Невідповідність бази: змініть runtime-базу, не латаючи бібліотеки, якщо ви не любите сюрпризів.
  • Відсутні артефакти: копіюйте їх явно, додайте тести, щоб збірка падала, якщо потрібні шляхи відсутні.
  • Занадто мінімально для дебагу: додайте debug-ціль; не засовуйте shell у продакшен «на всякий випадок».

Типові помилки: симптом → корінна причина → виправлення

Цей розділ існує, бо більшість помилок повторюється. Команди змінюються; фізика — ні.

1) «exec … no such file or directory», але файл існує

  • Симптом: контейнер одразу виходить; помилка згадує «no such file or directory».
  • Корінна причина: відсутній шлях динамічного лоадера (glibc бінарник у musl-образі), невірна архітектура або CRLF-кінець рядка в скриптах.
  • Виправлення: запустіть file і ldd у builder-стадії; вирівняйте базовий образ з libc; переконайтеся у правильних GOOS/GOARCH; використовуйте exec-form entrypoint; нормалізуйте кінці рядків.

2) Помилки TLS: «x509: certificate signed by unknown authority»

  • Симптом: додаток стартує, але не може звертатися до HTTPS-залежностей.
  • Корінна причина: відсутній бандл CA-сертифікатів у runtime-образі.
  • Виправлення: встановіть ca-certificates у runtime; або скопіюйте бандл сертифікатів з builder; перевірте це TLS-пробою.

3) Працює в CI, падає в продакшені: відсутні конфіги/шаблони/міграції

  • Симптом: помилки виконання про відсутні файли; ендпоінти повертають 500; старт скаржиться на шаблони.
  • Корінна причина: мультистадійне копіювання принесло лише бінарник, а не супутні файли.
  • Виправлення: визначте явну директорію артефактів у builder-виході і копіюйте її повністю; додайте перевірку на етапі збірки, що потрібні шляхи існують.

4) Permission denied при записі логів/кешу

  • Симптом: додаток падає при записі у /app, /var/log або каталоги кешу; працює як root.
  • Корінна причина: runtime запускається як не-root, але скопійовані файли/директорії належать root і не записувані.
  • Виправлення: створіть записувані директорії у стадії runtime; використовуйте COPY --chown; встановіть USER навмисно; віддавайте перевагу /tmp або виділеним томам для запису.

5) «sh: not found» або «bash: not found»

  • Симптом: контейнер падає, бо намагається виконати shell-команду.
  • Корінна причина: shell-форма CMD/ENTRYPOINT у мінімальному образі, який не має shell.
  • Виправлення: використовуйте exec-form (JSON-масив); прибирайте shell-скрипти або виберіть відповідну базу; для складної логіки старту розгляньте маленький init-бінарник.

6) Образ став маленьким, але збірки болісно повільні

  • Симптом: час CI збірки збільшився після «оптимізації».
  • Корінна причина: поганий порядок кешування, копіювання всього репозиторію до інсталяції залежностей або відключення BuildKit-функцій.
  • Виправлення: копіюйте маніфести залежностей першими; використовуйте BuildKit; налаштуйте .dockerignore; розгляньте cache mounts для пакетних менеджерів.

7) «Працює локально, падає в Kubernetes» після зменшення

  • Симптом: локальний docker run працює; у кластері падає з DNS/таймаутами/дозволами.
  • Корінна причина: контекст безпеки кластера використовує інший UID; файлову систему змонтовано тільки для читання; мережеві політики суворіші; відсутні інструменти для інспекції.
  • Виправлення: вирівняйте користувача runtime; записуйте у схвалені шляхи; тестуйте у тому ж security context; надайте debug-ціль або тимчасовий метод дебагу.

Жарт #2: Distroless чудовий, поки ви не усвідомите, що законтейнеризували вашу здатність тихо панікувати.

Три корпоративні міні-історії з польових боїв

Міні-історія 1: Інцидент, спричинений хибним припущенням

У них був Go-сервіс, який «очевидно» компілювався в статичний бінарник. Усі так говорять про Go-сервіси одразу перед тим, як хтось ввімкне CGO для однієї невеликої фічі і забуде про це. Команда змінила runtime-базу з Debian slim на Alpine, бо розмір виглядав фантастично на слайді.

Розгортання відбулося в робочий час. Кілька подів стартували, потім одразу впали. Логи були образливими: «no such file or directory». Люди перевірили образ; бінарник був на місці. Хтось сказав, що реєстр його пошкодив. Хтось інший сказав, що Kubernetes «має один з тих днів». Класика.

Насправді: бінарник був динамічно зв’язаним з glibc і очікував /lib64/ld-linux-x86-64.so.2. Alpine цього не мав. Бінарник навіть не дістався до main(). Це не була баг-программа. Це була проблема лоадера.

Виправлення було нудним: повернути runtime-базу на сумісний з glibc образ, а потім вирішити, чи дійсно потрібен CGO. Пізніше вони перебудували з CGO_ENABLED=0 і перевірили за допомогою file та ldd у CI.

Урок закріпився: «мінімальний» — це контракт виконання, а не дієта. Якщо ви не можете описати залежності бінарника, ви не зможете безпечно зменшити контейнер.

Міні-історія 2: Оптимізація, що повернулася бумерангом

Платформна команда вирішила пришвидшити CI, кешуючи все. Вони ввели агресивне кешування BuildKit і рано в Dockerfile скопіювали весь монорепозиторій, щоб збірки мали «контекст». Образ став меншим завдяки мультистадійності. Але часи збірок перетворилися на повільну катастрофу.

Чому? Бо копіювання всього репозиторію перед інсталяцією залежностей інвалідовувало кеш майже при кожному коміті. Зміни в документації поруч з сервісом призводили до перевбудови шару Node-залежностей. Інженери почали уникати мерджів біля релізів, бо збірки були занадто повільні для швидкої ітерації.

Команда додала другу «оптимізацію»: тотальний RUN rm -rf на фінальному етапі builder. Це не допомогло розміру runtime (мультистадія вже відкинула builder), але збільшило час збірки і ще більше зруйнувало повторне використання кешу, бо шари постійно змінювалися.

Врешті вони переробили підхід: контексти, специфічні для сервісу, строгий .dockerignore і раннє копіювання маніфестів залежностей. Також припинили «очищати» builder-шари, які ніколи не відправляються.

Операційний висновок: оптимізуйте те, за що ви реально платите. У мультистадійних збірках розмір runtime часто дешево виправляти; латентність збірки потребує дисципліни і структури.

Міні-історія 3: Нудна, але правильна практика, що врятувала день

Одна корпоративна команда переносила Python API у мультистадійні збірки. Усі хотіли distroless, бо безпека любить фразу «без shell». SREs відмовлялися не через любов до shell, а через любов до сну.

Вони зробили дві цілі: runtime (мінімальна) і debug (ті ж бінарні файли, плюс shell і базові мережеві інструменти). Debug-образ не розгортали за замовчуванням. Його використовували лише для розслідувань у контрольованому неймспейсі з явними дозволами.

Через два місяці залежність почала періодично падати при TLS-рукопотисканні через ротацію корпоративного MITM-проксі. У production-образі не було curl або openssl, як задумано. On-call запустив debug-ціль, щоб відтворити проблему, і підтвердив проблему з CA-ланцюгом за лічені хвилини, а не години.

Вирішення було простим: оновити довірені CA і перевірити налаштування проксі. Справжня перемога — час на діагностику. Команда не мусила перебудовувати «одноразовий debug-образ» під час інциденту під наглядом усіх.

Урок: наявність debug-цілі — це нудна робота з управління. Це також те, як уникнути перетворення простоїв на імпровізаційний театр.

Чеклісти / покроковий план

Покроковий план: міграція до мультистадійних збірок без зламу виконання

  1. Почніть з відомої робочої бази. Збережіть поточний Dockerfile/образ як орієнтир. Не видаляйте його, поки новий не доведе свою роботу.
  2. Назвіть стадії. Використовуйте AS build і AS runtime. Майбутній ви подякує поточному.
  3. Визначте директорію артефактів. Наприклад: /out містить бінарники, assets, міграції і конфіги, які мають потрапити в образ.
  4. Копіюйте лише артефакти в runtime. Не весь репозиторій. Не /root/.cache. Не ваші почуття.
  5. Оберіть runtime-базу, що відповідає зв’язуванню. Якщо динамічний glibc: використовуйте Debian/Ubuntu/distroless-base. Якщо статичний: distroless-static може бути чудовим.
  6. Додайте перевірки контракту виконання в CI. Перевіряйте наявність очікуваних файлів; тип бінарника; наявність CA-бандлу, якщо потрібно.
  7. Запускайте як не-root. Встановіть USER. Виправте власність файлів за допомогою COPY --chown і створюйте записувані директорії явно.
  8. Створіть debug-ціль. Ті ж бінарні файли, додаткові інструменти. Не відправляйте її в продакшен за замовчуванням, але тримайте можливість збірки.
  9. Виміряйте до/після. Розмір образу, час завантаження, час збірки, час сканування, час старту. Обирайте оптимізації, що реально покращують операційну роботу, а не лише естетику.
  10. Впроваджуйте поступово. Canary-розгортання. Слідкуйте за логами щодо відсутніх файлів/бібліотек. Якщо вас щось дивує — ваші перевірки неповні.

Чекліст: пункти контракту виконання

  • Entrypoint у exec-formі та існує
  • Архітектура бінарника відповідає вузлам кластера
  • Лінкування бінарника відповідає libc бази образу
  • Наявні CA-сертифікати для HTTPS
  • Стратегія часових поясів/локалі визначена (лише UTC або включити tzdata)
  • Налаштований не-root користувач; створені записувані директорії
  • Усі необхідні assets/config/міграції скопійовані
  • Healthcheck працює (або зовнішні health checks враховують мінімальні образи)

Поширені питання

1) Чи завжди варто використовувати мультистадійні збірки?

Ні. Якщо ваш runtime-образ уже малий і стабільний, і ви рідко перебираєтеся, ви можете не отримати достатньої вигоди. Але для більшості сервісів з частими деплойментами це варто зробити один раз і підтримувати правильно.

2) Чи варто використовувати Alpine для runtime, щоб заощадити місце?

Тільки якщо ви впевнені, що ваші runtime-залежності сумісні з musl, або ви збирали спеціально для Alpine. Інакше використовуйте Debian slim або distroless-варіанти, що відповідають вашому лінкуванню.

3) Distroless чи slim: що обрати?

Distroless, коли у вас сильна спостережуваність і чіткий шлях дебагу (debug-ціль, тимчасові debug-контейнери). Slim — коли вашій організації ще потрібна енергія «підключитися в контейнер», щоб пережити інциденти.

4) Чому мій образ зменшився, але старт став повільнішим?

Зазвичай це не через розмір образу. Це через відсутність кешів (очікувано), холодну JIT-компіляцію, зміни в поведінці DNS або логіці ініціалізації. Вимірюйте час старту і перевіряйте, що змінилося, окрім байтів.

5) Як уникнути копіювання секретів у runtime-стадію?

Тримайте контекст збірки чистим за допомогою .dockerignore, не копіюйте весь репозиторій, якщо вам потрібні лише артефакти, і ніколи не випікайте runtime-секрети в образи. Використовуйте механізми інжекції секретів під час виконання.

6) Чи можна запускати без shell і все одно ефективно дебажити?

Так. Використовуйте debug-цільний образ, sidecar-і або тимчасові debug-контейнери. Головне — планувати дебаг, а не вдавати, що він не знадобиться.

7) Який найнадійніший спосіб працювати з сертифікатами в мінімальних образах?

Віддавайте перевагу базі, що вже включає CA-сертифікати, або явно встановлюйте/копіюйте відомий CA-бандл. Потім протестуйте TLS в інтеграційній перевірці, яка голосно падає при проблемах.

8) Як зберегти швидкі часи збірки з мультистадійними Dockerfile?

Впорядкуйте Dockerfile для кешування: копіюйте маніфести залежностей першими, встановлюйте залежності, потім копіюйте сорс. Використовуйте BuildKit. Налаштуйте .dockerignore, щоб невідносні файли не інвалідовували шари.

9) Чи нормально мати кілька фінальних стадій?

Так. Це потужний шаблон: runtime для production, debug для інцидентів, test для CI. Та сама вихідна база і артефакти, різна упаковка.

10) Що робити, якщо моєму додатку потрібні OS-пакети під час виконання?

Тоді встановіть їх у стадії runtime — навмисно і мінімально. Мультистадійні збірки не забороняють runtime-залежності; вони забороняють випадкові.

Висновок: наступні кроки, що приносять користь

Якщо взяти одне: мультистадійні збірки — це не трюк для перемоги в конкурсі «найменший образ». Це механізм явно визначити та перевірити залежності середовища виконання.

Наступні кроки, які ви можете зробити цього тижня:

  1. Виберіть один сервіс з надто великим образом і реалізуйте розділення builder/runtime з іменованими стадіями.
  2. Додайте CI-перевірки, які валідовують контракт runtime: лінкування/архітектура бінарника, наявність потрібних файлів та CA-сертифікатів, якщо потрібно.
  3. Створіть debug ціль і задокументуйте, коли її можна використовувати.
  4. Виміряйте розмір образу, час збірки і час розгортання до/після. Залишайте лише ті зміни, що покращують операційну сторону, а не лише естетику.
← Попередня
Заголовки електронної пошти: правильно читати «Received» — відстежуйте, де трапився збій доставки
Наступна →
MySQL проти PostgreSQL: повнотекстовий пошук — коли вбудованого достатньо, а коли це пастка

Залишити коментар