PID 1, SIGTERM và graceful shutdown

3 câu hỏi~25 phútTừ khóa: PID 1, SIGTERM, SIGKILL, exec form, shell form, exec "$@", tini, --init, server.shutdown=graceful, docker stop -t, stop_grace_period

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

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 1java/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 stopVài giây (graceful)Đủ timeout rồi SIGKILL
SignalÝ nghĩaExit code
SIGTERM (15)Xin tắt lịch sự, bắt được143
SIGKILL (9)Giết ngay, không bắt được137
SIGINT (2)Ctrl+C130

7. Khi nào nên dùng / không nên dùng

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:

  1. 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.
  2. Vẫn muốn sh -c: thêm exec vào trong chuỗi:
    ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar app.jar"]
  3. 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ùng JAVA_TOOL_OPTIONS hoặ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 use JAVA_TOOL_OPTIONS or a script that ends with exec.

Q3. Graceful shutdown cho Spring Boot trong container cấu hình thế nào?

🇻🇳 Tiếng Việt:

Em bật server.shutdown=graceful và đặt spring.lifecycle.timeout-per-shutdown-phase, ví dụ 20 giây. Grace period của Docker hoặc terminationGracePeriodSeconds củ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=graceful and set spring.lifecycle.timeout-per-shutdown-phase, for example 20 seconds. The Docker grace period or Kubernetes terminationGracePeriodSeconds must 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


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.