1. COPY як джерело інвалідації кешу
У виводі збирання вже видно, де болить: важкий RUN ./gradlew ... легко залишається без CACHED. Тепер треба зрозуміти, що саме найчастіше ламає цей кеш. У Java/Gradle-проєкті причина дуже часто не в самому Gradle, а в тому, що саме ми копіюємо в образ і наскільки рано це робимо.
COPY у Dockerfile виглядає як найдобріша інструкція на світі: «візьми файли й поклади їх усередину образу». Після RUN, де щось справді компілюється, завантажується і відбувається магія, COPY здається нудною побутовою справою. Але насправді саме COPY найчастіше перетворює повторні збирання на нескінченний серіал «Знову завантажуємо залежності» (сезон 12, серія 48).
Причина проста: COPY — це межа між зовнішнім світом (вашим проєктом на диску) і внутрішнім світом образу (файловою системою шару). Щойно Docker бачить, що щось змінилося в наборі файлів, який ви копіюєте, він чесно каже: «Гаразд, цей шар уже не такий самий, отже кеш не підходить». Далі вмикається каскад: якщо шар інший, то й усі наступні кроки, які на нього спираються, теж потрібно перескладати.
Щоб далі не ходити колами, запам’ятайте одну думку: COPY ламає кеш не тому, що він «поганий», а тому, що часто виявляється надто широким і надто раннім.
2. Кеш Docker для COPY
Під час docker build Docker на кожному кроці намагається відповісти на запитання: «Чи можу я повторно використати результат із кешу?» Для COPY це звучить так: «Я вже копіював ось цей набір файлів ось сюди — він точно такий самий зараз?»
Тут важливо розуміти: Docker порівнює не ваше відчуття — «та я ж нічого важливого не змінював», — а факти. Якщо змінюється хоча б один файл у наборі копіювання, крок COPY вважається іншим. І це логічно: підсумкова файлова система шару має бути відтворюваною, а не залежати від настрою розробника.
Нижче — зручна таблиця, яка показує, від чого насправді залежить кеш COPY:
| Що змінилося | Приклад | Що відбувається з COPY | Що відбувається з усім нижче по Dockerfile |
|---|---|---|---|
| Текст інструкції COPY | змінили на |
промах кешу | зазвичай перескладається все, що нижче |
| Вміст будь-якого скопійованого файла | змінили src/.../CatalogItemService.java | промах кешу | наступний RUN майже напевно виконається заново |
| Зʼявився/зник файл у папці копіювання | додали src/main/java/.../NewClass.java | промах кешу | каскад далі |
| .dockerignore почав/перестав ігнорувати файл | додали build/ до списку ігнорування | може змінитися набір файлів, отже промах кешу | каскад далі |
| Шлях призначення (куди копіюємо) | було , стало |
промах кешу | каскад далі |
Дуже важлива практична думка: .dockerignore зменшує «сміття» в контексті збирання, але не замінює правильний порядок COPY. Можна ідеально виключити .idea і build/, але все одно поставити COPY . . занадто рано — і тоді будь-який чих у проєкті все одно ламатиме кеш.
3. Каскадна інвалідація
З каскадом у Dockerfile все трохи схоже на доміно. Коли падає одна кісточка — ранній шар перестав збігатися з кешем, — наступні кісточки теж падають не тому, що вони погані, а тому, що стоять на зміненому підґрунті. У Docker це видно дуже буквально: кожен наступний крок «бачить» файлову систему, отриману після попередніх кроків. Якщо ця файлова система інша — крок уже не можна вважати тим самим.
Давайте подивимося на це у вигляді простої схеми:
flowchart TD
A["FROM ..."] --> B["WORKDIR /app"]
B --> C["COPY ..."]
C --> D["RUN ./gradlew bootJar"]
D --> E["ENTRYPOINT ..."]
Якщо COPY (крок C) став іншим, Docker уже не може чесно сказати, що RUN ./gradlew bootJar (крок D) виконається в точно такому самому середовищі. Отже, кеш для D використовувати не можна, і крок виконується заново. Далі все йде за інерцією: будь-який наступний крок теж спирається на новий результат.
Покажемо це на дуже короткому (і дуже правдивому) Dockerfile:
FROM eclipse-temurin:25-jdk
# Робочий каталог усередині образу: далі всі шляхи будуть від нього
WORKDIR /app
# Копіюємо весь контекст збирання цілком (це і є головна пастка для кешу)
COPY . .
# Важке збирання — виконуватиметься заново за будь-якої зміни всередині контексту
RUN ./gradlew bootJar
# Нижче міг би бути будь-який наступний крок — після промаху кешу на COPY він теж піде заново
RUN echo "образ зібрано"
Тепер уявіть, що ви змінили один рядок в одному класі. Для людини це дрібниця. Для Docker — «файли всередині . змінилися», отже COPY . . — новий шар, отже RUN ./gradlew bootJar — знову виконується.
У виводі збирання це зазвичай виглядає приблизно так (спрощено):
[1/5] FROM eclipse-temurin:25-jdk
CACHED [2/5] WORKDIR /app
[3/5] COPY . .
[4/5] RUN ./gradlew bootJar
[5/5] RUN echo "image assembled"
Ми бачимо лише один CACHED (WORKDIR), а все важке знову виконується. І саме тут народжується знамените: «чому Docker-збирання таке повільне?» Відповідь проста: тому що COPY влаштований саме так, як ви його написали.
4. Пастка COPY . .
COPY . . — це та сама кнопка «Зробити добре» в голові новачка. Виглядає красиво, пишеться швидко, майже завжди працює. Але «майже завжди працює» дуже швидко перетворюється на «майже завжди гальмує»: у якийсь момент ви просто перестаєте це терпіти.
Проблема COPY . . у тому, що ви копіюєте весь контекст збирання одним шаром. А ваш репозиторій — це не лише src/. У нашому навчальному проєкті docker-java-catalog-service у корені живуть і Docker-артефакти, і requests/, і scripts/, і README, і вся дрібна інфраструктура, яку ви будете змінювати доволі часто, навіть не чіпаючи Java-код.
Уявіть типову життєву ситуацію: ви правите файл requests/catalog.http, бо хочете трохи зручніше перевірити API. Код застосунку не змінювався. Але під час COPY . . ця зміна потрапляє в шар COPY, а отже кеш COPY ламається. Слідом ламається кеш RUN ./gradlew ..., і Docker знову запускає важкі кроки.
З точки зору розробника це відчувається особливо прикро: «Я ж не змінював бізнес-логіку, чому мені знову чекати на збирання?» А Docker чесно відповідає: «Тому що ви попросили мене копіювати все, а все для мене — важлива частина вхідних даних».
До речі, це одна з причин, чому просто «додати .dockerignore і заспокоїтися» не завжди працює. .dockerignore справді викине з контексту частину сміття, але не розділить ваші зміни за змістом. Якщо ви й далі копіюєте «все корисне одразу», кеш усе одно ламатиметься на будь-якій корисній дрібниці.
5. Порядок COPY: стабільне → мінливе
Щоб COPY перестав бути вашим головним ворогом, треба перестати думати «копіювати все зручно» й почати думати «копіювати зручними шматками». Ми хочемо досягти дуже конкретної поведінки: коли ви змінюєте те, що змінюється часто (зазвичай код), перескладатися має лише нижня частина Dockerfile. А коли ви змінюєте те, що змінюється рідко (наприклад, залежності), перескладатися має верхня частина — і це нормально.
По суті, ми намагаємося скласти проєкт у Dockerfile так само, як ви складали б валізу в подорож. В окрему кишеню кладемо паспорт і квитки — вони змінюються рідко, але без них ніяк, — в окрему шкарпетки — вони змінюються часто, але їх легко перекласти. А не висипаємо всю шафу на підлогу щоразу, коли треба додати одну футболку.
У термінах Docker це звучить дуже інженерно: ви розбиваєте один великий COPY на кілька вужчих COPY, розташованих так, щоб файли, які часто змінюються, потрапляли в образ якомога пізніше. Тоді зміна такого файла ламає лише пізні шари, а не весь верх Dockerfile.
Поки без деталей Gradle, можна зафіксувати просту й корисну думку: файли, які описують збирання і залежності, зазвичай змінюються рідше, ніж вихідний код застосунку. Тому їх має сенс копіювати раніше, а вихідний код — пізніше.
6. Вужчі COPY замість одного
Зараз зробимо невеликий, але принциповий крок: не збираємо фінальний Dockerfile цілком, а просто зсуваємо межу інвалідації вниз. Нам важливо побачити, як один великий COPY перетворюється на кілька вужчих груп.
Спочатку подивимося на «до». Це той самий наївний варіант, де ранній COPY тягне в шар увесь репозиторій:
FROM eclipse-temurin:25-jdk
# Папка застосунку всередині контейнера
WORKDIR /app
# Наївний варіант: копіюємо взагалі все, що є в репозиторії
COPY . .
# Важке збирання: перескладатиметься навіть через правку README або requests/
RUN ./gradlew bootJar
Тепер — схематичне «після». Поки без фінального runtime-етапу і без усіх поліпшень; тут нам важлива саме нова межа кешу:
FROM eclipse-temurin:25-jdk
# Папка застосунку всередині контейнера
WORKDIR /app
# Спочатку копіюємо те, що змінюється рідко: Gradle wrapper і конфігурацію збирання
COPY gradlew ./
COPY gradle ./gradle
COPY build.gradle.kts settings.gradle.kts ./
# Потім — те, що змінюється найчастіше: вихідний код
COPY src ./src
# Перескладання тепер починається переважно з src/, а не з усього репозиторію
RUN ./gradlew bootJar
Що змінилося по суті? Ми перестали копіювати все підряд. Docker тепер може окремо повторно використовувати шари з Gradle wrapper і файлами збирання, а найчастіше змінювану частину — src/ — перераховувати окремо.
Якщо змінюється src/, кеш ламається тільки з COPY src ./src і кроків нижче. Якщо змінюється build.gradle.kts, межа піднімається вище — і це нормально, бо разом із ним змінюється граф залежностей.
Цього вже достатньо, щоб побачити головне: порядок COPY визначає, де саме почнеться перескладання. Тепер залишається розкласти сам Gradle-проєкт за частотою змін і не тягнути в ці шари зайвий шум на кшталт build/ та слідів IDE.
7. Типові помилки під час роботи з COPY
Помилка № 1: ставити COPY . . занадто рано, бо «так коротше».
Це найпопулярніший антипатерн завдовжки в один рядок. Поки проєкт малий, ви майже не відчуваєте болю, але щойно після копіювання з’являється важкий RUN (наприклад, Gradle-збирання), ви починаєте платити часом за кожну дрібницю. Важливо звикнути: коротко — не означає швидко, а «в один рядок» — не означає добре.
Помилка № 2: думати, що .dockerignore повністю рятує від поганого COPY.
.dockerignore справді має бути, і він прибирає з контексту частину зайвого. Але якщо ви копіюєте одним махом усі «корисні» файли проєкту, то кеш усе одно втрачатиме чинність під час кожної зміни в будь-якому з цих корисних файлів. .dockerignore зменшує шум, але не створює структуру.
Помилка № 3: копіювати в образ те, що не потрібно для збирання (або змінюється по сто разів на день).
Наприклад, випадково підтягнути requests/, локальні скрипти або якісь нотатки в той самий шар, який має бути максимально стабільним. У результаті ви змінюєте «документацію», а перескладаєте «залежності». Це особливо неприємно, бо причинно-наслідковий зв’язок неочевидний: здається, що Docker «випадково» перескладає кроки, хоча він просто чесно йде за вашим COPY.
Помилка № 4: дробити COPY на занадто дрібні шматки без розуміння, що саме ви виграєте.
Іноді після усвідомлення проблеми виникає бажання зробити десять COPY на кожен файл, «щоб точно все кешувалося». На практиці це може погіршити читабельність Dockerfile і ускладнити життя команді. Наша мета — не ідеальний кеш будь-якою ціною, а зрозумілий і передбачуваний сценарій збирання. Розумні групи файлів майже завжди кращі, ніж надмірне дроблення.
Помилка № 5: дивуватися каскаду й намагатися його «вимкнути», замість того щоб проєктувати порядок кроків.
Каскад — не баг Docker і не «якась дивна магія». Це прямий наслідок того, що кожен крок спирається на результат попереднього. Замість того щоб боротися з каскадом, ми вчимося керувати тим, де саме він починається. І COPY — найзручніший важіль, щоб зсунути межу перескладання нижче.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