JavaRush /Курсы /Docker for Spring /Выбор runtime base image для Java 25

Выбор runtime base image для Java 25

Docker for Spring
7 уровень , 0 лекция
Открыта

1. FROM в final stage — это ваша “операционка по умолчанию”

Когда человек впервые пишет Dockerfile, строка FROM ... воспринимается как формальность: «ну надо же откуда-то начать». Но в реальности FROM для финального (runtime) stage — это выбор среды, в которой ваше приложение будет жить каждый день. Это почти как выбрать ОС для сервера, только быстрее и чаще.

В multi-stage Dockerfile у нас есть минимум два мира: builder stage, где мы собираем артефакт, и runtime stage, где мы этот артефакт запускаем. В builder stage мы чаще терпим «тяжёлые» вещи (Gradle, дополнительные утилиты), потому что это временная кухня. А runtime stage — это уже «подача блюда»: именно он станет вашим финальным образом, который вы будете запускать снова и снова.

Чтобы не путаться в терминах, зафиксируем короткую карту понятий прямо здесь, без попытки превратить лекцию в словарь Docker’а:

Термин Простое объяснение Где встречается
base image База, от которой начинается stage; «что уже есть в файловой системе и окружении» FROM ...
stage Логическая часть Dockerfile, начинающаяся с FROM FROM ... AS builder /
FROM ...
runtime stage Финальный stage, который реально запускается как контейнер последний FROM
runtime image Образ, рассчитанный прежде всего на запуск уже собранного приложения (а не на сборку) обычно используется в runtime stage

Здесь важно одно: в runtime stage мы выбираем base image не потому, что “так принято”, а потому что он задаёт ключевые свойства среды. И эти свойства потом проявляются очень практично: у кого-то приложение внезапно не ходит по HTTPS, у кого-то время в логах «поплыло», у кого-то возникают странные ошибки нативных библиотек. И это не мистика, это последствия выбранной базы.

2. Что меняется при смене base image

Очень хочется думать так: «У меня же один и тот же app.jar, значит приложение одно и то же». И это почти правда — но с важной оговоркой: код не меняется, но среда меняется. А среда может влиять сильнее, чем ожидает новичок, особенно когда приложение перестаёт быть “Hello World” и начинает делать хоть что-то похожее на реальный backend.

Сначала про то, что не меняется. Ваш Java-код, ваши контроллеры, ваш сервисный слой — всё это остаётся тем же. Например, CatalogApplication как был, так и остаётся самым обычным Boot-приложением:

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class CatalogApplication {
    public static void main(String[] args) {
        // Точка входа приложения: именно отсюда Spring Boot поднимает контекст и сервер.
        SpringApplication.run(CatalogApplication.class, args);
    }
}

Теперь про то, что меняется. Меняется всё, что вокруг java -jar: какая именно JVM внутри образа, какие системные библиотеки рядом, есть ли корневые сертификаты, какая временная зона по умолчанию, что происходит с кодировками, есть ли минимальные утилиты для диагностики. И чаще всего это проявляется в виде «почему оно в одном образе нормально, а в другом как будто в плохом настроении».

Эту идею удобно видеть как схему: один и тот же артефакт, разные базы — и вы получаете разные контейнерные миры:

flowchart TD
    %% Один и тот же артефакт, собранный builder stage
    JAR["bootJar: catalog-service.jar
(один и тот же)"] %% Разные base image могут отличаться ОС, пакетами и настройками окружения B1["Base image A
Java 25 runtime
одни пакеты ОС"] B2["Base image B
Java 25 runtime
другие пакеты ОС"] %% Итог — разное поведение уже на этапе запуска контейнера C1["Container A
поведение, логи, сетевые нюансы"] C2["Container B
поведение, логи, сетевые нюансы"] JAR --> B1 --> C1 JAR --> B2 --> C2

Ключевой вывод для сегодняшнего дня звучит просто: “запустилось” — это только первый фильтр. Нам нужно выбрать base image так, чтобы он был разумным default для проекта, а не случайной удачей конкретной машины.

3. Критерии выбора runtime base image

Если подойти к теме “по-честному”, критериев может быть десятки, и половина из них будет звучать как «религиозная война дистрибутивов». Мы так делать не будем. Нам нужен набор критериев, который реально помогает Junior-разработчику принимать решение и потом объяснять его команде без фразы «ну я так чувствую».

Начнём с главной идеи: runtime base image в нашем курсе нужен, чтобы стабильно запускать Spring Boot 4.0.3 сервис на Java 25. Не собирать, не компилировать, не «поиграться в DevOps», а именно запускать. И поэтому критерии — практичные, приземлённые и проверяемые.

Ниже — матрица, которая помогает думать не “про размер”, а “про пригодность”:

Критерий Почему это важно для Spring Boot сервиса Как проявляется, если ошиблись
Совместимость с Java 25 В runtime stage должен быть корректный java нужной версии; иначе вы либо не запуститесь, либо получите странные несовместимости UnsupportedClassVersionError, неожиданные падения, «у меня локально работало»
Адекватная OS-база и системные зависимости Boot-сервис — это не только байткод: это TLS, DNS, файловая система, иногда нативные библиотеки Ошибки SSL, странности с сетью, проблемы с нативными либами
Наличие CA certificates Даже если ваш сервис сейчас не зовёт внешние API, завтра он начнёт; сертификаты — базовая вещь для HTTPS SSLHandshakeException, “PKIX path building failed”
Предсказуемость и сопровождаемость Вам нужно, чтобы образ был понятен команде и не превращался в «чёрный ящик» Сложно дебажить, сложно объяснять, сложно повторить результат
Баланс минимальности и диагностируемости Слишком “голый” образ может экономить мегабайты, но съедать часы жизни при любой проблеме «Нельзя зайти внутрь», «нет даже базовых утилит», «всё наугад»
Размер Размер влияет на скорость pull/push и на работу с кэшем, но это не единственная метрика Вы выигрываете 40 МБ и проигрываете 4 часа расследования

Заметьте, что в этой таблице “размер” стоит последним. Не потому что он не важен, а потому что размер — это метрика, а пригодность — это свойство. Метрика без свойства легко превращается в спорт: «мой образ на 20 МБ меньше, значит я победил». А потом выясняется, что победа была над здравым смыслом.

Совместимость с Java 25

Когда вы выбираете runtime base image, первое, что нужно спросить: “какая там Java, и та ли она вообще?”. Звучит очевидно, но это как с зонтиком: пока не пошёл дождь, кажется, что он не нужен. Потом идёшь мокрый и философски понимаешь, что очевидные вещи были не такими уж очевидными.

Минимальный тест совместимости — не запуск приложения, а проверка, что Java действительно нужной линии:

# Проверяем, что внутри образа действительно Java 25 (а не "что-то похожее").
docker run --rm your-runtime-image java -version

И вы должны увидеть внятный вывод про 25-ю линию. Это важно именно для курса с фиксированным baseline: мы сознательно держим Java 25 как “одну правду”, чтобы не разъезжалась среда студентов и примеры.

OS-база и системные “мелочи”

Вторая большая группа критериев — это всё, что обычно называют «ну это же просто Linux внутри». На практике эта фраза часто означает «я не знаю, что там внутри, но надеюсь, что оно нормальное». Docker, конечно, многое упрощает, но он не отменяет реальности: Java-приложение живёт в окружении, где есть DNS, сертификаты, файловая система, часовой пояс, иногда системные библиотеки.

Самый частый “контейнерный сюрприз” для Java-разработчика — TLS. Например, вы делаете исходящий HTTPS-запрос (даже банально в какой-то внутренний сервис), и внезапно ловите ошибку. Для демонстрации достаточно крошечного Java-примера, который просто ходит на HTTPS:

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;

public class HttpsPing {
    public static void main(String[] args) throws Exception {
        // Клиент будет опираться на TLS/SSL-настройки и сертификаты, которые есть в образе.
        var client = HttpClient.newHttpClient();

        // Простой HTTPS-запрос: если в образе нет CA certificates, здесь легко поймать SSLHandshakeException.
        var request = HttpRequest.newBuilder(URI.create("https://example.com"))
                .GET()
                .build();

        // Отправляем запрос, тело нам не важно — важен сам факт успешного TLS-рукопожатия.
        client.send(request, java.net.http.HttpResponse.BodyHandlers.discarding());

        System.out.println("OK"); // OK (если сертификаты в порядке)
    }
}

Если в образе нет нормального набора CA certificates, вы не получаете “какую-то мелкую проблему”, вы получаете ситуацию «сервис не может общаться по HTTPS». И это уже не про красоту Dockerfile, а про работоспособность.

Диагностируемость: чтобы “минимальный” не стал “неподдерживаемый”

Есть одна болезненная ловушка: можно выбрать образ настолько минимальный, что он начнёт мешать вам же. В теории “минимально = хорошо”, но в реальной жизни иногда нужно хотя бы понять, что происходит. Не чтобы жить в контейнере как в полноценной VM (мы этого не делаем), а чтобы иметь возможность быстро локализовать проблему.

Здесь полезно держать в голове простое правило: runtime base image должен быть минимальным ровно настолько, чтобы оставаться управляемым. Если вы выбираете образ, в котором нет вообще ничего, кроме java, вы выигрываете в размере, но проигрываете в сопровождении. И вот тут особенно легко стать героем анекдота: «контейнер не работает, но зато маленький».

4. Ловушка “самого маленького” образа

Тема “самого маленького образа” обычно приходит очень рано, потому что docker images показывает размер, и мозг радостно включает режим соревнования. Это как шагомер: сначала полезный инструмент, потом — источник странных решений (“я пройду круги по кухне, чтобы добить до 10k”). С образами так же: можно начать оптимизировать то, что ещё не стало проблемой.

В Java-мире есть несколько популярных классов “ультра-минимальных” решений: очень slim варианты дистрибутивов, Alpine-подобные базы, distroless-образы. Все они имеют право на жизнь, но плохо подходят как первый осознанный default для новичка. Не потому что они “плохие”, а потому что они требуют понимания компромиссов.

Самые частые проблемы при погоне за минимальностью выглядят так. Вы внезапно обнаруживаете, что в образе нет каких-то базовых вещей вроде сертификатов, timezone data или привычных утилит. Потом приходит следующее открытие: некоторые минимальные базы используют другую стандартную библиотеку C (например, musl вместо glibc), и это может всплыть через зависимости или нативные части. И наконец, вы понимаете, что “уменьшил размер” вы за 5 минут, а “объяснить команде, почему оно сломалось” не можете за 5 часов.

Важно: в рамках этой лекции мы не выбираем победителя среди дистрибутивов. Мы фиксируем мысль: минимальность — это не самоцель, а оптимизация, которая имеет смысл только после того, как вы обеспечили работоспособность и предсказуемость.

5. Runtime base image в multi-stage Dockerfile

Давайте аккуратно привяжем теорию к нашему сквозному сервису Container-Ready Catalog Service. Сейчас, после дня про multi-stage, у нас уже есть Dockerfile, где сборка происходит в builder stage, а финальный образ — это runtime stage. И именно в runtime stage живёт строка FROM ..., которая определяет вашу базу для запуска.

Чтобы не смешивать выбор base image с уже собранным cache-friendly baseline, ниже будет упрощённый multi-stage фрагмент. Порядок COPY, layout builder stage и остальные оптимизации остаются теми, что уже закреплены в проекте; здесь нам важно только место, где выбирается база для runtime stage.

Схематичный builder stage выглядит так (здесь база обычно “JDK-образ”, потому что мы реально собираем jar внутри контейнера):

FROM eclipse-temurin:25-jdk AS builder
WORKDIR /src

# Упрощённо копируем проект целиком: в этом фрагменте нам важна роль builder stage, а не cache-friendly порядок COPY.
COPY . .

# Собираем jar внутри builder stage. Здесь допустимы "тяжёлые" инструменты, потому что это временная кухня.
RUN ./gradlew --no-daemon bootJar

Обрати внимание: это не “финальная упаковка”, это кухня. Builder stage может быть толще, и это нормально — он не попадёт в final image, если вы всё сделали правильно.

А вот runtime stage — это то, что вы реально будете запускать:

FROM eclipse-temurin:25-jre
WORKDIR /app

# Забираем только результат сборки из builder stage — без Gradle и исходников.
COPY --from=builder /src/build/libs/*.jar app.jar

# В runtime stage нам нужна только JVM и наш jar.
ENTRYPOINT ["java", "-jar", "app.jar"]

Здесь имя образа пока служит только примером. И сам фрагмент выше — не новый canonical Dockerfile проекта, а изоляция одного решения: base image выбирается именно в final stage и должен быть осознанным runtime default.

И вот тут полезно морально разделить два вопроса, которые новички часто смешивают. Первый вопрос — “запускается ли?”. Второй вопрос — “является ли это хорошим default?”. В контейнерах эти два вопроса иногда расходятся: образ может запускаться, но быть слишком тяжёлым, слишком хрупким, слишком непредсказуемым или слишком неудобным для поддержки.

6. Типичные ошибки при выборе runtime base image

Перед тем как двигаться дальше по дню, полезно поймать себя на типичных автоматических решениях. Они обычно выглядят логично “по ощущению”, но потом превращаются в источник хаоса, особенно когда проект оказывается на ноутбуках у разных людей и начинает жить дольше пары дней.

Ошибка №1: выбирать образ только по размеру.
Размер — это видимая метрика, поэтому мозг цепляется за неё первой. Но если вы выбираете base image только по принципу “самый маленький”, вы по сути делаете ставку на то, что вам никогда не понадобятся ни нормальная диагностика, ни предсказуемые системные зависимости. В реальности это редко окупается в начале проекта: экономия мегабайт часто превращается в потерю часов.

Ошибка №2: считать, что “раз запускается — значит выбран правильно”.
Контейнер мог стартовать, показать красивый баннер Spring Boot и даже ответить на один endpoint. Это ещё не гарантия, что база подходит как default. Как только появится HTTPS, взаимодействие с файловой системой, разные локальные окружения или просто чуть более сложная эксплуатация, слабые места base image начнут проявляться. Лучше заранее мыслить критериям, а не удачей.

Ошибка №3: менять сразу всё: и Java, и vendor образа, и базовый дистрибутив.
Когда вы одновременно меняете несколько параметров, вы теряете причинно‑следственную связь. Если после смены образа что-то сломалось, вы не знаете, что именно виновато: другая Java, другой Linux, другие пакеты, другие настройки. Для нормальной инженерии меняют по одному параметру и проверяют эффект.

Ошибка №4: путать роль builder stage и runtime stage.
Очень частая привычка — взять один и тот же образ везде, потому что “так проще”. Но multi-stage как раз и был нужен, чтобы разделить роли. Сборка требует одного набора инструментов, запуск — другого. Если вы в runtime stage тащите всё подряд “на всякий случай”, вы постепенно возвращаетесь к single-stage по смыслу, только менее честному.

Ошибка №5: не формулировать критерии словами (и не оставлять след в репозитории).
Пока решение живёт только в голове автора Dockerfile, оно недолговечно. Через месяц кто-то поменяет FROM, потому что “нашёл вариант меньше”, и вы получите дрейф окружения. Даже короткая человеческая формулировка рядом с FROM (“почему выбран именно этот тип runtime base image”) делает проект взрослее и спокойнее.

1
Задача
Docker for Spring, 7 уровень, 0 лекция
Недоступна
Runtime image для final stage
Runtime image для final stage
1
Задача
Docker for Spring, 7 уровень, 0 лекция
Недоступна
Быстрое сравнение двух final image
Быстрое сравнение двух final image
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