1. Dockerfile: «рецепт» образа, а не магическая кнопка
Сейчас мы делаем первый взрослый шаг: начинаем управлять тем, что попадает внутрь образа. Если до этого Docker был для вас чем-то вроде «запусти контейнер, и пусть оно как-нибудь само», то Dockerfile как раз ломает эту магию. Это не конфиг «как запустить контейнер» и не скрипт «как починить жизнь». Это скорее рецепт или инструкция IKEA: что взять, куда положить и что в итоге должно стартовать.
Важно сразу поймать правильную логику. Dockerfile описывает, как собрать образ. А уже из образа потом запускается контейнер. Поэтому сейчас мы думаем не «как открыть порт», не «как подключить базу», не «как сделать красиво и быстро», а максимально приземлённо: «Как упаковать наш готовый jar в образ так, чтобы он мог стартовать».
Чтобы не расползаться по всей вселенной Dockerfile-инструкций, сегодня держим фокус на четырёх базовых строках. Это ровно тот минимум, который даёт рабочий результат и при этом не превращает файл в полотно из магии.
Ниже — маленькая таблица, чтобы у вас был ориентир «что делает каждая строка» ещё до чтения деталей:
| Инструкция | Что решает простыми словами | Что это значит для нашего Boot-сервиса |
|---|---|---|
| FROM | «На какой базе строим образ» | Нам нужен образ с Java, чтобы java -jar вообще существовало |
| WORKDIR | «Где живут файлы приложения внутри контейнера» | Мы хотим понятную папку вроде /app, чтобы не искать jar по всему образу |
| COPY | «Какие файлы из build context кладём внутрь образа» | Мы переносим build/libs/...jar внутрь образа, например как app.jar |
| ENTRYPOINT | «Что запускается при старте контейнера» | Команда java -jar app.jar |
Дальше разберём эти строки в том же порядке, в каком они появляются в Dockerfile, потому что Dockerfile читается сверху вниз, как сценарий.
2. FROM: базовый образ с Java
Когда вы впервые видите FROM, обычно возникают два вопроса. Первый: «Почему я должен начинать не с моего кода?» Второй: «Почему я вообще выбираю какой-то чужой образ?» Ответ очень инженерный: Docker image — это не пустая папка, а слоёный пирог, и самый нижний слой в нём — базовый образ. Если вам нужна Java, вы берёте базу, где Java уже есть.
Для нашего Spring Boot jar это критично. Внутри контейнера нет вашей локальной Java, нет IntelliJ IDEA и вообще нет вашей привычной среды. Контейнер — это отдельная файловая система и отдельный процесс. Если в этом мире не существует команды java, то ENTRYPOINT ["java", ...] не запустится, и контейнер закончится быстрее, чем вы успеете сказать: «но у меня же на ноутбуке работало!»
В рамках курса baseline у нас зафиксирован на 25, а значит базовый образ должен содержать Java 25 или совместимую версию. Для первого минимального Dockerfile логично взять простой и понятный образ с JDK. Он не самый компактный, зато максимально предсказуем для новичка: Java внутри точно есть, и запуск будет читаться без сюрпризов.
Пример базовой строки:
# Базовый образ, в котором уже есть JDK нужной версии
FROM eclipse-temurin:25-jdk
Несколько важных моментов — без ухода в будущие темы:
Во-первых, базовый образ — это то, что Docker при сборке, скорее всего, будет скачивать, если его ещё нет на вашей машине. Это нормально: вы не обязаны хранить в репозитории целую операционную систему и JDK.
Во-вторых, версию Java в образе лучше держать в голове как условие совместимости. Если вы собрали jar, который требует Java 25, а в контейнере Java 21, будет боль. Не философская, а вполне конкретная: приложение может просто не стартовать.
В-третьих, сейчас мы не спорим, какой базовый образ «лучше», и не устраиваем конкурс «кто соберёт самый маленький образ». Сегодня цель не минимальность, а понятность и первый рабочий результат. Мы закрепляем механику, а не полируем её до блеска.
3. WORKDIR: рабочая папка
В Dockerfile очень легко написать команды, которые «вроде бы работают», но оставляют внутри образа хаос из файлов. А потом вы открываете контейнер, видите app.jar где-то в корне рядом с чем-то ещё и чувствуете себя археологом, который случайно раскопал древний артефакт и теперь пытается понять, почему он лежит на кухне.
WORKDIR — это простая дисциплина: мы говорим Docker, что дальше все относительные пути внутри контейнера считаются от конкретной папки. Для человека это означает: «Приложение живёт вот здесь». Для команды — «Мы всегда знаем, где искать файлы приложения».
Минимальный пример:
# Дальше все относительные пути будут считаться от /app
WORKDIR /app
После этой строки можно думать так: «Контейнер зашёл в /app и дальше работает оттуда». Если вы копируете файл как app.jar, он окажется в /app/app.jar. Если вы запускаете java -jar app.jar, это фактически означает java -jar /app/app.jar.
И тут полезно немного сменить оптику: WORKDIR не имеет отношения к вашей локальной папке проекта. Он не создаёт на диске новую директорию рядом с src и build. Это папка внутри будущего образа. Её можно назвать /app, /opt/service или /workspace, но мы выбираем /app, потому что это просто и узнаваемо.
Пока мы не обсуждаем права, пользователей, «можно ли писать в эту директорию» и остальные взрослые вещи. Сейчас нам нужен чистый минимум: понятное место, где лежит наш jar.
4. COPY: переносим jar в образ
К этому моменту у нас уже есть два факта: исполняемый jar лежит в build/libs, а COPY берёт файлы только из build context. Здесь важно не переобъяснять всю механику заново, а спокойно приземлить её на одну строку Dockerfile.
# Берём jar из build context и кладём его в образ как /app/app.jar (из-за WORKDIR)
COPY build/libs/docker-java-catalog-service-0.0.1-SNAPSHOT.jar app.jar
Левая часть — путь к файлу в контексте сборки. Если вы собираете image из корня проекта и jar действительно лежит в build/libs, Docker его найдёт. Правая часть — имя файла уже внутри образа. Так как раньше мы задали WORKDIR /app, app.jar окажется по пути /app/app.jar.
Здесь полезно чётко отделить машину разработчика от образа. На вашей машине файл может называться длинно и не очень дружелюбно, с версией и SNAPSHOT. Внутри контейнера нам важнее стабильность, поэтому мы сразу даём ему короткое имя app.jar.
Почему мы не копируем весь проект? Потому что для первого рабочего образа нам нужен только уже собранный артефакт. Чем меньше лишних файлов попадает в образ, тем понятнее, что именно потом запускается.
Если нужный jar не попал в build context — например, вы забыли bootJar или слишком агрессивно порезали build/ через .dockerignore — COPY упадёт честно и довольно быстро. И это хороший симптом: проблема не в том, как запускается Java, а в том, что Docker просто не видит входной файл.
5. ENTRYPOINT: команда старта
В Docker есть одна очень практичная мысль: контейнер живёт, пока живёт основной процесс. Для нашего сервиса этим процессом будет запуск java -jar app.jar. Поэтому Dockerfile должен явно сказать: «Когда меня запустят, сделай вот это».
Для этого и существует ENTRYPOINT. Мы не обсуждаем здесь десятки вариантов, не спорим про CMD и не уходим в тонкости переопределений. Нам нужен один понятный факт: контейнер при старте должен выполнить команду запуска приложения.
Самый читаемый вариант для новичка — exec-форма, то есть JSON-массив. Она выглядит так:
# При старте контейнера запускаем приложение
ENTRYPOINT ["java", "-jar", "app.jar"]
Эта запись читается буквально: «Запусти программу java с аргументами -jar app.jar». И она хорошо ложится на уже знакомую проверку из лекции 1: java -jar build/libs/...jar. Только теперь jar уже лежит внутри образа под именем app.jar, и запуск идёт в контейнерной среде.
Почему мы не пишем так?
# Shell-форма: короче, но зависит от shell внутри образа
ENTRYPOINT java -jar app.jar
Потому что сейчас нам важнее предсказуемость, чем сходство с командой в терминале. Exec-форма меньше зависит от особенностей shell и обычно ведёт себя прямолинейнее. Но здесь мы сознательно тормозим и не раздуваем тему: детальный разбор форм запуска — это отдельный разговор. Сегодня вам нужно запомнить одно: ENTRYPOINT фиксирует команду старта.
И ещё маленький психологический лайфхак. ENTRYPOINT — это не «как запустить image». Образ сам по себе не запускается. Запускается контейнер, а ENTRYPOINT говорит ему, что делать при старте. Если держать это в голове, многие симптомы в Docker читаются заметно легче.
6. Минимальный Dockerfile на практике
Собираем минимальный Dockerfile
Сейчас соберём всё в один короткий Dockerfile, который можно положить в корень репозитория docker-java-catalog-service/. Он не «идеальный», не «самый маленький» и не «самый быстрый». Он просто честный: берёт базовый образ с Java, создаёт рабочую директорию, копирует jar и запускает его.
Вот минимальный вариант, который хорошо читается даже через полгода — а это, кстати, редкость в мире инфраструктурных файлов, где даже автор нередко пугается собственного творения:
FROM eclipse-temurin:25-jdk
WORKDIR /app
COPY build/libs/docker-java-catalog-service-0.0.1-SNAPSHOT.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
Если вам хочется чуть больше «человечности», можно добавить комментарии. Комментарии в Dockerfile начинаются с #, и это один из лучших способов помочь будущему себе и коллегам не превращаться в шамана, который «просто помнит правильное заклинание»:
# Базовый образ с JDK 25 (совместим с нашим jar)
FROM eclipse-temurin:25-jdk
# Папка, в которой будет лежать приложение внутри образа
WORKDIR /app
# Копируем уже собранный Spring Boot jar внутрь образа
COPY build/libs/docker-java-catalog-service-0.0.1-SNAPSHOT.jar app.jar
# При старте контейнера запускаем приложение
ENTRYPOINT ["java", "-jar", "app.jar"]
Обратите внимание на одну идею, которая часто ускользает: мы переименовываем файл внутри образа в app.jar. Это очень удобно. На хосте он может называться хоть service-0.0.1-SNAPSHOT-final-final2.jar — все видели такое в дикой природе, — но внутри контейнера имя остаётся стабильным.
Ещё раз проговорим границы. Этот Dockerfile предполагает, что вы уже выполнили ./gradlew bootJar и файл действительно существует. Если вы забыли собрать jar, Dockerfile не «магически соберёт его за вас». Он честно скажет «файл не найден», и это хороший сигнал: значит, вы смешали две разные задачи в одну.
Чтение Dockerfile и состав образа
Хороший Dockerfile отличается от плохого не только тем, что «работает». Плохой тоже иногда работает — особенно на компьютере автора и особенно в полнолуние. Хороший Dockerfile можно прочитать как мини-программу и заранее понять результат: какие файлы окажутся в образе и что произойдёт при старте контейнера.
Попробуем мысленно проиграть наш файл по шагам, без терминала и без гаданий.
После FROM eclipse-temurin:25-jdk у нас уже есть файловая система базового образа, в которой есть Java. Вы не обязаны знать, где именно лежит бинарник java — достаточно понимать, что он существует и доступен в PATH.
После WORKDIR /app внутри образа появляется, или выбирается, директория /app. И все дальнейшие относительные пути внутри контейнера будут читаться именно оттуда.
После COPY ... app.jar внутри образа появляется файл /app/app.jar. Он попал туда из build context, то есть из вашего проекта, который вы передали в docker build. Это и есть наш исполняемый Spring Boot jar.
После ENTRYPOINT ["java","-jar","app.jar"] вы получаете правило запуска: когда контейнер стартует, он выполнит команду java -jar /app/app.jar.
Если хочется зафиксировать это ещё нагляднее, можно представить состояние файлов как маленькую «карточку» после сборки:
| Где | Что должно быть |
|---|---|
| На хосте (до сборки) | build/libs/docker-java-catalog-service-0.0.1-SNAPSHOT.jar |
| В build context | тот же файл, если он не вырезан .dockerignore |
| Внутри образа | /app/app.jar |
| Команда старта контейнера | java -jar app.jar в каталоге /app |
Замечаете приятную вещь? Мы перестали зависеть от того, как именно называется файл на хосте. Внутри образа он всегда app.jar. Это мелочь, но именно из таких мелочей и складывается контейнеризация без хаоса.
7. Типичные ошибки Dockerfile
Ошибки на этом этапе почти всегда одинаковые, и это хорошая новость: если вы научитесь их быстро распознавать, то перестанете тратить время на мистику. Ниже — самые частые грабли именно для минимального Dockerfile из четырёх инструкций.
Ошибка №1: неправильный путь в COPY, потому что не проверили build/libs.
Часто Dockerfile пишут «на глазок»: COPY build/libs/app.jar app.jar, а потом оказывается, что файл называется иначе. В результате сборка падает с сообщением, что файл не найден. Лечится скучно: сначала ./gradlew bootJar, потом проверяем реальное имя файла в build/libs, и только после этого пишем COPY.
Ошибка №2: jar не попал в build context из-за .dockerignore.
Иногда студенты по привычке добавляют в .dockerignore строку build/, потому что «build — это мусор». Локально это мусор, а для сегодняшней сборки — как раз единственная полезная часть, потому что там лежит jar. Симптом будет похож на ошибку пути, но причина другая: Docker просто не видел этот файл. Правило простое: если файл нужен для COPY, он не должен быть отфильтрован.
Ошибка №3: docker build запускают не из корня проекта, и контекст становится «не тем».
Вы можете идеально написать COPY build/libs/..., но если запускать сборку из другой директории или указать другой контекст, Docker просто не найдёт нужные пути. Здесь важно помнить предыдущую лекцию: пути в COPY считаются относительно build context. Если контекст — не корень проекта, то build/libs для Docker может просто не существовать.
Ошибка №4: базовый образ с другой версией Java.
Если jar собран под Java 25, а в FROM вы взяли Java 21, приложение может не стартовать. Иногда это видно сразу, иногда проявляется странными ошибками на старте. Мы пока не обсуждаем стратегию выбора образов — это отдельная большая тема, — но базовая дисциплина уже нужна: версия Java в образе должна быть совместима с той версией, под которую собран jar.
Ошибка №5: перепутали «файл на хосте» и «файл внутри образа».
Новички иногда пишут ENTRYPOINT ["java", "-jar", "build/libs/docker-java...jar"], потому что именно так файл назывался на хосте. Но внутри образа вы уже скопировали его как app.jar в /app. В контейнере нет вашей структуры проекта, если вы специально её туда не копировали. Поэтому ENTRYPOINT должен ссылаться на путь внутри образа, а не на путь на хосте.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