Докер, Java и почему это меняет правила игры
Представьте: вы написали крутое Java-приложение. Оно работает на вашем MacBook, но когда вы отдаёте его коллеге на Windows — всё ломается. Знакомо? А если сервер на Linux? Тут даже гадать не надо — классическая история "на моей машине работает".
Контейнеризация с Docker решает эту проблему раз и навсегда. Вы упаковываете приложение вместе с его окружением (JVM, библиотеки, зависимости) в лёгкий контейнер, который запускается одинаково везде. Для Java это особенно актуально — ведь мы привыкли к "Write Once, Run Anywhere", но на практике эта идиома часто даёт сбой из-за версий JDK, системных библиотек или конфигураций.
В этой статье мы разберём контейнеризацию Java приложений с Docker от А до Я. Вы узнаете, как написать Dockerfile, оптимизировать образ, использовать Docker Compose и не наступить на типичные грабли. Поехали!
Что такое Docker и зачем он Java-разработчику?
Docker — это платформа для упаковки, доставки и запуска приложений в изолированных средах — контейнерах. В отличие от виртуальных машин, контейнеры используют общее ядро ОС, но имеют своё файловое пространство, сеть и процессы. Это делает их лёгкими (мегабайты, а не гигабайты) и быстрыми (запуск за секунды).
Для Java-разработчика Docker даёт три суперсилы:
- Воспроизводимость — больше никаких "у меня работает". Если контейнер запустился локально, он запустится на любом сервере.
- Изоляция — каждое приложение живёт в своём мире, не мешая другим. Хотите два Java-приложения с разными версиями JDK? Легко.
- Масштабирование — контейнеры легко клонировать, балансировать и оркестрировать (Kubernetes, Docker Swarm).
Первый Dockerfile для Java-приложения
Начнём с простого. У нас есть стандартное Java-приложение (например, Spring Boot или просто Main.java). Мы хотим упаковать его в Docker-образ. Самый простой способ — использовать официальный образ OpenJDK.
Вот минимальный Dockerfile:
# Используем официальный образ OpenJDK 17
FROM openjdk:17-jdk-alpine
# Устанавливаем рабочую директорию
WORKDIR /app
# Копируем JAR-файл в контейнер
COPY target/my-app.jar /app/my-app.jar
# Запускаем приложение
CMD ["java", "-jar", "my-app.jar"]
Теперь соберём образ и запустим контейнер:
# Сборка образа
docker build -t my-java-app .
# Запуск контейнера
docker run -p 8080:8080 my-java-app
Всё работает. Но это плохой пример. Почему? Давайте разберёмся.
Проблема №1: Размер образа
Образ openjdk:17-jdk-alpine весит около 150 МБ. Это нормально, но если вы добавите туда все зависимости, размер может вырасти до 500+ МБ. Для продакшена это много.
Проблема №2: Сборка и рантайм в одном образе
Мы копируем готовый JAR, а значит, сборку (mvn clean package) нужно делать заранее. Лучше использовать многостадийную сборку (multi-stage build), чтобы отделить этап компиляции от этапа выполнения.
Оптимизация: многостадийная сборка (multi-stage build)
Идея простая: на первом этапе мы используем образ с Maven и JDK, собираем проект, а на втором — только JRE и готовый JAR. Это уменьшает финальный образ в разы.
# Этап сборки
FROM maven:3.8.5-openjdk-17 AS builder
WORKDIR /build
COPY pom.xml .
COPY src ./src
RUN mvn clean package -DskipTests
# Этап выполнения
FROM openjdk:17-jre-alpine
WORKDIR /app
COPY --from=builder /build/target/my-app.jar /app/my-app.jar
EXPOSE 8080
CMD ["java", "-jar", "my-app.jar"]
Что изменилось?
- Первый этап (
builder) содержит Maven и JDK — он нужен только для компиляции. - Второй этап использует лёгкий JRE-образ (~50 МБ) и копирует только JAR.
- Финальный образ весит ~120 МБ вместо 400+.
Важно: для Spring Boot приложений используйте openjdk:17-jre-alpine или ещё лучше — eclipse-temurin:17-jre-alpine (поддерживается сообществом).
Работа с переменными окружения и конфигурацией
Java-приложения часто используют конфигурационные файлы (application.properties, application.yml). В контейнере лучше передавать настройки через переменные окружения. Это делает образ универсальным — один и тот же JAR можно запустить с разными базами данных или API-ключами.
Пример Dockerfile с поддержкой переменных:
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY target/my-app.jar /app/my-app.jar
# Объявляем переменные (значения по умолчанию)
ENV DB_HOST=localhost \
DB_PORT=5432 \
APP_PROFILE=prod
EXPOSE 8080
# Используем переменные в команде запуска
CMD java -jar -Dspring.profiles.active=${APP_PROFILE} \
-Ddb.host=${DB_HOST} \
-Ddb.port=${DB_PORT} \
my-app.jar
Запуск с переопределением:
docker run -e DB_HOST=my-db-server -e APP_PROFILE=dev -p 8080:8080 my-java-app
Docker Compose: поднимаем стек целиком
Редко когда Java-приложение живёт само по себе. Обычно рядом есть PostgreSQL, Redis, Kafka. Docker Compose позволяет описать всю инфраструктуру в одном YAML-файле и запустить одной командой.
Пример docker-compose.yml для Spring Boot + PostgreSQL:
version: '3.8'
services:
app:
build: .
ports:
- "8080:8080"
environment:
- DB_HOST=db
- DB_PORT=5432
- DB_NAME=mydb
- DB_USER=user
- DB_PASSWORD=secret
depends_on:
- db
db:
image: postgres:15-alpine
environment:
POSTGRES_DB: mydb
POSTGRES_USER: user
POSTGRES_PASSWORD: secret
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Теперь одной командой docker-compose up -d вы поднимаете и приложение, и базу данных, и всё это в изолированной сети.
Типичные ошибки при контейнеризации Java
Даже опытные разработчики иногда спотыкаются. Вот топ-5 граблей:
- С