Deploy version mới, pipeline xanh, pod cũ bị terminate. Vài chục request trả 502 trong 10 giây quanh thời điểm deploy — user đang giữa checkout hoặc form submit nhận connection reset. Code mới không có bug, nhưng pod cũ bị kill đúng lúc đang xử lý request.
Graceful shutdown làm đúng khoảnh khắc đó: server ngừng nhận việc mới, cho request đang chạy hoàn tất, rồi mới đóng tài nguyên và thoát.
Chuyện gì xảy ra khi kill server giữa chừng
Khi process bị kill đột ngột (SIGKILL), hệ điều hành thu hồi mọi tài nguyên ngay lập tức: file descriptor đóng, socket đóng, memory giải phóng. Với mỗi TCP connection đang mở, kernel gửi RST packet cho phía client.
Hậu quả tùy trạng thái request tại thời điểm bị kill:
- Request đang xử lý, chưa gửi response: client nhận connection reset, trình duyệt hiện
ERR_CONNECTION_RESET, load balancer trả 502. - Response đang gửi dở (chunked transfer, streaming): client nhận data không đầy đủ, file download bị truncate, SSE stream đứt giữa chừng.
- Server đang ghi database, chưa commit: transaction rollback, data không nhất quán trong khi client tưởng request đã thành công.
Với hệ thống QPS vài nghìn, xác suất có request in-flight tại bất kỳ thời điểm nào gần như 100%. Kill process không drain connection đồng nghĩa với mất request — không phải “có thể” mà là “chắc chắn”.
SIGTERM và SIGKILL: hai tín hiệu khác nhau hoàn toàn
Unix có hai tín hiệu dùng để dừng process:
SIGTERM (signal 15) là tín hiệu lịch sự — “hãy dừng lại”. Process có quyền bắt (catch) tín hiệu này và quyết định dừng theo cách riêng: drain connection, flush log, rồi mới exit.
SIGKILL (signal 9) là tín hiệu cưỡng chế — kernel terminate process ngay lập tức, không thể bắt hay ignore.
Kubernetes, systemd, Docker đều tuân theo quy ước: gửi SIGTERM trước → chờ một khoảng thời gian (grace period) → nếu process vẫn chưa tắt, gửi SIGKILL. Grace period mặc định trong Kubernetes là 30 giây, trong Docker là 10 giây.
Quy trình shutdown đúng: ba bước theo thứ tự
sequenceDiagram
participant K8s as Kubernetes / Kubelet
participant App as Application
participant LB as Load Balancer
participant DB as Database
K8s->>App: SIGTERM
App->>App: Bước 1: Ngừng nhận connection mới
App->>LB: Readiness → 503 unhealthy
LB->>LB: Gỡ khỏi target group
App->>App: Bước 2: Chờ in-flight requests hoàn thành
App->>DB: Bước 3: Đóng connection pool
App->>App: Exit(0)
Note over K8s,App: Nếu quá grace period
K8s->>App: SIGKILL
Thứ tự ba bước là quan trọng:
- Ngừng nhận connection mới: đóng listening socket, báo load balancer dừng gửi traffic.
- Chờ in-flight request hoàn thành: mọi request đã nhận phải được xử lý đến khi trả response xong.
- Giải phóng tài nguyên và exit: đóng database connection pool, flush log buffer, đóng message consumer, rồi exit code 0.
Nếu đóng database pool trước khi in-flight request xong → request đang query sẽ lỗi. Nếu không ngừng nhận connection mới → request mới vẫn vào, server không bao giờ tắt được, hết grace period → SIGKILL.
Triển khai trong code
Node.js
Node.js có sẵn server.close() — ngừng nhận connection mới và chờ connection hiện tại đóng trước khi gọi callback:
import { createServer } from "node:http";
const server = createServer((req, res) => {
// handle request
});
server.listen(3000);
process.on("SIGTERM", () => {
console.log("SIGTERM received, shutting down gracefully");
server.close(() => {
console.log("All connections closed, exiting");
process.exit(0);
});
// Force exit nếu quá lâu
setTimeout(() => {
console.error("Forced exit after timeout");
process.exit(1);
}, 25_000);
});
Có một gotcha với Node.js: keep-alive connection sẽ giữ server mở vô hạn vì server.close() chờ tất cả connection đóng, kể cả connection idle. Cần track và chủ động đóng connection idle:
const connections = new Set();
server.on("connection", (conn) => {
connections.add(conn);
conn.on("close", () => connections.delete(conn));
});
process.on("SIGTERM", () => {
server.close(() => process.exit(0));
// Gửi FIN cho tất cả connection, chờ response xong
for (const conn of connections) {
conn.end();
}
// Sau timeout, destroy connection còn mở
setTimeout(() => {
for (const conn of connections) {
conn.destroy();
}
process.exit(1);
}, 25_000);
});
Go
Go xử lý graceful shutdown rất tự nhiên nhờ context và signal.NotifyContext. srv.Shutdown() trong stdlib đã làm đúng: ngừng nhận connection mới, chờ in-flight request, hoặc timeout theo context.
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM)
defer stop()
srv := &http.Server{Addr: ":8080", Handler: mux}
go func() {
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
<-ctx.Done()
log.Println("SIGTERM received, shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Fatalf("shutdown: %v", err)
}
log.Println("Server stopped cleanly")
}
Python
Với Python (uvicorn hoặc gunicorn), signal handler cần cẩn thận vì Python có GIL và signal chỉ được xử lý trên main thread:
import signal
import asyncio
from contextlib import asynccontextmanager
shutdown_event = asyncio.Event()
def handle_sigterm(signum, frame):
shutdown_event.set()
signal.signal(signal.SIGTERM, handle_sigterm)
@asynccontextmanager
async def lifespan(app):
yield
await drain_connections()
await close_db_pool()
Gunicorn và uvicorn đều handle SIGTERM mặc định — gunicorn gửi SIGTERM cho worker, worker dừng nhận request mới và chờ request hiện tại xong. Nhưng nếu viết server từ đầu hoặc dùng framework khác, phải tự implement.
Health check: readiness và liveness
Trong Kubernetes, hai loại health check phục vụ hai mục đích khác nhau:
- Liveness probe: “process có còn sống không?” — nếu fail liên tục, kubelet restart container.
- Readiness probe: “process có sẵn sàng nhận traffic không?” — nếu fail, pod bị loại khỏi Service endpoints.
Khi graceful shutdown, readiness phải fail ngay khi nhận SIGTERM để báo cho Kubernetes dừng gửi traffic. Nếu readiness vẫn pass trong khi process đang shutdown, load balancer tiếp tục gửi request và request đó bị drop.
readinessProbe:
httpGet:
path: /healthz/ready
port: 8080
periodSeconds: 5
failureThreshold: 1
livenessProbe:
httpGet:
path: /healthz/live
port: 8080
periodSeconds: 10
failureThreshold: 3
Trong code:
var isReady atomic.Bool
func readinessHandler(w http.ResponseWriter, r *http.Request) {
if !isReady.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}
// Trong signal handler:
isReady.Store(false) // readiness fail ngay khi nhận SIGTERM
Liveness thì ngược lại — nên vẫn pass trong suốt quá trình shutdown vì process vẫn sống, đang hoàn thành công việc. Nếu liveness fail, kubelet có thể restart container trước khi in-flight request xong.
Race condition giữa SIGTERM và endpoint removal
Đây là vấn đề tinh vi nhất trong graceful shutdown trên Kubernetes, và là nguyên nhân gốc của hầu hết 502 khi deploy.
Khi pod bị xóa, Kubernetes làm hai việc song song: gửi SIGTERM cho container, và bắt đầu loại pod khỏi Service endpoints. Quy trình loại endpoint mất thời gian: API server cập nhật Endpoints object → kube-proxy trên mỗi node cập nhật iptables/IPVS rules → external load balancer (Ingress controller, ALB) cập nhật target group. Toàn bộ chuỗi này có thể mất vài giây.
sequenceDiagram
participant API as API Server
participant KP as kube-proxy
participant LB as Ingress / Load Balancer
participant Pod as Pod
API->>Pod: SIGTERM (t=0)
API->>KP: Update Endpoints (t=0)
Note over Pod: Bắt đầu shutdown
Pod->>Pod: server.close() (t=0.1s)
Note over KP: Cập nhật iptables (t=1-3s)
LB->>Pod: Request mới (t=0.5s) → 502!
Note over LB: Cập nhật target group (t=2-5s)
LB->>LB: Gỡ pod khỏi pool (t=5s)
Note over Pod: Không còn request mới
Khoảng từ t=0 đến t=5s là “vùng nguy hiểm”: server đang tắt nhưng traffic vẫn đến.
preStop hook: giải pháp cho race condition
Kubernetes cho phép chạy một lệnh trước khi gửi SIGTERM cho container, gọi là preStop hook. Cách dùng đơn giản: cho preStop sleep vài giây, đủ thời gian để endpoint removal hoàn thành trước khi container bắt đầu shutdown thật sự.
containers:
- name: app
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 7"]
terminationGracePeriodSeconds: 40
Luồng thực tế khi có preStop: pod marked for deletion → Kubernetes bắt đầu loại endpoint (song song) → preStop chạy sleep 7 → sau 7 giây, SIGTERM gửi cho container → container bắt đầu graceful shutdown → lúc này endpoint đã được loại xong, không còn traffic mới.
Sleep bao lâu? Với cluster nhỏ, 3-5 giây thường đủ; với cluster lớn nhiều node hoặc external load balancer chậm, có thể cần 10 giây. Cách đo: log timestamp khi nhận SIGTERM và timestamp của request cuối cùng đến pod — diff giữa hai cái là thời gian propagation thực tế.
terminationGracePeriodSeconds tính từ khi preStop bắt đầu, không phải từ khi SIGTERM gửi. Nếu preStop sleep 7 giây và app cần 25 giây để drain, tổng cần 32 giây — terminationGracePeriodSeconds phải lớn hơn 32. Đặt 40 giây để có margin an toàn.Drain database connection và message consumer
Graceful shutdown không chỉ là HTTP request. Mọi tài nguyên cần drain đúng thứ tự.
Database connection pool: đóng sau khi tất cả in-flight request hoàn thành, vì request đang xử lý có thể cần query database. Trình tự đúng: ngừng nhận HTTP request mới → chờ in-flight request xong → đóng connection pool → exit. Nếu đóng pool trước, request đang chạy nhận lỗi “connection pool closed”.
Message consumer (Kafka, RabbitMQ): ngừng poll message mới khi nhận SIGTERM, nhưng phải xử lý xong message đang process. Với Kafka, consumer.Close() sẽ commit offset cuối cùng và rời consumer group — consumer khác trong group sẽ nhận partition và đảm nhận phần việc. Nếu kill consumer không commit offset, message cuối cùng bị reprocess.
go func() {
<-ctx.Done()
log.Println("Stopping consumer, finishing current message")
consumer.Close() // commit offset, leave group
}()
Redis connection, gRPC client, external API client — tất cả nên có Close() method và được gọi trong shutdown sequence. Thứ tự close ngược với thứ tự init: resource nào khởi tạo cuối thì đóng trước.
Long-running task và WebSocket
Không phải request nào cũng xong trong vài millisecond.
Background worker (Sidekiq, Bull, Celery): khi nhận SIGTERM, worker ngừng nhận job mới từ queue, nhưng chờ job đang chạy hoàn thành. Nếu job chạy lâu hơn grace period, có hai lựa chọn: tăng grace period đủ lớn, hoặc implement checkpoint để job có thể dừng giữa chừng và resume sau.
// Bull worker với graceful shutdown
process.on("SIGTERM", async () => {
await worker.close(); // chờ job hiện tại xong
process.exit(0);
});
WebSocket: cần thông báo cho client trước khi đóng. Gửi close frame với reason code 1001 (Going Away) để client biết nên reconnect sang server khác thay vì coi là lỗi. Client nhận close frame, mở connection mới, load balancer route đến pod khác.
for _, ws := range activeConnections {
ws.WriteControl(
websocket.CloseMessage,
websocket.FormatCloseMessage(1001, "server shutting down"),
time.Now().Add(5*time.Second),
)
}
Grace period cần tính đủ cho cả thời gian gửi close frame và chờ client acknowledge. Với hàng nghìn WebSocket connection, gửi close frame tuần tự sẽ chậm — cần gửi song song hoặc batch.
Connection draining ở load balancer
Bên cạnh application-level drain, load balancer cũng có cơ chế drain riêng. AWS ALB có “deregistration delay”: khi target bị loại khỏi target group, ALB vẫn giữ connection hiện tại mở thêm một khoảng thời gian (mặc định 300 giây), chỉ ngừng gửi request mới. In-flight request vẫn được phép hoàn thành.
Giá trị deregistration delay nên bằng hoặc lớn hơn thời gian request chậm nhất. Nếu endpoint có request chạy 60 giây (file upload lớn, report generation), delay nên ít nhất 60 giây. Đặt quá ngắn → request dài bị cắt; đặt quá dài → deploy chậm vì phải chờ drain.
Nginx Ingress controller trên Kubernetes cũng có config tương tự: proxy-read-timeout và upstream keepalive ảnh hưởng đến cách Nginx handle connection khi backend pod bị loại. Nếu Nginx giữ keepalive connection đến pod đang tắt, request mới trên connection đó sẽ fail.
Kiểm tra graceful shutdown
Thiết kế shutdown đúng trên lý thuyết chưa đủ — cần kiểm chứng rằng thực tế không mất request khi deploy.
Cách đơn giản nhất: gửi traffic liên tục trong khi trigger deploy, rồi đếm số request lỗi:
# Terminal 1: gửi traffic liên tục
hey -z 120s -c 50 -q 100 http://service.example.com/api/health
# Terminal 2: trigger rolling deploy
kubectl rollout restart deployment/my-app
# Sau deploy, kiểm tra output của hey → expect: 0 errors
Cách chi tiết hơn: gửi request có unique ID, server log ID khi bắt đầu và khi trả response. Sau deploy, diff hai tập hợp — request nào có log “bắt đầu” nhưng không có log “trả response” là request bị drop. Con số này phải bằng 0.
Sai lầm thường gặp
Không handle SIGTERM: sai lầm phổ biến nhất. Rất nhiều ứng dụng Node.js, Python, thậm chí Go deploy lên production không có signal handler. Process nhận SIGTERM → exit ngay → mất request. Đây là lỗi mà mọi production service phải fix.
Grace period quá ngắn: mặc định 30 giây nghe nhiều, nhưng nếu preStop sleep 10 giây thì chỉ còn 20 giây cho app drain. Nếu app có request chạy 25 giây, SIGKILL đến trước khi request xong. Công thức: terminationGracePeriodSeconds > preStop sleep + max request duration + buffer.
Drain sai thứ tự: đóng connection pool rồi mới chờ in-flight request → request đang query database nhận lỗi “pool closed”. Hoặc chờ HTTP request xong nhưng quên Kafka consumer vẫn đang process message → message bị xử lý dở, reprocess khi consumer khác nhận partition.
Readiness không phản ánh trạng thái shutdown: readiness vẫn trả 200 trong khi app đang shutdown → load balancer vẫn gửi traffic → traffic bị refuse hoặc timeout. Khi nhận SIGTERM, readiness phải trả 503 ngay lập tức.
Không test: team implement graceful shutdown, deploy lên staging, “có vẻ ổn” vì staging không có traffic. Lên production dưới load thật mới phát hiện race condition hoặc timing sai. Test dưới load là bắt buộc.
Tổng kết
Graceful shutdown là ba bước theo thứ tự: ngừng nhận connection mới → chờ in-flight request hoàn thành → giải phóng tài nguyên rồi exit. Handle SIGTERM trong code là bắt buộc với mọi production service.
Trên Kubernetes, race condition giữa SIGTERM và endpoint removal là nguyên nhân chính gây 502. preStop hook sleep 5-10 giây giải quyết bằng cách cho endpoint propagation hoàn thành trước khi app bắt đầu shutdown. Luôn tính terminationGracePeriodSeconds đủ lớn hơn tổng preStop sleep + thời gian drain.
Mấy nguyên tắc chính:
- Handle SIGTERM: không có handler = mất request, không có ngoại lệ
- preStop sleep: đợi endpoint removal hoàn tất trước khi shutdown
- Drain đúng thứ tự: HTTP trước, database pool sau; init cuối thì close trước
- Readiness fail nhanh: trả 503 ngay khi nhận SIGTERM
- Liveness vẫn pass: process còn sống trong lúc drain
- Test dưới load: gửi traffic liên tục khi deploy, số lỗi phải bằng 0
Bài toán này không nên được phát hiện bởi user lúc 5 giờ chiều thứ Sáu, nhất là khi pipeline có thể bắt nó trước.