Недавно я снова начал играть с 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 .
Класс основного приложения показан ниже, и, как вы можете видеть, я больше ничего не добавил в проект. Я планирую использовать конечную точку работоспособности привода по умолчанию, чтобы позже протестировать приложение.
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.