Command Palette

Search for a command to run...

[Spring Boot Basics] Đóng gói và chạy ứng dụng Spring Boot: JAR thực thi, profile và Dockerfile đơn giản

Từ đầu khóa tới giờ, mọi ứng dụng đều được Gradle hoặc IDE khởi động. Một ứng dụng đã deploy thì chạy từ một artifact: một file duy nhất mà server hay container chạy được mà không cần Gradle, không cần source code và không cần editor của bạn. Với Spring Boot, artifact đó là JAR thực thi, và bài này đi theo nó từ ./gradlew bootJar tới một container đang nói chuyện với PostgreSQL.

Ba thứ quyết định mọi việc có suôn sẻ hay không: build đưa gì vào JAR, config đi vào JAR từ bên ngoài như thế nào — profile, URL database, password — và container khởi động rồi dừng JVM ra sao. Những Dockerfile chép từ mạng hay sai nhất ở điểm thứ ba, nên cách xử lý signal, kích thước heap và kích thước image bên dưới đều được đo chứ không nhắc lại từ nguồn khác.

Một file JAR chạy trên host và bên trong container

Các ví dụ dùng Spring Boot 4.1.1 và Java 21, cùng Docker, Docker Compose và PostgreSQL 18. Ứng dụng dùng host port 8141 và PostgreSQL dùng host port 5441 thay vì các port mặc định, nên đó là các port bạn thấy trong các lệnh. Đường dẫn dài được rút gọn thành /…/.

Ứng dụng được đóng gói

Project được Spring Initializr sinh ra với bảy dependency:

Bash
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=demo&name=demo&packageName=com.example.demo&dependencies=web,validation,data-jpa,h2,postgresql,flyway,actuator" -o demo.zip

Bên trên là một catalogue sản phẩm rút gọn: entity Product map vào table products với khóa IDENTITY, sku unique và price có type numeric(10,2), một JpaRepository, hai DTO record và một controller. Vừa đủ ứng dụng để có thứ đáng đóng gói:

Tree
src/main
├── java/com/example/demo
│   ├── DemoApplication.java
│   └── product
│       ├── Product.java
│       ├── ProductController.java
│       ├── ProductRepository.java
│       ├── ProductRequest.java
│       └── ProductResponse.java
└── resources
    ├── application.properties
    ├── application-postgres.properties
    └── db/migration
        └── V1__create_products.sql
src/main/java/com/example/demo/product/ProductController.java
@RestController
@RequestMapping("/api/products")
class ProductController {
 
    private final ProductRepository repository;
 
    ProductController(ProductRepository repository) {
        this.repository = repository;
    }
 
    @GetMapping
    List<ProductResponse> findAll() {
        return repository.findAll(Sort.by("id")).stream().map(ProductResponse::from).toList();
    }
 
    @GetMapping("/{id}")
    ResponseEntity<ProductResponse> findById(@PathVariable Long id) {
        return ResponseEntity.of(repository.findById(id).map(ProductResponse::from));
    }
 
    @PostMapping
    ResponseEntity<ProductResponse> create(@Valid @RequestBody ProductRequest request) {
        Product saved = repository.save(new Product(request.name(), request.sku(), request.price()));
        var location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}").buildAndExpand(saved.getId()).toUri();
        return ResponseEntity.created(location).body(ProductResponse.from(saved));
    }
}
src/main/resources/db/migration/V1__create_products.sql
CREATE TABLE products (
    id    BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name  VARCHAR(120)   NOT NULL,
    sku   VARCHAR(40)    NOT NULL,
    price NUMERIC(10, 2) NOT NULL,
    CONSTRAINT uk_products_sku UNIQUE (sku)
);
src/main/resources/application.properties
spring.application.name=demo
spring.jpa.open-in-view=false
src/main/resources/application-postgres.properties
spring.datasource.url=jdbc:postgresql://localhost:5441/shop
spring.datasource.username=shop
spring.jpa.hibernate.ddl-auto=validate

Không có profile thì không có URL datasource, nên Boot khởi động một database H2 in-memory và Flyway migrate nó: đó là môi trường development. Profile postgres trỏ tới PostgreSQL và để Hibernate validate schema mà Flyway đã tạo. Profile này có username nhưng không có password. Password phải đi vào ứng dụng từ bên ngoài JAR, và cách nó đi vào là sợi chỉ xuyên suốt phần còn lại của bài.

./gradlew build so với ./gradlew bootJar

build là task mà hầu hết mọi người chạy. --console=plain khiến Gradle in tên từng task nó thực thi:

Bash
./gradlew clean build --console=plain
ls -l build/libs
Text
> Task :clean
> Task :compileJava
> Task :processResources
> Task :classes
> Task :resolveMainClassName
> Task :bootJar
> Task :jar
> Task :assemble
> Task :compileTestJava
> Task :processTestResources NO-SOURCE
> Task :testClasses
> Task :test
> Task :check
> Task :build
-rw-r--r--@ 1 you      wheel      7749 Sep 16 15:22 demo-0.0.1-SNAPSHOT-plain.jar
-rw-r--r--@ 1 you      wheel  58557202 Sep 16 15:22 demo-0.0.1-SNAPSHOT.jar

build chạy assemble, task này chạy cả hai task tạo archive, rồi chạy check, task chạy test. bootJar chỉ chạy những task mà JAR thực thi cần, và bỏ qua test:

Bash
./gradlew clean bootJar --console=plain
Text
> Task :clean
> Task :compileJava
> Task :processResources
> Task :classes
> Task :resolveMainClassName
> Task :bootJar

Trong build/libs chỉ còn demo-0.0.1-SNAPSHOT.jar. Dùng build khi muốn test chặn artifact lại nếu fail, và bootJar khi test đã chạy ở nơi khác.

File -plain.jar là gì và cách tắt nó

demo-0.0.1-SNAPSHOT-plain.jar là sản phẩm của task jar thông thường trong plugin java của Gradle: 7,749 byte gồm class và resource của chính bạn, không có dependency và không có Main-Class, nên java -jar không chạy được. Nó chỉ có ý nghĩa khi project khác dùng project này như một library; với một ứng dụng, nó là file mà sớm muộn sẽ có người deploy nhầm. Tắt task đó đi:

build.gradle
tasks.named('test') {
	useJUnitPlatform()
}
 
tasks.named('jar') { 
	enabled = false
} 

Giờ ./gradlew build in ra > Task :jar SKIPPED và chỉ để lại một file. Dockerfile ở phần sau dựa vào điều này.

Maven đi tới cùng trạng thái theo cách khác, nên đây là một trong số ít chỗ mà build tool tạo ra khác biệt. Lệnh chỉ build JAR thực thi:

./gradlew bootJar

Mỗi tool để lại gì, từ cùng một source code:

build/libs/
└── demo-0.0.1-SNAPSHOT.jar                58,557,202 bytes, executable

Với Maven 3.9.16, plugin jar ghi plain jar trước, sau đó spring-boot-maven-plugin repackage nó tại chỗ và đổi tên bản gốc sang một bên:

Text
[INFO] --- jar:3.5.1:jar (default-jar) @ demo ---
[INFO] --- spring-boot:4.1.1:repackage (repackage) @ demo ---
[INFO] Replacing main artifact /…/demo/target/demo-0.0.1-SNAPSHOT.jar with repackaged archive, adding nested dependencies in BOOT-INF/.

Cách đổi tên file JAR của Spring Boot

Gradle ghép tên file từ rootProject.name trong settings.gradleversion trong build.gradle. Đổi version = '0.0.1-SNAPSHOT' thành version = '1.0.0' cho ra build/libs/demo-1.0.0.jar, với manifest ghi Implementation-Version: 1.0.0. Để tự chọn toàn bộ tên:

build.gradle
tasks.named('bootJar') {
	archiveFileName = 'app.jar'
}

Kết quả là build/libs/app.jar, và target/app.jar nằm cạnh target/app.jar.original. Phần còn lại của bài giữ tên mặc định, vì version trong tên file thường là cách nhanh nhất để biết server đang chạy bản nào; dù sao Dockerfile cũng đổi tên file bên trong image.

Bên trong JAR thực thi của Spring Boot có gì?

JAR là một file zip, nên công cụ zip nào cũng liệt kê được:

Bash
unzip -l build/libs/demo-0.0.1-SNAPSHOT.jar

Rút gọn còn một hai entry cho mỗi loại:

Text
Archive:  build/libs/demo-0.0.1-SNAPSHOT.jar
  Length      Date    Time    Name
---------  ---------- -----   ----
      424  02-01-1980 00:00   META-INF/MANIFEST.MF
      855  02-01-1980 00:00   org/springframework/boot/loader/launch/JarLauncher.class
     8388  02-01-1980 00:00   org/springframework/boot/loader/launch/LaunchedClassLoader.class
      733  02-01-1980 00:00   BOOT-INF/classes/com/example/demo/DemoApplication.class
     4658  02-01-1980 00:00   BOOT-INF/classes/com/example/demo/product/ProductController.class
       59  02-01-1980 00:00   BOOT-INF/classes/application.properties
      131  02-01-1980 00:00   BOOT-INF/classes/application-postgres.properties
      239  02-01-1980 00:00   BOOT-INF/classes/db/migration/V1__create_products.sql
 15279748  02-01-1980 00:00   BOOT-INF/lib/hibernate-core-7.4.5.Final.jar
  3623633  02-01-1980 00:00   BOOT-INF/lib/tomcat-embed-core-11.0.24.jar
  1220948  02-01-1980 00:00   BOOT-INF/lib/postgresql-42.7.13.jar
   172312  02-01-1980 00:00   BOOT-INF/lib/HikariCP-7.0.2.jar
     3725  02-01-1980 00:00   BOOT-INF/classpath.idx
      212  02-01-1980 00:00   BOOT-INF/layers.idx
---------                     -------
 58751978                     226 files

Mọi ngày tháng đều là 02-01-1980 00:00 vì plugin Gradle của Boot ghi archive reproducible: hai lần build cùng một code cho ra cùng 58,557,202 byte với cùng checksum SHA-256. Gom nhóm lại, 226 entry tạo thành cây này:

Tree
demo-0.0.1-SNAPSHOT.jar                       58,557,202 bytes, 226 entries
├── META-INF/
│   └── MANIFEST.MF                           Main-Class and Start-Class
├── org/springframework/boot/loader/          99 classes copied in by the build
│   ├── launch/JarLauncher.class              the Main-Class
│   ├── launch/LaunchedClassLoader.class
│   └── jar/NestedJarFile.class               reads a jar stored inside the jar
└── BOOT-INF/
    ├── classes/                              your code and src/main/resources
    │   ├── com/example/demo/…                6 classes
    │   ├── application.properties
    │   ├── application-postgres.properties
    │   └── db/migration/V1__create_products.sql
    ├── lib/                                  84 dependency jars, 58,332,704 bytes
    │   ├── hibernate-core-7.4.5.Final.jar
    │   ├── tomcat-embed-core-11.0.24.jar
    │   └── …
    ├── classpath.idx                         83 jars in classpath order
    └── layers.idx                            layer groups for container images

Gần như toàn bộ file là code của người khác: 84 jar dependency chiếm 58,332,704 trên tổng 58,751,978 byte trong danh sách, còn sáu class và ba file resource của ứng dụng chỉ vài kilobyte.

Manifest nối các phần đó lại với nhau:

Bash
unzip -p build/libs/demo-0.0.1-SNAPSHOT.jar META-INF/MANIFEST.MF
Text
Manifest-Version: 1.0
Main-Class: org.springframework.boot.loader.launch.JarLauncher
Start-Class: com.example.demo.DemoApplication
Spring-Boot-Version: 4.1.1
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx
Spring-Boot-Layers-Index: BOOT-INF/layers.idx
Build-Jdk-Spec: 21
Implementation-Title: demo
Implementation-Version: 0.0.1-SNAPSHOT
AttributeDùng để làm gì
Main-ClassClass mà JVM khởi động: JarLauncher của Spring Boot, không phải class của bạn
Start-ClassClass @SpringBootApplication của bạn, launcher gọi nó khi class loader đã sẵn sàng
Spring-Boot-ClassesNơi launcher tìm class đã compile và resource của bạn
Spring-Boot-LibNơi nó tìm các jar dependency
Spring-Boot-Classpath-Indexclasspath.idx: các jar theo thứ tự classpath — 83 trên 84, trừ spring-boot-jarmode-tools-4.1.1.jar, vốn là công cụ chứ không phải dependency
Spring-Boot-Layers-Indexlayers.idx: chia entry thành dependencies, spring-boot-loader, snapshot-dependenciesapplication cho layered image, chủ đề của khóa Advanced
Build-Jdk-Spec, Implementation-*Phiên bản Java mà build ghi lại, tên project và version

Chính bố cục đó là lý do không thể coi JAR như một entry classpath bình thường:

Bash
java -cp build/libs/demo-0.0.1-SNAPSHOT.jar com.example.demo.DemoApplication
Text
Error: Could not find or load main class com.example.demo.DemoApplication
Caused by: java.lang.ClassNotFoundException: com.example.demo.DemoApplication

Class loader của JDK tìm com/example/demo/DemoApplication.class ở gốc archive, trong khi build đặt nó dưới BOOT-INF/classes, và nó không bao giờ mở một jar nằm bên trong jar khác, nên 84 dependency cũng không hiện ra. JarLauncher tồn tại để đọc đúng bố cục này: nó dựng một LaunchedClassLoader trên BOOT-INF/classes và các jar lồng bên trong mà classpath.idx liệt kê, rồi mới load Start-Class và gọi method main của nó.

Bốn phần của JAR thực thi cùng kích thước, và đường đi từ java -jar qua Main-Class JarLauncher tới Start-Class DemoApplication

Chạy JAR bằng java -jar

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8141

Những dòng log khởi động trả lời các câu hỏi bạn có ở thời điểm này:

Text
2026-09-16T15:15:41.661+07:00  INFO 55211 --- [demo] [           main] com.example.demo.DemoApplication         : Starting DemoApplication v0.0.1-SNAPSHOT using Java 21.0.6 with PID 55211 (/…/demo/build/libs/demo-0.0.1-SNAPSHOT.jar started by you in /…/demo)
2026-09-16T15:15:41.662+07:00  INFO 55211 --- [demo] [           main] com.example.demo.DemoApplication         : No active profile set, falling back to 1 default profile: "default"
2026-09-16T15:15:43.337+07:00  INFO 55211 --- [demo] [           main] o.s.boot.tomcat.TomcatWebServer          : Tomcat started on port 8141 (http) with context path '/'
2026-09-16T15:15:43.340+07:00  INFO 55211 --- [demo] [           main] com.example.demo.DemoApplication         : Started DemoApplication in 1.843 seconds (process running for 2.049)
  • Starting … cho biết version của ứng dụng, Java runtime đang thực sự chạy nó (21.0.6: là java nào nằm trên PATH, không phải toolchain mà Gradle dùng để compile), PID, file JAR, user và thư mục làm việc. Hai thông tin cuối sẽ quan trọng ở phần sau.
  • No active profile set nghĩa là đang dùng H2. Bên dưới, Flyway in Successfully applied 1 migration to schema "PUBLIC".
  • Tomcat started on port 8141 xác nhận port mà server thực sự bind.
  • Started DemoApplication in 1.843 seconds là lần tốt nhất trong ba lần chạy ở load average khoảng 4, nên chỉ mang tính tham khảo.

