Інвалідація кешу під час збірки Docker: чому збірки повільні і як їх пришвидшити

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

Кожна команда рано чи пізно стикається з цим моментом: ви змінюєте один рядок у коді додатку, а «швидке перезбирання» перетворюється на 12-хвилинне паломництво через інсталяцію залежностей, оновлення пакетів ОС і підозріло багато «Sending build context.»

Зазвичай Docker не «гальмує». Інструментально інвалідується ваш кеш — іноді правильно, іноді випадково, іноді тому, що ваша CI-система поводиться з кешами як із сороміцькими секретами. Давайте зробимо збірки передбачувано швидкими без культу рандомних прапорів.

Зміст

Ментальна модель: що таке кеш насправді

Кеш збірки Docker виглядає як магія, поки ви не почнете уявляти його як файлову систему і компілятор водночас. Кеш — це не «загальна пам’ять про збірку». Це набір незмінних результатів збірки, адресованих вхідними даними. Змінюєте вхідні дані — отримуєте інший результат. Ніяких драм. Ніяких винятків. Тільки сльози.

Шари — це результат інструкцій, адресований за вмістом

Традиційні збірки Docker виконують Dockerfile рядок за рядком. Кожна інструкція породжує знімок файлової системи («шар») і метадані. Ключ кешу для цієї інструкції фактично є дайджестом від:

  • самої інструкції (включно з точними рядками, значеннями ARG у межах області видимості, змінними оточення ENV у межах області видимості).
  • дайджесту батьківського шару (те, що йшло раніше).
  • для COPY/ADD — вмісту й метаданих файлів, що копіюються у збірку.

Якщо будь-що з цього змінюється — інструкція дає пропуск кешу, і всі інструкції після неї також будуть пропускати, бо змінився батьківський шар. Оце і є ефект «інвалідує решту збірки».

Build context — частина площі вхідних даних

Коли ви запускаєте docker build ., Docker надсилає build context до билдера. Якщо ваш контекст величезний, збірка може бути повільною ще до старту. Також, коли ви робите COPY . ., ви кажете Docker: «хешуй по суті все». Один змінений таймстамп або згенерований файл може отруїти ваш кеш.

BuildKit змінює правила, але не фізику

BuildKit — новіший рушій збірки. Він додає паралелізацію, краще кешування, експорт/імпорт кешу та фічі як cache mounts і secrets. Він не робить чародійськи погані Dockerfile хорошими. Він просто робить наслідки помилок помітнішими і швидшими.

Парафразована ідея (атрибуція): Gene Kim часто стверджує, що надійність походить від зворотних зв’язків і швидкого відновлення, а не від героїзму. Кешування Docker — це теж проблема зворотних зв’язків.

Цікавинки й історичний контекст (можна використати на вечірках)

  1. Ранній рушій збірки Docker («класичний builder») був однопотоковим і досить буквальним; пізніше BuildKit запровадив DAG-граф збірки, що дозволяє паралелізувати незалежні кроки.
  2. Кешування шарів існувало ще до Docker; union-файлові системи і копіювання при записі використовувалися в різних формах ще до популярності контейнерів.
  3. RUN --mount=type=cache у BuildKit змінив підхід до кешування менеджерів пакетів: тепер можна зберігати кеші, не запікаючи їх у фінальний образ.
  4. Історично багато CI-систем робили Docker-збірки в привілейованому режимі з локальними кешами; сучасні епемерні раннери зробили «перехід кешу» навмисною архітектурною опцією, а не випадковістю.
  5. Файл .dockerignore існує тому, що люди систематично відсилали демережі гігабайтів сміття (наприклад, node_modules) демонізму даемона і потім дорікали Docker.
  6. Мультистадійні збірки популяризували чіткий поділ «залежності для збірки» і «рантайм-образу», що також робить стратегію кешування більш свідомою.
  7. Специфіка OCI зробила образи портативнішими, але портативність не означає, що ваш кеш йде разом із ними — локальність кешу залишається практичним обмеженням.
  8. Ключі кешу Docker чутливі до порядку інструкцій; невелике переміщення рядків може перевести вас із «секунд» у «хвилини», не змінивши функціональності образа.
  9. Віддалений експорт/імпорт кешу (наприклад, через Buildx) фактично є «кешуванням артефактів збірки», схожим за духом на Bazel або кеші компілятора, але з контейнерними шарами як артефактами.

