tonghoangvu
Senior Member
Lần trước có post nhưng bị mod xóa vì backlink, nay đăng lại bài full luôn. Link gốc tại đây nhé https://blog.pocketo.org/build-optimized-docker-images-for-spring-boot-applications
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.
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.
Eclipse Temurin đáp ứng các tiêu chí nhóm đặt ra, bao gồm:
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.
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 Maven và Gradle (các version mới đã bật theo mặc định).
Ứ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.
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.
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.
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.
Ứ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).
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.
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/121...er-image-over-50-slower-than-the-ubuntu-image
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.
Source code: https://github.com/pocketo-app/spring-boot-dockerizing
Cover image: https://developer.okta.com/blog/2020/12/28/spring-boot-docker
Nguồn tham khảo:
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
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
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.
Diff:
# syntax=docker/dockerfile:1
FROM eclipse-temurin:17-jre-alpine
COPY ./target/app.jar ./app.jar
ENTRYPOINT ["java", "-jar", "./app.jar"]
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.
Code:
# 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"]
Custom JRE with JLink
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.
Code:
# 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
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.
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.
Code:
# Excludes all files & folders by default
*.*
*/
# Includes necessary files & folders
!target/app.jar
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.
Code:
# Use Fat JAR for simplicity
ENTRYPOINT ["java", "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005", "-jar", "./app.jar"]
EXPOSE 5005
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ể.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.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.Có nên dùng JLink để tạo ra image siêu nhỏ không?
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.
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.Có nên dùng Alpine Linux không? Hay nên dùng các base image khác như Ubuntu?
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/121...er-image-over-50-slower-than-the-ubuntu-image
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.
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 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.
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.Nên build image từ source code hay từ file JAR bên ngoài?
Source code: https://github.com/pocketo-app/spring-boot-dockerizing
Cover image: https://developer.okta.com/blog/2020/12/28/spring-boot-docker
Nguồn tham khảo:
Last edited: