Build optimized Docker images for Spring Boot applications

Build optimized Docker images for Spring Boot applications

·

8 min read

Dockerize ứng dụng Spring Boot vừa đơn giản nhưng cũng vừa phức tạp. Tối ưu image build ra là một trong những vấn đề quan trọng cần tính đến.

Bài viết trình bày kinh nghiệm nhóm Pocketo có được sau thời gian tìm hiểu & áp dụng Docker cho project Pocketo.

1. Approaches

Có các công cụ xây dựng Docker image cho Spring Boot mà không cần tự viết Dockerfile, như Cloud Native Buildpacks (CNB) hay JIB.

Spring Boot tích hợp sẵn CNB, giúp build image thông qua command mvn spring-boot:build-image hoặc gradle bootBuildImage. JIB, một công cụ dockerize của Google, lại có khả năng build image không cần Docker daemon.

https://ashishtechmill.com/comparing-modern-day-container-image-builders-jib-buildpacks-and-docker

Hạn chế chính của các công cụ dockerize này là việc cấu hình phức tạp. Ví dụ, CNB có rất nhiều cấu hình cần tìm hiểu nếu muốn build được image tối ưu. Do đó, nhóm Pocketo chọn cách viết Dockerfile truyền thống, tránh cấu hình phức tạp và kiểm soát image tốt hơn.

2. Write Dockerfile

2a. Choose base image

Nhóm Pocketo chọn OpenJDK làm base image, nhưng đã chuyển sang Eclipse Temurin sau khi OpenJDK có thông báo deprecation chính thức.

Eclipse Temurin đáp ứng các tiêu chí nhóm đặt ra, bao gồm:

  • Là official image trên Docker Hub

  • Hỗ trợ Java 17

  • Có bản build dựa trên Alpine Linux

Việc hỗ trợ JRE là một điểm cộng lớn khi so sánh với các nhà cung cấp khác. Nhóm nhận thấy image size giảm đến hơn 50% khi chuyển base image từ openjdk:17-alpine sang eclipse-temurin:17-jre-alpine.

2b. Layered JAR

Spring Boot mặc định build ra một Fat JAR chứa toàn bộ code, dependencies, resources,... có thể chạy độc lập. Việc dockerize một Fat JAR khá đơn giản, chỉ cần copy vào image là được.

# syntax=docker/dockerfile:1
FROM eclipse-temurin:17-jre-alpine
COPY ./target/app.jar ./app.jar
ENTRYPOINT ["java", "-jar", "./app.jar"]

Tuy nhiên đây chưa phải cách làm tối ưu. Spring Boot 2.3.0 cung cấp một phương pháp tốt hơn, tách Fat JAR thành 4 thư mục riêng dựa theo tần suất thay đổi (Layered JAR). Các thư mục trên được copy vào image tạo ra 4 layer tương ứng.

Layered JAR tận dụng được Docker cache, các layer nào không đổi sẽ được cache lại, giúp tăng tốc quá trình build, tạo ra image nhẹ và tối ưu hơn. Hình bên dưới là cấu trúc các layer của một Spring Boot app tiêu chuẩn và kích thước từng layer.

Với cách làm này, khi code thay đổi chỉ có application layer được build lại, các layer trước giữ nguyên nhờ cache. Phiên bản image mới chỉ bổ sung vài chục KB so với image trước đó.

Cách làm Fat JAR ban đầu chỉ tạo duy nhất một JAR layer. Khi code thay đổi dù là nhỏ nhất, toàn bộ layer phải build lại, không tận dụng được cache. Như vậy, mỗi phiên bản image build ra đều có thêm hơn 50 MB không cần thiết.

https://spring.io/blog/2020/08/14/creating-efficient-docker-images-with-spring-boot-2-3/

https://www.baeldung.com/docker-layers-spring-boot

Quá trình build lúc này chia thành hai giai đoạn, extract JAR thành các thư mục layer và copy vào stage mới.