Чому виникають пропуски кешу: реальна механіка

1) Ви змінили щось раніше, ніж думали

Найпоширеніший сюрприз із повільною збіркою — це зміна файлу, який використовується в ранньому COPY. Якщо ви COPY . . перед встановленням залежностей, будь-яка зміна коду примушує перевстановлення. Це не Docker має злий намір — це наївний Dockerfile.

2) Ваш build context нестабільний

Згенеровані файли. Метадані Git. Локальні артефакти збірки. Файли тимчасових редакторів. Відмінності у CI checkout. Усе це може змінювати хеш вмісту ваших COPY входів. Якщо ці файли в контексті і не ігноруються, вони є частиною ключа кешу.

3) Ви використовуєте «завжди-змінні» інструкції

RUN apt-get update — класична підлість для кешування. Навіть якщо Docker і намагається повторно використовувати кеш, ви, ймовірно, не хочете використовувати шар, створений з індексом пакетів три тижні тому. Маєте суперечливі цілі: швидкість проти свіжості. Обирайте свідомо.

4) Зміни ARG/ENV інвалідовують більше, ніж ви думаєте

Значення ARG у межах області видимості входять в ключі кешу. Також ENV впливає на наступні інструкції. Якщо ви встановили ENV BUILD_DATE=... на початку, вітаю: ви інвалідуєте кеш щоразу при збірці — саме як задумано.

5) Різні билдера — різні кеші

Локальні кеші розробника не є вашими CI-кешами. Навіть у CI різні раннери не ділять кеш, якщо ви явно не експортуєте/імпортуєте його. Люди припускають, що «кеш у реєстрі». Ні. Образ є в реєстрі. Кеш — можливо ні.

6) Ви збираєте для кількох платформ

Multi-arch збірки (linux/amd64 і linux/arm64) породжують різні шари. Повторне використання кешу між архітектурами обмежене, і деякі кроки (наприклад, компіляція нативних залежностей) є платформозалежними.

Жарт №1: кешування Docker як пам’ять — чудове, коли працює, і якось воно забуває саме те, що вам було потрібно п’ять секунд тому.

Швидкий план діагностики (перші/другі/треті перевірки)

Це потік «перестаньте гадати». Запустіть його, коли збірки сповільнюються і в Slack починає пахнути панікою.

Перше: з’ясуйте, куди йде час (контекст чи кроки збірки)

  • Перевірте час передачі build context: якщо «Sending build context» повільно, у вас проблема з контекстом, а не з залежностями.
  • Увімкніть plain progress: подивіться, який крок повільний і чи був він кешований.

Друге: підтвердіть, чи кеш взагалі використовується

  • Шукайте «CACHED» (BuildKit) або «Using cache» (класичний builder).
  • Підтвердіть налаштування билдера та BuildKit: ви можете збиратися з різними рушіями локально і в CI.
  • Підтвердіть експорт/імпорт кешу в CI: епемерні раннери починають порожніми, якщо ви їм не даєте кеш.

Третє: знайдіть перший пропуск кешу і виправте порядок шарів

  • Знайдіть найраніший крок, що пропускається; все після нього — колатеральна шкода.
  • Шукайте ранній COPY . . або змінні ARG.
  • Виправте стабільним шаром залежностей: копіюйте спочатку тільки lockfiles, встановлюйте залежності, потім копіюйте решту.

Четверте: якщо все ще повільно, перевірте вузькі місця у сховищі та мережі

  • Тямущі витягування/записи у реєстр уповільнені? DNS ненадійний? Корпоративний проксі переписує TLS?
  • Диск билдера заповнений або на повільному сховищі? Overlay2 на маленькому VM-диску — дорога забава.

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

Кожне завдання нижче має: команду, що типовий вивід означає, і яке рішення приймати далі. Запускайте їх на машині розробника або CI-раннері (де можливо). Команди навмисно нудні. Нудне — добре.

Завдання 1: Переконайтеся, що BuildKit увімкнено

