Docker Layers Spring Boot alkalmazásnál

Amennyiben dockerizáljuk a Spring Bootos alkalmazásunkat, oda kell figyelni néhány dologra. Nem elegendő ugyanis, hogy a jar fájlunkat bemásoljuk (über jar, szép német kifejezéssel, vagy fat jar - tehát a jar mely a függőségeket is tartalmazza).

A Docker ugyanis a hatékony adattárolás miatt egy image-et nem egy megbonthatatlan fájlként tárol, hanem ún. layerekben. Ugyanis ha kiindulunk egy image-ből, és pl. új fájlokat másolunk, ezzel létrehozva egy új image-et, nem tárolja el az új image-et teljes egészében, csak a különbséget, azaz a felmásolt fájlokat. Ez a különbség a layer. Ezzel jelentős helyet takarít meg, hiszen a két image-ben közös fájlokat csak egyszer tárolja el. Így persze egy image több layer fájlból épül fel.

Vegyünk egy egyszerű Spring Boot alkalmazást, és egy hozzá tartozó Dockerfile fájlt.

FROM adoptopenjdk:14-jre-hotspot
WORKDIR /opt/app
COPY target/*.jar demo.jar
CMD ["java", "-jar", "demo.jar"]

Ebben a posztban azt fogom leírni, hogy ez miért nem jó!

Az egyszerűség kedvéért képzeljük el, hogy ez egy olyan image, mely egy ubuntu image-ből épül fel, arra épül rá az AdoptOpenJDK, kialakítva az adoptopenjdk image-et, majd arra a saját alkalmazásunk, egy JAR állománnyal, melynek eredménye a demo image. Ez layer szinten elnagyoltan így néz ki.

Docker layers

Ebből az ábrából már elég szépen látható, hogy mennyi tárhelyet nyerünk, ha az ubuntu és adoptopenjdk layer csak egyszer kerül letárolásra. (Gondoljunk bele, ha napi több commit esetén napi több image-et hoz létre a CI szerver.)

A probléma ott kezdődik, hogy viszont az alkalmazáshoz tartozó réteg, amiben esetleg egy-két fájl változik, újra és újra letárolásra kerül, hiszen az mindig egy új külön réteget hoz létre.

A Spring Boot azonban a 2.3.0.M2 verziótól kezdve beépített támogatást tartalmaz, hogy magát az alkalmazást is több rétegre bontsuk fel.

Az alapötlet egyszerű. A 3rd party library-khoz tartozó JAR-ok, akár a Tomcat, akár a Spring Boot JAR-jai is sokkal ritkábban változnak, mint a class fájljaink, ezért pakoljuk át ezeket egy külön rétegbe.

Ehhez egyrészt elő kell készíteni, hogy a JAR állományunk is rétegelt legyen. Ehhez a spring-boot-maven-plugin plugint kell konfigurálnunk a pom.xml. fájlban.

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <configuration>
                <layers>
                    <enabled>true</enabled>
                </layers>
            </configuration>
        </plugin>
    </plugins>
</build>

Ekkor egy olyan jar jön létre, mely külön könyvtárakba képes kicsomagolni az alkalmazás különböző részeit. Ezt a következő paranccsal érhetjük el:

java -Djarmode=layertools -jar demo.jar extract

Még egy trükköt fogunk alkalmazni, az ún. multi-stage buildet. Ezzel azt lehet elérni, hogy létrehozunk egy Docker konténert, melyben előkészítjük az alkalmazásunkat (kitömörítjük az előző paranccsal), és ennek eredményét, a könyvtárakat külön másoljuk át a végleges konténerbe. Ez azért fog működni, mert minden egyes külön kiadott COPY parancs egy külön layert fog létrehozni.

Így a Dockerfile a következő:

FROM adoptopenjdk:14-jre-hotspot as builder
WORKDIR app
COPY target/*.jar demo.jar
RUN java -Djarmode=layertools -jar demo.jar extract

FROM adoptopenjdk:14-jre-hotspot
WORKDIR app
COPY --from=builder app/dependencies/ ./
COPY --from=builder app/spring-boot-loader/ ./
# COPY --from=builder app/snapshot-dependencies/ ./
COPY --from=builder app/application/ ./
ENTRYPOINT ["java", \
  "org.springframework.boot.loader.JarLauncher"]

Az első blokk tehát létrehoz egy konténert, melybe kicsomagolja külön könyvtárakba a JAR állományt (dependencies, spring-boot-loader, snapshot-dependencies és application). Látható, hogy még a snapshot függőségeknek is egy külön könyvtárt hoz létre. (Hiszen gyakrabban változhatnak, mint a végleges verziójú függőségek.) A második blokk külön COPY parancsban átmásolja a könyvtárakat, mindegyik eredményeként egy új layert hozva létre. Látható, hogy a snapshot-dependencies könyvtárhoz tartozó COPY megjegyzésbe van téve. Ez azért van, mert a Docker Linuxon képes layer does not exist hibaüzenettel elszállni, ha üres könyvtárat másolunk. (Az én alkalmazásomban nem volt SNAPSHOT dependency, így ezt nem kellett másolni.) Az alkalmazásunk így (nagyjából) a következő layerekből épül fel.

Spring Boot layers

Már direkt nem egymásba ágyazva rajzoltam a rétegeket, jelezve, hogy ezek függetlenek egymástól. Az alkalmazásom egy darab statikus html állományt tartalmazott. Látható, hogy ebben az esetben mennyi helyet takarítunk meg, ha csak a html állományt szerkesztjük. Buildenként legalább 16 MB-ot. És képzeljünk el egy olyan alkalmazást, ahol sokkal több függőség, van, akár 100 MB környékén. Ehhez képest a saját class állományaink mérete tényleg elhanyagolható lehet.

És most nézzük végig, hogy pontosan mi is történik a háttérben. Először induljunk ki egy Spring Bootos alkalmazásból a http://start.spring.io oldalon, Web függőséggel. Az src/main/resources/static könyvtárban helyezzünk el egy index.html állományt. A buildeléshez adjuk ki a ./mvnw package -DskipTests parancsot. A Dockerfile legyen az ebben a posztban említett első, rossz Dockerfile. A Docker image előállításához adjuk ki a docker build -t demo . parancsot.

Ekkor valami ilyesmit fogunk látni:

Sending build context to Docker daemon  16.68MB
Step 1/4 : FROM adoptopenjdk:14-jre-hotspot
 ---> 14a0e3b4f7f3
Step 2/4 : WORKDIR /opt/app
 ---> 41ba9592b425
Step 3/4 : COPY target/*.jar demo.jar
 ---> 30d8ff34aa90
Step 4/4 : CMD ["java", "-jar", "demo.jar"]
 ---> Running in 9cc7f691622f
Removing intermediate container 9cc7f691622f
 ---> 7698b9790f4f
Successfully built 7698b9790f4f
Successfully tagged demo:latest

Az image-hez tartozó layereket a docker history demo paranccsal tudjuk lekérdezni.

e8cbe617fe62        4 minutes ago       /bin/sh -c #(nop)  CMD ["java" "-jar" "demo.…   0B                  
56864ac365e8        4 minutes ago       /bin/sh -c #(nop) COPY file:6670678da1e96212…   16.5MB              
41ba9592b425        2 weeks ago         /bin/sh -c #(nop) WORKDIR /opt/app              0B                  
14a0e3b4f7f3        3 weeks ago         /bin/sh -c #(nop)  ENV JAVA_HOME=/opt/java/o…   0B                  
<missing>           3 weeks ago         /bin/sh -c set -eux;     ARCH="$(dpkg --prin…   167MB               
<missing>           3 weeks ago         /bin/sh -c #(nop)  ENV JAVA_VERSION=jdk-14.0…   0B                  
<missing>           3 weeks ago         /bin/sh -c apt-get update     && apt-get ins…   35.7MB              
<missing>           3 weeks ago         /bin/sh -c #(nop)  ENV LANG=en_US.UTF-8 LANG…   0B                  
<missing>           3 weeks ago         /bin/sh -c #(nop)  CMD ["/bin/bash"]            0B                  
<missing>           3 weeks ago         /bin/sh -c mkdir -p /run/systemd && echo 'do…   7B                  
<missing>           3 weeks ago         /bin/sh -c [ -z "$(apt-get indextargets)" ]     0B                  
<missing>           3 weeks ago         /bin/sh -c set -xe   && echo '#!/bin/sh' > /…   745B                
<missing>           3 weeks ago         /bin/sh -c #(nop) ADD file:4974bb5483c392fb5…   63.2MB    

Azért látható, hogy jóval több, mint három réteg van a háttérben. Viszont látható, hogy a JAR-t hordozó 568 kezdetű réteg kb. 17 MB.

Ha most változtatjuk az index.html állományt, és újra eljárjuk az esőtáncot (Maven és Docker build), akkor a következő lesz az eredménye:

7698b9790f4f        46 seconds ago      /bin/sh -c #(nop)  CMD ["java" "-jar" "demo.…   0B                  
30d8ff34aa90        47 seconds ago      /bin/sh -c #(nop) COPY file:ff842752b1d34e46…   16.5MB              
41ba9592b425        2 weeks ago         /bin/sh -c #(nop) WORKDIR /opt/app              0B                  
14a0e3b4f7f3        3 weeks ago         /bin/sh -c #(nop)  ENV JAVA_HOME=/opt/java/o…   0B                  
<missing>           3 weeks ago         /bin/sh -c set -eux;     ARCH="$(dpkg --prin…   167MB               
<missing>           3 weeks ago         /bin/sh -c #(nop)  ENV JAVA_VERSION=jdk-14.0…   0B                  
<missing>           3 weeks ago         /bin/sh -c apt-get update     && apt-get ins…   35.7MB              
<missing>           3 weeks ago         /bin/sh -c #(nop)  ENV LANG=en_US.UTF-8 LANG…   0B                  
<missing>           3 weeks ago         /bin/sh -c #(nop)  CMD ["/bin/bash"]            0B                  
<missing>           3 weeks ago         /bin/sh -c mkdir -p /run/systemd && echo 'do…   7B                  
<missing>           3 weeks ago         /bin/sh -c [ -z "$(apt-get indextargets)" ]     0B                  
<missing>           3 weeks ago         /bin/sh -c set -xe   && echo '#!/bin/sh' > /…   745B                
<missing>           3 weeks ago         /bin/sh -c #(nop) ADD file:4974bb5483c392fb5…   63.2MB  

A 41b kezdetű layerig nem hozott létre a Docker új layert, hanem a már meglévőket hasznáta fel. Azonban a 30d az új JAR-t tartalmazó 17 MB-os új layer.

Nos nézzük meg ugyanezt az helyes Dockerfile használatával.

Sending build context to Docker daemon  50.08MB
Step 1/10 : FROM adoptopenjdk:14-jre-hotspot as builder
 ---> 14a0e3b4f7f3
Step 2/10 : WORKDIR app
 ---> Using cache
 ---> 5764828bedbc
Step 3/10 : COPY target/*.jar demo.jar
 ---> db86930a59d5
Step 4/10 : RUN java -Djarmode=layertools -jar demo.jar extract
 ---> Running in 992df808b7fa
Removing intermediate container 992df808b7fa
 ---> 0adad9dcf537
Step 5/10 : FROM adoptopenjdk:14-jre-hotspot
 ---> 14a0e3b4f7f3
Step 6/10 : WORKDIR app
 ---> Using cache
 ---> 5764828bedbc
Step 7/10 : COPY --from=builder app/dependencies/ ./
 ---> d99f206d05f4
Step 8/10 : COPY --from=builder app/spring-boot-loader/ ./
 ---> 7d91d24a8157
Step 9/10 : COPY --from=builder app/application/ ./
 ---> f462da7b9616
Step 10/10 : ENTRYPOINT ["java",   "org.springframework.boot.loader.JarLauncher"]
 ---> Running in bdbebbee0abe
Removing intermediate container bdbebbee0abe
 ---> ab4fe2572b78
Successfully built ab4fe2572b78
Successfully tagged demo:latest

Sokkal hosszabb, hiszen itt már multi-stage build van. Látszik, hogy a 6-ik lépésig még Using cache, azaz a már meglévő layereket használja, onnan a különböző könyvtáraknak külön layert hoz létre. Mit mond a docker history demo parancs?

IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
ab4fe2572b78        53 seconds ago      /bin/sh -c #(nop)  ENTRYPOINT ["java" "org.s…   0B                  
f462da7b9616        53 seconds ago      /bin/sh -c #(nop) COPY dir:f2f5110fcfc7bf2e7…   4.17kB              
7d91d24a8157        12 minutes ago      /bin/sh -c #(nop) COPY dir:34fffe734ed638d06…   241kB               
d99f206d05f4        12 minutes ago      /bin/sh -c #(nop) COPY dir:a06c3500a0e17c527…   16.4MB              
5764828bedbc        12 minutes ago      /bin/sh -c #(nop) WORKDIR /app                  0B                  
14a0e3b4f7f3        3 weeks ago         /bin/sh -c #(nop)  ENV JAVA_HOME=/opt/java/o…   0B                  
<missing>           3 weeks ago         /bin/sh -c set -eux;     ARCH="$(dpkg --prin…   167MB               
<missing>           3 weeks ago         /bin/sh -c #(nop)  ENV JAVA_VERSION=jdk-14.0…   0B                  
<missing>           3 weeks ago         /bin/sh -c apt-get update     && apt-get ins…   35.7MB              
<missing>           3 weeks ago         /bin/sh -c #(nop)  ENV LANG=en_US.UTF-8 LANG…   0B                  
<missing>           3 weeks ago         /bin/sh -c #(nop)  CMD ["/bin/bash"]            0B                  
<missing>           3 weeks ago         /bin/sh -c mkdir -p /run/systemd && echo 'do…   7B                  
<missing>           3 weeks ago         /bin/sh -c [ -z "$(apt-get indextargets)" ]     0B                  
<missing>           3 weeks ago         /bin/sh -c set -xe   && echo '#!/bin/sh' > /…   745B                
<missing>           3 weeks ago         /bin/sh -c #(nop) ADD file:4974bb5483c392fb5…   63.2MB    

Létrejött a d99, 7d9, f46 layer, összesen ezek is 17 MB-ot tesznek ki.

Azonban miután belenyúltam az index.html állományba, a következő került kiírásra:

Sending build context to Docker daemon  50.08MB
Step 1/10 : FROM adoptopenjdk:14-jre-hotspot as builder
 ---> 14a0e3b4f7f3
Step 2/10 : WORKDIR app
 ---> Using cache
 ---> 5764828bedbc
Step 3/10 : COPY target/*.jar demo.jar
 ---> b8ab4c3ea04f
Step 4/10 : RUN java -Djarmode=layertools -jar demo.jar extract
 ---> Running in 20a96aa8e12d
Removing intermediate container 20a96aa8e12d
 ---> 7a1f919cadf2
Step 5/10 : FROM adoptopenjdk:14-jre-hotspot
 ---> 14a0e3b4f7f3
Step 6/10 : WORKDIR app
 ---> Using cache
 ---> 5764828bedbc
Step 7/10 : COPY --from=builder app/dependencies/ ./
 ---> Using cache
 ---> d99f206d05f4
Step 8/10 : COPY --from=builder app/spring-boot-loader/ ./
 ---> Using cache
 ---> 7d91d24a8157
Step 9/10 : COPY --from=builder app/application/ ./
 ---> e1ad63d4bbbd
Step 10/10 : ENTRYPOINT ["java",   "org.springframework.boot.loader.JarLauncher"]
 ---> Running in 61a30ff4519c
Removing intermediate container 61a30ff4519c
 ---> eb4519e26e13
Successfully built eb4519e26e13
Successfully tagged demo:latest

Látható, hogy itt már szinte az összes layert cache-eli, kivéve az utolsó layert, mely a html fájlt tartalmazza. A history:

IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
eb4519e26e13        20 seconds ago      /bin/sh -c #(nop)  ENTRYPOINT ["java" "org.s…   0B                  
e1ad63d4bbbd        20 seconds ago      /bin/sh -c #(nop) COPY dir:41e31d1d5706c11a9…   4.18kB              
7d91d24a8157        16 minutes ago      /bin/sh -c #(nop) COPY dir:34fffe734ed638d06…   241kB               
d99f206d05f4        16 minutes ago      /bin/sh -c #(nop) COPY dir:a06c3500a0e17c527…   16.4MB              
5764828bedbc        16 minutes ago      /bin/sh -c #(nop) WORKDIR /app                  0B                  
14a0e3b4f7f3        3 weeks ago         /bin/sh -c #(nop)  ENV JAVA_HOME=/opt/java/o…   0B                  
<missing>           3 weeks ago         /bin/sh -c set -eux;     ARCH="$(dpkg --prin…   167MB               
<missing>           3 weeks ago         /bin/sh -c #(nop)  ENV JAVA_VERSION=jdk-14.0…   0B                  
<missing>           3 weeks ago         /bin/sh -c apt-get update     && apt-get ins…   35.7MB              
<missing>           3 weeks ago         /bin/sh -c #(nop)  ENV LANG=en_US.UTF-8 LANG…   0B                  
<missing>           3 weeks ago         /bin/sh -c #(nop)  CMD ["/bin/bash"]            0B                  
<missing>           3 weeks ago         /bin/sh -c mkdir -p /run/systemd && echo 'do…   7B                  
<missing>           3 weeks ago         /bin/sh -c [ -z "$(apt-get indextargets)" ]     0B                  
<missing>           3 weeks ago         /bin/sh -c set -xe   && echo '#!/bin/sh' > /…   745B                
<missing>           3 weeks ago         /bin/sh -c #(nop) ADD file:4974bb5483c392fb5…   63.2MB              

Azaz az összes réteg ugyanaz, mint az előbb, kivéve az utolsó 4kB-os layert. A 16 MB-os d99 layert újrahasznosította. Ez alapján már el lehet képzelni, hogy a layerek használatávál mennyi tárhelyet és hálózati erőforrást takarítunk meg.