Статьи

Создание, упаковка и запуск приложений Spring Boot с помощью Docker

Сидя на скамье подсудимых …

Недавно я снова начал играть с Docker и решил собрать несколько постов, чтобы поделиться тем, что я узнал. В этой статье я покажу вам, как скомпилировать, упаковать и запустить простое приложение Spring Boot в контейнере Docker. Чтобы размер Docker был как можно меньше, я буду использовать базовые образы Alpine и многоэтапную сборку.


Вам также может понравиться: 
Создание микросервисов с использованием Spring Boot и Docker

Почему я должен рассмотреть возможность использования Docker?

Docker — это технология контейнеризации, которая позволяет вам создавать образ, содержащий ваше приложение и все зависимости, необходимые для его запуска. Образ является развертываемым артефактом и может использоваться для запуска контейнеров на любой виртуальной или физической машине с установленным Docker. Это мощная концепция, поскольку она позволяет вам запускать один и тот же образ в процессе разработки, тестирования, подготовки и производства, не беспокоясь об установке или настройке зависимостей в каждой среде.

Использование Docker для сборки и запуска приложения

С точки зрения разработчика Java, типичный вариант использования Docker — это запуск вашего приложения внутри контейнера Docker. Это здорово, но не лучше ли было бы использовать Docker и для создания приложения? В этой статье я покажу вам, как использовать Docker для компиляции, сборки и запуска приложения Spring Boot в контейнере Docker.

Вы создадите образ Docker, который выполняет следующие действия:

  • Копирует исходный код приложения с хост-машины во временный каталог сборки в образе
  • Использует Maven для компиляции и упаковки приложения в виде исполняемого JAR
  • Используйте JRE для запуска исполняемого JAR

Примечание к размеру изображения

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

Использовать небольшие базовые изображения

Лучше всего выбрать базовое изображение, которое содержит основные элементы и ничего больше. Я буду использовать этот подход в примере приложения позже, используя изображения Alpine. Alpine — это супер тонкий дистрибутив Linux, занимающий всего 5 МБ. Это делает его идеальным для создания скудных изображений. В Alpine есть менеджер пакетов, поэтому вы можете установить все, что вам нужно, но важно отметить, что вы начинаете с очень маленького образа. Если вы посмотрите на DockerHub, вы увидите, что многие популярные изображения имеют альпийскую версию. Вы увидите несколько примеров этого позже, когда мы будем использовать изображения Maven и Open JDK JRE со вкусом Alpine.

Откажитесь от того, что вам не нужно

Образ, который вы определите позже, скомпилирует, упакует и запустит приложение Spring Boot. Конечный образ Docker — это ваш развертываемый артефакт, поэтому он должен содержать только зависимости приложения и среды выполнения. Замечательно иметь возможность создавать и запускать приложение в одном контейнере, но вы не хотите, чтобы конечное изображение содержало Maven (и объем локального репозитория Maven) или все содержимое целевого каталога. Все, что вам действительно нужно в конечном образе — это исполняемый JAR и Java JRE для его запуска.

Что вы хотите сделать, это создать приложение, а затем отказаться от всего, что вам не нужно из окончательного изображения. Это где многоступенчатые сборки вступают в игру. Они позволяют вам разбить сборку Docker на отдельные этапы и копировать определенные элементы между этапами, отбрасывая все остальное. Это позволит вам отказаться от инструментов сборки и всего, что не является необходимым для запуска приложения.

Настройка проекта

Структура проекта очень проста. Я создал стандартное приложение Spring Boot с одним классом Application и добавил Dockerfile в корневой каталог проекта. Если вы хотите быстро приступить к работе, вы можете получить полный исходный текст этого поста на GitHub .

Поиск Dockerfile

Класс основного приложения показан ниже, и, как вы можете видеть, я больше ничего не добавил в проект. Я планирую использовать конечную точку работоспособности привода по умолчанию, чтобы позже протестировать приложение.

package com.blog.samples.docker;

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

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }  
}

Определение образа Docker

После завершения весенней загрузки перейдем к образу Docker. Изображение определено в Dockerfile ниже, и хотя оно компактно, многое происходит. Я объясню каждую строку подробно ниже.

FROM maven:3.5.2-jdk-8-alpine AS MAVEN_BUILD

MAINTAINER Brian Hannaway

COPY pom.xml /build/
COPY src /build/src/

WORKDIR /build/
RUN mvn package

FROM openjdk:8-jre-alpine

WORKDIR /app

COPY --from=MAVEN_BUILD /build/target/docker-boot-intro-0.1.0.jar /app/

ENTRYPOINT ["java", "-jar", "docker-boot-intro-0.1.0.jar"]

FROM maven:3.5.2-jdk-8-alpine AS MAVEN_BUILDговорит Docker использовать образ Maven  maven:3.5.2-jdk-8-alpineв качестве базового для первого этапа сборки. Docker будет искать это изображение локально, и если оно недоступно, оно будет извлечено из DockerHub. Хотя Maven будет удален из окончательного образа (см. COPY --fromКоманду позже), я использовал образ Maven с альпийским вкусом, так как он быстрее загружается.

MAINTAINER Brian Hannaway не является существенным, но улучшает удобство обслуживания, предоставляя точку контакта для автора изображения.

COPY pom.xml /build/создает buildкаталог в образе и копирует в него файл pom.xml.

COPY src /build/src/копирует srcкаталог в buildкаталог на изображении.

WORKDIR /build/устанавливает buildв качестве рабочего каталога. Все дальнейшие команды будут запускаться из buildкаталога.

RUN mvn packageзапускает mvn packageкоманду для компиляции и упаковки приложения как исполняемый JAR. При первом создании образа Maven извлекает все необходимые зависимости из общедоступного репозитория Maven и кэширует их локально в образе. Последующие сборки будут использовать кэшированную версию этого слоя изображения, что означает, что на зависимости будут ссылаться локально, и их больше не придется удалять.

На данный момент я определил образ до того момента, когда он создаст исполняемый JAR. Это первая часть многоступенчатой ​​сборки закончена. На следующем этапе возьмите JAR и запустите его.

FROM openjdk:8-jre-alpineсообщает Docker, что вы хотите использовать openjdk:8-jre-alpineбазовый образ для следующего этапа многоэтапной сборки. Опять же, я использовал легкий альпийский образ для Java 8 JRE. Использование альпийского изображения здесь более важно, чем использование его для изображения Maven ранее. Пока изображение Maven будет сброшено, оно openjdk:8-jre-alpineстанет частью окончательного изображения. Поэтому выбор легкого JRE-изображения важен, если вы хотите, чтобы конечное изображение было как можно меньше.

WORKDIR /appговорит Docker создать новый рабочий каталог в образе с именем /app. Все дальнейшие команды будут выполняться из этого каталога.

COPY --from=MAVEN_BUILD /build/target/docker-boot-intro-0.1.0.jar /app/сообщает Docker о необходимости копирования docker-boot-intro-0.1.0.jarиз /build/targetкаталога на этапе MAVEN_BUILD в /appкаталог текущего этапа. Как упоминалось ранее, многоэтапные сборки хороши тем, что позволяют копировать определенные артефакты с одного этапа сборки на другой и отбрасывать все остальное. Если бы вы сохранили все со стадии MAVEN_BUILD, вы бы получили изображение, содержащее Maven, локальное хранилище Maven. и все файлы классов, сгенерированные в целевом каталоге. Выбрав то, что вы хотите на этапе MAVEN_BUILD, и отбросив все остальное, вы получите гораздо меньшее изображение.

ENTRYPOINT ["java", "-jar", "app.jar"]сообщает Docker, какую команду запускать при запуске контейнера из этого образа. Отдельные части команды разделены запятыми. В этом случае вы хотите запустить исполняемый JAR, который вы скопировали в /appкаталог.

Построение имиджа

Теперь, когда у вас есть определенный образ Docker, пришло время его построить. Откройте каталог, содержащий Dockerfile (корень проекта). Чтобы создать образ, выполните следующую команду.  docker image build -t docker-boot-intro .Параметр -tпозволяет указать имя и, необязательно, тег. Если вы не укажете тег, Docker автоматически пометит изображение как latest.

$ docker image build -t docker-boot-intro .
Sending build context to Docker daemon  26.56MB
Step 1/10 : FROM maven:3.5.2-jdk-8-alpine AS MAVEN_BUILD
 ---> 293423a981a7
Step 2/10 : MAINTAINER Brian Hannaway
 ---> Using cache
 ---> db354a426bfd
Step 3/10 : COPY pom.xml /build/
 ---> Using cache
 ---> 256340699bc3
Step 4/10 : COPY src /build/src/
 ---> Using cache
 ---> 65eb0f98bb79
Step 5/10 : WORKDIR /build/
 ---> Using cache
 ---> b16b294b6b74