cr0x@server:~$ docker version --format '{{.Server.Version}}'
27.3.1
cr0x@server:~$ echo $DOCKER_BUILDKIT
1

Що це означає: Сучасні версії Docker підтримують BuildKit; DOCKER_BUILDKIT=1 означає, що CLI використовуватиме його для збірок.

Рішення: Якщо BuildKit не увімкнено, увімкніть його (локально і в CI) перед будь-якою оптимізацією. Інакше ви оптимізуєте не той рушій.

Завдання 2: Запустіть збірку з plain progress, щоб побачити попадання в кеш

cr0x@server:~$ docker build --progress=plain -t demo:cache-test .
#1 [internal] load build definition from Dockerfile
#1 DONE 0.0s
#2 [internal] load .dockerignore
#2 DONE 0.0s
#3 [internal] load metadata for docker.io/library/alpine:3.20
#3 DONE 0.6s
#4 [1/6] FROM docker.io/library/alpine:3.20@sha256:...
#4 CACHED
#5 [2/6] RUN apk add --no-cache bash
#5 CACHED
#6 [3/6] COPY . /app
#6 DONE 0.3s
#7 [4/6] RUN make -C /app build
#7 DONE 24.8s

Що це означає: Кроки, помічені CACHED, повторно використали кеш; кроки без нього виконалися. Тут COPY не був закешований, і крок збірки зайняв 24.8s.

Рішення: Виправте найраніший некешований крок, який не повинен часто змінюватися (зазвичай інсталяція залежностей або завантаження тулчейну).

Завдання 3: Виміряйте розмір build context (тихий вбивця)

cr0x@server:~$ docker build --no-cache --progress=plain -t demo:nocache .
#1 [internal] load build definition from Dockerfile
#1 DONE 0.0s
#2 [internal] load .dockerignore
#2 DONE 0.0s
#3 [internal] load build context
#3 transferring context: 1.42GB 12.1s done
#3 DONE 12.2s

Що це означає: Ви відсилаєте 1.42GB билдеру щоразу. Це вже не збірка; це мувінгова компанія.

Рішення: Додайте/виправте .dockerignore. Якщо не вдається контролювати контекст, жодна хитрість з кешем вам не допоможе.

Завдання 4: Підтвердіть, що в контексті насправді є (швидко і грубо)

cr0x@server:~$ tar -czf - . | wc -c
1523489123

Що це означає: Це наближає стиснений розмір контексту. Якщо він величезний, ви, ймовірно, включили node_modules, результати збірки або каталоги vendor.

Рішення: Уточніть .dockerignore і уникайте COPY . ., поки не створите стабільні шари.

Завдання 5: Перевірте використання диска Docker і чи не виганяється кеш

cr0x@server:~$ docker system df
TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          48        11        19.3GB    12.7GB (65%)
Containers      7         1         412MB     388MB (94%)
Local Volumes   23        8         6.1GB     2.4GB (39%)
Build Cache     214       0         18.9GB    18.9GB (100%)

Що це означає: Є багато кешу збірки, але нічого не активного; він може бути застарілим або ваші збірки не посилаються на нього через зміни в входах чи рушій збірки.

Рішення: Якщо диск обмежений, встановіть політику кешу замість періодичного «випалення» через docker system prune -a. Якщо кеш ніколи не активний — виправте порядок Dockerfile або кешування в CI.

Завдання 6: Перегляньте екземпляри билдера (buildx) і підтвердіть, який ви використовуєте

cr0x@server:~$ docker buildx ls
NAME/NODE       DRIVER/ENDPOINT             STATUS    BUILDKIT   PLATFORMS
default         docker
  default       default                     running   v0.16.0    linux/amd64,linux/arm64
ci-builder*     docker-container
  ci-builder0   unix:///var/run/docker.sock running   v0.16.0    linux/amd64

Що це означає: У вас кілька билдера. Кожен билдера може мати свій кеш. Якщо CI використовує ci-builder, а ваша машина — default, поведінка кешу відрізнятиметься.

Рішення: Стандартизуйте один билдера для CI і документуйте його. Якщо потрібен віддалений кеш — віддавайте перевагу builder-у з драйвером container з явним експортом/імпортом кешу.

Завдання 7: Точно ідентифікуйте перший пропуск кешу (пересоби двічі)