# syntax=docker/dockerfile:1
FROM eclipse-temurin:17-jre-alpine AS builder
COPY ./target/app.jar ./app.jar
RUN java -Djarmode=layertools -jar ./app.jar extract

FROM eclipse-temurin:17-jre-alpine
COPY --from=builder /dependencies/ ./
COPY --from=builder /spring-boot-loader/ ./
COPY --from=builder /snapshot-dependencies/ ./
COPY --from=builder /application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]

Cần đảm bảo file JAR build ra là định dạng Layered, bằng cách cấu hình Spring Boot plugin cho MavenGradle (các version mới đã bật theo mặc định).

Java 9 cung cấp công cụ JLink, giúp xây dựng JRE tùy chỉnh chỉ bao gồm các module cần thiết cho ứng dụng. Do đó giúp làm giảm kích thước Java runtime hơn nữa so với JRE hoặc JDK.

Ứng dụng Spring Boot tiêu chuẩn chỉ yêu cầu module java.se, các tính năng khác (như JVM remote debugging) có thể yêu cầu thêm các module tương ứng.

# syntax=docker/dockerfile:1
FROM eclipse-temurin:17-jdk-jammy AS builder
RUN $JAVA_HOME/bin/jlink \
    --add-modules java.se \
    --strip-debug \
    --no-man-pages \
    --no-header-files \
    --compress=2 \
    --output /jre/

FROM ubuntu:jammy
ENV JAVA_HOME=/opt/java/jre
ENV PATH "${JAVA_HOME}/bin:${PATH}"
COPY --from=builder /jre/ $JAVA_HOME

# Use Fat JAR for simplicity
COPY ./target/app.jar ./app.jar
ENTRYPOINT ["java", "-jar", "./app.jar"]

Sử dụng custom JRE yêu cầu thư viện glibc, trong khi Alpine chỉ có sẵn musl. Do đó không thể dùng Alpine làm base image được. Việc chuyển đổi base image sang OS có hỗ trợ glibc (như Ubuntu) sẽ làm tăng kích thước image, tuy nhiên sẽ không quá ảnh hưởng (xem phần Conclusion).

3. Others

3a. Enable BuildKit

Khi tùy chọn BuildKit được bật, Docker sử dụng builder mới giúp xây dựng image nhanh và hiệu quả hơn. Trên Docker Desktop hoặc Docker v23.0 trở lên, BuildKit được bật theo mặc định.

Nên đặt biến môi trường DOCKER_BUILDKIT=1 trước khi chạy docker build để đảm bảo tùy chọn luôn được bật.

3b. Add .dockerignore file

Luôn nên bao gồm file .dockerignore khi xây dựng Docker image. Bài viết dưới đây mô tả chi tiết quá trình build image và lý do vì sao nên làm vậy.

https://codefresh.io/blog/not-ignore-dockerignore-2/

Dự án Pocketo thực hiện build source code thành JAR từ bên ngoài, sau đó mới copy vào image. Trong trường hợp này, build context chỉ cần bao gồm các output JAR là được.

# Excludes all files & folders by default
*.*
*/

# Includes necessary files & folders
!target/app.jar

3c. Debugging

Nhu cầu phát sinh khi chạy ứng dụng trong container là khả năng debug. Việc này yêu cầu điều chỉnh một số cấu hình trong IDE và Dockerfile.

Trong IDE cần setup tính năng Remote JVM debug. Chú ý chọn version JDK và copy lại tham số dòng lệnh hiện ra.

Ứng dụng Java trong container cần bật hỗ trợ debug với JDWP bằng cách thêm option đã copy khi chạy lệnh java.

# Use Fat JAR for simplicity
ENTRYPOINT ["java", "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005", "-jar", "./app.jar"]
EXPOSE 5005

Ứng dụng Java khi chạy sẽ listen trên port 5005, khi chạy debugger trong IDE sẽ kết nối đến port này và bắt đầu debug. Do đó, cần expose port 5005 ra ngoài container (chỉ định EXPOSE trong Dockerfile và dùng port mapping).

