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. Це займає хвилини, але економить години паніки в майбутньому.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