cr0x@server:~$ docker build --progress=plain -t demo:twice .
#6 [3/8] COPY package.json package-lock.json /app/
#6 CACHED
#7 [4/8] RUN npm ci
#7 CACHED
#8 [5/8] COPY . /app
#8 DONE 0.4s
#9 [6/8] RUN npm test
#9 DONE 18.2s

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

Рішення: Якщо npm ci закешовано — ви вже виграли більшу частину битви. Якщо ні — змініть порядок копіювання і ізолюйте lockfiles.

Завдання 8: Підтвердіть, які файли інвалідують ваш шар залежностей

cr0x@server:~$ git status --porcelain
 M src/app.js
?? dist/bundle.js
?? .DS_Store

Що це означає: Згенеровані артефакти (dist/) і файли ОС (.DS_Store) перебувають у робочому дереві.

Рішення: Ігноруйте згенеровані артефакти в .dockerignore (і, ймовірно, у .gitignore), щоб вони не викликали пропусків кешу при широкому копіюванні.

Завдання 9: Перевірте, що .dockerignore дійсно застосовується

cr0x@server:~$ printf "dist\nnode_modules\n.git\n.DS_Store\n" > .dockerignore
cr0x@server:~$ docker build --progress=plain -t demo:ignore-test .
#3 [internal] load build context
#3 transferring context: 24.7MB 0.3s done
#3 DONE 0.3s

Що це означає: Передача контексту впала з «болісно» до «нормальною».

Рішення: Тримайте .dockerignore під рев’ю як production-код. Це — production-код.

Завдання 10: Подивіться історію шарів образа, щоб помітити патерни, що ламають кеш

cr0x@server:~$ docker history --no-trunc demo:cache-test | head -n 8
IMAGE          CREATED        CREATED BY                                      SIZE
sha256:...     2 minutes ago  RUN /bin/sh -c make -C /app build               312MB
sha256:...     2 minutes ago  COPY . /app                                     18.4MB
sha256:...     10 minutes ago RUN /bin/sh -c apk add --no-cache bash          8.2MB
sha256:...     10 minutes ago FROM alpine:3.20                                7.8MB

Що це означає: Великий шар RUN make натякає, що ви виробляєте артефакти збірки всередині образа. Це нормально для стадій збірки, сумнівно для рантайм-стадій.

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

Завдання 11: Перевірте експорт/імпорт кешу в CI (buildx)

cr0x@server:~$ docker buildx build --progress=plain \
  --cache-from=type=local,src=/tmp/buildkit-cache \
  --cache-to=type=local,dest=/tmp/buildkit-cache,mode=max \
  -t demo:cache-export --load .
#10 [4/8] RUN npm ci
#10 CACHED
#11 exporting cache
#11 DONE 0.8s

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

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

Завдання 12: Виявте, чи ваш збірка постійно тягне базові образи

cr0x@server:~$ docker images alpine --digests
REPOSITORY   TAG    DIGEST                                                                    IMAGE ID       CREATED       SIZE
alpine       3.20   sha256:4bcff6...                                                          11f7b3...      3 weeks ago   7.8MB

Що це означає: Базовий образ існує локально з конкретним дайджестом.

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

Завдання 13: Переконайтеся, що --no-cache випадково не використовується

cr0x@server:~$ grep -R --line-number "docker build" .github/workflows 2>/dev/null | head
.github/workflows/ci.yml:42:      run: docker build --no-cache -t org/app:${GITHUB_SHA} .

Що це означає: Хтось примусово ввімкнув холодні збірки в CI. Іноді це заради «свіжості». Частіше — через суєвір’я.

Рішення: Видаліть --no-cache, якщо немає конкретної причини (безпека/комплаєнс) і ви не готові платити цю ціну.

Завдання 14: Перевірте на наявність аргументів, що ламають кеш

cr0x@server:~$ grep -nE 'ARG|BUILD_DATE|GIT_SHA|CACHE_BUST' Dockerfile
5:ARG BUILD_DATE
6:ARG GIT_SHA
7:ENV BUILD_DATE=${BUILD_DATE}

Що це означає: Якщо BUILD_DATE змінюється щоразу і використовується рано, ви інвалідуєте кеш для всього, що після нього.

Рішення: Перемістіть нестабільні метадані в кінець або в LABEL лише у фінальній стадії.

Дизайн Dockerfile, що зберігає кеші живими

Кеш — це не те, що «вмикають». Його заробляють, роблячи входи стабільними. Найшвидший Dockerfile — зазвичай той, що розумно розділяє те, що часто змінюється, і те, що ні.

Правило 1: Відокремлюйте визначення залежностей від коду програми

Якщо залежності визначені lockfiles, копіюйте їх спочатку і встановлюйте залежності до копіювання всього репозиторію. Так зміни коду не змушуватимуть перевстановлення залежностей.

Поганий патерн: копіювати все, потім встановлювати. Це гарантує пропуски кешу при інсталяції залежностей.

Добрий патерн: копіювати лише манифести залежностей, встановити, потім копіювати решту.

Правило 2: Тримайте нестабільні аргументи поза ранніми шарами

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

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

BuildKit cache mounts дозволяють повторно використовувати кеші менеджерів пакетів між збірками без роздування фінальних шарів. Це справжня трансформація, яку дає BuildKit.

Правило 4: Використовуйте мультистадійні збірки як інструмент кешування, а не лише для зменшення розміру

Мультистадійні збірки дозволяють закріпити важкі, повільні операції (компілятори, збірка залежностей) у стадії, що рідко змінюється, зберігаючи рантайм-стадію мінімальною. Також це робить пуші меншими й швидшими, що важить більше, ніж усі визнають.

Правило 5: Фіксуйте базові образи свідомо

Якщо ви використовуєте плаваючі теги на кшталт ubuntu:latest, рано чи пізно отримаєте течію кешу, несподівані оновлення і археологію «працювало в мене». Фіксуйте до дайджесту для відтворюваності, коли це важливо; фіксуйте до стабільного мінорного тегу, коли потрібні контрольовані оновлення.

Правило 6: Використовуйте .dockerignore серйозно

Ігноруйте .git, результати збірки, локальні кеші і каталоги залежностей, які мають бути встановлені в образі. Ваш build context має виглядати як вихідний код, а не як життєпис вашого ноутбука.

BuildKit: сучасний рушій кешування і як ним користуватись

BuildKit — це те місце, де Docker-збірки перестали бути просто послідовністю «виконати інструкції» і стали чимось ближчим до системи збірки. Але треба використовувати його фічі свідомо.

Найсильніші інструменти BuildKit для кешування

  • Cache mounts: повторно використовуйте кеші менеджерів пакетів без коміту їх у шари.
  • Secret mounts: діставайте приватні залежності, не витікаючи токенами у шари образа (також допомагає уникнути «token changed» хаків для кешування).
  • Експорт/імпорт кешу: робіть CI-збірки швидкими навіть на «свіжих» раннерах.
  • Кращий прогрес виводу: діагностика поведінки кешу стає менш гадальною.

Cache mounts: швидкі збірки, чисті образи

Класичний Docker навчив людей видаляти кеші менеджерів пакетів, щоб зменшити образи. Це окей для рантайм-образів. Але це погано для швидкості збірки, якщо ви часто перебираєтеся. Cache mounts дозволяють тримати кеш поза шарами образа.

Якщо ви збираєте мови як Go, Rust, Java, Node, Python — можна кешувати модулі та компіляційні кеші. Точні mount-ші відрізняються, але принцип однаковий: тримайте змінні кеші поза незмінними шарами.

Віддалений кеш: ваш CI-раннер має амнезію

Ефемерні CI-раннери стартують з нуля. Якщо у CI повільно, а локально швидко, зазвичай причина в тому, що локальна машина має «теплий» кеш, а раннер — ні.

Експорт кешу на локальне персистентне сховище — найпростіший шлях. Коли це неможливо, експортуйте в реєстр-торбут. Це не безкоштовно: збільшить трафік реєстру. Але часто дешевше, ніж платити інженерам за споглядання прогрес-барів.

Жарт №2: єдина річ більш ефемерна, ніж CI-раннер — це впевненість того, хто щойно додав --no-cache «на всякий випадок».

Реалії CI: віддалені кеші, ефемерні раннери й здоровий глузд

