Optimasi Docker Image Spring Boot
Pada artikel terdahulu, kita sudah membahas pembuatan docker image untuk aplikasi berbasis Spring Boot. Kita membuat docker image dengan empat baris perintah dalam file Dockerfile
seperti ini
FROM openjdk:latest
ADD target/belajar-ci.jar /opt/app.jar
RUN bash -c 'touch /opt/app.jar'
ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/opt/app.jar"]
Walaupun sederhana dan mudah, ada beberapa kekurangan dari Dockerfile tersebut, yaitu:
- Untuk melakukan build, kita membutuhkan Java SDK dan Maven di mesin yang dilakukan untuk build. Dengan demikian, kita harus menyediakan Java dan Maven di build server.
- image yang dihasilkan hanya terdiri dari satu layer saja, berisi:
- kode program aplikasi
- library spring
- library lain yang dibutuhkan spring ataupun aplikasi kita
Karena hanya terdiri dari satu layer, maka docker tidak dapat melakukan cache. Akibatnya:
- proses build lebih lama, karena tiap kali dijalankan, Docker akan membuat ulang layer tersebut
- upload ke repository lebih berat dan lama, karena keseluruhan image harus diupload
- penyimpanan image di repository lebih boros, karena tiap image dianggap file baru
Solusinya adalah memisahkan image kita menjadi beberapa layer, yaitu :
- layer berisi library spring dan dependensi lainnya
- layer berisi kode program kita
Sebetulnya layer yang sering berubah hanyalah layer kode program kita sendiri. Layer berisi library spring hanya akan berubah ketika kita mengubah versi library ataupun ketika kita menambah/mengurangi library.
Dengan memisahkan kedua hal tersebut menjadi layer tersendiri, Docker bisa membuat cache untuk layer dependensi library. Dengan demikian, proses build menjadi lebih cepat. Proses upload ke registry juga akan lebih cepat, karena layer dependensi akan terdeteksi oleh docker registry dan tidak akan dikirim ulang. Demikian juga untuk penyimpanan, hanya layer kode program kita saja yang akan disimpan. Sedangkan layer dependensi bisa diarahkan ke image versi sebelumnya.
Dockerfile
yang sudah multi-layer seperti ini ada dijelaskan dan dicontohkan di dokumentasi resmi Spring Boot
Bentuknya seperti ini
FROM bellsoft/liberica-openjre-alpine:21-cds AS builder
WORKDIR /builder
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} application.jar
RUN java -Djarmode=tools -jar application.jar extract --layers --destination extracted
FROM bellsoft/liberica-openjre-alpine:21-cds
WORKDIR /application
COPY --from=builder /builder/extracted/dependencies/ ./
COPY --from=builder /builder/extracted/spring-boot-loader/ ./
COPY --from=builder /builder/extracted/snapshot-dependencies/ ./
COPY --from=builder /builder/extracted/application/ ./
ENTRYPOINT ["java", "-jar", "application.jar"]
Dockerfile tersebut terdiri dari dua tahap. Tahap pertama meng-extract file jar yang dihasilkan dari mvn package
menjadi beberapa folder. Tahap kedua memasukkan masing-masing folder secara terpisah, sehingga menjadi layer berbeda di dalam docker image.
Untuk menguji hasilnya, berikut langkah yang bisa kita lakukan:
- Build aplikasi dengan perintah
mvn clean package
. Langkah ini akan menghasilkan file aplikasi di foldertarget
. - Build docker image dengan perintah berikut :
docker build -t endymuhardin/training-kubernetes .
- Ubah sedikit source codenya. Tambahkan komentar, baris kosong, atau apapun. Jangan ubah file
pom.xml
- Jalankan lagi build image seperti di langkah 2. Perhatikan log filenya.
Log output untuk Dockerfile
yang 4 baris hasilnya seperti ini:
Build Pertama
[+] Building 4.9s (8/8) FINISHED docker:default
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 221B 0.0s
=> [internal] load metadata for docker.io/library/eclipse-temurin:21 4.2s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load build context 0.3s
=> => transferring context: 50.19MB 0.3s
=> CACHED [1/3] FROM docker.io/library/eclipse-temurin:21@sha256:3d92c3222b9d376e420e7099054604dc0d7bafbebbcb4f79ffd77763c 0.0s
=> => resolve docker.io/library/eclipse-temurin:21@sha256:3d92c3222b9d376e420e7099054604dc0d7bafbebbcb4f79ffd77763cd6a1bcd 0.0s
=> [2/3] ADD target/*.jar /opt/app.jar 0.1s
=> [3/3] RUN bash -c 'touch /opt/app.jar' 0.2s
=> exporting to image 0.1s
=> => exporting layers 0.1s
=> => writing image sha256:644817b896919c813952ccda540c44fb9f4da025b9d5033d3dc172d60612b646 0.0s
=> => naming to docker.io/endymuhardin/training-kubernetes
Build Kedua
[+] Building 2.1s (8/8) FINISHED docker:default
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 221B 0.0s
=> [internal] load metadata for docker.io/library/eclipse-temurin:21 1.3s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load build context 0.4s
=> => transferring context: 50.19MB 0.4s
=> CACHED [1/3] FROM docker.io/library/eclipse-temurin:21@sha256:3d92c3222b9d376e420e7099054604dc0d7bafbebbcb4f79ffd77763c 0.0s
=> => resolve docker.io/library/eclipse-temurin:21@sha256:3d92c3222b9d376e420e7099054604dc0d7bafbebbcb4f79ffd77763cd6a1bcd 0.0s
=> [2/3] ADD target/*.jar /opt/app.jar 0.1s
=> [3/3] RUN bash -c 'touch /opt/app.jar' 0.2s
=> exporting to image 0.1s
=> => exporting layers 0.1s
=> => writing image sha256:f0f1f58fab4960c7cc9de5b33107c1d8ac5058c058990f075cdd6b83a81e16e0 0.0s
=> => naming to docker.io/endymuhardin/training-kubernetes
Log output untuk Dockerfile
dua tahap hasilnya seperti ini:
Build Pertama
[+] Building 47.0s (14/14) FINISHED docker:default
=> [internal] load build definition from Dockerfile.2.multistage-no-compile 0.0s
=> => transferring dockerfile: 633B 0.0s
=> [internal] load metadata for docker.io/bellsoft/liberica-openjre-alpine:21-cds 1.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [builder 1/4] FROM docker.io/bellsoft/liberica-openjre-alpine:21-cds@sha256:b95306fc6976e49dae211342f55782b2a53b2641a4 44.8s
=> => resolve docker.io/bellsoft/liberica-openjre-alpine:21-cds@sha256:b95306fc6976e49dae211342f55782b2a53b2641a4d046c4c8e 0.0s
=> => sha256:d713f0701364d1770e7f9b95a110d8e20b56af29fe461f19325bccd1329ca048 951B / 951B 0.0s
=> => sha256:8e8c330ab61c78b7d57fd1dc389be0a21e54d48924f8b784749ea97814a5103c 9.11kB / 9.11kB 0.0s
=> => sha256:2fcc4bc920aad44bf67b5ac2282ec9f49911dc3a2a036ab4cd4bc61cdf064794 2.67MB / 2.67MB 2.8s
=> => sha256:4432e8e7f24baae9d2fe038bbe38c0ed6a5ba5677ab2b42391fc421e21c5abad 52.85MB / 52.85MB 44.1s
=> => sha256:b95306fc6976e49dae211342f55782b2a53b2641a4d046c4c8e04fbe6f23c5e0 741B / 741B 0.0s
=> => extracting sha256:2fcc4bc920aad44bf67b5ac2282ec9f49911dc3a2a036ab4cd4bc61cdf064794 0.1s
=> => extracting sha256:4432e8e7f24baae9d2fe038bbe38c0ed6a5ba5677ab2b42391fc421e21c5abad 0.6s
=> [internal] load build context 0.4s
=> => transferring context: 50.19MB 0.4s
=> [stage-1 2/6] WORKDIR /application 0.2s
=> [builder 2/4] WORKDIR /builder 0.2s
=> [builder 3/4] COPY target/*.jar application.jar 0.1s
=> [builder 4/4] RUN java -Djarmode=tools -jar application.jar extract --layers --destination extracted 0.5s
=> [stage-1 3/6] COPY --from=builder /builder/extracted/dependencies/ ./ 0.1s
=> [stage-1 4/6] COPY --from=builder /builder/extracted/spring-boot-loader/ ./ 0.0s
=> [stage-1 5/6] COPY --from=builder /builder/extracted/snapshot-dependencies/ ./ 0.0s
=> [stage-1 6/6] COPY --from=builder /builder/extracted/application/ ./ 0.0s
=> exporting to image 0.1s
=> => exporting layers 0.1s
=> => writing image sha256:0fa86e9b1c8aaabd58b185258f7ff65fce6e58fecd2b2078f9150d1ba787252a 0.0s
=> => naming to docker.io/endymuhardin/training-kubernetes
Build Kedua
[+] Building 3.6s (15/15) FINISHED docker:default
=> [internal] load build definition from Dockerfile.2.multistage-no-compile 0.0s
=> => transferring dockerfile: 633B 0.0s
=> [internal] load metadata for docker.io/bellsoft/liberica-openjre-alpine:21-cds 2.2s
=> [auth] bellsoft/liberica-openjre-alpine:pull token for registry-1.docker.io 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [builder 1/4] FROM docker.io/bellsoft/liberica-openjre-alpine:21-cds@sha256:b95306fc6976e49dae211342f55782b2a53b2641a4d 0.0s
=> [internal] load build context 0.5s
=> => transferring context: 50.19MB 0.5s
=> CACHED [builder 2/4] WORKDIR /builder 0.0s
=> [builder 3/4] COPY target/*.jar application.jar 0.2s
=> [builder 4/4] RUN java -Djarmode=tools -jar application.jar extract --layers --destination extracted 0.6s
=> CACHED [stage-1 2/6] WORKDIR /application 0.0s
=> CACHED [stage-1 3/6] COPY --from=builder /builder/extracted/dependencies/ ./ 0.0s
=> CACHED [stage-1 4/6] COPY --from=builder /builder/extracted/spring-boot-loader/ ./ 0.0s
=> CACHED [stage-1 5/6] COPY --from=builder /builder/extracted/snapshot-dependencies/ ./ 0.0s
=> [stage-1 6/6] COPY --from=builder /builder/extracted/application/ ./ 0.0s
=> exporting to image 0.0s
=> => exporting layers 0.0s
=> => writing image sha256:54dfa1446d9bcd964e32e616f1f420fe4b575c9a98df9edcaed25523b5f8356f 0.0s
=> => naming to docker.io/endymuhardin/training-kubernetes
Seperti kita lihat, seluruh kegiatan build diulang pada Dockerfile
yang 4 baris. Tapi kita melihat adanya penggunaan cache di tahap 1-5 pada Dockerfile
yang dua tahap.
Kita juga bisa melihat perbedaannya pada waktu mengupload image ke Dockerhub. Jalankan perintah berikut untuk mengupload image
docker push -a endymuhardin/training-kubernetes
Log output untuk Dockerfile
yang 4 baris hasilnya seperti ini:
Push Pertama
The push refers to repository [docker.io/endymuhardin/training-kubernetes]
316e1b6d0314: Pushing [==================================================>] 50.18MB
10447a2655ba: Pushing [===============================================> ] 47.19MB/50.18MB
fac3e1ffa8ab: Mounted from library/eclipse-temurin
5987eea8dcdd: Mounted from library/eclipse-temurin
bad7e359b364: Mounted from library/eclipse-temurin
29594091ba9c: Mounted from library/maven
24a48d4af45b: Mounted from library/maven
Push Kedua
7afc8e1853dd: Pushing [==================> ] 18.88MB/50.18MB
9803de726c80: Pushing [====================> ] 20.45MB/50.18MB
fac3e1ffa8ab: Layer already exists
5987eea8dcdd: Mounted from library/maven
bad7e359b364: Layer already exists
29594091ba9c: Layer already exists
24a48d4af45b: Layer already exists
Pada push yang kedua kali, kita lihat bahwa layer-layer yang berasal dari eclipse-temurin
dan maven
dinyatakan sudah ada dan tidak perlu diupload lagi. Sedangkan aplikasi kita yang berbentuk jar
berukuran 50MB diupload lagi semuanya.
Log output untuk Dockerfile
dua tahap hasilnya seperti ini:
Push Pertama
The push refers to repository [docker.io/endymuhardin/training-kubernetes-2]
e01105745d9d: Pushing [==================================================>] 13.31kB
8dea912e698c: Preparing
e9e37808484f: Pushing [=============> ] 13.09MB/49.96MB
0529d1c8e878: Mounted from bellsoft/liberica-openjre-alpine
7b393f6f1292: Pushing [==================================================>] 6.558MB
Push Kedua
c9ed4c871ce4: Pushing [==================================================>] 13.31kB
8dea912e698c: Layer already exists
e9e37808484f: Layer already exists
0529d1c8e878: Layer already exists
7b393f6f1292: Layer already exists
Kita bisa lihat hasilnya. Dockerfile
dua tahap hanya mengubah satu layer yang berisi kode program kita, sehingga ukurannya kecil. Hanya 13KB saja. Sedangkan layer lain sama dengan yang kita push pertama kali. Dengan demikian, push berikutnya akan berjalan sangat cepat, hemat bandwidth, dan juga hemat space harddisk.
Build Image Menggunakan Plugin Spring Boot
Spring boot juga sudah membuatkan plugin yang cara kerjanya sama dengan Dockerfile
dua tahap di atas. Ini lebih baik daripada kita buat sendiri, karena prosedur buildnya akan terus dimaintain dan diimprove oleh para developer Spring. Detailnya dijelaskan di dokumentasi resmi Spring Boot
Berikut perintah untuk build dengan plugin Spring.
mvn clean spring-boot:build-image
Build tanpa Instalasi Maven
Dockerfile
dua tahap di atas membutuhkan Java SDK dan Maven terinstal di komputer yang melakukan build. Untuk build di laptop programmer, ini tidak menjadi masalah. Akan tetapi untuk di CI/CD server, belum tentu Java SDK dan Maven tersedia. Untuk itu, kita bisa memodifikasi Dockerfile
agar menyertakan tahap kompilasi dan pembuatan file jar
. Bentuknya seperti ini
# stage 1 : build jar dulu
FROM maven:3-eclipse-temurin-21 AS mvnbuild
# membuat working folder
WORKDIR /opt/aplikasi
# menambahkan pom.xml dari laptop ke dalam container
COPY pom.xml .
# donlod dependensi dulu, supaya menjadi layer terpisah dari compile
RUN mvn dependency:go-offline
# menambahkan folder src ke dalam container
COPY src ./src
# menjalankan compile source code dan buat jar file
RUN mvn package
# stage 2 : extract jar untuk memisahkan layer
FROM bellsoft/liberica-openjre-alpine:21-cds AS jarlayer
WORKDIR /builder
COPY --from=mvnbuild /opt/aplikasi/target/*.jar application.jar
RUN java -Djarmode=tools -jar application.jar extract --layers --destination extracted
# stage 3 : rakit layer menjadi docker image
FROM bellsoft/liberica-openjre-alpine:21-cds
WORKDIR /application
COPY --from=jarlayer /builder/extracted/dependencies/ ./
COPY --from=jarlayer /builder/extracted/spring-boot-loader/ ./
COPY --from=jarlayer /builder/extracted/snapshot-dependencies/ ./
COPY --from=jarlayer /builder/extracted/application/ ./
ENTRYPOINT ["java", "-jar", "application.jar"]
Pada Dockerfile
di atas, kita juga memisahkan tahap download dependensi ke layer tersendiri, sehingga bisa di-cache oleh Docker builder dan tidak perlu mengunduh lagi di build berikutnya.
Dockerfile
tersebut bisa langsung dijalankan dengan perintah berikut
docker build -t endymuhardin/training-kubernetes .
tanpa perlu menginstal Java SDK dan Maven di komputer yang melakukan build. Bahkan walaupun ada Java SDK dan Maven, kita tidak perlu menjalankan mvn clean package
terlebih dahulu.