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.
![]()
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:
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.zipBê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:
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@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));
}
}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)
);spring.application.name=demo
spring.jpa.open-in-view=falsespring.datasource.url=jdbc:postgresql://localhost:5441/shop
spring.datasource.username=shop
spring.jpa.hibernate.ddl-auto=validateKhô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:
./gradlew clean build --console=plain
ls -l build/libs> 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.jarbuild 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:
./gradlew clean bootJar --console=plain> Task :clean
> Task :compileJava
> Task :processResources
> Task :classes
> Task :resolveMainClassName
> Task :bootJarTrong 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:
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./mvnw package -DskipTestsMỗi tool để lại gì, từ cùng một source code:
build/libs/
└── demo-0.0.1-SNAPSHOT.jar 58,557,202 bytes, executabletarget/
├── demo-0.0.1-SNAPSHOT.jar 58,559,651 bytes, executable
└── demo-0.0.1-SNAPSHOT.jar.original 9,054 bytes, plainVớ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:
[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.gradle và version 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:
tasks.named('bootJar') {
archiveFileName = 'app.jar'
}<build>
<finalName>app</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>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:
unzip -l build/libs/demo-0.0.1-SNAPSHOT.jarRút gọn còn một hai entry cho mỗi loại:
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 filesMọ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:
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 imagesGầ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:
unzip -p build/libs/demo-0.0.1-SNAPSHOT.jar META-INF/MANIFEST.MFManifest-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| Attribute | Dùng để làm gì |
|---|---|
Main-Class | Class mà JVM khởi động: JarLauncher của Spring Boot, không phải class của bạn |
Start-Class | Class @SpringBootApplication của bạn, launcher gọi nó khi class loader đã sẵn sàng |
Spring-Boot-Classes | Nơi launcher tìm class đã compile và resource của bạn |
Spring-Boot-Lib | Nơi nó tìm các jar dependency |
Spring-Boot-Classpath-Index | classpath.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-Index | layers.idx: chia entry thành dependencies, spring-boot-loader, snapshot-dependencies và application 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:
java -cp build/libs/demo-0.0.1-SNAPSHOT.jar com.example.demo.DemoApplicationError: Could not find or load main class com.example.demo.DemoApplication
Caused by: java.lang.ClassNotFoundException: com.example.demo.DemoApplicationClass 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ó.

Chạy JAR bằng java -jar
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8141Nhữ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:
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àjavanào nằm trênPATH, 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 setnghĩa là đang dùng H2. Bên dưới, Flyway inSuccessfully applied 1 migration to schema "PUBLIC".Tomcat started on port 8141xác nhận port mà server thực sự bind.Started DemoApplication in 1.843 secondslà 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:
curl -s -i -H 'Content-Type: application/json' -d '{"name":"Mechanical keyboard","sku":"KB-01","price":89.90}' http://localhost:8141/api/productsHTTP/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}curl -s http://localhost:8141/actuator/health{"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:
docker run -d --name sb-a41-pg -e POSTGRES_USER=shop -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=shop -p 5441:5432 postgres:18Chỉ bật profile mà không làm gì thêm sẽ cho thấy vì sao password phải đến từ đâu đó:
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=postgres --server.port=81412026-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ừ đâu | Lệnh |
|---|---|
| Command-line argument | java -jar demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=postgres |
| Environment variable | SPRING_PROFILES_ACTIVE=postgres java -jar demo-0.0.1-SNAPSHOT.jar |
| JVM system property | java -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:
SPRING_PROFILES_ACTIVE=postgres SPRING_DATASOURCE_PASSWORD=secret SERVER_PORT=8141 java -jar build/libs/demo-0.0.1-SNAPSHOT.jar2026-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_PASSWORD và server.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:
deploy/
├── demo-0.0.1-SNAPSHOT.jar
└── config/
└── application.properties spring.datasource.password=secretcd deploy && java -jar demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=postgres --server.port=81412026-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:
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ào | Exit status |
|---|---|
kill <pid> (SIGTERM) | 143 |
Ctrl+C (SIGINT) | 130 |
| Khởi động thất bại, ví dụ thiếu password | 1 |
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:
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8141 -Xmx256mjcmd 70676 VM.command_line | grep -E 'jvm_args|java_command'
jcmd 70676 VM.flags | tr ' ' '\n' | grep '^-XX:MaxHeapSize'java_command: build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8141 -Xmx256m
-XX:MaxHeapSize=4294967296Cùng hai lệnh jcmd đó với JVM được khởi động có tùy chọn đặt ở trước:
java -Xmx256m -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8141jvm_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:
docker run --rm eclipse-temurin:21-jre sh -c 'cat /etc/os-release | head -4; java -version'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:
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 8080chỉ ghi chú port. Nó không publish gì cả;-pmớ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:
.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.
docker build -t sb-a41-demo:1 .Rút gọn còn các bước:
#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 donedocker images sb-a41-demo
docker images eclipse-temurinIMAGE 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.
docker run -d --name sb-a41-app -p 8141:8080 sb-a41-demo:1
curl -s -i http://localhost:8141/api/products/1Sau khi tạo bàn phím bằng đúng request POST như trên host:
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.jar và demo-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 vì . đứ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:
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.jarDocker 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:
docker exec sb-a41-app ps -o pid,ppid,user,args
docker exec sb-a41-shell ps -o pid,ppid,user,args PID PPID USER COMMAND
1 0 root java -jar /app/app.jar
58 0 root ps -o pid,ppid,user,args 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,argsdocker 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:
time docker stop sb-a41-appsb-a41-app
docker stop sb-a41-app 0.01s user 0.00s system 6% cpu 0.176 totaltime docker stop sb-a41-shellsb-a41-shell
docker stop sb-a41-shell 0.01s user 0.01s system 0% cpu 10.235 total| Dạng exec | Dạng shell | |
|---|---|---|
| PID 1 | java | /bin/sh (dash 0.5.12), java là PID 7 |
docker stop mất | 0.176 s | 10.235 s |
| Log shutdown | từ Commencing graceful shutdown… tới HikariPool-1 - Shutdown completed. | không có gì sau Started DemoApplication |
| Exit code | 143 (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 groupadd và useradd có sẵn trong /usr/sbin:
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:
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=$?'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=1Dò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.… và /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ả:
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
buildbắ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
COPYchí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 testbỏ qua test. Test thuộc về CI hoặc./gradlew buildtrên host, trước khi build image, không phải mỗi lầndocker build.--no-daemongiữ 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:
docker build --progress=plain -t sb-a41-demo:3 .#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.8sSau đó 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:
#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.1sMọ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ì:
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'165M /root/.gradle/wrapper
65M /root/.gradle/caches
23
285Bả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:
COPY src src
RUN ./gradlew bootJar -x test --no-daemon
RUN --mount=type=cache,target=/root/.gradle/caches ./gradlew bootJar -x test --no-daemonLầ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:
| Image | DISK USAGE | CONTENT SIZE |
|---|---|---|
eclipse-temurin:21-jdk | 750MB | 227MB |
eclipse-temurin:21-jre | 479MB | 118MB |
sb-a41-demo:3, image multi-stage | 586MB | 166MB |
Riêng stage build (--target build) | 1.4GB | 502MB |
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.

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:
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 ' 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 run | Heap 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=512m | 134,217,728 byte = 128 MiB |
--memory=512m -e JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=75 | 402,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:
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:3Database 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:
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 đó:
docker exec sb-a41-db psql -U shop -d shop -c 'select id, name, sku, price from products' 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:
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 refusedCù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:
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: .buildDockerfilemulti-stage trong cùng thư mục.db-data:/var/lib/postgresqllà mount point chopostgres:18, image này khai báoVOLUME /var/lib/postgresqlvà giữ data trong/var/lib/postgresql/18/docker. Một volume gắn vào đường dẫn cũ/var/lib/postgresql/datakhiến container thoát ngay vớiError: in 18+, these Docker images are configured to store database data in a format which is compatible with "pg_ctlcluster".pg_isready -h localhostkiể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áoaccepting connectionstrong khi cách dùng TCP vẫn làno response, rồi server khởi động lại.condition: service_healthygiữ ứng dụng lại cho tới khi healthcheck đó pass. Không có condition,depends_onchỉ chờ container khởi động.- Healthcheck của ứng dụng gọi
/actuator/healthbằngcurl, có sẵn trong image Temurin, nêndocker compose psbáo được ứng dụng healthy chứ không chỉ đang chạy. - Tên service
dblà hostname trong JDBC URL, giống vai trò của tên container trên network thường.
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à:
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 Waiting và Healthy là condition: service_healthy đang hoạt động: container ứng dụng chỉ khởi động sau khi healthcheck của database pass.
docker compose -p sb-a41 psNAME 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/tcpdocker compose -p sb-a41 logs appNhững dòng cho thấy profile, kết nối và migration:
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:
curl -s -i -H 'Content-Type: application/json' -d '{"name":"USB-C hub","sku":"HUB-07","price":39.00}' http://localhost:8141/api/productsHTTP/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:
docker compose -p sb-a41 down
docker volume ls --filter name=sb-a41
docker compose -p sb-a41 up -d 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-datadown 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": 1 và Schema "public" is up to date. No migration necessary., và sản phẩm vẫn còn đó:
curl -s http://localhost:8141/api/products[{"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.

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 trongcompose.yamlkhi ứ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.