JavaRush /Курси /Docker for Spring /Buildpacks і шари <...

Buildpacks і шари Spring Boot

Docker for Spring
Рівень 9 , Лекція 2
Відкрита

1. Шари: Docker, Spring Boot, buildpacks

Щойно bootBuildImage перестає бути абстракцією і стає робочою командою, одразу виникає слушне запитання: що відбувається зі знайомою нам шаруватістю? У Docker є своя «рідна мова» оптимізації — це мова шарів: образ збирається як шаруватий пиріг, і кожен шар або повторно використовується, або перебудовується заново. Важливо вловити просту думку: buildpacks не скасовують шари і не ховають їх назавжди — вони просто автоматизують створення шаруватого образу, використовуючи стандартизовані правила, зокрема Spring Boot-специфічні.

Щоб далі не плутатися, ще раз розкладемо значення слова «шар» на два різні рівні, бо саме тут новачки найчастіше губляться.

Шари Spring Boot jar — це логічний поділ вмісту jar на групи: залежності, завантажувач, класи застосунку. Вони існують усередині jar і описані в layers.idx. Ці шари потрібні для того, щоб розумний пакувальник — чи то Dockerfile із jarmode=tools, чи buildpacks — міг відокремити те, що змінюється рідко, від того, що змінюється часто.

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

Зручна схема виглядає так:

flowchart TD
    %% Зверху вниз: jar → buildpacks → підсумковий OCI-образ із реальними шарами
    A["Spring Boot jar (bootJar)"] --> B["BOOT-INF/layers.idx
опис шарів jar"] B --> C["Buildpacks (bootBuildImage)
пакування за правилами CNB"] C --> D["OCI/Docker image
реальні Docker-шари"] D --> E["docker run / docker inspect / docker history
звичайний робочий процес Docker"]

Отже, layers.idx — це «план будинку», а Docker-шари — це «бетон і цегла», які зрештою й опиняються в образі. Buildpacks тут — це бригада будівельників, яка вміє читати план і зводити типовий будинок без того, щоб ви самі писали інструкцію: «візьми цеглу, поклади цеглу».

2. layers.idx: де лежить і як перевірити

Дуже легко прийняти на віру ідею, що Spring Boot jar шаруватий, — ніби це магічна властивість будь-якого jar. Але нам корисніше мислити як інженери: «Покажіть мені файл, інакше я не вірю». Тому насамперед варто один раз руками переконатися, що layers.idx справді є в jar нашого Container-Ready Catalog Service і де саме він лежить.

Припустімо, наш jar уже зібрано — ми робили це раніше, — і він лежить у build/libs/. Тоді можна просто подивитися вміст jar стандартним інструментом jar:

./gradlew bootJar
# Збираємо Spring Boot jar (усередині якого буде layers.idx)

jar tf build/libs/docker-java-catalog-service-0.0.1-SNAPSHOT.jar | grep layers.idx
# Перевіряємо, що layers.idx справді є всередині jar
# BOOT-INF/layers.idx

Так, це буквально файл усередині jar. І він не декоративний: саме він говорить пакувальнику «ось ці файли — шар dependencies, ось ці — application». Можна навіть зазирнути всередину, щоб побачити список шарів, не розпаковуючи весь jar у файловий менеджер:

jar xf build/libs/docker-java-catalog-service-0.0.1-SNAPSHOT.jar BOOT-INF/layers.idx
# Витягуємо рівно один файл із jar, не розпаковуючи весь архів цілком

cat BOOT-INF/layers.idx
# Дивимося, які логічні шари Spring Boot описав усередині jar
# dependencies:
# - "BOOT-INF/lib/..."
# application:
# - "BOOT-INF/classes/..."

Вивід у вас буде довшим, і це нормально. Тут важлива не краса, а сам факт: Spring Boot сам підготував структуру, яку можна використати під час пакування образу.

І ось тепер ключовий місток до buildpacks: у ручному шляху з layered Dockerfile ми витягуємо шари самі через jarmode=tools. А в шляху buildpacks ми шари вручну не витягуємо — але це не означає, що вони зникли. Вони просто стають частиною автоматизованого сценарію.

3. Як buildpacks перетворюють jar-шари на Docker-шари

У світі buildpacks теж є слово layer, але воно означає трохи ширшу ідею. Кожен buildpack уміє додавати шари в образ: один відповідає за JRE, інший — за сертифікати, третій — за застосунок Spring Boot. І ось той самий Paketo Spring Boot buildpack якраз уміє працювати так, щоб не звалювати весь застосунок в один шар, а враховувати різницю між тим, що залежності змінюються рідко, і тим, що ваш код змінюється часто.

Щоб не перетворювати лекцію на розбір внутрішностей CNB lifecycle, нам достатньо простої, але чесної моделі.

Коли ви запускаєте ./gradlew bootBuildImage, усередині відбувається приблизно таке:

1. Система buildpacks розпізнає тип застосунку (detection) і обирає набір buildpacks.
2. Buildpacks готують середовище виконання, наприклад обирають і додають відповідну JVM/JRE в образ.
3. Spring Boot buildpack бере ваш jar і розуміє, що він layered (бачить layers.idx), а потім розкладає вміст по шарах найраціональніше.
4. Підсумковий образ складається з базового run image і набору доданих шарів (launch layers).

Навіть якщо вам здається, що «нічого не видно», насправді видно — у логах збірки. Спробуйте один раз запустити збірку і просто пошукати у виводі слова layers або Spring Boot:

./gradlew bootBuildImage --info
# --info просить Gradle друкувати більше деталей, щоб у логах було видно роботу buildpacks
# ... тут буде багато тексту ...
# ... десь з'явиться згадка про Spring Boot buildpack і шари ...

У різних версіях buildpacks формат логів трохи відрізняється, але принцип один: у виводі буде видно, що вибрано Spring Boot buildpack і що він пакує ваш jar.

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

4. docker history: рентген buildpacks-образу

Коли ви вперше бачите buildpacks-образ, хочеться запитати: «Окей, де мій FROM, де мій COPY, що взагалі вийшло?» Найпростіший «рентген», який не вимагає читати величезний JSON із inspect, — це docker history. Він показує історію шарів і їхні приблизні розміри.

Припустімо, у попередній лекції ми налаштували імʼя образу як docker-java-catalog-service:buildpacks. Тоді:

docker history docker-java-catalog-service:buildpacks
# Показує шари та їхні розміри в порядку додавання в образ
# IMAGE          CREATED        CREATED BY        SIZE      COMMENT
# <layer-id>     ...            ...               ...       ...

І ось як це правильно читати по-людськи. Внизу історії зазвичай будуть шари базового run image: операційна система, базові системні компоненти. Вище підуть шари, додані buildpacks: JVM/JRE, сертифікати, можливо, службові шари. Ще вище — те, що стосується вашого застосунку: залежності та ваш код.

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

Щоб прив’язати це до шарів Spring Boot усередині jar, зручно тримати мінітаблицю відповідності того, що змінюється і як часто. Це не сувора гарантія «1 шар = 1 шар», але це добра інженерна інтуїція, яка допомагає пояснювати поведінку.

Логічна частина Приклад вмісту Як часто змінюється Що хочеться мати в підсумку в образі
Залежності (dependencies) jar-файли бібліотек рідко окремий шар, щоб повторно використовувати
Снапшоти (snapshot-dependencies) snapshot-версії іноді також окремо, щоб не ламати все
Завантажувач (spring-boot-loader) boot loader вкрай рідко окремий шар, майже завжди кешується
Код застосунку (application) ваші classes, ресурси часто окремий верхній шар

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

5. docker image inspect: RootFS і Entrypoint

docker history — чудовий «рентген», але іноді хочеться точніше: «А скільки взагалі шарів?» і «Це справді Docker-шари?». Тут нам допоможе docker image inspect, але ми скористаємося зручним прийомом: замість величезного JSON попросимо Docker вивести лише потрібний фрагмент через --format.

Список реальних шарів файлової системи лежить у .RootFS.Layers:

docker image inspect --format '{{json .RootFS.Layers}}' \
  docker-java-catalog-service:buildpacks
# Виводимо хеші реальних шарів RootFS — це те, що Docker реально кешує
# ["sha256:...","sha256:...","sha256:..."]

Так, це виглядає як нудний список хешів. Але сенс важливий: це і є реальні шари, з яких складається файлова система образу. Їхня кількість зазвичай більша, ніж у вашому ручному Dockerfile, тому що buildpacks — це не один крок COPY, а послідовність дій від різних buildpacks.

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

docker image inspect --format '{{len .RootFS.Layers}} layers' \
  docker-java-catalog-service:buildpacks
# Швидко рахуємо кількість шарів, щоб не гортати весь список хешів
# 18 layers

Число залежатиме від builder/run image і набору buildpacks, але ідея не змінюється: buildpacks-образ — це звичайний образ, просто шарів у ньому «по-чесному багато», бо його зібрано стандартизованим пайплайном.

І ще одна корисна річ, яку можна побачити через inspect, — чим контейнер реально стартує. У шляху Dockerfile ми зазвичай бачимо свій ENTRYPOINT ["java", "-jar", "app.jar"]. У buildpacks-шляху часто побачите launcher або process wrapper buildpacks:

docker image inspect --format '{{json .Config.Entrypoint}}' \
  docker-java-catalog-service:buildpacks
# Конкретне значення залежить від builder/stack/version.
# Можна побачити, наприклад:
# ["/cnb/lifecycle/launcher"]
# або:
# ["/cnb/process/web"]

Обидві форми нормальні. Тут важливий не останній сегмент шляху, а сам факт: контейнер стартує через стандартний runtime buildpacks, який уже запускає Java-застосунок із потрібними домовленостями.

6. Що змінюється під час правки коду

Шари потрібні не для краси і не для того, щоб можна було написати пост у соцмережах: «Дивіться, у мене шари». Шари — це спосіб зробити так, щоб розробник не витрачав час на очікування збірки образу та його повторне завантаження. Тому давайте проведемо уявний експеримент: ви змінюєте один рядок Java-коду в Container-Ready Catalog Service, наприклад у логуванні старту застосунку або в рядку відповіді тестової кінцевої точки.

Якщо образ влаштовано добре, а buildpacks якраз намагаються зробити саме так, то під час повторної збірки:

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

Звідси два практичні ефекти, які ви відчуєте навіть на локальній машині.

По-перше, повторна збірка стає швидшою: Docker може повторно використати шари, які не змінилися, а buildpacks ще й уміють оптимізувати цей процес на своєму рівні.

По-друге, якщо образ зберігається в registry і його завантажують інші люди, або CI, або інший ваш ноутбук, то завантажувати доведеться менше: в ідеалі оновиться лише «верхівка» з кодом.

І тут дуже доречно згадується слово rebase, яке ми бачили в першій лекції дня. Ребейз у контексті buildpacks — це ідея «оновити базові шари, наприклад патч безпеки в run image, без перебудови прикладної частини». Навіть якщо ви поки що не використовуєте rebase вручну, важливо зрозуміти принцип: дуже корисно, коли база і застосунок лежать у різних шарах. І layered-jar плюс buildpacks якраз допомагають тримати це розділення.

Якщо хочеться «помацати» цей експеримент руками, можна зробити просту послідовність: зібрати buildpacks-образ, потім змінити один рядок у Java, а потім знову зібрати й подивитися, що змінилося в docker history. Так, у виводі ви не побачите: «ось це шар application», але побачите, що перебудувалося не все підряд. Головне — дивитися не на абсолютні секунди, бо в усіх різні машини, а на характер поведінки: «перебудувалося багато» чи «перебудувалося мало».

7. Метадані образу: labels і env

Коли люди чують «buildpacks», вони часто бояться, що образ стане чорною скринькою: мовляв, незрозуміло, що всередині, а отже, страшно використовувати. Насправді buildpacks-образи зазвичай навіть більш «самоописні», ніж саморобні, тому що вони люблять додавати метадані.

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

Наприклад, можна вивести частину labels:

docker image inspect --format '{{json .Config.Labels}}' \
  docker-java-catalog-service:buildpacks
# Labels допомагають зрозуміти, якими buildpacks/stack зібрано образ і звідки він узявся
# {"io.buildpacks.stack.id":"...","org.opencontainers.image.revision":"..."}

Вивід буде великим, тому не лякайтеся. Тут вам важливо одне: образ містить метадані про buildpacks, стек, можливо, про версію збірки. Це допомагає діагностиці: коли у вас «на одній машині працює, на іншій — ні», метадані — ваш друг, а не ворог.

Ще корисно подивитися env-змінні, які buildpacks кладуть усередину образу. Не для того, щоб одразу все вивчити, а щоб просто знати: «Так, там є налаштування Java, і вони можуть впливати на запуск».

docker image inspect --format '{{json .Config.Env}}' \
  docker-java-catalog-service:buildpacks
# Дивимося змінні середовища, які buildpacks додали в образ (часто там налаштування JVM)
# ["PATH=...","JAVA_TOOL_OPTIONS=...","SPRING_OUTPUT_ANSI_ENABLED=..."]

Не потрібно зараз розбирати кожну таку змінну окремо. Тут важливий сам факт: buildpacks можуть додавати значення за замовчуванням, які роблять Java-застосунок більш container-friendly. Це плюс стандартизації: менше ручних рішень, але більше вбудованих правил, які варто хоча б помічати.

Окремо зазначу річ, яка зазвичай дивує новачків: у buildpacks-образі ви часто не побачите файл app.jar у корені, як ми робили в шляху Dockerfile. І це нормально: buildpacks можуть розкладати застосунок інакше, але він усе одно запускається і поводиться як звичайний контейнерний сервіс.

Мініперевірка: це все ще звичайний образ

Коли ми довго говоримо про шари і метадані, легко втратити просту реальність: мета не в тому, щоб красиво інспектувати JSON, а в тому, щоб сервіс запускався. Тому давайте зробимо коротку sanity-перевірку: buildpacks-образ має запускатися звичайною командою docker run, а кінцеві точки мають відповідати.

Запускаємо:

docker run --rm -p 8080:8080 docker-java-catalog-service:buildpacks
# ... логи Spring Boot ...
# ... Started CatalogApplication in ... seconds

Кілька моментів, які варто перевірити очима в логах — не як домашнє завдання, а як звичку розробника. Там зазвичай видно, що застосунок стартував, на якому порту, які профілі активні. Якщо вам хочеться зовсім маленький smoke-check, відкрийте інший термінал і перевірте стан Actuator:

curl -i http://localhost:8080/actuator/health
# HTTP/1.1 200
# {"status":"UP", ...}

Якщо це працює, значить ключовий факт підтверджено: шлях buildpacks не створює «особливого типу образу». Він створює звичайний образ, який запускається звичайним Dockerʼом, логується в stdout/stderr і який можна досліджувати через inspect/history як будь-який інший.

І ось це, чесно, найважливіша психологічна точка: після неї buildpacks перестають здаватися містикою. Вони стають лише «іншим способом зібрати те саме».

8. Типові помилки

Помилка № 1: думати, що buildpacks «ігнорують шари Spring Boot», бо ви не витягували їх вручну.
Це дуже людська логіка: якщо я не робив extract через jarmode=tools, значить шаруватості немає. На практиці шаруватість задається layers.idx, і buildpacks через Spring Boot buildpack уміють її враховувати. Правильний спосіб зняти сумнів — один раз перевірити наявність BOOT-INF/layers.idx у jar, а потім подивитися шари образу через docker history і .RootFS.Layers.

Помилка № 2: очікувати побачити в buildpacks-образі той самий файл app.jar у тому самому місці, як у шляху Dockerfile.
У Dockerfile ви самі вирішуєте, що і куди копіювати, тому все виглядає так, як написали ви. У buildpacks шлях інший: пакування стандартизоване, і структура файлів може відрізнятися. Це не поломка і не «поганий образ», а просто інший стиль пакування. Перевіряти потрібно не «чи лежить jar у /app», а чи запускається контейнер і чи відповідає сервіс.

Помилка № 3: порівнювати шаруватість лише за кількістю шарів або тільки за розміром образу.
Іноді студент бачить, що шарів стало більше, і робить висновок: «Стало гірше». Але більше шарів не означає гірше — це може означати, що образ став модульнішим: окремо JRE, окремо сертифікати, окремо залежності, окремо застосунок. Так само «образ менший» не завжди означає «образ кращий», якщо заради цього пожертвували діагностованістю або відтворюваністю.

Помилка № 4: вважати, що раз у Entrypoint зʼявився launcher, то тепер «це не Java» і «Docker запускає щось незрозуміле».
Buildpacks часто запускають контейнер через runtime buildpacks, і конкретний рядок може відрізнятися в різних builder/stack/version. Це нормальна частина стандартизованого runtime. Перевіряйте це практикою: контейнер має запускатися, логи мають бути логами Spring Boot, а ендпоїнти мають відповідати.

Помилка № 5: не робити «рентген» образу після збірки й залишати buildpacks у статусі «ну наче працює, але я не знаю що».
Парадоксально, але buildpacks часто обирають саме для того, щоб «не думати». А потім, коли щось іде не так, думати доводиться ще більше. Щоб уникнути цього, достатньо кількох звичок: один раз подивитися .RootFS.Layers, один раз подивитися Entrypoint, один раз побачити docker history. Це займає хвилини, але економить години паніки в майбутньому.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