Прежде чем запустить контейнер, нужно собрать образ — упакованную копию вашего приложения со всем необходимым окружением. Образ описывается файлом Dockerfile. Разберём, как это устроено внутри и как написать Dockerfile для Spring Boot приложения с нуля.
Главное здесь — порядок инструкций: он решает, что при следующей сборке возьмётся из кэша, а что соберётся заново.
Кэш сбрасывается не только на изменённом шаге, но и на всех, что идут ниже: COPY app.jar в середине тянет за собой переустановку unzip — 3 шага из 5 заново вместо 1.
Проблема: «у меня работает, а на сервере нет»
Откуда берётся «работает у меня, а на сервере нет», разобрано в предыдущей статье: на сервере другая Java, нет библиотеки, конфиг не там. Решение — упаковать приложение вместе с рантаймом и всеми зависимостями в единый образ. Тогда везде, где запущен Docker, поведение будет одинаковым: на ноутбуке, в CI, на сервере.
Образ — это неизменяемый шаблон, из которого создаются контейнеры. Один образ — сколько угодно контейнеров на его основе.
Что такое образ и слои
Образ Docker — это не просто архив с файлами. Внутри он состоит из слоёв (layers), уложенных один поверх другого.
Слой появляется не от каждой инструкции, а только от тех, что и правда меняют файлы: RUN, COPY и ADD. Слой фиксирует эти изменения: добавленные файлы, установленные пакеты, распакованный архив. Финальный образ — это стопка слоёв, которые Docker склеивает в единую файловую систему.
Остальные инструкции — WORKDIR, ENV, EXPOSE, CMD, ENTRYPOINT — на диск ничего не кладут. Они дописывают настройки в описание образа: где рабочая директория, какие переменные окружения, чем стартовать. Весят такие записи нисколько.
Короткая формула: образ = базовый образ + слои от RUN, COPY и ADD + настройки, которые дописали остальные инструкции.
Зачем слои? Для переиспользования кэша. Если вы поменяли только код приложения, Docker не будет повторно скачивать базовый образ и переустанавливать зависимости — эти слои уже есть локально. Перестраивается только то, что изменилось, и всё, что идёт после. Это значительно ускоряет повторные сборки.
Образ — не цельный архив, а стопка слоёв поверх готового базового: одинаковые слои лежат на диске в одном экземпляре и качаются один раз, сколько бы образов их ни использовало. Плата — файл, удалённый в верхнем слое, остаётся лежать в нижнем и продолжает весить.
Dockerfile — инструкция по сборке
Dockerfile — текстовый файл с набором инструкций. Docker читает его сверху вниз и выполняет каждую инструкцию, создавая очередной слой.
Разберём основные инструкции на реальном примере.
FROM — базовый образ
Образ не строят с нуля — нужна готовая система с Java внутри, от которой отталкиваться:
FROM eclipse-temurin:21-jre
FROM — первая и обязательная инструкция. Она говорит Docker, от какого образа отталкиваться. eclipse-temurin:21-jre — это официальный образ с JRE 21 (только runtime, без JDK и компилятора). Для запуска Spring Boot jar-файла этого достаточно.
21-jre — это тег: версия образа. Всегда указывайте конкретный тег, а не latest — иначе сборка может сломаться при выходе новой версии.
WORKDIR — рабочая директория
Чтобы не писать /app/... в каждой следующей инструкции, задают рабочую директорию:
WORKDIR /app
WORKDIR задаёт директорию внутри контейнера, в которой будут выполняться последующие инструкции (COPY, RUN, CMD). Если директории нет — она создаётся автоматически. Это как сделать cd /app, только для Docker.
COPY — копирование файлов
Собранный jar должен оказаться внутри образа:
COPY build/libs/app.jar app.jar
COPY копирует файлы из вашей локальной файловой системы (слева) внутрь образа (справа). Здесь мы копируем собранный jar в рабочую директорию контейнера под именем app.jar.
Первый аргумент — путь относительно контекста сборки. Контекст задаёт последний аргумент команды docker build — чаще всего это точка, то есть текущая директория, но там может стоять и любой другой путь. Второй аргумент — путь внутри образа.
RUN — выполнение команд при сборке
Иногда базовому образу не хватает пакета или что-то нужно подготовить заранее, во время сборки:
RUN apt-get update \
&& apt-get install -y --no-install-recommends unzip \
&& rm -rf /var/lib/apt/lists/*
RUN выполняет команду во время сборки образа и сохраняет результат как новый слой. Используется для установки пакетов, создания директорий, любой подготовки окружения.
Несколько команд лучше объединять через && в одну инструкцию RUN — так получается меньше слоёв. Но одно объединение мусор не убирает: список пакетов, который скачал apt-get update, останется лежать в /var/lib/apt/lists и будет весить в образе десятки мегабайт. Поэтому в конце и стоит rm -rf — и именно в той же инструкции: если вынести удаление в отдельный RUN, файлы уже попадут в предыдущий слой, а верхний слой их только спрячет, не уменьшив образ.
Ключ --no-install-recommends — из той же серии: без него apt тянет ворох «рекомендованных» пакетов, которые вам не нужны.
Прежде чем что-то ставить, загляните, нет ли этого в базовом образе. В eclipse-temurin уже есть и curl, и wget — строка RUN apt-get install -y curl там только добавит лишний слой.
ENV — переменные окружения
Настройки JVM удобно зашить в образ, чтобы не повторять их при каждом запуске:
ENV JDK_JAVA_OPTIONS="-XX:MaxRAMPercentage=75"
ENV задаёт переменные окружения внутри образа. Они доступны как при сборке, так и при запуске контейнера. Удобно для настройки JVM или передачи конфигурации приложению.
Здесь легко промахнуться с именем. Очень часто пишут ENV JAVA_OPTS="-Xmx512m" и ждут, что JVM это подхватит — она не подхватит. JAVA_OPTS читают стартовые скрипты (Tomcat, Maven, Gradle), а не сама команда java. Если запуск идёт строкой ENTRYPOINT ["java", "-jar", "app.jar"], переменная просто пролежит без дела, а приложение стартует с размером кучи по умолчанию. Саму java слушается переменная JDK_JAVA_OPTIONS — её и задавайте.
Переменные, которые должны меняться при запуске (пароли, URL баз данных), лучше передавать через docker run -e или docker compose — не зашивать в образ.
EXPOSE — документирование порта
Тому, кто запускает образ, надо знать, на каком порту приложение слушает:
EXPOSE 8080
EXPOSE объявляет, на каком порту слушает приложение внутри контейнера. Сам по себе порт наружу он не открывает — реальный проброс настраивается при запуске контейнера (docker run -p).
Но «просто комментарием» это тоже не назовёшь: объявленные порты попадают в описание образа. Команда docker run -P (заглавная P) публикует наружу как раз их, сама подбирая свободные порты на хосте. Тот же список читают инструменты, которые работают поверх Docker. Ну и человеку, открывшему ваш Dockerfile, сразу видно, куда стучаться.
CMD и ENTRYPOINT — запуск приложения
Это самые часто путаемые инструкции. Обе задают, что выполнить при старте контейнера, но по-разному.
CMD — команда по умолчанию. Её можно заменить при запуске контейнера, передав другую команду в docker run. Например:
CMD ["java", "-jar", "app.jar"]
docker run my-app:1.0 sh выполнит sh вместо java -jar app.jar.
ENTRYPOINT — фиксированная точка входа. То, что передаётся через docker run, не заменяет её, а добавляется к ней как аргументы. Пример:
ENTRYPOINT ["java", "-jar", "app.jar"]
docker run my-app:1.0 --debug выполнит java -jar app.jar --debug.
Самый распространённый приём — комбинация: ENTRYPOINT фиксирует исполняемый файл, CMD даёт аргументы по умолчанию (которые можно переопределить):
ENTRYPOINT ["java"]
CMD ["-jar", "app.jar"]
docker run my-app:1.0 # java -jar app.jar
docker run my-app:1.0 -version # java -version: CMD заменён, ENTRYPOINT остался
Подменить саму точку входа всё-таки можно — флагом --entrypoint. Это первое, что пригодится, когда образ не стартует и хочется заглянуть внутрь:
docker run --rm -it --entrypoint sh my-app:1.0
Для простых случаев достаточно одного ENTRYPOINT. Используйте exec-форму (массив JSON, как выше) — она запускает процесс напрямую, без промежуточного shell, что важно для корректной обработки сигналов остановки контейнера.
Стоит объяснить, что именно ломает вторая форма, потому что «важно для сигналов» звучит как формальность, а последствия конкретные. Записанный строкой (ENTRYPOINT java -jar /app.jar) запуск Docker выполняет через shell: /bin/sh -c "java -jar /app.jar". Первым процессом в контейнере — с номером 1 — становится не Java, а shell, а Java запускается его дочерним процессом.
Дальше происходит вот что. docker stop отправляет процессу с номером 1 сигнал SIGTERM — просьбу завершиться. Shell этот сигнал получает, но дочернему процессу его не передаёт: он просто ждёт, пока тот закончит работу. JVM о просьбе не узнаёт: обработчики завершения Spring не выполняются, пул соединений не закрывается, текущие запросы обрываются на середине. Через десять секунд (срок по умолчанию) Docker теряет терпение и отправляет SIGKILL, который убивает всё без разговоров.
Внешне это выглядит как «контейнер останавливается ровно десять секунд» — узнаваемый признак shell-формы. С exec-формой номер 1 получает сама JVM, SIGTERM доходит до неё, Spring Boot корректно завершает работу, и контейнер останавливается за сотни миллисекунд.
Отсюда два практических правила. Массив JSON в ENTRYPOINT и CMD — всегда, кроме случаев, когда shell действительно нужен (подстановка переменных окружения в команду). И если нужна именно строковая форма, запускайте процесс через exec: ENTRYPOINT exec java -jar /app.jar заменяет shell собой, и номер 1 снова достаётся JVM.
Одна и та же команда docker run образ ls /app в четырёх записях запуска: смотрите, где ls /app подменяет CMD, где дописывается к ENTRYPOINT, а где вовсе теряется в оболочке.
ADD и COPY: почему по умолчанию COPY
Рядом с COPY в Dockerfile существует похожая инструкция ADD, и разница между ними — частый вопрос.
COPY делает ровно одно: копирует файлы из контекста сборки в образ. ADD умеет то же плюс две вещи, которые выглядят удобством, а работают как сюрприз:
- Распаковывает архивы.
ADD app.tar.gz /opt/не положит архив, а распакует его. Если вы этого не ждали, в образе окажется дерево файлов вместо одного файла — и наоборот, кто-то рассчитывает на распаковку, а она не срабатывает для.zip. - Скачивает по ссылке.
ADD https://example.com/lib.jar /opt/загрузит файл из сети. Это ломает воспроизводимость (на том конце файл может измениться), не использует прокси и учётные данные так, как вы ожидаете, и не проверяет контрольную сумму.
Правило простое: по умолчанию COPY. ADD берут осознанно, когда нужна именно распаковка локального архива. Скачивать в образе лучше явным RUN curl -fsSL ... && echo "<sha256> file" | sha256sum -c: видно, откуда файл, и проверяется, что он тот самый.
ARG и ENV: переменные сборки и переменные запуска
Следующий вопрос после «как собрать образ» — как передать в сборку номер версии или адрес внутреннего репозитория. Для этого есть две инструкции, и путать их неудобно.
ARG — переменная времени сборки. Объявляется в Dockerfile, значение приходит из командной строки, в запущенном контейнере её нет:
ARG APP_VERSION=dev
COPY build/libs/app-${APP_VERSION}.jar /app.jar
docker build --build-arg APP_VERSION=1.4.2 -t my-app:1.4.2 .
ENV — переменная окружения, которая остаётся в образе и видна процессу при запуске:
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75"
Три вещи, которые важно знать про обе.
ARG до FROM и после — разные области видимости. Объявленный до первой инструкции FROM аргумент виден в самих строках FROM (это способ подставить версию базового образа), но не виден внутри этапа сборки; чтобы использовать его дальше, его объявляют ещё раз внутри этапа.
Значение ARG не секрет. Оно остаётся в истории образа, и docker history my-app:1.4.2 покажет его текстом любому, кто скачал образ. То же и с ENV. Поэтому пароли и токены так не передают вовсе — для них есть отдельный механизм монтирования секретов на время сборки, о котором статья про промышленные образы.
ENV виден всем процессам контейнера и попадает в вывод docker inspect. Настройки — да, пароль от базы — нет: его передают при запуске, а лучше монтируют файлом.
Полный Dockerfile для Spring Boot
Собираем всё вместе:
FROM eclipse-temurin:21-jre
WORKDIR /app
# копируем собранный jar (предполагается: ./gradlew bootJar уже выполнен)
COPY build/libs/app.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Это минимальный рабочий Dockerfile. Он берёт готовый jar, помещает его в образ и запускает при старте контейнера.
.dockerignore: что не уедет в сборку
Одна деталь про COPY, которую стоит узнать до первой сборки. Перед тем как выполнить Dockerfile, Docker собирает контекст сборки — то есть упаковывает каталог, указанный в конце команды (docker build . — это текущий каталог), и отправляет его сборщику. Целиком, со всем, что там лежит.
В обычном проекте Java это build/ или target/ с прошлыми сборками, .git со всей историей, .gradle с кэшем, каталоги среды разработки, а иногда и локальный .env с паролями. Сотни мегабайт, из которых нужен один jar-файл. Последствия три: сборка медленно начинается («sending build context»), любое изменение в этих каталогах сбрасывает кэш слоя COPY, а лишние файлы могут попасть в образ, если COPY . . написан широко.
Лечится файлом .dockerignore рядом с Dockerfile — по смыслу это .gitignore для контекста сборки:
.git
.gradle
build
target
*.log
.env
.idea
Проверить, что он работает, проще всего по первой строке вывода docker build: размер контекста должен измеряться единицами мегабайт, а не сотнями. Подробнее, что туда кладут и почему это влияет на воспроизводимость, — в статье про слои и многоэтапную сборку.
Сборка образа: docker build
Собрать образ из Dockerfile можно командой:
# собрать образ и присвоить тег my-app:1.0
docker build -t my-app:1.0 .
Флаг -t (от tag) задаёт имя и тег образа. Точка в конце — это контекст сборки: директория, содержимое которой Docker может использовать в инструкциях COPY. Обычно это текущая директория проекта.
Посмотреть созданный образ:
docker images
После сборки образ можно запустить:
docker run -p 8080:8080 my-app:1.0
Ключ -p 8080:8080 пробрасывает порт: <порт на хосте>:<порт в контейнере>.
Кэш слоёв и порядок инструкций
Docker кэширует слои. Если при повторной сборке слой не изменился — он берётся из кэша, а не перестраивается. Но как только один слой изменился — все последующие пересобираются.
И тут важна деталь, которую обычно узнают через месяц отладки: Docker по-разному решает, изменился ли слой, в зависимости от инструкции.
Для COPY и ADD он считает контрольную сумму от содержимого и прав копируемых файлов. Изменили байт в файле — слой пересобирается; поменяли только время изменения файла — не пересобирается. Это то, чего все и ожидают.
Для RUN он смотрит только на текст команды. Не на результат, не на то, что происходит в сети, — на строку в Dockerfile. Отсюда классика:
RUN apt-get update && apt-get install -y curl
Текст не менялся полгода — значит слой все полгода берётся из кэша, и в образ ставятся пакеты того состояния списка, что было при первой сборке. Формально всё честно, практически — вы месяцами собираете образ с устаревшими, в том числе уязвимыми, пакетами. Лечится это тем, что apt-get update никогда не пишут отдельной инструкцией от установки (иначе кэшированное обновление совсем разъезжается с установкой), а обновление слоя провоцируют явно: меняют тег базового образа, пересобирают с --no-cache по расписанию или добавляют в команду аргумент, который меняется при сознательном обновлении.
То же правило объясняет и другую неожиданность: RUN git clone или RUN curl из кэша вернёт содержимое годичной давности, потому что текст команды тот же. Всё, что тянет данные из сети внутри RUN, кэшируется по строке — и это надо либо закреплять версией в самой команде, либо выносить из сборки.
Из этого следует важное правило: инструкции, которые меняются редко, ставьте выше; то, что меняется часто — ниже (именно это показывает схема в начале статьи).
В Spring Boot приложении jar-файл меняется при каждой сборке, поэтому COPY app.jar стоит в конце. Если бы COPY app.jar стоял выше, а установка зависимостей через RUN — ниже него, этот RUN пришлось бы выполнять заново при каждом изменении кода, хотя сами зависимости не менялись. Правильный порядок экономит минуты на каждой итерации.
И последнее, без чего вся глава про порядок инструкций может не сработать: кэш слоёв лежит на машине, которая собирает. На вашем ноутбуке он накапливается сам, а на чистом исполнителе конвейера его нет вовсе — там каждая сборка начинается с пустого кэша, и «правильный порядок» не экономит ни секунды, пока кэш не подключён отдельно. Как это делается, разбирает статья про слои и многоэтапную сборку.
Для ещё более эффективного кэширования (разделение зависимостей и кода приложения) используют многоэтапную сборку — это отдельная тема, разобранная в статье про слои и многоэтапную сборку.
Коротко
- Образ — неизменяемый шаблон для запуска контейнеров; состоит из слоёв.
- Слои создают только
RUN,COPYиADD;WORKDIR,ENV,EXPOSE,CMDиENTRYPOINTлишь дописывают настройки образа. Неизменённые слои берутся из кэша при повторной сборке. - Тег базового образа фиксируйте явно:
eclipse-temurin:21-jre, а неlatest. Несколько команд в одномRUNвместе с уборкой мусора в той же инструкции, а флаги JVM черезJDK_JAVA_OPTIONS:JAVA_OPTSкомандаjavaне читает. ENTRYPOINTфиксирует точку входа;CMDдаёт аргументы по умолчанию (можно переопределить при запуске).docker build -t имя:тег .собирает образ;.— контекст сборки. Редко меняющиеся инструкции ставьте выше поDockerfile— так кэш используется эффективнее.- Контекст сборки уезжает целиком:
.dockerignoreс.git,build,.gradleи.envускоряет сборку, спасает кэшCOPYи не пускает лишнее в образ. - Кэш для
COPYсчитается по содержимому файлов, а дляRUN— только по тексту команды: поэтомуapt-get updateмесяцами берётся из кэша, аRUN curlвозвращает вчерашние данные. - По умолчанию
COPY:ADDраспаковывает архивы и скачивает по ссылке, и оба поведения неожиданны.ARG— переменная сборки,ENVостаётся в образе, и значения обеих видны вdocker history. - Shell-форма
ENTRYPOINTделает первым процессом shell:SIGTERMдо JVM не доходит, остановка занимает ровно десять секунд и заканчиваетсяSIGKILL; exec-форма илиexecв команде это лечит.
Что почитать дальше
- Что такое Docker и зачем он нужен — если ещё не знакомы с базовыми концепциями контейнеризации.
- Контейнеризация Spring Boot приложения — пошаговый разбор от сборки jar до запуска в контейнере.
- Многоэтапная сборка и оптимизация слоёв — как уменьшить размер образа и ускорить сборку с multi-stage builds.