Một vòng tạo sản phẩm và endpoint health:

Bash
curl -s -i -H 'Content-Type: application/json' -d '{"name":"Mechanical keyboard","sku":"KB-01","price":89.90}' http://localhost:8141/api/products
Text
HTTP/1.1 201 
Location: http://localhost:8141/api/products/1
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 16 Sep 2026 08:04:20 GMT
 
{"id":1,"name":"Mechanical keyboard","sku":"KB-01","price":89.90}
Bash
curl -s http://localhost:8141/actuator/health
Text
{"groups":["liveness","readiness"],"status":"UP"}

/actuator/health là endpoint Actuator duy nhất được expose qua HTTP theo mặc định; mảng groups liệt kê hai nhóm liveness và readiness mà Boot 4.1.1 cũng bật sẵn. File Compose ở cuối bài dùng endpoint này làm healthcheck cho container.

Chạy profile postgres với PostgreSQL 18

PostgreSQL chạy trong container, publish ra host port 5441:

Bash
docker run -d --name sb-a41-pg -e POSTGRES_USER=shop -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=shop -p 5441:5432 postgres:18

Chỉ bật profile mà không làm gì thêm sẽ cho thấy vì sao password phải đến từ đâu đó:

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=postgres --server.port=8141
Text
2026-09-16T15:19:05.789+07:00 ERROR 58594 --- [demo] [           main] o.s.boot.SpringApplication               : Application run failed
...
Caused by: org.postgresql.util.PSQLException: The server requested SCRAM-based authentication, but no password was provided.

Process kết thúc với exit status 1. Bản thân profile có ba cách bật, và cả ba đều in cùng dòng The following 1 profile is active: "postgres" trên JAR này:

Profile đến từ đâuLệnh
Command-line argumentjava -jar demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=postgres
Environment variableSPRING_PROFILES_ACTIVE=postgres java -jar demo-0.0.1-SNAPSHOT.jar
JVM system propertyjava -Dspring.profiles.active=postgres -jar demo-0.0.1-SNAPSHOT.jar

Bài 13 đã đo thứ tự ưu tiên giữa chúng khi đặt nhiều hơn một cách — argument thắng, rồi tới system property, rồi environment variable — và cho thấy -Dspring.profiles.active đặt sau tên JAR bị bỏ qua mà không báo gì. Với một ứng dụng thật, không điều nào trong đó thay đổi.

Ghi đè một property bằng biến môi trường

Environment variable đặt được mọi property, không riêng profile. Lần chạy này lấy profile, password và port từ môi trường:

