PID 1, SIGTERM và graceful shutdown
Tiếp đi: docker stop thì order-service bên trong tắt như thế nào?
Câu hỏi này dẫn tới một lỗi rất phổ biến: container mất 10 giây mới tắt, và request đang xử lý bị cắt ngang. Gốc rễ là PID 1 và signal.
1. Bài toán
Một bạn viết Dockerfile để truyền JVM option cho tiện:
ENTRYPOINT java $JAVA_OPTS -jar app.jar
time docker stop order-1
real 0m10.4s
Log Spring Boot không có dòng "Graceful shutdown" nào. Đơn hàng đang ghi dở thì client nhận lỗi.
2. Vấn đề
docker stop làm hai bước:
1. gửi SIGTERM cho PID 1 của container
2. đợi (mặc định 10s) → nếu còn sống: SIGKILL
Dòng ENTRYPOINT trên là shell form. Docker chạy nó thành /bin/sh -c "java $JAVA_OPTS -jar app.jar":
PID 1 /bin/sh -c "java ..." ← nhận SIGTERM, KHÔNG chuyển tiếp cho con
└── PID 7 java -jar app.jar ← không biết gì, chạy tiếp
10s sau: SIGKILL → chết ngay, không cleanup
Thêm một điểm: PID 1 trong Linux đặc biệt. Kernel không áp dụng hành động mặc định của signal cho PID 1; nếu process không tự đăng ký handler thì SIGTERM bị bỏ qua. JVM có đăng ký handler nên java làm PID 1 thì ổn; sh thì không.
3. Giải pháp
- Dùng exec form (JSON array): Docker chạy thẳng java, java là PID 1, nhận SIGTERM trực tiếp.
- Nếu cần script khởi động: cuối script dùng
exec java ...để java thay thế shell, giữ PID 1. - Bật graceful shutdown của Spring Boot: ngừng nhận request mới, xử lý xong request đang chạy rồi mới đóng.
- Đặt timeout của Docker lớn hơn timeout graceful của Spring.
- Tùy chọn: init process nhỏ (
tini,docker run --init) làm PID 1 để chuyển tiếp signal và dọn zombie process.
4. Code từng bước
Bước 1: Exec form, truyền option qua JAVA_TOOL_OPTIONS
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=builder /app/target/order-service-1.0.0.jar app.jar
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0"
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
JVM tự đọc JAVA_TOOL_OPTIONS, nên không cần shell để expand $JAVA_OPTS.
Bước 2: Nếu bắt buộc dùng script
#!/bin/sh
# entrypoint.sh
set -e
echo "Starting order-service, profile=${SPRING_PROFILES_ACTIVE:-default}"
exec java $JAVA_OPTS -jar /app/app.jar "$@"
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
ENTRYPOINT ["/app/entrypoint.sh"]
exec thay process shell bằng java (cùng PID 1). Thiếu exec là quay về lỗi cũ.
Bước 3: Graceful shutdown trong Spring Boot (application.properties):
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=20s
Từ Spring Boot 3.4, server.shutdown=graceful là mặc định, nhưng ghi rõ vẫn tốt. Cleanup riêng (flush buffer, đóng consumer) đặt trong @PreDestroy:
@Component
public class OrderEventPublisher {
private static final Logger log = LoggerFactory.getLogger(OrderEventPublisher.class);
@PreDestroy
public void flushPending() {
log.info("Flushing pending order events before shutdown");
// gửi nốt event còn trong buffer
}
}
Bước 4: Timeout của Docker > timeout của Spring
docker stop -t 30 order-1
# compose.yaml
services:
order-service:
build: .
stop_grace_period: 30s
init: true # tùy chọn: dùng tini làm PID 1
Test: gửi request chậm rồi stop ngay.
curl localhost:8080/orders/slow & # endpoint giả lập xử lý 5s
docker stop -t 30 order-1
docker logs order-1 | tail
# Commencing graceful shutdown. Waiting for active requests to complete
# Graceful shutdown complete
Kiểm tra PID 1 là ai:
docker exec order-1 cat /proc/1/cmdline | tr '\0' ' '
# java -jar app.jar ✅
# /bin/sh -c java ... ❌
5. Cách hoạt động
docker stop -t 30 order-1
│
│ SIGTERM → PID 1 (java)
▼
JVM shutdown hook → Spring context close
│
├── Tomcat ngừng nhận request mới
├── đợi request đang chạy xong (≤ 20s)
├── @PreDestroy, đóng HikariCP, Redis connection
▼
JVM exit (code 143 = 128 + 15)
│
trong 30s? ── có ──► container Exited (143), sạch sẽ
│
└── không ──► SIGKILL → Exited (137), cắt ngang
6. So sánh
Exec form ["java","-jar","app.jar"] | Shell form java -jar app.jar | |
|---|---|---|
| PID 1 | java | /bin/sh -c |
| Nhận SIGTERM | ✅ trực tiếp | ❌ shell không chuyển tiếp |
Expand $VAR | ❌ (không có shell) | ✅ |
Thời gian docker stop | Vài giây (graceful) | Đủ timeout rồi SIGKILL |
| Signal | Ý nghĩa | Exit code |
|---|---|---|
| SIGTERM (15) | Xin tắt lịch sự, bắt được | 143 |
| SIGKILL (9) | Giết ngay, không bắt được | 137 |
| SIGINT (2) | Ctrl+C | 130 |
7. Khi nào nên dùng / không nên dùng
- Luôn dùng exec form cho ENTRYPOINT/CMD.
- Nên dùng
init: true/tini khi app sinh process con (gọi script, chạy tool ngoài) để dọn zombie. - Nên:
stop_grace_period(Docker) /terminationGracePeriodSeconds(K8s) >timeout-per-shutdown-phase. - Không nên: shell form; script khởi động thiếu
exec; dựa vào việc "container chết thì thôi" cho tác vụ đang ghi dở.
Nhưng exec form không đọc được $JAVA_OPTS, vậy mình muốn đổi option JVM theo môi trường thì làm sao? Viết ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] được không?
Bạn đặt câu hỏi đúng chỗ, nhưng cách đó lại đưa sh lên làm PID 1, quay về lỗi cũ. Có ba cách tốt hơn:
JAVA_TOOL_OPTIONS: JVM tự đọc biến này, không cần shell. Đổi lúc chạy:docker run -e JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=70" .... Đơn giản nhất.- Vẫn muốn
sh -c: thêmexecvào trong chuỗi:ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar app.jar"] - Script có
exec java ... "$@"như Bước 2.
Config của Spring (port, URL DB) thì không cần dính tới dòng lệnh: Spring Boot đọc thẳng biến môi trường như SERVER_PORT, SPRING_DATASOURCE_URL.
Phỏng vấn hỏi PID 1, SIGTERM, graceful shutdown trong container thì trả lời sao?
Q1. Chuyện gì xảy ra khi chạy docker stop?
🇻🇳 Tiếng Việt:
Docker gửi SIGTERM cho PID 1 trong container, đợi grace period (mặc định 10 giây), nếu process chưa thoát thì gửi SIGKILL. Vì vậy app phải là PID 1 hoặc có init process chuyển tiếp signal, và phải tắt xong trong khoảng thời gian đó.
🇬🇧 English:
Docker sends SIGTERM to PID 1 in the container, waits for the grace period, 10 seconds by default, and then sends SIGKILL if the process is still running. So the application must be PID 1 or run behind an init process that forwards signals, and it must finish shutting down within that period.
Q2. Exec form khác shell form thế nào?
🇻🇳 Tiếng Việt:
Exec form là JSON array, Docker chạy thẳng executable nên java là PID 1 và nhận SIGTERM. Shell form chạy qua
/bin/sh -c, shell làm PID 1 và thường không chuyển tiếp signal, nên app bị SIGKILL sau timeout. Cần biến môi trường thì em dùngJAVA_TOOL_OPTIONShoặc script cóexec.
🇬🇧 English:
The exec form is a JSON array, so Docker runs the executable directly and java becomes PID 1 and receives SIGTERM. The shell form runs through
/bin/sh -c, so the shell is PID 1 and usually does not forward signals, and the app is killed after the timeout. For variables I useJAVA_TOOL_OPTIONSor a script that ends withexec.
Q3. Graceful shutdown cho Spring Boot trong container cấu hình thế nào?
🇻🇳 Tiếng Việt:
Em bật
server.shutdown=gracefulvà đặtspring.lifecycle.timeout-per-shutdown-phase, ví dụ 20 giây. Grace period của Docker hoặcterminationGracePeriodSecondscủa Kubernetes phải lớn hơn con số đó. Khi nhận SIGTERM, Tomcat ngừng nhận request mới, đợi request đang chạy xong, rồi đóng connection pool.
🇬🇧 English:
I enable
server.shutdown=gracefuland setspring.lifecycle.timeout-per-shutdown-phase, for example 20 seconds. The Docker grace period or KubernetesterminationGracePeriodSecondsmust be longer than that. On SIGTERM, Tomcat stops accepting new requests, waits for in-flight requests to finish and then closes the connection pools.
Cách nhớ ngắn gọn
docker stop= SIGTERM → đợi 10s → SIGKILL.- Exec form: java là PID 1. Shell form: sh nuốt signal.
- Script khởi động phải kết thúc bằng
exec. - Grace period của Docker/K8s > timeout graceful của Spring.
- Exit 143 = tắt sạch; 137 = bị giết.
Tự kiểm tra
Vì sao ENTRYPOINT java -jar app.jar làm docker stop mất 10 giây?
Shell form: /bin/sh -c là PID 1, không chuyển SIGTERM cho java, Docker đợi hết timeout rồi SIGKILL.
exec trong script khởi động có tác dụng gì?
Thay process shell bằng java, giữ nguyên PID 1, nên java nhận signal trực tiếp.
Exit code 143 và 137 nghĩa là gì?
143 = thoát do SIGTERM (tắt bình thường). 137 = bị SIGKILL (hết grace period hoặc OOM).
init: true / tini giải quyết gì?
Một init nhỏ làm PID 1, chuyển tiếp signal cho app và dọn zombie process.
Graceful timeout Spring 20s, grace period Docker 10s thì sao?
Docker SIGKILL ở giây thứ 10, request đang xử lý vẫn bị cắt. Grace period phải lớn hơn, ví dụ 30s.