1. Runnable jar и удобная упаковка
Когда вы впервые собрали bootJar и запустили сервис через java -jar, возникает очень естественное чувство: «Ну всё, победа. У нас один файл, он запускается, что ещё надо?». Это нормальная реакция — примерно такая же, как после первого успешного git push: мир стал лучше, воздух свежее, а кофе вкуснее. Но дальше начинается реальность, в которой приложение живёт не один запуск в IDE, а десятки и сотни итераций сборки, пересборки и переносов.
Давайте аккуратно разделим два понятия, которые новичку очень легко спутать.
Runnable (запускаемый) артефакт — это когда вы получили файл (в нашем случае jar), который можно запустить, и он работает: поднимает embedded‑сервер, регистрирует контроллеры, возвращает JSON, читает конфигурацию и не падает в первые три секунды. Это уже огромная ценность: вы смогли вынести приложение «из IDE наружу».
Convenient (удобный для упаковки/повторной упаковки) артефакт — это когда форма этого файла помогает вам дальше жить в цепочке «поменял код → собрал → упаковал → доставил». Удобный артефакт учитывает, что разные части приложения изменяются с разной скоростью, и позволяет инструментам использовать это.
Почему вообще «удобство формы артефакта» имеет значение? Потому что в современных практиках разработки приложение редко живёт как “просто jar, который кто-то запускает вручную”. Обычно jar становится входом в следующий этап упаковки: его куда-то кладут, из него что-то строят, его сканируют, его кэшируют, его переносят между окружениями. И если jar — монолитный «кирпич», то каждый маленький чих в коде превращается в пересборку всего кирпича, даже если 95% содержимого вообще не менялось.
Минимальная отправная точка у нас уже есть, и она выглядит знакомо:
# Собираем runnable jar (результат попадёт в build/libs)
./gradlew bootJar
# Берём именно executable jar из bootJar, а не *-plain.jar
JAR="$(find build/libs -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | head -n 1)"
# Запускаем сервис напрямую, без IDE
java -jar "$JAR"
Так меньше шансов случайно запускать не тот артефакт, если Gradle положил в build/libs versioned jar или рядом лежит *-plain.jar.
Сейчас мы не добавляем новых фич в контроллеры, не трогаем сервисы и не меняем доменную модель. Мы меняем мышление о том, что такое артефакт, и почему “один файл” — ещё не финальная форма мысли.
2. Монолитный jar и переиспользование
Проблема монолитного артефакта не в том, что он “плохой”. Он даже очень хороший — иначе мы бы не радовались bootJar. Проблема в другом: монолитный артефакт не даёт инструментам подсказки, какие части внутри него можно считать «стабильными», а какие «часто меняющимися». Для инструмента это просто один большой файл: поменялся один байт — значит, поменялся “весь файл”.
Чтобы почувствовать боль, не нужно быть DevOps‑инженером, не нужно знать Docker и не нужно понимать, что такое OCI. Достаточно вообразить обычный цикл разработки.
Вы меняете одну строчку в контроллере — например, поправили текст в ответе, или переименовали поле в JSON (в учебном проекте такое тоже бывает). После этого вы запускаете сборку. В результате новый jar отличается от предыдущего, даже если внешне «внутри всё то же самое, кроме этой одной строчки».
Теперь представьте, что следующий этап вашей цепочки (какой бы он ни был) умеет переиспользовать старые результаты, если входные данные совпадают. Но входные данные у него — “файл jar”. И вот он смотрит: jar поменялся. Значит, по его логике, нужно делать всё заново.
В этом месте очень уместна бытовая аналогия. Если вы каждый раз, когда купили новую кружку, вызываете грузчиков и перевозите всю квартиру вместе с мебелью, — это не «плохие грузчики». Это вы выбрали слишком грубую единицу переноса: “квартира целиком”. Вам нужен способ перевозить только то, что изменилось, и оставлять остальное на месте.
В инженерном мире «оставить остальное на месте» обычно называется кэшированием или переиспользованием результатов. Это не магия и не “оптимизация ради оптимизации”, а просто здравый смысл: если часть данных не менялась, не надо тратить время и ресурсы, чтобы “создавать её заново”.
И вот тут мы упираемся в ключевую мысль дня: внутри нашего runnable jar есть части, которые реально меняются редко (например, зависимости), и части, которые мы меняем постоянно (наш код и ресурсы). Но если всё это упаковано в один «неразличимый» файл, инструментам сложно (или невозможно) переиспользовать стабильные части без полного повторения.
Мы пока не обсуждаем, «какой именно следующий этап упаковки» и «какие именно инструменты». Нам достаточно понять принцип: монолитный вход почти всегда ломает переиспользование.
3. Частота изменений в Boot‑сервисе
Сейчас будет важный “медленный” момент. В программировании многие проблемы решаются не новыми библиотеками, а честным ответом на вопрос: «что именно у нас меняется, и как часто». Для новичка это звучит как философия. Для команды — это разница между “быстро выкатили фикс” и “почему у нас сборка занимает 12 минут, если мы изменили одну букву”.
В проекте catalog-service (как и в любом обычном Boot‑сервисе) можно грубо выделить несколько категорий содержимого, которые живут в разном ритме:
— то, что почти не меняется (или меняется не каждый день),
— то, что меняется регулярно,
— то, что меняется вообще без пересборки (потому что это конфигурация снаружи).
Я специально формулирую это “по‑человечески”, без “транзитивных зависимостей” и “слоёв образа”, чтобы вы сначала уловили логику.
Посмотрите на такую табличку как на календарь изменений, а не как на спецификацию:
| Часть сервиса | Примеры в catalog-service | Как часто меняется | Почему это важно для упаковки |
|---|---|---|---|
| Зависимости (библиотеки) | Spring Boot starters, Jackson, логирование и т.д. | Обычно редко: раз в неделю/месяц, а иногда и реже | Если мы отделим их от остального, то при обычных правках кода можно их “не трогать” |
| Код приложения | контроллеры, сервисы, репозиторий, доменные модели | Часто: каждый день, иногда десятки раз в день | Именно это хочется уметь упаковывать быстро и без лишних повторов |
| Ресурсы приложения | static/index.html, application.yaml внутри jar, любые ресурсные файлы | Тоже часто, особенно в учебном проекте | Они обычно должны “ехать” вместе с кодом, но при этом не должны заставлять пересобирать зависимости |
| Внешняя конфигурация | конфиг вне jar, env vars, CLI args | Может меняться вообще без сборки | Это мы уже умеем: конфигурация — не повод пересобирать jar |
Обратите внимание на тонкость. Когда мы говорим «библиотеки меняются редко», это не означает «никогда не меняются». Мы обновляли зависимости, добавляли actuator, подключали devtools и т.д. Просто это происходит существенно реже, чем правки прикладного кода.
Здесь рождается инженерный принцип:
# Идея: «разный ритм изменений» нужно делать видимым инструментам упаковки
Если части системы меняются с разной частотой,
то выгодно упаковывать их так, чтобы это различие было видно инструментам.
Если это звучит слишком абстрактно, то вот практическая расшифровка: вы хотите, чтобы при правке одного контроллера у вас “обновилась” только часть артефакта, связанная с вашим кодом, а всё, что относится к библиотекам, осталось прежним и переиспользовалось.
И да, звучит как мечта. Но это мечта, которую Spring Boot умеет приблизить — именно на уровне packaging.
4. Container-friendly packaging и слои
Термин “container-friendly” очень легко понять неправильно. Частая ошибка новичка: «Если речь про контейнеры, значит мы сейчас будем писать Dockerfile и запускать контейнеры». Нет. Мы сегодня говорим не про эксплуатацию, а про форму артефакта. То есть про то, как выглядит ваш результат сборки, и как эта форма помогает последующей упаковке.
Контейнерный образ (на очень бытовом уровне) — это “упакованный комплект”, который можно запустить в контейнерной среде. Он обычно состоит из слоёв. Слой — это такая часть образа, которая может быть переиспользована, если она не изменилась. Это похоже на то, как вы храните кэш Gradle: если зависимости не менялись, Gradle не скачивает их заново. Только здесь речь про упаковку приложения для запуска.
Почему слои вообще существуют? Потому что это очень здоровая инженерная идея: разделить “редко меняющееся” и “часто меняющееся”. Тогда, если вы поменяли только часто меняющееся, всё остальное можно взять готовым и не тратить время.
И вот тут runnable jar начинает выглядеть чуть наивно. Runnable jar отвечает на вопрос «можно ли это запустить?». Container-friendly packaging отвечает на другой вопрос: «можно ли это быстро и эффективно переупаковывать, когда изменилось только приложение?».
В рамках нашего курса важно удержать границу: мы не обсуждаем настройку контейнерного рантайма, лимиты памяти, порты в Kubernetes и прочие вещи, которые превращают лекцию в инфраструктурный курс. Мы обсуждаем только мысль: форма артефакта влияет на скорость и стоимость последующих упаковочных шагов.
Чтобы увидеть контраст, представьте две модели упаковки:
# Контраст: «один неделимый файл» против «видимой структуры»
Модель А (монолит):
[ весь jar одним куском ]
Модель Б (разделённая):
[ библиотеки (редко) ] + [ ваш код и ресурсы (часто) ]
В модели А любое изменение кода делает “весь кусок” новым. В модели Б инструментам легче переиспользовать “библиотечный кусок”, а заменить только “кусок приложения”. Даже если вы ещё ни разу не собирали контейнерный образ, сама логика уже полезна: вы начинаете мыслить артефактом как структурой, а не как «одним файлом на выходе».
5. Граница: меняем артефакт, не код
Очень хочется “делать что-то в коде”, потому что код — это понятно: написал, запустил, увидел результат. Packaging же напоминает сантехнику: пока всё работает, вы о нём не думаете; когда не работает — думаете очень громко и иногда нецензурно. Но именно поэтому сегодня важная дисциплина — не подменять тему.
Container-friendly packaging в Spring Boot почти всегда достигается не переписыванием контроллеров, не “оптимизацией сервисов” и не рефакторингом доменной модели, а работой на уровне сборки и артефакта. В нашем catalog-service это означает: ваши пакеты catalog, config, actuator, support остаются как есть. Ваши эндпоинты /api/catalog/... остаются как есть. Ваши YAML‑профили тоже остаются как есть.
Меняется только то, как мы упаковываем результат.
Это, кстати, один из приятных моментов Spring Boot как платформы. Он старается решать инфраструктурные вопросы так, чтобы вы не превращали прикладной код в “специальный код для упаковки”. В идеальном мире прикладной код остаётся прикладным. А упаковка — остаётся упаковкой.
Если посмотреть на это глазами junior-разработчика, то хороший знак такой: когда вам говорят «сделаем сервис container-friendly», а вы в ответ идёте править CourseCatalogController, — вы почти наверняка идёте не туда. Верный путь начинается с понимания артефакта: что внутри, какие части стабильны, какие части меняются, и как это выразить в форме сборки.
И ещё один важный акцент. Мы не пытаемся «ускорить приложение», «уменьшить память» или «настроить сервер». Мы делаем куда более простую (и полезную) вещь: делаем артефакт объяснимым и пригодным для повторной упаковки.
6. Мини‑сценарий: фикс и пересборка
Сценарий будет очень простой и чуть-чуть драматичный (как любой хороший сценарий). У нас есть catalog-service, который уже собирается и запускается вне IDE. Это наш базовый факт. Его можно подтвердить тем же базовым сценарием запуска вне IDE:
# Пересобираем jar после изменения кода/ресурсов
./gradlew bootJar
# Берём именно executable jar из bootJar, а не *-plain.jar
JAR="$(find build/libs -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | head -n 1)"
# Запускаем именно тот артефакт, который получился после сборки
java -jar "$JAR"
Здесь важен не конкретный шаблон имени файла, а то, что вы запускаете именно executable jar, который только что собрали.
Теперь представим типичный “микро-фикс”. Не новая фича, не новый модуль, а что-то очень земное: вы поправили один текст в landing page, или изменили формат одного поля в JSON, или добавили лог в StartupSummaryRunner. Это всё относится к части «application» (то есть к вашему приложению), и меняется часто.
После этого вы снова делаете bootJar. На выходе вы снова получаете jar. И для вас это выглядит “правильно”: вы действительно изменили приложение, значит jar должен обновиться.
Но вот ключевой вопрос: что именно обновилось? Только ваш код и ресурсы. Библиотеки внутри — те же. Boot loader — тот же. Большая часть содержимого — та же. Однако в виде одного файла это всё “слиплось”. Для внешнего мира (для любых последующих инструментов) это “новый файл”. А значит, любая стадия, которая могла бы переиспользовать старые результаты, скорее всего, не сможет: вход поменялся целиком.
Именно здесь становится понятно, зачем вообще “слои” и “структура артефакта”. Мы хотим научиться упаковывать результат так, чтобы:
— наш часто меняющийся кусок (код + ресурсы) обновлялся быстро и отдельно,
— редко меняющийся кусок (зависимости) мог переиспользоваться как есть,
— и всё это происходило без переписывания бизнес‑кода catalog-service.
Если вы сейчас чувствуете лёгкую несправедливость в духе «почему из-за одной строчки нужно “тронуть” весь артефакт», то поздравляю: вы начали думать как инженер по сборке, а не как человек, который “просто запускает код”. Это хороший, но иногда болезненный этап взросления. Примерно как понимание, что git merge — это не “кнопка объединить”, а целая философия жизни.
И ещё раз подчеркну границу: мы пока не обсуждаем “как именно устроены слои”. Мы зафиксировали мотивацию. Если вы держите в голове эту мотивацию, дальнейшие инструменты Spring Boot будут восприниматься не как магия, а как рациональный ответ на очень конкретную боль.
7. Типичные ошибки при упаковке jar
Эта тема кажется «околоинфраструктурной», поэтому ошибки здесь обычно не про синтаксис, а про мышление. И, честно говоря, это те самые ошибки, которые потом создают проекты “работает на моём ноутбуке”, “почему билд такой медленный” и “давайте просто добавим ещё один скрипт в CI — и оно само починится”.
Ошибка №1: считать, что сегодняшний разговор полностью дублирует прошлий уровень.
Мы отвечали на вопрос “как получить runnable jar и честно запустить его вне IDE”. Сегодня мы отвечаем на другой вопрос: “как сделать артефакт удобным для повторной упаковки и переиспользования стабильных частей”. Эти темы соседние, но не одинаковые: runnable — про запуск, container-friendly — про форму и структуру.
Ошибка №2: моментально свести всё к контейнерному рантайму и «тюнингу Docker».
Когда слышишь “container-friendly”, руки тянутся обсуждать память, CPU, сеть, тома и прочие страшные слова. Но это другой слой реальности. Сегодня мы работаем с артефактом сборки и его упаковочной моделью. Эксплуатационные параметры — это отдельная дисциплина, и если смешать их сейчас, вы утонете в деталях и потеряете главную мысль: разделение стабильного и изменяемого.
Ошибка №3: думать, что ради container-friendly упаковки нужно менять прикладной код.
Это ловушка “я знаю только молоток, значит всё — гвозди”. В нашем курсе и в нашем проекте основная работа сегодня происходит на уровне сборки и структуры jar, а не в контроллерах, сервисах и репозиториях. Если вы начинаете переписывать CourseCatalogController ради упаковки — вы вряд ли делаете правильное действие.
Ошибка №4: игнорировать частоту изменений частей приложения.
Если не держать в голове, что зависимости меняются редко, а код и ресурсы — часто, то идея разделения кажется декоративной: “ну слои какие-то, зачем”. Как только вы честно признаёте разный ритм изменений, слои перестают быть “фичой”, а становятся инструментом, который помогает не делать лишнюю работу снова и снова.
Ошибка №5: воспринимать упаковку как «раз собрал и забыл».
Упаковка — это не финальная точка, а часть ежедневного цикла. Если вы делаете сервис, который будут развивать, то упаковка должна поддерживать быстрый цикл изменений, а не тормозить его. Runnable jar — отличный старт, но инженерное взросление начинается там, где вы начинаете думать: “как этот артефакт будет жить при сотне пересборок”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