Bài này về khoảng cách giữa "chạy được trên máy tôi" và "chạy trên máy chủ".

Jar phân tầng

  java -Djarmode=tools -jar app.jar list-layers

    dependencies
    spring-boot-loader
    snapshot-dependencies
    application
  dependencies/          25 MB
  spring-boot-loader/   4,0 KB
  snapshot-dependencies/ 4,0 KB
  application/           12 KB      <- mã của bạn
  jar gốc:              24,4 MB

Toàn bộ mã ứng dụng là 12 kilobyte trong một jar 24 megabyte.

Đó là lý do jar phân tầng tồn tại. Đóng gói thông thường bỏ cả 24 MB vào một tầng Docker, nên sửa một dòng mã là dựng lại và đẩy đi cả 24 MB. Với phân tầng, tầng dependencies được đệm lại và chỉ tầng application 12 KB được dựng mới.

FROM eclipse-temurin:21-jre AS build
WORKDIR /app
COPY target/app.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/extracted/dependencies/ ./
COPY --from=build /app/extracted/spring-boot-loader/ ./
COPY --from=build /app/extracted/snapshot-dependencies/ ./
COPY --from=build /app/extracted/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

Thứ tự COPY quan trọng: thứ ít đổi nhất chép trước. Đảo lại là mất sạch lợi ích bộ đệm.

Chú ý -Djarmode=tools là cú pháp từ Spring Boot 3.3; bản cũ hơn dùng -Djarmode=layertools ... extract.

Buildpacks: không cần Dockerfile

./mvnw spring-boot:build-image

Spring Boot dựng ảnh bằng Cloud Native Buildpacks: tự chọn JRE, tự phân tầng, tự đặt tham số JVM theo bộ nhớ container, chạy bằng người dùng không phải root.

Đây là lựa chọn tôi khuyên cho phần lớn dự án — nó làm đúng nhiều thứ mà Dockerfile viết tay hay bỏ sót.

Viết Dockerfile riêng khi bạn cần kiểm soát ảnh nền (yêu cầu bảo mật, ảnh nội bộ), hoặc cần thêm công cụ vào ảnh.

Những thứ phải làm đúng trong container

Bài 97 sê-ri Java đã đo phần kích thước ảnh — 866 MB xuống 126 MB qua năm cách đóng gói, và kết luận đáng nhớ: ảnh nhỏ không khởi động nhanh hơn. Lợi ích thật là thời gian kéo ảnh và bề mặt tấn công.

Sáu điều còn lại:

Đặt TZ. Mặc định trong container là UTC. CLAUDE.md của blog này ghi rõ hậu quả: cron chạy 6 giờ sáng giờ Việt Nam thấy máy chủ đang là 23 giờ hôm trước, và chủ đề của hôm nay chưa tới hạn.

Kiểm bảng mã. Bài 97 sê-ri Java đo được ảnh debian:12-slim cho native.encoding = ANSI_X3.4-1968 — tức ASCII — và mọi chữ tiếng Việt ra dấu hỏi. Đặt ENV LANG=C.UTF-8.

Dùng dạng CMD ["java", ...], không dùng CMD java .... Dạng thứ hai chạy qua shell, JVM thành tiến trình con và không nhận SIGTERM — hook tắt máy không chạy, và bạn mất 10 giây chờ SIGKILL.

Đừng chạy bằng root.

-XX:MaxRAMPercentage thay cho -Xmx, như bài 56.

.dockerignore. Thiếu nó thì COPY . . mang theo cả target/, .git/, node_modules/. Tôi từng thấy ngữ cảnh build 2 GB chỉ vì lý do đó.

Tắt cho đàng hoàng

server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

Nhận SIGTERM, Tomcat ngừng nhận request mới nhưng chờ request đang chạy hoàn tất. Không có nó, mọi request đang xử lý bị cắt giữa chừng ở mỗi lần triển khai.

Kết hợp với readiness probe (bài 51): pod báo NOT_READY trước, cân bằng tải rút nó ra, rồi mới tắt. Spring Boot làm việc này tự động khi bật probe.

Nhiều bản sao thì đổi gì

Bốn thứ ngầm định "chỉ có một bản sao" sẽ hỏng:

Session trong bộ nhớ — người dùng bị đăng xuất ngẫu nhiên. Dùng Spring Session với Redis (bài 37).

Cache trong bộ nhớ — xoá ở một bản sao không xoá ở bản khác (bài 35).

@Scheduled — chạy nhiều lần (bài 53). Dùng khoá phân tán.

Tệp lưu trên đĩa container — mất khi container bị thay. Dùng kho đối tượng (bài 19).

Migration khi triển khai

Bài 33 đã nói: chạy Flyway ở một bước riêng trước khi triển khai, không phải lúc ứng dụng khởi động.

Với nhiều bản sao cùng khởi động, Flyway có khoá nên chỉ một cái thắng — nhưng những cái kia chờ, và với migration dài chúng hết hạn kiểm tra sức khoẻ rồi bị khởi động lại.

Và luôn: sao lưu CSDL trước khi cập nhật.

Mô hình hai máy của blog này

Blog này chạy theo mô hình đáng tham khảo cho dự án nhỏ:

   MÁY CHỦ                              MÁY VIẾT BÀI
  nginx (TLS)                          Claude Code
    └── blog :8080 (127.0.0.1)  <----  container công cụ
  PostgreSQL, MinIO có sẵn      HTTPS + khoá API

Compose chỉ chạy đúng một service là ứng dụng. PostgreSQL và MinIO đã có trên máy chủ nên chỉ khai địa chỉ trong .env; TLS và tên miền do nginx lo.

Ứng dụng nghe trên 127.0.0.1:8080 để không ai vào thẳng, bỏ qua nginx. Container không giữ dữ liệu gì — bài viết trong PostgreSQL, ảnh trong MinIO — nên xoá và dựng lại là chuyện an toàn.

Hai chi tiết đã trả giá:

localhost trong container là chính container đó. PostgreSQL cài trên máy chủ thì phải dùng host.docker.internal.

Sửa mã xong phải docker compose build. docker compose up -d không tự dựng lại image. Đã hai lần chạy nhầm image cũ rồi ngồi tìm lỗi không tồn tại.

Thử ba mươi giây

docker images | grep <ảnh của bạn>
docker history <ảnh của bạn> | head -5

Thấy một tầng chiếm gần hết kích thước ảnh và tầng đó chứa jar của bạn — mỗi lần sửa một dòng mã là đẩy đi ngần ấy megabyte. Bảng đầu bài cho biết con số thật đáng lẽ là 12 kilobyte.

Ngày mai: ảnh native — khởi động tính bằng mili giây, và cái giá của nó.