ЯдроКодаподготовка к экзаменам
Учебная платформа

Загружаем материалы

Подготавливаем материалы и навигацию по разделу.

Java в Docker: multi-stage сборка JDK → JRE и остановка HTTP-сервиса

Автор: · Обновлено

Java-приложению нужен компилятор во время сборки и среда выполнения после неё. Docker позволяет разделить эти этапы: JDK компилирует исходник в первой стадии, а итоговый…

Java-приложению нужен компилятор во время сборки и среда выполнения после неё. Docker позволяет разделить эти этапы: JDK компилирует исходник в первой стадии, а итоговый контейнер запускает полученные классы в JRE. Соберём небольшой HTTP-сервис без Maven, Gradle и внешних библиотек, проверим непривилегированного пользователя и доставку SIGTERM в JVM.

Это упаковка самого приложения. В [уроке Testcontainers](/lessons/java/java-for-developers/java-developer-15) Java-тесты создают вспомогательную базу; здесь контейнер содержит Java-сервис, который можно запускать независимо от тестовой JVM. Версию 17 выбираем явно для простого воспроизводимого примера, не называя её новейшей.

Исходник с HTTP и shutdown hook

Создайте отдельную папку java-docker-lab и файл App.java:

import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;

public final class App {
    public static void main(String[] args) throws Exception {
        HttpServer server = HttpServer.create(
            new InetSocketAddress("0.0.0.0", 8080), 0);
        server.createContext("/", exchange -> {
            String path = exchange.getRequestURI().getPath();
            String message = System.getenv().getOrDefault("GREETING", "java-ready");
            byte[] body = (path.equals("/healthz") ? "ready" : message)
                .getBytes(StandardCharsets.UTF_8);
            exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
            exchange.sendResponseHeaders(200, body.length);
            try (var output = exchange.getResponseBody()) {
                output.write(body);
            }
        });
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            System.out.println("stopping");
            server.stop(2);
            System.out.println("stopped");
        }, "http-shutdown"));
        server.start();
        System.out.println("ready");
    }
}

HttpServer входит в модуль jdk.httpserver. Контекст / обрабатывает подходящие пути по префиксу, поэтому наш маленький сервер отвечает и на /healthz, и на остальные пути. Это намеренно простой учебный обработчик, не готовая схема маршрутизации приложения. Content-Length получает число байтов UTF-8, а не число символов Java-строки; поток ответа закрывается через try-with-resources.

При штатной остановке JVM запускает зарегистрированный hook. server.stop(2) прекращает приём новых запросов и ограниченно ждёт завершения обработчиков. Hook не следует превращать в бесконечную операцию: Docker также имеет конечный бюджет остановки. При SIGKILL или аварийном прекращении процесса рассчитывать на выполнение hook нельзя. HTTP API Java 17, контракт shutdown hooks).

Две стадии: компиляция и выполнение

Создайте Dockerfile рядом с исходником:

FROM eclipse-temurin:17-jdk-jammy AS build
WORKDIR /src
COPY App.java ./App.java
RUN javac --release 17 --add-modules jdk.httpserver -d /out App.java

FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
COPY --from=build --chown=10001:10001 /out/ /app/
USER 10001:10001
EXPOSE 8080
CMD ["java", "--add-modules", "jdk.httpserver", "-cp", "/app", "App"]

Первая стадия получает JDK и выполняет javac. Вторая начинает с отдельного JRE-образа: из build переносятся только результаты в /out. Путь исходника не становится автоматически частью финального образа, и компилятор из первой стадии не наследуется. USER задаёт числовой UID/GID; запись с таким именем в /etc/passwd этому приложению не нужна. Классы доступны для чтения, запись в /app во время запуска не требуется.

Обе стадии используют Java 17, а --release фиксирует цель компиляции. Если позже компилировать более новым JDK без подходящего --release и запускать на старом runtime, возможен UnsupportedClassVersionError. Это несовместимость версии байткода и JVM, а не ошибка проброса портов. Состав вариантов проверяйте в официальном каталоге Temurin; major-теги обновляются, поэтому для выпуска сохраняйте digest проверенного артефакта.

Создайте .dockerignore, чтобы локальные классы и инструменты разработки не увеличивали контекст:

.git
.idea
*.class
target
build
.env

