Kontenery Dockera stały się bardzo popularne. Bardzo ułatwiają tworzenie i deployment aplikacji. Szybko możemy zbudować kontener wraz z wszystkimi zależnościami projektu i zdeployować go na środowisku testowym/produkcyjnym lub wysłać do innych zespołów, które korzystają z nasze aplikacji. Jeśli pracujemy z mikroserwisami, jest to idealne rozwiązanie, aby uruchomić wszystkie potrzebne serwisy, a następnie skupić się na rozwijaniu swojego. Pozwala to też bardzo szybko uruchamiać środowiska testowe całej naszej aplikacji, na przykład na potrzeby CI/CD.
W tym wpisie pokażę jak zamknąć prostą aplikację napisaną w Spring Boocie w kontenerze. Dodatkowo pokażę też kilka sztuczek, przydatnych w sprawdzaniu co się dzieje z naszym kontenerem, oraz jak wysłać nasz kontener do zewnętrznego repozytorium Docker Hub. Zaczynajmy!
Kod do postu znajduje się w repozytorium na GitHubie: https://github.com/mloza/spring-boot-docker
Aplikacja Spring Boot
Zacznijmy od stworzenia prostej aplikacji w Spring Boocie. Będzie ona miała jedno zadanie, wyświetlić w przeglądarce napis Hello World!.
Maven i potrzebne zależności
Na początek tworzę nowy projekt Mavenowy i wrzucam tam zależności Springa do pom.xml. Całość wygląda tak jak poniżej.
<?xml version="1.0" encoding="UTF-8"?> | |
<project xmlns="http://maven.apache.org/POM/4.0.0" | |
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | |
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> | |
<modelVersion>4.0.0</modelVersion> | |
<groupId>pl.mloza</groupId> | |
<artifactId>spring-boot-docker</artifactId> | |
<version>1.0-SNAPSHOT</version> | |
<dependencyManagement> | |
<dependencies> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-parent</artifactId> | |
<version>2.2.4.RELEASE</version> | |
<scope>import</scope> | |
<type>pom</type> | |
</dependency> | |
</dependencies> | |
</dependencyManagement> | |
<dependencies> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-web</artifactId> | |
</dependency> | |
</dependencies> | |
<build> | |
<finalName>spring-docker</finalName> | |
<plugins> | |
<plugin> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-maven-plugin</artifactId> | |
<configuration> | |
<mainClass>pl.mloza.Main</mainClass> | |
</configuration> | |
<executions> | |
<execution> | |
<goals> | |
<goal>repackage</goal> | |
</goals> | |
</execution> | |
</executions> | |
</plugin> | |
<plugin> | |
<groupId>org.apache.maven.plugins</groupId> | |
<artifactId>maven-compiler-plugin</artifactId> | |
<version>3.8.0</version> | |
<configuration> | |
<release>14</release> | |
</configuration> | |
</plugin> | |
</plugins> | |
</build> | |
</project> |
Nie używam tutaj parent poma od Springa, zamiast tego importuję go w sekcji dependencyManagement. W zależnościach dodaję tylko Spring Boot Starter Web. Aplikacja ma tylko wyświetlić „Hello World!” więc to wystarczy. Ważną rzeczą jest spring-boot-maven-plugin! Dzięki niemu nasza aplikacja zostanie zapakowana do wykonywalnego jara. W konfiguracji tej wtyczki wskazujemy, która klasa powinna zostać uruchomiona po starcie aplikacji.
Aplikacja – jedna prosta klasa
Cała nasza aplikacja może zostać zamknięta w jednej klasie. Będziemy potrzebowali jeden kontroler z jedną metodą, która zwróci nam odpowiedni napis. Dodatkowo metodę main, która odpali Spring Boota. Całość wygląda jak poniżej.
@SpringBootApplication | |
@Controller | |
public class Main { | |
@RequestMapping("/") | |
@ResponseBody | |
public String index() { | |
return "<h1>Hello World!</h1>"; | |
} | |
public static void main(String[] args) { | |
SpringApplication.run(Main.class); | |
} | |
} |
Jeśli potrzebujesz objaśnienia poszczególnych elementów, zapraszam do wpisu o podstawach Spring Boota, tam wyjaśniam, do czego służą poszczególne adnotacje i w jaki sposób zbudować aplikację.
W tym momencie możemy uruchomić naszą aplikację, przejść pod adres http://localhost:8080 w przeglądarce i naszym oczom powinien się ukazać napis Hello World!
Pozostaje nam już tylko zbudowanie archiwum jar. Aby to zrobić, wpisujemy komendę mvn package. W katalogu target pojawi nam się plik spring-docker.jar. Możesz w tym momencie odpalić aplikację, aby się upewnić, że wszystko poszło dobrze. Wystarczy uruchomić polecenie java -jar target/spring-docker.jar i przejść przeglądarką na stronę. Pamiętaj, aby wcześniej zabić aplikację uruchomioną w IDE. Jeśli tego nie zrobisz, otrzymasz błąd mówiący o zajętym porcie 8080.
Docker – tworzymy kontener
Mając już gotową aplikację, czas ją zapakować do kontenera. Aby to zrobić, będziemy potrzebowali Dockerfile. Jest to zwykły plik tekstowy mówiący Dockerowi, w jaki sposób ma być, zbudować kontener. Plik ten tworzymy w głównym katalogu projektu. Dla naszej aplikacji wystarczy kilka linii, aby całość zadziałała.
Dockerfile
FROM adoptopenjdk/openjdk14:alpine-jre | |
WORKDIR /opt | |
COPY target/spring-docker.jar application.jar | |
ENTRYPOINT ["java", "-jar", "application.jar"] |
Pierwsza linia pliku mówi, z jakiego obrazu kontenera powinien skorzystać Docker jako bazy dla naszego obrazu. W zależności od tego co wybierzemy, taki obraz będzie miał zainstalowane inne pakiety i będzie zajmował więcej lub mniej miejsca. Ja wybrałem obraz od Adopt OpenJDK z zainstalowanym JRE 14 opartym na Alpine Linux (jeden z mniejszych obrazów z zainstalowanym JRE).
Następnie określamy katalog roboczy. Kolejne polecenia będą traktowały ten katalog jako katalog bazowy. Przykładowo, następnym poleceniem jest kopiowanie pliku jar z naszego komputera do kontenera. Zostanie on skopiowany właśnie do tego katalogu.
Ostatnia linijka określa polecenie, które ma zostać wykonane przy uruchomieniu kontenera. Przyjmuje ono tablicę argumentów. Dla nas jest to oczywiście java -jar application.jar.
Budujemy kontener
Mając już wszystko przygotowane, możemy zbudować kontener. W głównym katalogu aplikacji wydajemy komendę:
docker build .
Jako wyjście powinieneś ujrzeć coś w tym rodzaju (może się różnić u Ciebie):
Sending build context to Docker daemon 17.6MB
Step 1/4 : FROM adoptopenjdk/openjdk14:alpine-jre
alpine-jre: Pulling from adoptopenjdk/openjdk14
df20fa9351a1: Already exists
600e2408a8d5: Pull complete
59570a1fe844: Pull complete
Digest: sha256:1847d76ac5ff2a19856ffe05aab4601eaebf668f47f232864084cc7fdf3aa402
Status: Downloaded newer image for adoptopenjdk/openjdk14:alpine-jre
---> 13f4bc5a87c3
Step 2/4 : WORKDIR /opt
---> Running in 7c24b64b058e
Removing intermediate container 7c24b64b058e
---> 2bcba4470168
Step 3/4 : COPY target/spring-docker.jar application.jar
---> 24600e60d332
Step 4/4 : ENTRYPOINT ["java", "-jar", "application.jar"]
---> Running in 152cf7fcb2da
Removing intermediate container 152cf7fcb2da
---> 447767e52cc2
Successfully built 447767e52cc2
Interesuje nas ostatnia linia mówiąca o sukcesie i podaje id obrazu (447767e52cc2). Za jego pomocą możemy uruchomić kontener.
Uruchamiamy kontener
Uruchomienie kontenera jest bardzo proste, wpisujemy po prostu docker run id-obrazu. W naszym przypadku musimy jeszcze przekierować port lokalny do kontenera. Robimy to za pomocą opcji -p.
docker run -p 8080:8080 447767e52cc2
Na wyjściu powinny zostać wypisane logi z aplikacji. Jeśli chcemy uruchomić kontener w tle, wystarczy dodać modyfikator -d. Wtedy zostanie nam zwrócone na wyjście id kontenera i wrócimy do wiersza poleceń. Za pomocą opcji -p mapujemy porty. W naszym przypadku mówimy, że port lokalny 8080 ma zostać przekierowany na port 8080 w kontenerze.
Jeżeli wszystko zostało wykonane poprawnie to pod adresem http://localhost:8080, powinniśmy ujrzeć napis Hello World!.
Możesz spokojnie uruchomić więcej instancji kontenera. Pamiętaj, aby zmienić port hosta, z którego mapujesz do kontenera, ponieważ kilka aplikacji nie może słuchać na jednym porcie.
Podstawy pracy z Dockerem
Aby wyświetlić aktualnie działające kontenery, wpisujemy docker ps. Jeśli chcemy zobaczyć też kontenery, które zakończyły pracę, dodajemy flagę -a.
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
e6687130b97e 447767e52cc2 "java -jar applicati…" 5 minutes ago Up 5 minutes nervous_gates
$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
e6687130b97e 447767e52cc2 "java -jar applicati…" 5 minutes ago Up 5 minutes nervous_gates
e388017bbd0e 447767e52cc2 "java -jar applicati…" 5 minutes ago Exited (130) 5 minutes ago elated_banzai
Kontenery zatrzymujemy poprzez polecenie docker stop id-kontenera. Po zatrzymaniu kontener dalej istnieje. Usunąć go można poleceniem docker rm id-kontenera. Z czasem lista kontenerów robi się dość długa. Aby usunąć wszystkie kontenery, możemy skorzystać z jednolinijkowaca:
docker ps -a | cut -d' ' -f1 | xargs docker rm {}
Wszystkie zainstalowane aktualnie obrazy możemy wyświetlić wpisując docker images.
Interakcja z kontenerem
Jeżeli uruchomimy nasz kontener jako demon (-d) konsola wypisze tylko id kontenera. Możemy jednak podejmować pewne interakcje z naszym kontenerem. Pierwszą z nich jest wypisanie wszystkich logów ze standardowego wyjścia. Służy do tego polecenie docker logs id-kontenera.
$ docker logs e6687130b97e
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v2.2.4.RELEASE)
2020-09-12 19:27:53.732 INFO 1 --- [ main] pl.mloza.Main : Starting Main on e6687130b97e with PID 1 (/opt/application.jar started by root in /opt)
2020-09-12 19:27:53.737 INFO 1 --- [ main] pl.mloza.Main : No active profile set, falling back to default profiles: default
2020-09-12 19:27:54.937 INFO 1 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 8080 (http)
(.....)
Kolejną rzeczą, jaką możemy zrobić to uruchomienie polecenia w kontenerze. Dzięki temu możemy też uruchomić powłokę shellową i wejść do naszego kontenera. Robimy to przez polecenie docker exec id-kontenera polecenie. Na przykład poniższe polecenie uruchomi wspomniany wcześniej wiersz poleceń.
docker exec -ti e6687130b97e /bin/sh
W kontenerze opartym na Alpine Linuxie nie ma basha, więc musi nam wystarczyć powłoka sh. Flaga -i mówi nam, że ma to być uruchomione interaktywnie – standardowe wejście pozostaje podłączone, oraz -t – aby zaalokować pseudo terminal.
Dzielenie się obrazem
Jak mamy już zbudowany obraz, możemy umieścić go w repozytorium. Najprościej jest skorzystać z darmowego oficjalnego repozytorium Docker Hub. Pozwala on na stworzenie za darmo jednego prywatnego repozytorium oraz nieograniczonej liczby publicznych. Oprócz Docker Huba możemy użyć np. AWS ECR od Amzaona. Często w organizacjach używa się Nexusa jako repozytorium pakietów Mavenowych. Nexus 3 może też być repozytorium obrazów dockerowych.
Aby opublikować obraz w Docker Hub, należy go najpierw odpowiednio otagować. Tag powinien się składać z id-użytkownika/id-repozytorium:wersja. Możemy to zrobić przy budowaniu, przy pomocy opcji -t tag lub poleceniem docker tag id-obrazu tag. Przykładowo:
$ docker build -t mloza/spring-docker:1.0 .
$ docker tag 447767e52cc2 mloza/spring-docker:1.0
Mając już obraz, musimy się tylko zalogować i możemy wysłać nasz obraz. Robimy to odpowiednio poleceniami docker login –username=nazwa-użytkownika oraz docker push tag-obrazu.
$ docker login --username=mloza
$ docker push mloza/spring-docker:1.0
Teraz nasz obraz jest dostępny dla innych. Jeśli wybraliśmy, aby nasze repozytorium było publiczne, to każdy może wykonać komendę docker pull tag-obrazu i uruchomić u siebie nasz obraz.
$ docker pull mloza/spring-docker:1.0
$ docker run mloza/spring-docker:1.0
Podsumowanie
Zamykanie aplikacji w kontenerze jest bardzo wygodne i ułatwia wielu programistom pracę. W artykule opisałem tylko podstawy. Docker daje nam dużo szersze możliwości. Dodatkowo ogromna ilość narzędzi takich jak docker compose, Kubernetes, AWS ECS pozwala nam uruchamiać i zarządzać aplikacjami złożonymi z wielu kontenerów. Możemy przykładowo automatycznie skalować ilość instancji kontenerów w zależności od obciążenia. Możliwości są ogromne, więc zachęcam do zgłębienia tematu. Na pewno nie jest to też ostatni post, jaki pojawi się na blogu w temacie Dockera.
Bardzo przejrzyście opisane!
Czekam na kolejne wpisy ?
Dockerfile jest słabym sposobem na dokeryzacje aplikacji javowych. Twój dockerfile powoduje że każda wersja aplikacji zajmuje nawet ze 200 MB, bo wszystkie biblioteki i klasy są wrzucane do jednego obrazu.
Należy najpierw wrzucić biblioteki, na końcu twój kod. Wtedy górna ciągle zmieniająca się warstwa to zaledwie kwestia kilobajtów, może megabajtów. Nie trzeba nawet pisać do tego dockerfile. Narzędzia jak Google jib robią to z automatu.
Dziękuję za komentarz 🙂
W tym wpisie chciałem na razie przybliżyć, jak najprościej można to zrobić. Planuję również post na temat narzędzi usprawniających budowanie obrazów, jest klika pluginów do mavena które w tym pomagają. Google JIB jeszcze nie sprawdzałem, dzięki za podrzucenie.
Co do rozmiaru obrazu to kolejne wersje powinny w tym przypadku zajmować tyle, co cały jar. W przypadku takiej aplikacji, jak w przykładzie, jest to niecałe 18MB, reszta warstw jest współdzielona pomiędzy obrazami.
Jako rozwinięcie tematu, można by jeszcze pokazać w jaki sposób podzielić aplikację na warstwy, poprzez rozbicie fat jara. Przyspiesza to w ten sposób budowanie obrazu. Commit po commicie jest to pokazane np. tu: https://github.com/mate0021/docker-containerized-spring-app
Dzięki za podrzucenie, postaram się temu przyjrzeć. 🙂
Można jeszcze prościej – korzystając z wbudowanych w spring boota buildpacks ?https://spring.io/blog/2020/01/27/creating-docker-images-with-spring-boot-2-3-0-m1