Bash
SPRING_PROFILES_ACTIVE=postgres SPRING_DATASOURCE_PASSWORD=secret SERVER_PORT=8141 java -jar build/libs/demo-0.0.1-SNAPSHOT.jar
Text
2026-09-16T15:19:06.435+07:00  INFO 58623 --- [demo] [           main] com.example.demo.DemoApplication         : The following 1 profile is active: "postgres"
2026-09-16T15:19:07.252+07:00  INFO 58623 --- [demo] [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
2026-09-16T15:19:07.336+07:00  INFO 58623 --- [demo] [           main] com.zaxxer.hikari.pool.HikariPool        : HikariPool-1 - Added connection org.postgresql.jdbc.PgConnection@34aa8b61
2026-09-16T15:19:07.337+07:00  INFO 58623 --- [demo] [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
2026-09-16T15:19:07.347+07:00  INFO 58623 --- [demo] [           main] org.flywaydb.core.FlywayExecutor         : Database: jdbc:postgresql://localhost:5441/shop (PostgreSQL 18.6)
2026-09-16T15:19:07.380+07:00  INFO 58623 --- [demo] [           main] o.f.core.internal.command.DbValidate     : Successfully validated 1 migration (execution time 00:00.009s)
2026-09-16T15:19:07.437+07:00  INFO 58623 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Current version of schema "public": << Empty Schema >>
2026-09-16T15:19:07.441+07:00  INFO 58623 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "1 - create products"
2026-09-16T15:19:07.456+07:00  INFO 58623 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Successfully applied 1 migration to schema "public", now at version v1 (execution time 00:00.006s)
2026-09-16T15:19:08.562+07:00  INFO 58623 --- [demo] [           main] o.s.boot.tomcat.TomcatWebServer          : Tomcat started on port 8141 (http) with context path '/'

PgConnection trong dòng của Hikari nghĩa là PostgreSQL đã chấp nhận đăng nhập, và Flyway tạo products trong database rỗng. Cách đổi tên property sang environment variable là máy móc: viết hoa, đổi dấu chấm thành gạch dưới và bỏ dấu gạch ngang, nên spring.datasource.password thành SPRING_DATASOURCE_PASSWORDserver.port thành SERVER_PORT. URL và username vẫn lấy từ application-postgres.properties bên trong JAR. Đây là cách mà mọi nền tảng container dùng, nên các phần Docker bên dưới dùng lại nguyên như vậy.

File config bên ngoài JAR cho password

Trên một server không dùng container, một file đặt cạnh ứng dụng là chỗ quen thuộc còn lại để giữ secret. Boot đọc config/application.properties trong thư mục làm việc và xếp nó trên mọi file đóng gói trong JAR:

Tree
deploy/
├── demo-0.0.1-SNAPSHOT.jar
└── config/
    └── application.properties        spring.datasource.password=secret
Bash
cd deploy && java -jar demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=postgres --server.port=8141
Text
2026-09-16T15:19:19.297+07:00  INFO 58883 --- [demo] [           main] com.example.demo.DemoApplication         : Starting DemoApplication v0.0.1-SNAPSHOT using Java 21.0.6 with PID 58883 (/…/deploy/demo-0.0.1-SNAPSHOT.jar started by you in /…/deploy)
2026-09-16T15:19:19.299+07:00  INFO 58883 --- [demo] [           main] com.example.demo.DemoApplication         : The following 1 profile is active: "postgres"
2026-09-16T15:19:20.293+07:00  INFO 58883 --- [demo] [           main] org.flywaydb.core.FlywayExecutor         : Database: jdbc:postgresql://localhost:5441/shop (PostgreSQL 18.6)
2026-09-16T15:19:20.347+07:00  INFO 58883 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Current version of schema "public": 1
2026-09-16T15:19:20.348+07:00  INFO 58883 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Schema "public" is up to date. No migration necessary.

File bên ngoài cung cấp đúng key mà JAR thiếu, và Flyway thấy schema đã ở version 1. Phần in /…/deploy ở dòng đầu là thư mục Boot đã tìm: bài 13 cho thấy cùng JAR đó khởi động từ thư mục khác sẽ không bao giờ thấy file này. File này thuộc về server, không thuộc về repository.

Dừng ứng dụng: graceful shutdown và exit code

Ctrl+C trong terminal gửi SIGINT; kill <pid> gửi SIGTERM. Cả hai đều chạy shutdown hook của JVM, và hook của Spring Boot đóng ứng dụng theo thứ tự. kill 58883 với lần chạy ở trên in ra:

Text
2026-09-16T15:19:21.877+07:00  INFO 58883 --- [demo] [ionShutdownHook] o.s.boot.tomcat.GracefulShutdown         : Commencing graceful shutdown. Waiting for active requests to complete
2026-09-16T15:19:21.881+07:00  INFO 58883 --- [demo] [tomcat-shutdown] o.s.boot.tomcat.GracefulShutdown         : Graceful shutdown complete
2026-09-16T15:19:21.883+07:00  INFO 58883 --- [demo] [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2026-09-16T15:19:21.885+07:00  INFO 58883 --- [demo] [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown initiated...
2026-09-16T15:19:21.886+07:00  INFO 58883 --- [demo] [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown completed.

Web server ngừng nhận request mới và chờ các request đang chạy, sau đó EntityManagerFactory và connection pool đóng lại. Graceful shutdown không cần bật: property metadata trong spring-boot-web-server-4.1.1.jar cho server.shutdown giá trị mặc định graceful, và spring.lifecycle.timeout-per-shutdown-phase mặc định là 30s, thời gian tối đa Boot chờ các request đang xử lý.

Dừng bằng cách nàoExit status
kill <pid> (SIGTERM)143
Ctrl+C (SIGINT)130
Khởi động thất bại, ví dụ thiếu password1

143 và 130 là 128 cộng số hiệu của signal, cách JVM báo rằng nó kết thúc vì một signal. Một process supervisor coi mọi giá trị khác 0 là crash cần biết 143 là một lần dừng bình thường.

Tùy chọn JVM phải đặt trước -jar

Mọi thứ sau tên JAR được truyền vào main, kể cả tùy chọn dành cho JVM. Với -Xmx256m ở cả hai vị trí, jcmd đọc command line và các flag của JVM đang chạy, với PID lấy từ dòng Starting:

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8141 -Xmx256m
Bash
jcmd 70676 VM.command_line | grep -E 'jvm_args|java_command'
jcmd 70676 VM.flags | tr ' ' '\n' | grep '^-XX:MaxHeapSize'
Text
java_command: build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8141 -Xmx256m
-XX:MaxHeapSize=4294967296

Cùng hai lệnh jcmd đó với JVM được khởi động có tùy chọn đặt ở trước:

Bash
java -Xmx256m -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8141
Text
jvm_args: -Xmx256m 
java_command: build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8141
-XX:MaxHeapSize=268435456

Đặt sau JAR, -Xmx256m chỉ là một chuỗi trong args, không có JVM argument nào được ghi nhận, và giới hạn heap giữ mặc định 4 GiB, bằng một phần tư 16 GB của máy này; đặt trước -jar, nó trở thành giới hạn 256 MiB. Giữ process chạy sau khi bạn logout, khởi động lại khi lỗi và tự chạy khi boot máy là việc của một service manager như systemd, thuộc khóa Advanced. Phần còn lại của bài giao việc đó cho container runtime.

Dockerfile đơn giản cho JAR Spring Boot

Base image chỉ cần một Java runtime. eclipse-temurin là bản build OpenJDK của Eclipse Adoptium, và tag 21-jre chứa runtime mà không có compiler:

Bash
docker run --rm eclipse-temurin:21-jre sh -c 'cat /etc/os-release | head -4; java -version'
Text
PRETTY_NAME="Ubuntu 26.04.1 LTS"
NAME="Ubuntu"
VERSION_ID="26.04"
VERSION="26.04.1 LTS (Resolute Raccoon)"
openjdk version "21.0.12" 2026-07-21 LTS
OpenJDK Runtime Environment Temurin-21.0.12+8 (build 21.0.12+8-LTS)
OpenJDK 64-Bit Server VM Temurin-21.0.12+8 (build 21.0.12+8-LTS, mixed mode, sharing)

21-jre là tag di động: lúc viết bài nó trỏ tới Temurin 21.0.12 trên Ubuntu 26.04. Hãy pin một tag cụ thể hơn hoặc một digest khi cần lần nào cũng đúng base đó. Dockerfile đầu tiên copy một JAR đã build trên host:

Dockerfile
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
  • COPY build/libs/*.jar app.jar đặt cho JAR một tên cố định trong image, bất kể version trong tên file.
  • EXPOSE 8080 chỉ ghi chú port. Nó không publish gì cả; -p mới làm việc đó.
  • ENTRYPOINT [...] là dạng exec, một mảng JSON. Phần sau cho thấy điều gì thay đổi nếu không dùng nó.

File .dockerignore giữ phần còn lại của project ngoài tầm với của COPY:

.dockerignore
.git
.gradle
.idea
build/*
!build/libs/

Hai dòng cuối loại mọi thứ dưới build trừ build/libs. BuildKit chỉ gửi những đường dẫn mà COPY nêu tên, nên với Dockerfile này build context là 58.57 MB dù có file hay không. File bắt đầu có tác dụng ngay khi một COPY lấy cả thư mục: không có nó, COPY . . sẽ đưa .git, thư mục .gradle cục bộ và mọi output của build vào image.

Bash
docker build -t sb-a41-demo:1 .

Rút gọn còn các bước:

Text
#4 [1/3] FROM docker.io/library/eclipse-temurin:21-jre@sha256:f5e749f83c8a6d0b14b729ad35eebb9a96493b38178d2ddd5429e5a0733b6ee5
#5 [internal] load build context
#5 transferring context: 58.57MB 0.3s done
#7 [3/3] COPY build/libs/*.jar app.jar
#7 DONE 0.1s
#8 naming to docker.io/library/sb-a41-demo:1 done
Bash
docker images sb-a41-demo
docker images eclipse-temurin
Text
IMAGE           ID             DISK USAGE   CONTENT SIZE   EXTRA
sb-a41-demo:1   b9d8beb897de        585MB          166MB        
IMAGE                    ID             DISK USAGE   CONTENT SIZE   EXTRA
eclipse-temurin:21-jdk   56a062b5a795        750MB          227MB        
eclipse-temurin:21-jre   f5e749f83c8a        479MB          118MB        

Docker 29 in hai kích thước. CONTENT SIZE là nội dung image đã nén, gần với lượng dữ liệu một lần pull tải về — các layer của image 21-jre bản arm64 cộng lại là 112,962,074 byte. DISK USAGE tính thêm các layer đã giải nén trên máy này; du -sh / bên trong image JRE báo 344M. JAR thêm 48 MB nội dung nén vào base.

Bash
docker run -d --name sb-a41-app -p 8141:8080 sb-a41-demo:1
curl -s -i http://localhost:8141/api/products/1

Sau khi tạo bàn phím bằng đúng request POST như trên host:

Text
HTTP/1.1 200 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 16 Sep 2026 08:08:41 GMT
 
{"id":1,"name":"Mechanical keyboard","sku":"KB-01","price":89.90}

-p 8141:8080 map host port 8141 vào port 8080 của container, nơi Tomcat lắng nghe vì bên trong container không có gì ghi đè server.port. Log khởi động có hai điểm khác so với lần chạy trên host: Starting DemoApplication v0.0.1-SNAPSHOT using Java 21.0.12 with PID 1 (/app/app.jar started by root in /app), và timestamp theo UTC vì image không đặt time zone.

COPY build/libs/*.jar khi plain JAR vẫn còn bật

Trước khi tắt task jar, build/libs có hai file và wildcard khớp cả hai. Build không fail. /app/app.jar là JAR thực thi, nhưng chỉ là may mắn: BuildKit copy lần lượt từng file khớp vào cùng một đích, và một phép thử với hai file tên demo-a.jardemo-z.jar xác nhận file đứng cuối theo thứ tự sắp xếp sẽ thắng. demo-0.0.1-SNAPSHOT.jar xếp sau demo-0.0.1-SNAPSHOT-plain.jar. đứng sau -. Chỉ cần đặt tên khác đi là plain jar có thể lọt vào image mà build không báo lỗi gì. Khi đã tắt plain jar, wildcard khớp đúng một file. Với Maven, dòng này là COPY target/*.jar app.jar, và demo-0.0.1-SNAPSHOT.jar.original không khớp *.jar.

ENTRYPOINT dạng exec và dạng shell: docker stop có tới được JVM không?

Dạng shell trông như cùng một chỉ thị, chỉ bỏ dấu ngoặc:

Dockerfile
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
ENTRYPOINT java -jar /app/app.jar

Docker lưu nó thành ["/bin/sh","-c","java -jar /app/app.jar"], và điều đó thay đổi process nào là PID 1. Image dạng shell được build thành sb-a41-demo:shell và chạy thành sb-a41-shell với cùng các tùy chọn docker run như sb-a41-app. Cùng lệnh ps trong mỗi container, dạng exec trước:

Bash
docker exec sb-a41-app ps -o pid,ppid,user,args
docker exec sb-a41-shell ps -o pid,ppid,user,args
Text
  PID  PPID USER     COMMAND
    1     0 root     java -jar /app/app.jar
   58     0 root     ps -o pid,ppid,user,args
Text
  PID  PPID USER     COMMAND
    1     0 root     /bin/sh -c java -jar /app/app.jar
    7     1 root     java -jar /app/app.jar
   59     0 root     ps -o pid,ppid,user,args

docker stop gửi SIGTERM tới PID 1, chờ 10 giây, rồi gửi SIGKILL. Mỗi container được khởi động, chờ tới khi Started DemoApplication xuất hiện, rồi dừng:

Bash
time docker stop sb-a41-app
Text
sb-a41-app
docker stop sb-a41-app  0.01s user 0.00s system 6% cpu 0.176 total
Bash
time docker stop sb-a41-shell
Text
sb-a41-shell
docker stop sb-a41-shell  0.01s user 0.01s system 0% cpu 10.235 total
Dạng execDạng shell
PID 1java/bin/sh (dash 0.5.12), java là PID 7
docker stop mất0.176 s10.235 s
Log shutdowntừ Commencing graceful shutdown… tới HikariPool-1 - Shutdown completed.không có gì sau Started DemoApplication
Exit code143 (SIGTERM)137 (SIGKILL)

Thời gian là lần tốt nhất trong ba lần chạy ở load average khoảng 5. Ở dạng shell, SIGTERM tới dash, và dash không xử lý cũng không chuyển tiếp nó; một process PID 1 không có handler cho signal thì không bị signal đó kết thúc. JVM không hề biết mình đang bị dừng: không graceful shutdown, connection pool không được đóng, và lần dừng nào cũng tốn trọn 10 giây trước khi SIGKILL. Shell có đứng chen giữa hay không tùy vào shell: chạy bằng bash -c "java -jar /app/app.jar" trong cùng image, bash tự thay thế mình và java là PID 1, còn dash thì không. Đừng dựa vào khác biệt đó; hãy dùng dạng exec. Nếu thật sự cần shell, chẳng hạn để expand variable, ENTRYPOINT exec java -jar /app/app.jar đưa java về lại PID 1, và docker stop trả về trong chưa tới một giây với exit code 143.

Chạy container bằng user không phải root

Dòng Starting ghi started by root. Ứng dụng này không cần quyền root cho việc gì, và một process bị chiếm quyền khi đang chạy bằng root trong container thì có nhiều thứ để lợi dụng hơn. Image Temurin là Ubuntu, nên groupadduseradd có sẵn trong /usr/sbin:

Dockerfile
FROM eclipse-temurin:21-jre
RUN groupadd --system spring && useradd --system --gid spring --no-create-home spring
WORKDIR /app
COPY build/libs/*.jar app.jar
USER spring
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

--system chọn một id dưới 1000, điều này quan trọng với base này: image Ubuntu đã có sẵn user ubuntu với uid 1000. USER đứng sau COPY, nên JAR vẫn thuộc sở hữu của root và ứng dụng đọc được nhưng không thay được nó. Build thành sb-a41-demo:2 và chạy như trước:

Bash
docker exec sb-a41-app id
docker exec sb-a41-app ps -o pid,user,args
docker exec sb-a41-app ls -l /app
docker exec sb-a41-app sh -c 'touch /app/x 2>&1; echo rc=$?'
Text
uid=999(spring) gid=999(spring) groups=999(spring)
  PID USER     COMMAND
    1 spring   java -jar /app/app.jar
   64 spring   ps -o pid,user,args
total 57192
-rw-r--r-- 1 root root 58557202 Sep 16 08:14 app.jar
touch: cannot touch '/app/x': Permission denied
rc=1

Dòng log đổi thành (/app/app.jar started by spring in /app), và /actuator/health vẫn trả về {"groups":["liveness","readiness"],"status":"UP"}. Ứng dụng không ghi gì vào /app: Tomcat tạo các thư mục làm việc /tmp/tomcat.8080.…/tmp/tomcat-docbase.8080.…, thuộc sở hữu của spring, trong /tmp, nơi user nào cũng ghi được.

Dockerfile multi-stage: build JAR bên trong Docker

Image single-stage dựa vào một JAR mà ai đó build trên máy mình với JDK nào đó họ có. Multi-stage build compile bên trong Docker với một JDK được pin và chỉ ship kết quả:

Dockerfile
FROM eclipse-temurin:21-jdk AS build
WORKDIR /workspace
COPY gradlew settings.gradle build.gradle ./
COPY gradle gradle
RUN ./gradlew dependencies --no-daemon > /dev/null
COPY src src
RUN ./gradlew bootJar -x test --no-daemon
 
FROM eclipse-temurin:21-jre
RUN groupadd --system spring && useradd --system --gid spring --no-create-home spring
WORKDIR /app
COPY build/libs/*.jar app.jar
COPY --from=build /workspace/build/libs/*.jar app.jar
USER spring
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
  • Stage build bắt đầu từ image JDK. Chỉ stage sau nó trở thành image; JDK, Gradle và source code bị bỏ lại.
  • Thứ tự các dòng COPY chính là mấu chốt. Docker dùng lại một layer cho tới khi thứ mà nó dựa vào thay đổi. Wrapper và các file build hiếm khi đổi, nên ./gradlew dependencies — lệnh tải bản phân phối Gradle 9.7.1 và resolve dependency graph — được cache cho tới khi chúng đổi. src đổi ở mọi commit, nên được copy sau cùng.
  • -x test bỏ qua test. Test thuộc về CI hoặc ./gradlew build trên host, trước khi build image, không phải mỗi lần docker build.
  • --no-daemon giữ Gradle không để lại daemon; nó vẫn fork một daemon dùng một lần, và có in ra thông báo.

Lần build đầu tiên, từ cache rỗng:

Bash
docker build --progress=plain -t sb-a41-demo:3 .
Text
#11 [build 5/7] RUN ./gradlew dependencies --no-daemon > /dev/null
#11 DONE 32.2s
#12 [build 6/7] COPY src src
#12 DONE 0.0s
#13 [build 7/7] RUN ./gradlew bootJar -x test --no-daemon
#13 DONE 10.8s

Sau đó ProductController thay đổi — findAll giờ sắp xếp theo id, tức một dòng import và một dòng sửa — và cùng lệnh đó chạy lại. Rút gọn:

Text
#8 [build 2/7] WORKDIR /workspace
#8 CACHED
#9 [build 3/7] COPY gradlew settings.gradle build.gradle ./
#9 CACHED
#10 [build 4/7] COPY gradle gradle
#10 CACHED
#11 [build 5/7] RUN ./gradlew dependencies --no-daemon > /dev/null
#11 CACHED
#12 [build 6/7] COPY src src
#12 DONE 0.0s
#13 [build 7/7] RUN ./gradlew bootJar -x test --no-daemon
#13 9.171 > Task :compileJava
#13 10.52 > Task :bootJar
#13 10.52 BUILD SUCCESSFUL in 10s
#13 DONE 10.7s
#14 [stage-1 2/4] RUN groupadd --system spring && useradd --system --gid spring --no-create-home spring
#14 CACHED
#15 [stage-1 3/4] WORKDIR /app
#15 CACHED
#16 [stage-1 4/4] COPY --from=build /workspace/build/libs/*.jar app.jar
#16 DONE 0.1s

Mọi thứ tới bước dependency đều lấy từ cache, kể cả 32 giây tải về. Từ COPY src src trở đi build chạy lại, và ở stage runtime chỉ layer chứa JAR mới được build lại.

Layer dependency không cache những gì

bootJar vẫn mất 10.7 giây, và chín giây trôi qua trước khi compileJava in ra bất cứ gì. Build riêng năm dòng đầu của Dockerfile thành một image khác cho thấy layer được cache chứa gì:

Bash
head -5 Dockerfile > Dockerfile.deps
docker build -q -f Dockerfile.deps -t sb-a41-deps:1 .
docker run --rm --entrypoint sh sb-a41-deps:1 -c 'du -sh /root/.gradle/wrapper /root/.gradle/caches; find /root/.gradle/caches/modules-2/files-2.1 -name "*.jar" | wc -l; find /root/.gradle/caches/modules-2/files-2.1 -name "*.pom" | wc -l'
Text
165M	/root/.gradle/wrapper
65M	/root/.gradle/caches
23
285

Bản phân phối Gradle và 285 file POM, nhưng chỉ 23 jar — ứng dụng cần 84. ./gradlew dependencies resolve dependency graph từ metadata và không tải artifact, nên mỗi lần source thay đổi, các jar dependency lại được tải về. Một BuildKit cache mount giữ cache của Gradle giữa các lần build mà không đưa nó vào layer:

Dockerfile
COPY src src
RUN ./gradlew bootJar -x test --no-daemon
RUN --mount=type=cache,target=/root/.gradle/caches ./gradlew bootJar -x test --no-daemon

Lần build đầu với mount lấp đầy nó và bước đó mất 29.1 giây; sau hai lần sửa nhỏ tiếp theo, mỗi lần thêm một constraint @Size vào ProductRequest, bước đó mất 5.7 và 5.9 giây, so với 10.7 giây khi không có mount. Các số đo lấy ở load average từ 3 tới 8.5 và chỉ mang tính tham khảo. Mount nằm trong builder chứ không nằm trong image, nên docker builder prune hoặc một CI runner mới sẽ bắt đầu lại với mount rỗng. Đây là phiên bản mà file Compose bên dưới build.

Các image, theo docker images:

ImageDISK USAGECONTENT SIZE
eclipse-temurin:21-jdk750MB227MB
eclipse-temurin:21-jre479MB118MB
sb-a41-demo:3, image multi-stage586MB166MB
Riêng stage build (--target build)1.4GB502MB

Image cuối cùng là base JRE cộng JAR và một user: cùng 166 MB nội dung như image single-stage. Nếu ship stage build thay vào đó thì sẽ là 502 MB nội dung nén, gồm JDK, bản phân phối Gradle và dependency cache mà runtime không dùng tới.

Stage build dùng JDK với các layer được cache cho wrapper và file build cùng layer source được build lại, tạo ra JAR mà stage runtime dùng JRE copy vào và chạy bằng user spring

Container Spring Boot được bao nhiêu heap?

JVM tính heap tối đa mặc định từ lượng bộ nhớ nó nhìn thấy, và bên trong container đó là giới hạn của container. -XX:+PrintFlagsFinal cho thấy kết quả mà không cần khởi động ứng dụng:

Bash
docker run --rm --entrypoint java sb-a41-demo:2 -XX:+PrintFlagsFinal -version 2>&1 | grep -E 'Picked up| MaxHeapSize | MaxRAMPercentage '
docker run --rm --memory=512m --entrypoint java sb-a41-demo:2 -XX:+PrintFlagsFinal -version 2>&1 | grep -E 'Picked up| MaxHeapSize | MaxRAMPercentage '
docker run --rm --memory=512m -e JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=75 --entrypoint java sb-a41-demo:2 -XX:+PrintFlagsFinal -version 2>&1 | grep -E 'Picked up| MaxHeapSize | MaxRAMPercentage '
Text
   size_t MaxHeapSize                              = 2082471936                                {product} {ergonomic}
   double MaxRAMPercentage                         = 25.000000                                 {product} {default}
 
   size_t MaxHeapSize                              = 134217728                                 {product} {ergonomic}
   double MaxRAMPercentage                         = 25.000000                                 {product} {default}
 
Picked up JAVA_TOOL_OPTIONS: -XX:MaxRAMPercentage=75
   size_t MaxHeapSize                              = 402653184                                 {product} {ergonomic}
   double MaxRAMPercentage                         = 75.000000                                 {product} {environment}
Tùy chọn docker runHeap tối đa
không giới hạn (VM của Docker Desktop có 7.75 GiB)2,082,471,936 byte, khoảng 1.94 GiB
--memory=512m134,217,728 byte = 128 MiB
--memory=512m -e JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=75402,653,184 byte = 384 MiB

Java 21 đọc giới hạn cgroup v2 — java -XshowSettings:system -version trong cùng container in Memory Limit: 512.00M — và mặc định cho heap 25% giới hạn đó. Một phần tư là lựa chọn dè dặt cho container chỉ chạy mỗi JVM. JAVA_TOOL_OPTIONS do chính JVM đọc, nên dùng được với ENTRYPOINT dạng exec mà không cần shell để expand variable, và dòng Picked up xác nhận giá trị đã tới nơi. Không nên đẩy lên 100%: cả hai biến thể của ứng dụng dùng khoảng 288 MiB trên giới hạn 512 MiB ngay sau khi khởi động theo docker stats, và JVM cần bộ nhớ ngoài heap cho class, code đã compile và thread. Với lượng bộ nhớ ít như vậy, JVM cũng chọn garbage collector khác; tinh chỉnh cả hai thuộc khóa Advanced.

Chạy container Spring Boot cùng PostgreSQL

Hai container trên một user-defined network

Các container trên cùng một user-defined network gọi nhau bằng tên container:

Bash
docker network create sb-a41-net
docker run -d --name sb-a41-db --network sb-a41-net -e POSTGRES_USER=shop -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=shop postgres:18
docker run -d --name sb-a41-app --network sb-a41-net -p 8141:8080 -e SPRING_PROFILES_ACTIVE=postgres -e SPRING_DATASOURCE_URL=jdbc:postgresql://sb-a41-db:5432/shop -e SPRING_DATASOURCE_PASSWORD=secret sb-a41-demo:3

Database không cần -p vì chỉ ứng dụng nói chuyện với nó, qua network. JDBC URL dùng tên container, sb-a41-db, và port bên trong nó, 5432:

Text
2026-09-16T08:16:54.847Z  INFO 1 --- [demo] [           main] com.example.demo.DemoApplication         : The following 1 profile is active: "postgres"
2026-09-16T08:16:55.836Z  INFO 1 --- [demo] [           main] com.zaxxer.hikari.pool.HikariPool        : HikariPool-1 - Added connection org.postgresql.jdbc.PgConnection@112c2930
2026-09-16T08:16:55.847Z  INFO 1 --- [demo] [           main] org.flywaydb.core.FlywayExecutor         : Database: jdbc:postgresql://sb-a41-db:5432/shop (PostgreSQL 18.6)
2026-09-16T08:16:55.923Z  INFO 1 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Successfully applied 1 migration to schema "public", now at version v1 (execution time 00:00.004s)
2026-09-16T08:16:56.926Z  INFO 1 --- [demo] [           main] o.s.boot.tomcat.TomcatWebServer          : Tomcat started on port 8080 (http) with context path '/'

Một request POST qua port 8141 tạo một sản phẩm, và PostgreSQL có row đó:

Bash
docker exec sb-a41-db psql -U shop -d shop -c 'select id, name, sku, price from products'
Text
 id |      name      |  sku  | price 
----+----------------+-------+-------
  1 | Wireless mouse | MS-01 | 24.50
(1 row)

Bỏ SPRING_DATASOURCE_URL đi thì ứng dụng dùng URL trong JAR, localhost:5441. Bên trong container, localhost là chính container đó, nên khởi động thất bại:

Text
Caused by: org.postgresql.util.PSQLException: Connection to localhost:5441 refused. Check that the hostname and port are correct and that the postmaster is accepting TCP/IP connections.
Caused by: java.net.ConnectException: Connection refused

Cùng stack đó với Docker Compose

Ba lệnh docker run với cả chục flag rất khó lặp lại chính xác. Compose ghi chúng xuống file:

compose.yaml
services:
  db:
    image: postgres:18
    environment:
      POSTGRES_DB: shop
      POSTGRES_USER: shop
      POSTGRES_PASSWORD: secret
    volumes:
      - db-data:/var/lib/postgresql
    healthcheck:
      test: ["CMD", "pg_isready", "-h", "localhost", "-U", "shop", "-d", "shop"]
      interval: 2s
      timeout: 3s
      retries: 15
 
  app:
    build: .
    depends_on:
      db:
        condition: service_healthy
    environment:
      SPRING_PROFILES_ACTIVE: postgres
      SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/shop
      SPRING_DATASOURCE_PASSWORD: secret
    ports:
      - "8141:8080"
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8080/actuator/health"]
      interval: 5s
      timeout: 3s
      retries: 12
      start_period: 10s
 
volumes:
  db-data:
  • build: . build Dockerfile multi-stage trong cùng thư mục.
  • db-data:/var/lib/postgresql là mount point cho postgres:18, image này khai báo VOLUME /var/lib/postgresql và giữ data trong /var/lib/postgresql/18/docker. Một volume gắn vào đường dẫn cũ /var/lib/postgresql/data khiến container thoát ngay với Error: in 18+, these Docker images are configured to store database data in a format which is compatible with "pg_ctlcluster".
  • pg_isready -h localhost kiểm tra qua TCP. Ở lần khởi động đầu, entrypoint của image chạy một server tạm chỉ lắng nghe trên Unix socket trong lúc tạo database; khi poll cả hai cách trong lần khởi động đó, cách dùng socket báo accepting connections trong khi cách dùng TCP vẫn là no response, rồi server khởi động lại.
  • condition: service_healthy giữ ứng dụng lại cho tới khi healthcheck đó pass. Không có condition, depends_on chỉ chờ container khởi động.
  • Healthcheck của ứng dụng gọi /actuator/health bằng curl, có sẵn trong image Temurin, nên docker compose ps báo được ứng dụng healthy chứ không chỉ đang chạy.
  • Tên service db là hostname trong JDBC URL, giống vai trò của tên container trên network thường.
Bash
docker compose -p sb-a41 up --build -d

-p đặt tên project, nếu không mặc định là tên thư mục, và được dùng làm tiền tố cho mọi thứ Compose tạo ra. Sau các bước BuildKit của phần build image, output là:

Text
 Image sb-a41-app Built 
 Network sb-a41_default Creating 
 Network sb-a41_default Created 
 Volume sb-a41_db-data Creating 
 Volume sb-a41_db-data Created 
 Container sb-a41-db-1 Creating 
 Container sb-a41-db-1 Created 
 Container sb-a41-app-1 Creating 
 Container sb-a41-app-1 Created 
 Container sb-a41-db-1 Starting 
 Container sb-a41-db-1 Started 
 Container sb-a41-db-1 Waiting 
 Container sb-a41-db-1 Healthy 
 Container sb-a41-app-1 Starting 
 Container sb-a41-app-1 Started 

WaitingHealthycondition: service_healthy đang hoạt động: container ứng dụng chỉ khởi động sau khi healthcheck của database pass.

Bash
docker compose -p sb-a41 ps
Text
NAME           IMAGE         COMMAND                  SERVICE   CREATED          STATUS                    PORTS
sb-a41-app-1   sb-a41-app    "java -jar /app/app.…"   app       11 seconds ago   Up 8 seconds (healthy)    0.0.0.0:8141->8080/tcp, [::]:8141->8080/tcp
sb-a41-db-1    postgres:18   "docker-entrypoint.s…"   db        11 seconds ago   Up 10 seconds (healthy)   5432/tcp
Bash
docker compose -p sb-a41 logs app

Những dòng cho thấy profile, kết nối và migration:

Text
app-1  | 2026-09-16T08:17:58.749Z  INFO 1 --- [demo] [           main] com.example.demo.DemoApplication         : The following 1 profile is active: "postgres"
app-1  | 2026-09-16T08:17:59.739Z  INFO 1 --- [demo] [           main] org.flywaydb.core.FlywayExecutor         : Database: jdbc:postgresql://db:5432/shop (PostgreSQL 18.6)
app-1  | 2026-09-16T08:17:59.808Z  INFO 1 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Successfully applied 1 migration to schema "public", now at version v1 (execution time 00:00.006s)
app-1  | 2026-09-16T08:18:00.803Z  INFO 1 --- [demo] [           main] o.s.boot.tomcat.TomcatWebServer          : Tomcat started on port 8080 (http) with context path '/'

Một vòng request qua cả stack:

Bash
curl -s -i -H 'Content-Type: application/json' -d '{"name":"USB-C hub","sku":"HUB-07","price":39.00}' http://localhost:8141/api/products
Text
HTTP/1.1 201 
Location: http://localhost:8141/api/products/1
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 16 Sep 2026 08:18:06 GMT
 
{"id":1,"name":"USB-C hub","sku":"HUB-07","price":39.00}

Giờ xóa cả hai container và network, rồi tạo lại:

Bash
docker compose -p sb-a41 down
docker volume ls --filter name=sb-a41
docker compose -p sb-a41 up -d
Text
 Container sb-a41-app-1 Stopping 
 Container sb-a41-app-1 Stopped 
 Container sb-a41-app-1 Removing 
 Container sb-a41-app-1 Removed 
 Container sb-a41-db-1 Stopping 
 Container sb-a41-db-1 Stopped 
 Container sb-a41-db-1 Removing 
 Container sb-a41-db-1 Removed 
 Network sb-a41_default Removing 
 Network sb-a41_default Removed 
DRIVER    VOLUME NAME
local     sb-a41_db-data

down xóa container và network nhưng không xóa named volume. Sau up, log ứng dụng ghi Current version of schema "public": 1Schema "public" is up to date. No migration necessary., và sản phẩm vẫn còn đó:

Bash
curl -s http://localhost:8141/api/products
Text
[{"id":1,"name":"USB-C hub","sku":"HUB-07","price":39.00}]

docker compose down -v xóa luôn volume, và data đi theo nó. Password ghi thẳng trong compose.yaml chỉ chấp nhận được cho stack trên máy của bạn: mọi thứ trong file đó đều vào version control, nên deployment thật inject password từ secret store của nền tảng.

Cùng một JAR chạy theo ba cách — trên host với argument và file bên ngoài, trên host với environment variable, và trong Compose — cùng profile, URL, username, password và port mà mỗi lần chạy nhận được và nguồn của từng giá trị

Ngoài phạm vi một Dockerfile đơn giản

Mỗi mục dưới đây xây trên những gì bài này đã làm, và đều thuộc khóa Advanced:

  • Layered JAR (java -Djarmode=tools -jar app.jar extract --layers --launcher) tách dependency thành các layer riêng trong image, để một thay đổi code không phải ghi lại 58 MB.
  • Cloud Native Buildpacks (./gradlew bootBuildImage) build image mà không cần Dockerfile.
  • Docker Compose support của Spring Boot (spring-boot-docker-compose) khởi động các service trong compose.yaml khi ứng dụng khởi động ở môi trường development.
  • GraalVM native image compile ứng dụng ahead-of-time thành file thực thi native.
  • Kubernetes, systemd service, CI/CD pipeline, tinh chỉnh JVM và garbage collector, và reverse proxy với TLS đặt trước ứng dụng.

FAQ

Nên chạy ./gradlew build hay ./gradlew bootJar?

build compile, chạy test và ghi cả JAR thực thi lẫn plain JAR; bootJar chỉ compile và ghi JAR thực thi. Dùng build trên CI để test fail thì chặn được artifact, và bootJar trong Docker build hoặc ở bất cứ đâu test đã chạy rồi. Tắt task jar trong ứng dụng để build không sinh -plain.jar nữa.

Vì sao docker stop mất 10 giây với container Spring Boot?

Hầu như luôn do ENTRYPOINT dạng shell, hoặc một script khởi động java mà không có exec. /bin/sh trở thành PID 1, nhận SIGTERM rồi không làm gì với nó, và Docker gửi SIGKILL sau timeout 10 giây — đo được 10.235 s với exit code 137 và không có log shutdown. Dạng exec, ENTRYPOINT ["java", "-jar", "/app/app.jar"], dừng trong 0.176 s với graceful shutdown và exit code 143.

Vì sao container Spring Boot không kết nối được PostgreSQL qua localhost?

Vì bên trong container, localhost là chính container đó. Đặt cả hai container trên cùng một user-defined network, hoặc trong cùng một file Compose, và dùng tên container hoặc tên service kia làm host: jdbc:postgresql://db:5432/shop. Port là port PostgreSQL lắng nghe bên trong container của nó, 5432, không phải host port đã publish.

Base image nên là JDK hay JRE?

JRE cho image bạn chạy. Ứng dụng cần runtime, không cần compiler. eclipse-temurin:21-jre có 118 MB nội dung so với 227 MB của 21-jdk, và multi-stage build chỉ dùng JDK trong stage sẽ bị bỏ đi.

Truyền -Xmx hoặc tùy chọn JVM khác vào container Spring Boot thế nào?

Đặt JAVA_TOOL_OPTIONS, ví dụ -e JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=75 hoặc một mục environment: trong Compose. JVM tự đọc variable này, nên nó dùng được với ENTRYPOINT dạng exec, và in Picked up JAVA_TOOL_OPTIONS lúc khởi động. Một giá trị phần trăm đi theo giới hạn bộ nhớ của container; với --memory=512m, mặc định 25% cho heap 128 MiB và 75% cho 384 MiB.

Có nên để password database trong application-postgres.properties không?

Không, nếu file nằm trong src/main/resources: mọi thứ ở đó được copy vào JAR, và ai có JAR hoặc repository đều đọc được. Giữ file profile cho các setting không bí mật và cung cấp password lúc chạy, qua SPRING_DATASOURCE_PASSWORD, một file config/application.properties cạnh ứng dụng trên server, hoặc một secret store ở production.

Kết luận

./gradlew bootJar tạo ra một file 58.6 MB với manifest khởi động JarLauncher, class này dựng class loader trên BOOT-INF/classes và 84 JAR lồng bên trong trước khi gọi main của bạn. Cùng file đó chạy với H2 mặc định, với profile postgres trên PostgreSQL 18, và bên trong container, còn mọi thứ khác nhau giữa các lần chạy — profile, URL, password, port — đi vào từ bên ngoài qua argument, environment variable hoặc một file trong thư mục làm việc. Khi dừng bằng SIGTERM, ứng dụng mặc định shutdown graceful và thoát với 143.

Trong Docker, ba chi tiết quyết định container có hoạt động đúng hay không: ENTRYPOINT dạng exec, để docker stop tới được JVM trong chưa tới một giây thay vì kill nó sau 10 giây; một user không phải root; và heap có kích thước phù hợp với container, vốn mặc định chỉ được 25% giới hạn bộ nhớ. Multi-stage build giữ JDK và Gradle ngoài image 166 MB, dù layer dependency của nó cache bản phân phối Gradle chứ không cache các jar dependency, trừ khi có cache mount giữ chúng lại. Compose nối ứng dụng với PostgreSQL bằng healthcheck, tên service làm hostname và một named volume tồn tại qua down.

Bài tiếp theo là dự án tổng kết khóa cơ bản: một REST API hoàn chỉnh quản lý đơn hàng, gồm JPA, validation, JWT, test, tài liệu API và Docker, dùng lại Dockerfile và compose.yaml của bài này.

Bài viết liên quan

[Spring Boot Basics] Công cụ tăng năng suất trong Spring Boot: DevTools, Lombok và Actuator cơ bản

Spring Boot DevTools, Lombok và Actuator trên Spring Boot 4.1.1: vì sao developmentOnly giữ DevTools ngoài bootJar, base classloader và restart classloader với lần restart đo được 0.185 s so với khởi động lạnh 1.488 s, kích hoạt restart bằng ./gradlew -t classes, vì sao build resource bằng Gradle vẫn làm ứng dụng restart, các giá trị property mặc định DevTools áp dụng và LiveReload bị deprecate từ 4.1.0; Lombok sinh ra gì theo javap, @Value và @Builder so với Java record với Jackson 3 và @Jacksonized, các bẫy của @Data trên entity (StackOverflowError, HashSet làm mất entity, LazyInitializationException, @Builder không có no-args constructor) và tập annotation an toàn; Actuator /actuator, /actuator/health với show-details và 503 DOWN, expose /actuator/info với thông tin build, git, java và os, vì sao include=* nguy hiểm, và bảo vệ Actuator bên cạnh chain securityMatcher("/api/**").

[Spring Boot Basics] Unit test trong Spring Boot: JUnit 6, AssertJ và Mockito cho tầng Service

Unit test cho tầng service của ứng dụng Spring Boot 4.1.1 với JUnit, AssertJ và Mockito: unit test thay thế những gì, test task của Gradle và report, mỗi test method một instance mới được chứng minh bằng identity, @Nested và tên hiển thị của parameterized test trong JUnit 6, bẫy isEqualTo với BigDecimal và soft assertion cùng thông báo lỗi, @Mock với constructor injection so với @InjectMocks truyền null, stub, verify và ArgumentCaptor, UnnecessaryStubbingException và PotentialStubbingProblem dưới strict stubs, một Clock cố định, và nạp Mockito dưới dạng -javaagent để bỏ cảnh báo self-attaching.

[Spring Boot Basics] Spring Data JPA và Hibernate trong Spring Boot: Entity, @Id, @GeneratedValue và CRUD với JpaRepository

Spring Data JPA và Hibernate trên Spring Boot 4.1.1, với H2 và PostgreSQL: JPA khác Hibernate và Spring Data thế nào, entity đầu tiên với @Id, @GeneratedValue, @Column, @Enumerated và DDL sinh ra trên cả hai database, bẫy EnumType.ORDINAL, entity thiếu constructor không tham số, record và class final, id IDENTITY, SEQUENCE, AUTO, UUID qua các lần khởi động lại, log SQL và bind parameter, giá trị ddl-auto mặc định thật sự, CRUD với JpaRepository kèm SQL của từng method, dirty checking và first-level cache, thay repository in-memory và trả 409 khi trùng SKU, open-in-view và equals/hashCode cho entity.

[Spring Boot Basics] Logging trong Spring Boot: SLF4J, Logback, log level và ghi log ra file

Logging trong Spring Boot 4.1.1: SLF4J là facade và Logback là implementation, hai bridge jul-to-slf4j và log4j-to-slf4j, parameterised và fluent logging, ghi log exception, log level, cây logger và log group, --debug so với --trace, pattern dòng log mặc định, logging.file.name kèm rotation, logback-spring.xml với springProfile, MDC và chuyển sang Log4j2.