Здесь --add-modules явно указывает необходимый HTTP-модуль. Если команда решит собирать минимальный runtime через jlink, потребуется включить модули приложения и проверить их наличие. Этот урок использует обычный JRE-образ, а не предполагает, что всякий минимальный Java runtime уже содержит jdk.httpserver.

Запуск и проверка полезного результата

Порт 18090 и имя yk-java-app должны быть свободны. В Bash из папки проекта:

docker build -t yk-java-app:1 .
docker run -d --name yk-java-app -p 127.0.0.1:18090:8080 -e GREETING=container-java yk-java-app:1
curl --fail --retry 20 --retry-all-errors --retry-delay 1 --retry-max-time 30 --max-time 2 http://127.0.0.1:18090/healthz
curl --fail http://127.0.0.1:18090/
docker exec yk-java-app id
docker exec yk-java-app java -version
docker logs --tail 20 yk-java-app

Для GET /healthz настроены ограниченные повторы curl: до открытия listener Docker может вернуть как отказ соединения, так и краткий connection reset. Это проверка готовности чтением, а не правило повторять произвольные изменяющие запросы.

Ожидаются ready и container-java. Команда id покажет UID 10001; java -version проверяет runtime именно внутри запущенного образа, независимо от Java на ноутбуке. Если проверяется код завершения curl, его успешный HTTP-ответ должен предшествовать тесту остановки: остановка сразу после docker run может попасть в фазу инициализации до регистрации hook.

Число после -p слева — порт хоста, справа — порт listener внутри контейнера. Привязка Java к 0.0.0.0 позволяет Docker доставить запрос на интерфейс контейнера. Публикация на 127.0.0.1 оставляет учебный сервис локальным. EXPOSE не заменяет -p и не является правилом сетевой защиты.

Увидеть остановку JVM

docker stop --timeout 10 yk-java-app
docker logs yk-java-app
docker inspect --format '{{.State.ExitCode}} {{.State.OOMKilled}}' yk-java-app
docker rm yk-java-app

После ready в журнале должны появиться stopping и stopped. В проверенном Linux/Temurin-сценарии JVM завершается после SIGTERM с кодом 143 и OOMKilled=false: 128+15 отражает сигнал, а не автоматически неудачную работу hook. Для заключения о корректной остановке нужны журнал, бюджет времени и результат завершения обработчиков. Код 137 после исчерпания timeout требует другого разбора: процесс мог получить SIGKILL и не успеть закончить работу.

JSON-форма CMD важна и здесь: основная команда — java, а не оболочка, которая запустила JVM фоном и завершилась. Если ваш entrypoint выполняет подготовку через shell, последняя команда должна использовать exec для передачи управления приложению. Общая последовательность описана в [уроке PID 1 и сигналов](/lessons/without-university/docker-containerization/docker-developer-26).

Как перенести приём в настоящий Java-проект

В проекте с Maven или Gradle первая стадия обычно использует wrapper и создаёт JAR вместе с нужной структурой зависимостей. Во вторую копируют конкретный проверенный артефакт и запускают документированную команду. Не предполагайте, что любой JAR самодостаточен: отсутствие библиотеки в classpath проявится уже во время выполнения. Не копируйте случайный файл из target по слишком широкому шаблону, если там одновременно находятся обычный, тестовый и перепакованный архивы.

Успешная сборка образа ещё не проверяет HTTP или остановку. В [CI/CD контейнеров](/lessons/without-university/docker-containerization/docker-developer-37) добавьте smoke test именно финального JRE-образа. Не подменяйте его запуском из build-стадии, где случайно доступны компилятор и лишние зависимости. Механика переноса файлов между стадиями подробно разобрана в [multi-stage уроке](/lessons/without-university/docker-containerization/docker-developer-11).

Самостоятельная проверка

Добавьте отдельный /version, возвращающий версию вашего приложения, соберите тег :2 и сравните его с :1 на другом свободном порту. Затем измените только GREETING и убедитесь, что пересборка исходников не нужна. Наконец намеренно укажите неверный main class в CMD: образ может собраться, но запуск завершится до появления ready. Сохраните точную строку JVM из docker logs и объясните, почему healthcheck или restart policy не исправляют ошибку упаковки.

Источники