CI — це місце, де стратегії кешування вмирають, якщо ви їх не проєктуєте. Ключова різниця між локальними збірками і CI — не швидкість. Це персистентність. Розробники мають постійні диски. CI-раннери часто — ні.

Оберіть одну з трьох стратегій кешування в CI

  1. Локальний постійний кеш раннера: працює, коли раннери довго живуть. Простіше, швидше, але менш відтворювано при зміні пула раннерів.
  2. CI-артефакт кеш: зберігайте локальний каталог кешу BuildKit як артефакт CI. Добре працює; залежить від розміру кешу CI і політики видалення.
  3. Реєстровий кеш: експортуйте/імпортуйте кеш через реєстр. Портативно, працює між раннерами, але збільшує трафік і може навантажити реєстр.

Не плутайте шари образа з шарами кешу

Пуш образа не означає автоматично, що його кеш буде повторно використаний наступного разу. Частина кешу можна вивести з існуючих образів (особливо якщо ви збираєте той самий Dockerfile і базу), але надійне повторне використання в CI зазвичай потребує явного експорт/імпорт кешу.

Мережа — це частина збірки

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

У таких середовищах локальні дзеркала і проксі залежностей — не розкіш. Це інфраструктура збірки.

Три короткі історії з корпоративного фронту

Коротка історія №1: Інцидент через хибне припущення

Команда мала розумну мету: тримати базові образи в курсі. Вони використовували FROM debian:stable і запускали apt-get update && apt-get upgrade -y під час збірок. Хтось спитав про кеш; відповіли «все нормально, Docker кешує шари». Це припущення увійшло в продакшн, як ніби воно там керує.

Потім розгорнули новий CI-кластер з епемерними раннерами. З ночі часи збірки підскочили. Пайплайн почав таймаутитись, інженери перезапускали job-и, і пікові навантаження обрушили репозиторій артефактів та дзеркала пакетів ОС. Дзеркала почали дроселювати, збірки повторювалися, і цикл зворотного зв’язку погіршувався: повільні збірки спричиняли більше повторів, що робило збірки ще повільнішими.

Корінь проблеми не був у Docker. Проблема була в тому, що «stable» трактували як стабільне, а кеш — як глобальний. debian:stable змінювався. apt-get update змінював результати. І раннери не мали «теплого» кешу. Кожна збірка була холодним стартом плюс повне оновлення дистрибутиву.

Виправлення було приземленим: зафіксували базові образи до дайджесту для релізної гілки, припинили оновлювати всю ОС під час збірки образа і перестали оновлювати бази безконтрольно; базові образи збирали за розкладом з контрольованим розгортанням. Також експортували BuildKit кеш у спільний бекенд. Часи збірки стали передбачуваними знову, а «передбачувано» важливіше за «швидко», коли відлік на деплой йде.

Коротка історія №2: Оптимізація, що відбилася боком

Інша організація намагалася пришвидшити збірки, об’єднавши кроки Dockerfile. Вони взяли п’ять інструкцій RUN і злили їх в один мегакомандний рядок, щоб «зменшити кількість шарів». Образ виглядав чистішим, і хтось опублікував скрін docker history як трансформацію фітнесу.

Перший тиждень все було гаразд. Потім змінилася одна залежність — оновлення версії пакета. Оскільки все було в одному RUN, пропуск кешу змусив пере виконати довгий ланцюжок: системні пакети, встановлення рантайму, завантаження залежностей, налаштування інструментарію збірки. Кеш мав менше точок входу, тож став менш повторно використовуваним. Вони оптимізували для кількості шарів, а не для часу перезбірки.

У CI стало гірше. Мега-крок було важко діагностувати. З меншими кроками логи б показували «завантаження тулчейну» або «встановлення залежностей» як повільну частину. З мегакроком це був просто 9-хвилинний shell-скрипт, що іноді падав через мережеві помилки. Коли він падав — падав пізно.

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

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

Платформна команда підтримувала «золотий» шаблон збірки контейнера. Він не був модним. Він примусово застосовував гігієну .dockerignore, мультистадійні збірки і інсталяцію залежностей, прив’язану до lockfiles. Також він вимагав, щоб нестабільні метадані (час збірки, git SHA) були застосовані як мітки лише у фінальній стадії.