Cần thêm module jdk.jdwp.agent khi build custom JRE, bằng cách chạy jlink với tham số --add-modules java.se,jdk.jdwp.agent.

4. Conclusion

Trên đây là các phương pháp xây dựng và tối ưu Docker image cho Spring Boot. Trong thực tế, việc lựa chọn mức độ tối ưu phụ thuộc vào từng trường hợp cụ thể.

4a. Image size

Trong nhiều trường hợp, image size không quá quan trọng nhờ có Docker cache. Các layer nặng như OS, Java runtime chỉ phải transfer một lần và được cache lại. Layer ứng dụng sẽ tận dụng được cache nếu build theo dạng Layered JAR.

Có nên dùng JLink để tạo ra image siêu nhỏ không?

Thông thường là không, việc dùng JLink tối ưu image size không có quá nhiều lợi ích. Bên cạnh đó, JLink cũng mang lại một số hạn chế, như không thể dùng Alpine base image và nguy cơ thiếu module.

Thay vào đó, chỉ cần sử dụng JRE (nếu có) thay vì JDK. Việc này giúp giảm image size mà không có nhược điểm nào.

Có nên dùng Alpine Linux không? Hay nên dùng các base image khác như Ubuntu?

Tùy vào từng trường hợp, cần cân nhắc thêm các yếu tố khác, thay vì chỉ chú trọng vào image size.

Alpine có dung lượng nhỏ hơn, tuy nhiên lại không đầy đủ tính năng như Ubuntu, độ phổ biến cũng kém hơn. Một số chương trình có nguy cơ suy giảm hiệu suất khi chạy trên Alpine, do Alpine sử dụng musl thay vì glibc.

https://superuser.com/questions/1219609/why-is-the-alpine-docker-image-over-50-slower-than-the-ubuntu-image

4b. Build time

Build time đôi lúc cũng không quá quan trọng, khác biệt một vài giây có thể bỏ qua. Điều này lại phụ thuộc vào tần suất build image, nếu image phải build thường xuyên thì nên giảm thời gian build.

Ví dụ, một hệ thống thực hiện build image trong CI pipeline, việc build không diễn ra thường xuyên nên không cần quá chú trọng build time (tất nhiên cũng không được quá chậm).

Thêm một ví dụ khác, nếu bắt buộc chạy ứng dụng trong container ở môi trường local development, nghĩa là phải build thường xuyên, thì build time phải càng nhanh càng tốt, tránh ảnh hưởng năng suất làm việc.

Ví dụ 2 là một red flag của việc lạm dụng Docker quá mức. Dù image có build nhanh thế nào, việc build lại image với mỗi code change sẽ dẫn tới DX (development experience) cực kỳ tệ. Thay vào đó, nên làm cho ứng dụng chạy bình thường (không ở trong container) khi làm việc dưới local environment.

Nên chọn Fat JAR hay Layered JAR? Tôi biết Layered JAR tối ưu hơn, nhưng Fat JAR thì build nhanh hơn.

Luôn nên chọn Layered JAR. Như trên, build time chênh lệch 1, 2 giây sẽ không quá ảnh hưởng so với các lợi ích nhận được từ phương pháp Layered JAR.

Nên build image từ source code hay từ file JAR bên ngoài?

Với ứng dụng Java thì nên build source code bên ngoài và copy file JAR vào image. Các phương pháp tối ưu hóa ở trên sẽ được áp dụng dễ dàng hơn.


Source code: https://github.com/pocketo-app/spring-boot-dockerizing

Docker Hub images: https://hub.docker.com/r/tonghoangvu/spring-boot-dockerizing

Cover image: https://developer.okta.com/blog/2020/12/28/spring-boot-docker

Nguồn tham khảo:

VOZ thread: https://voz.vn/t/xay-dung-docker-image-toi-uu-cho-spring-boot.795897/