Step 6/10 : RUN mvn package
 ---> Using cache
 ---> c48659e0197e
Step 7/10 : FROM openjdk:8-jre-alpine
 ---> f7a292bbb70c
Step 8/10 : WORKDIR /app
 ---> Using cache
 ---> 1723d5b9c22f
Step 9/10 : COPY --from=MAVEN_BUILD /build/target/docker-boot-intro-0.1.0.jar /app/
 ---> Using cache
 ---> d0e2f8fbe5c9
Step 10/10 : ENTRYPOINT ["java", "-jar", "docker-boot-intro-0.1.0.jar"]
 ---> Using cache
 ---> f265acb14147
Successfully built f265acb14147
Successfully tagged docker-boot-intro:latest
SECURITY WARNING: You are building a Docker image from Windows against a non-Windows Docker host. All files and directories added to build context will have '-rwxr-xr-x' permissions. It is recommended to double check and reset permissions for sensitive files and directories.

Brians Computer@DESKTOP-077OUJ8 MINGW64 /c/dev/docker-boot-intro (master)

Когда вы запускаете сборку, Docker выполняет каждую команду в файле Docker как отдельный шаг. Слой создается для каждого шага вместе с уникальным идентификатором. Например, на шаге 1 был создан слой с идентификатором 293423a981a7.

В первый раз, когда вы создаете образ, Docker извлекает из DockerHub все необходимые внешние изображения и создает новые слои с нуля. Как и следовало ожидать, это может сделать первую сборку довольно медленной.

Во время сборки Docker проверяет свой кеш, чтобы узнать, есть ли у него уже кэшированная версия каждого слоя, прежде чем пытаться его построить. Если доступна кэшированная версия слоя, Docker использует ее вместо создания слоя с нуля. Это означает, что после того, как вы построили изображение, последующие сборки станут намного быстрее. Вы можете видеть кэшированные слои, используемые в выводе сборки выше, где выводит Docker, --> Using Cacheа затем хэш используемого слоя.

As part of the RUN mvn package command, Docker pulls all POM dependencies from the public Maven repo, builds an executable JAR, and stores all of this in layer c48659e0197e. The next time you build the image, the Maven dependencies and the application JAR will be taken from the cached layer and won’t have to be downloaded and built again. This is what’s happening in step 6 above.

How Big is the Image?

Run the docker image ls command to list all your local images. You’ll see the docker-boot-intro image listed with a size of 105 MB.

Brians Computer@DESKTOP-077OUJ8 MINGW64 /c/dev/docker-boot-intro (master)
$ docker image ls
REPOSITORY                               TAG                  IMAGE ID            CREATED             SIZE
docker-boot-intro                        latest               823730301d60        15 minutes ago      105MB
<none>                                   <none>               853d42b823c3        15 minutes ago      136MB
<none>                                   <none>               39ac5e9e9562        19 minutes ago      105MB
<none>                                   <none>               dfda2356bd36        19 minutes ago      136MB
<none>                                   <none>               b9918f1a40f3        9 hours ago         105MB
<none>                                   <none>               5c8d8feadac4        9 hours ago         136MB
<none>                                   <none>               37ce6059c665        10 hours ago        105MB
<none>                                   <none>               a3c65e472ef0        10 hours ago        136MB
<none>                                   <none>               9c923ffa281f        5 days ago          103MB
<none>                                   <none>               f265acb14147        6 days ago          103MB
<none>                                   <none>               355c0df6df00        6 days ago          103MB
<none>                                   <none>               c48659e0197e        6 days ago          135MB
<none>                                   <none>               12ccc0ebd9ab        6 days ago          135MB
<none>                                   <none>               fbfb36e47739        11 days ago         103MB
<none>                                   <none>               c0592f908083        11 days ago         96.4MB
<none>                                   <none>               c64ed1838d04        11 days ago         169MB
<none>                                   <none>               a79ee088cc15        2 weeks ago         165MB
<none>                                   <none>               590ce685a660        2 weeks ago         165MB
<none>                                   <none>               cae8c5edec73        2 weeks ago         165MB
<none>                                   <none>               313ee2b229f0        2 weeks ago         535MB
<none>                                   <none>               1f125f2b48e9        2 weeks ago         535MB
<none>                                   <none>               544620bb08d0        2 weeks ago         570MB
<none>                                   <none>               a45d48c069ea        2 weeks ago         570MB
<none>                                   <none>               ef4a7b464400        2 weeks ago         570MB
<none>                                   <none>               0e9351cb5d2f        2 weeks ago         570MB
<none>                                   <none>               27a435a1fffe        2 weeks ago         570MB
<none>                                   <none>               b87ba7aef3f8        2 weeks ago         570MB
<none>                                   <none>               cce6a99f54d0        2 weeks ago         570MB
<none>                                   <none>               c1c3f114662d        2 weeks ago         506MB
<none>                                   <none>               c1c5e0d7c404        2 weeks ago         506MB
tomcat                                   latest               ee48881b3e82        2 weeks ago         506MB
jdk-image-from-docker-file               latest               82e9207f76af        2 weeks ago         468MB
myjdkimage                               latest               a5aeca271399        2 weeks ago         468MB
tomcat                                   <none>               96c4e536d0eb        5 weeks ago         506MB
ubuntu                                   latest               a2a15febcdf3        6 weeks ago         64.2MB
openjdk                                  latest               e1e07dfba89c        7 weeks ago         470MB
openjdk                                  8-jre-alpine         f7a292bbb70c        4 months ago        84.9MB
hello-world                              latest               fce289e99eb9        9 months ago        1.84kB
openjdk                                  8u131-jdk-alpine     a99736768b96        22 months ago       101MB
maven                                    3.5.2-jdk-8-alpine   293423a981a7        23 months ago       116MB