Коли в індустрії стався інцидент зі supply-chain і всі почали часто перебирати образи, пайплайни цієї команди залишилися спокійними. Не через вдачу — а тому, що їхні перебудови були інкрементними. Базові образи були зафіксовані і перебиралися за розкладом; збірки аплікацій повторно використовували шари залежностей; CI-качі експортувалися у персистентне сховище.

Інші команди пере будувалися з нуля і конкурували за пропускну здатність завантаження тих самих залежностей. Збірки цієї команди в основному потрапляли в кеш і тягнули лише те, що змінилося. Під час паніки вони швидше викочували патчі без «червоних» пайплайнів.

Практика, що їх врятувала, не була секретним прапором. Це було ставлення до Dockerfile як до production-коду і до кешування як до інженерної системи: входи, виходи і життєвий цикл. Нудно. Правильно. Ефективно.

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

1) Симптом: «Sending build context» триває вічно

Корінь: роздуте context (node_modules, dist, .git), слабкий .dockerignore або збірка з кореня репозиторія, коли потрібна лише підпапка.

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

2) Симптом: інсталяція залежностей виконується при кожній зміні коду

Корінь: COPY . . перед інсталяцією залежностей; lockfiles не ізольовані; шар залежностей залежить від усього джерела.

Виправлення: копіюйте тільки маніфести залежностей першими (lockfile, список пакетів), встановлюйте залежності, потім копіюйте код.

3) Симптом: CI завжди холодний, хоча локально швидко

Корінь: епемерні раннери без персистентного кешу; немає експорт/імпорт BuildKit кешу.

Виправлення: експортуйте/імпортуйте кеш з buildx; зберігайте локальний каталог кешу як CI-артефакт; або використовуйте реєстровий кеш.

4) Симптом: збірки сповільнилися після «очищення»

Корінь: хтось додав docker system prune -a за розкладом або скоротив збереження кешу, постійно виганяючи build cache.

Виправлення: встановіть дискові бюджети; робіть prune вибірково; зберігайте кеш билдера для активних гілок; уникайте повного prune на загальних раннерах.

5) Симптом: кеші пропускаються при зміні тільки метаданих

Корінь: нестабільні ARG/ENV (дата збірки, git SHA) визначені рано і входять у ключ кешу.

Виправлення: перемістіть метадані в кінець; використовуйте LABEL у фінальній стадії; не запаковуйте час у ранні шари.

6) Симптом: multi-arch збірки дуже повільні

Корінь: компіляція нативних залежностей для кожної платформи; відсутність per-platform кешів; накладні витрати QEMU при крос-компіляції.

Виправлення: використовуйте нативні билдера для кожної архітектури, коли можливо; експортуйте кеші окремо для кожної платформи; зменшіть нативну компіляцію всередині Docker-збірки, де це можливо.

7) Симптом: пуші образів повільні, навіть якщо збірки швидкі

Корінь: рантайм-стадія містить артефакти збірки; великі шари часто змінюються; неправильне використання мультистадійних збірок.

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

8) Симптом: «Вчора це було закешовано» — таємниця

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

Виправлення: стандартизуйте билдера; фіксуйте бази; перевірте .dockerignore; перевірте, які файли змінились; уникайте плаваючих тегів для критичних шляхів.

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

Чекліст A: Виправити повільну збірку сьогодні (30–90 хвилин)

  1. Перезапустіть збірку з --progress=plain і визначте перший дорогий пропуск кешу.
  2. Виміряйте час передачі контексту; якщо >100MB — ставте це як баг.
  3. Додайте/виправте .dockerignore, щоб виключити: .git, node_modules, dist, target, build, сміття редакторів, тестові артефакти.
  4. Рефакторніть Dockerfile так, щоб маніфести залежностей копіювалися першими; встановлюйте залежності; потім копіюйте код.
  5. Перемістіть нестабільні args/labels у фінальну стадію і якнайпізніше.
  6. Перезберіть двічі; другий запуск повинен бути значно швидшим і показувати CACHED для важких кроків.