Brians Computer@DESKTOP-077OUJ8 MINGW64 /c/dev/docker-boot-intro (master)

I mentioned earlier that it’s good practice to keep your images as light as possible. Let’s take a look a closer look at the docker-boot-intro image and see how we arrived at 105 MB. If you run the docker image history boot-docker-intro command you’ll see a breakdown of the various layers in the image.

Brians Computer@DESKTOP-077OUJ8 MINGW64 /c/dev/docker-boot-intro/target (master)
$ docker image history docker-boot-intro
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
823730301d60        19 minutes ago      /bin/sh -c #(nop)  ENTRYPOINT ["java" "-jar"...   0B
7e43d899f02f        19 minutes ago      /bin/sh -c #(nop) COPY file:05f3666306f8c7af...   20.1MB
1723d5b9c22f        6 days ago          /bin/sh -c #(nop) WORKDIR /app                  0B
f7a292bbb70c        4 months ago        /bin/sh -c set -x  && apk add --no-cache   o...   79.4MB
<missing>           4 months ago        /bin/sh -c #(nop)  ENV JAVA_ALPINE_VERSION=8...   0B
<missing>           4 months ago        /bin/sh -c #(nop)  ENV JAVA_VERSION=8u212       0B
<missing>           4 months ago        /bin/sh -c #(nop)  ENV PATH=/usr/local/sbin:...   0B
<missing>           4 months ago        /bin/sh -c #(nop)  ENV JAVA_HOME=/usr/lib/jv...   0B
<missing>           4 months ago        /bin/sh -c {   echo '#!/bin/sh';   echo 'set...   87B
<missing>           4 months ago        /bin/sh -c #(nop)  ENV LANG=C.UTF-8             0B
<missing>           4 months ago        /bin/sh -c #(nop)  CMD ["/bin/sh"]              0B
<missing>           4 months ago        /bin/sh -c #(nop) ADD file:a86aea1f3a7d68f6a...   5.53MB

Brians Computer@DESKTOP-077OUJ8 MINGW64 /c/dev/docker-boot-intro/target (master)

The super-slim 5.53 MB Alpine base image is listed as the first layer. A number of environment variables are configured in the next few layers and then the 79.4 MB JRE is added. The final three layers come from the Dockerfile we defined and include the 20.1 MB application JAR. This is a nice lightweight image with only the bare essentials needed to run the application.

Running a Container

Now that the image is image built you can run a container with the following command docker container run -p 8080:8080 docker-boot-intro. The run command takes an optional -p option that allows you to map a port from the container to your host machine. If you’re familiar with Spring Boot, you’ll probably know that by default a Boot app starts on port 8080. When you run a container, Docker will run the executable JAR and the application will start on port 8080 inside the container. In order to access the application running in the container you need to map the internal container port to a port on the host machine. The -p 8080:8080 option maps the containers internal port 8080 to port 8080 on the host machine.

If everything works as expected you should see the Boot app start on port 8080 as follows.

Brians Computer@DESKTOP-077OUJ8 MINGW64 /c/dev/docker-boot-intro/target (master)
$ docker container run -p 8080:8080 docker-boot-intro

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.1.7.RELEASE)

5436 [main] INFO  com.blog.samples.docker.Application - Starting Application v0.1.0 on 934a1d731576 with PID 1 (/app/docker-boot-intro-0.1.0.jar started by root in /app)
5466 [main] INFO  com.blog.samples.docker.Application - No active profile set, falling back to default profiles: default
16585 [main] INFO  o.s.b.w.e.tomcat.TomcatWebServer - Tomcat initialized with port(s): 8080 (http)
16742 [main] INFO  o.a.coyote.http11.Http11NioProtocol - Initializing ProtocolHandler ["http-nio-8080"]
16886 [main] INFO  o.a.catalina.core.StandardService - Starting service [Tomcat]
16892 [main] INFO  o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/9.0.22]
17622 [main] INFO  o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring embedded WebApplicationContext
17628 [main] INFO  o.s.web.context.ContextLoader - Root WebApplicationContext: initialization completed in 11614 ms
21399 [main] INFO  o.s.s.c.ThreadPoolTaskExecutor - Initializing ExecutorService 'applicationTaskExecutor'
23347 [main] INFO  o.s.b.a.e.web.EndpointLinksResolver - Exposing 2 endpoint(s) beneath base path '/actuator'
23695 [main] INFO  o.a.coyote.http11.Http11NioProtocol - Starting ProtocolHandler ["http-nio-8080"]
23791 [main] INFO  o.s.b.w.e.tomcat.TomcatWebServer - Tomcat started on port(s): 8080 (http) with context path ''
23801 [main] INFO  com.blog.samples.docker.Application - Started Application in 21.831 seconds (JVM running for 25.901)

Testing the Application

If you see output similar to that shown above, your container has started and you should be able to test the app. If you’re running Docker on Windows or Mac, you’re using Docker Toolbox, which is a Linux VM. You’ll need to get the IP of the Linux VM by running the docker-machine ip command. When I run this command I can see that my Linux VM IP is 192.168.99.100.

Brians Computer@DESKTOP-077OUJ8 MINGW64 /c/dev/docker-boot-intro (master)
$ docker-machine ip
192.168.99.100

Once you know the IP you can test the app by calling its health check endpoint using cURL curl 192.168.99.100:8080/actuator/health
If the application is up and running you should get a HTTP 200 response with a response body {"status":"UP"}.

Brians Computer@DESKTOP-077OUJ8 MINGW64 /c/dev/docker-boot-intro (master)
$ curl 192.168.99.100:8080/actuator/health
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    15    0    15    0     0    937      0 --:--:-- --:--:-- --:--:--   937{"status":"UP"}

Limitations With the Approach Used

I mentioned earlier that Docker caches layers so that they can be reused to reduce build times. While this stands true, there’s something you need to consider when building Java apps. Every time you make a change to the application source or POM, Docker will see that layer as changed and disregard the cached copy. This in itself is fine as we want to rebuild the layer.

The problem is the Maven dependencies that were saved in the cached layer are lost. So when you rebuild the application layer with the mvn package command, all Maven dependencies will be pulled from the remote repository again. This obviously slows the build considerably and will become a real pain during development. When you’re building Java apps without Docker, you pull down the Maven dependencies from the remote repository the first time you build and then reference them in your local Maven cache after that. Unfortunately using Docker means that when the application layer is rebuilt, you lose the local Maven cache.

So What’s the Answer?

The workaround for this issue is to use a local Maven repository on the host machine as the source of your Maven dependencies. Using volumes, you can tell Docker to access the local Maven repository on the host rather than pulling the dependencies from a public repository. There are pros and cons to this approach.

On the upside, you can change the application source and rebuild without sacrificing quick build times because you’re using cached Maven dependencies. On the downside, your Docker image has lost some of its autonomy. Remember, one of the main reasons you’re using Docker is so that you don’t have to worry about configuring software on the environment it’s running on.

Ideally, your Docker image should be self-contained and have everything it needs to build and run without any dependencies on the host. By using a Maven cache on the host you lose that autonomy.

I’ll cover Docker volumes in my next post and show you how they can be used to access a Maven repo on the host machine.

Wrapping Up

In this post, you defined a Docker image to build and run a Spring Boot application. We talked about the importance of keeping images as light as possible and you did that by using Alpine base images and a multi-stage build to discard build. We also looked at the limitations of building Java apps with Docker and a potential workaround. You can grab the full source for this post from GitHub. If you have any comments or questions please leave a note below.

Further Reading

A Start to Finish Guide to Docker With Java, Part 1

Docker Layers Explained