Чекліст B: Зробити кешування в CI реальним (півдня)

  1. Переконайтеся, що CI використовує BuildKit і консистентний builder (docker buildx).
  2. Оберіть стратегію персистенції кешу: CI-артефакт або реєстровий кеш.
  3. Додайте --cache-from і --cache-to у кроки збірки CI.
  4. Переконайтеся, що ключі кешу враховують гілку або політику mainline (щоб уникнути «отруєння» кешу між несумісними змінами).
  5. Встановіть політики збереження/вилучення, щоб кеш не очищувався щодня.
  6. Додайте метрику в пайплайн: розбивку часу збірки (час контексту vs час збірки vs час пушу).

Чекліст C: Тримати швидкість місяцями (операційна дисципліна)

  1. Переглядайте зміни Dockerfile так само, як production-конфіг: дивіться diff на ризики інвалідації кешу.
  2. Фіксуйте базові образи для релізних гілок; оновлюйте за розкладом.
  3. Підтримуйте стандартний шаблон .dockerignore для кожного стеку мов.
  4. Періодично аудитуйте використання диска билдера; робіть prune обдумано, а не в гніві.
  5. Запускайте збірки в контрольованому середовищі: стабільні версії Docker/BuildKit по всьому CI-флоту.

FAQ

1) Чому при зміні одного файлу перезбирається все після певного кроку?

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

2) Чи впливає .dockerignore лише на розмір контексту чи ще й на кеш?

І на те, й на інше. Він зменшує час передачі і зменшує множину файлів, що можуть інвалідувати COPY кроки. Менше ентропії вхідних даних — більше попадань у кеш.

3) Чи погано використовувати apt-get update у Dockerfile?

Ні, але часто його використовують неправильно. Поєднуйте update і install в одному шарі і не розраховуйте на стабільне кешування, якщо прагнете свіжих індексів. Для відтворюваності фіксуйте пакети або використовуйте кастомні базові образи.

4) Чому локальна збірка швидка, а CI повільна?

Ваш ноутбук має постійний кеш билдера. CI-раннери зазвичай — ні. Експорт/імпорт BuildKit кешу або персистентне сховище для билдера вирішують проблему.

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

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

6) Чи варто зливати RUN кроки, щоб зменшити кількість шарів?

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

7) У чому різниця між образом і кешем?

Образ — це виконуваний артефакт, який ви пушите й деплоїте. Кеш — це метадані збірки і проміжні результати, які пришвидшують наступні збірки. Вони іноді перекриваються, але покладатися на це в CI ненадійно.

8) Чи інвалідує зміна тега базового образа весь кеш?

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

9) Чи безпечні cache mounts? Чи витечуть вони у фінальний образ?

Cache mounts за замовчуванням не комітяться у шари образа. Ось у чому їхній сенс. Кеш зберігається на хості билдера (або експортується в бекенд), а не у фінальній файловій системі образа.

10) Який найвищий ROI-фікс для повільних збірок Docker?

Припиніть копіювати весь репозиторій перед інсталяцією залежностей. Ізолюйте lockfiles, встановіть залежності в стабільному шарі, а потім копіюйте код. Усе інше другорядне.

Наступні кроки, що реально допомагають

Якщо ви хочете швидших збірок — припиніть ставитися до кешування Docker як до настрії, і почніть трактувати його як хешування вхідних даних.

  1. Запустіть одну збірку з --progress=plain і зафіксуйте перший дорогий пропуск кешу.
  2. Виміряйте розмір контексту. Якщо він великий — виправте .dockerignore насамперед. Завжди.
  3. Перефакторіть Dockerfile так, щоб інсталяція залежностей залежала лише від lockfiles, а не від усього дерева джерела.
  4. Увімкніть BuildKit всюди, а потім додайте експорт/імпорт кешу для CI, щоб холодні раннери перестали псувати вам настрій.
  5. Перемістіть нестабільні метадані в кінець і тримайте рантайм-образи мінімальними за допомогою мультистадійних збірок.

Зробіть ці п’ять кроків — і ваші збірки стануть не лише швидшими, а й передбачуваними. Передбачувані збірки — це шлях доставляти вчасно без підкупу богів пайплайнів.

← Попередня
Docker Compose: depends_on вам збрехав — правильна готовність без костилів
Наступна →
ZFS проти btrfs: де btrfs зручний, а де підводить

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