Deploy version mới, pipeline xanh, pod cũ bị terminate. Vài chục request trả 502 trong khoảng 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.

Đây là bài toán graceful shutdown: làm sao tắt server mà không đánh rơi request nào đang xử lý giữa chừng.


Chuyện gì xảy ra khi kill server giữa chừng

Khi một process bị kill đột ngột, 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. Client (hoặc load balancer phía trước) nhận RST, hiểu là connection bị cắt bất thường — tuỳ vào trạng thái request mà user thấy lỗi khác nhau.

Nếu request đang được server xử lý nhưng response chưa gửi, client nhận connection reset — trình duyệt hiện “ERR_CONNECTION_RESET”, load balancer trả 502 cho user. Nếu response đang gửi dở (chunked transfer hoặc streaming), client nhận data không đầy đủ — file download bị truncate, SSE stream đứt giữa chừng. Nếu server đang ghi database mà bị kill trước khi commit, transaction rollback — data không nhất quán nếu client nghĩ request đã thành công.

Với hệ thống có QPS vài nghìn, xác suất có request đang in-flight tại bất kỳ thời điểm nào gần như 100%. Kill process mà không drain connection trước đồ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 liên quan trực tiếp đến việc dừng process. SIGTERM (signal 15) là tín hiệu lịch sự — gửi cho process biết “hãy dừng lại”, nhưng process có quyền bắt (catch) tín hiệu này và quyết định dừng theo cách riêng. SIGKILL (signal 9) là tín hiệu cưỡng chế — kernel terminate process ngay lập tức, process không thể bắt hay ignore.

Kubernetes, systemd, Docker — tất cả đều tuân theo quy ước: gửi SIGTERM trước, chờ một khoảng thời gian (grace period), rồi mới gửi SIGKILL nếu process vẫn chưa tắt. Grace period mặc định trong Kubernetes là 30 giây, trong Docker là 10 giây. Khoảng thời gian này chính là cửa sổ để process hoàn thành công việc đang dở.

Vấn đề là rất nhiều ứng dụng không handle SIGTERM. Process nhận tín hiệu, không có handler, hành vi mặc định của SIGTERM là terminate — tức process vẫn chết ngay lập tức, không khác gì SIGKILL về mặt kết quả. Grace period 30 giây mà process chết sau 0 giây thì 30 giây đó vô nghĩa.


Quy trình shutdown đúng cách

Graceful shutdown có ba bước rõ ràng, theo đúng thứ tự. Bước một: ngừng nhận connection mới — server đóng listening socket, load balancer không gửi thêm request đến. Bước hai: chờ các request đang xử lý hoàn thành — in-flight request được phép chạy đến khi trả response xong. Bước ba: giải phóng tài nguyên và tắt — đóng database connection pool, flush log buffer, đóng message consumer, rồi process exit với code 0.

sequenceDiagram participant OS as OS / Kubelet participant App as Application participant LB as Load Balancer participant DB as Database OS->>App: SIGTERM App->>App: Stop accepting new connections App->>LB: Health check → unhealthy LB->>LB: Remove from target group App->>App: Wait for in-flight requests to finish App->>DB: Close connection pool App->>App: Exit(0) Note over OS,App: If still running after grace period OS->>App: SIGKILL

Thứ tự ba bước này quan trọng. 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 mà chỉ chờ in-flight, request mới vẫn đổ vào và server không bao giờ tắt được — grace period hết, SIGKILL đến.


Triển khai trong code

Node.js

Node.js có sẵn server.close() — method này 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 quan trọng với Node.js: keep-alive connection sẽ giữ server mở vô thời hạn vì server.close() chờ tất cả connection đóng, kể cả connection idle. Giải pháp là track connection và destroy connection idle khi shutdown:

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));

  // Destroy idle connections
  for (const conn of connections) {
    conn.end();  // gửi FIN, chờ response xong
  }

  setTimeout(() => {
    for (const conn of connections) {
      conn.destroy();  // cưỡng chế nếu vẫn mở
    }
    process.exit(1);
  }, 25_000);
});

Go

Go xử lý graceful shutdown rất tự nhiên nhờ contextsignal.NotifyContext:

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")
}

srv.Shutdown() trong Go stdlib đã xử lý đúng: ngừng nhận connection mới, chờ in-flight request hoàn thành, hoặc timeout theo context. Code ngắn gọn vì stdlib thiết kế tốt cho use case này.

Python

Với Python (ví dụ 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)

# Với uvicorn, dùng lifespan event
@asynccontextmanager
async def lifespan(app):
    yield
    # cleanup khi shutdown
    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ụ mục đích khác nhau. Liveness probe trả lời câu hỏi “process có còn sống không?” — nếu fail liên tục, kubelet restart container. Readiness probe trả lời “process có sẵn sàng nhận traffic không?” — nếu fail, pod bị loại khỏi Service endpoints, load balancer ngừng gửi request đến.

Khi graceful shutdown, readiness probe phải fail trước khi SIGTERM đến — hoặc ngay khi nhận SIGTERM. Điều này báo cho Kubernetes biết pod không nên nhận traffic nữa. Nếu readiness probe vẫn pass trong khi process đang shutdown, load balancer vẫn gửi request đến — request đó có thể 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, khi nhận SIGTERM, set readiness endpoint trả 503 ngay lập tức:

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 probe thì nên vẫn pass trong suốt quá trình shutdown — 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 — phản tác dụng hoàn toàn.


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 rất nhiều 502 khi deploy.

Khi pod bị xoá, Kubernetes làm hai việc song song: gửi SIGTERM cho container, và bắt đầu quy trình 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.

Trong khoảng thời gian đó, container đã nhận SIGTERM và bắt đầu shutdown, nhưng load balancer vẫn gửi request đến vì chưa cập nhật xong endpoint list. Request đến pod đang tắt — nếu server đã đóng listening socket, request bị refuse; nếu server đã exit, request bị connection reset.

sequenceDiagram participant API as API Server participant KP as kube-proxy participant LB as Ingress / LB 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 (t=2-5s) LB->>LB: Loại 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. Trick đơn giản nhưng hiệu quả: 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.

Tại sao sleep 7 mà không phải 3 hay 15? Con số này phụ thuộc vào tốc độ endpoint propagation trong cluster — 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. Đo bằng cách log timestamp SIGTERM received và timestamp request cuối cùng đến pod — diff giữa hai cái là thời gian propagation thực tế.

Lưu ý quan trọng: 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, nếu không SIGKILL đến trước khi app drain xong. Đặt 40 giây để có margin an toàn.


Drain database connection và message consumer

Graceful shutdown không chỉ là HTTP request. Database connection pool, message consumer (Kafka, RabbitMQ), background worker — tất cả cần drain đúng thứ tự.

Database connection pool nên đó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 sẽ nhận lỗi “connection pool closed” giữa chừng.

Message consumer (Kafka consumer, RabbitMQ subscriber) cần ngừng poll message mới khi nhận SIGTERM, nhưng phải xử lý xong message đang process. Với Kafka, gọi consumer.close() sẽ commit offset cuối cùng và rời consumer group — consumer khác trong group sẽ nhận partition rebalance và đảm nhận phần việc. Nếu kill consumer mà không commit offset, message cuối cùng sẽ bị reprocess khi consumer khác nhận partition — cần đảm bảo consumer idempotent.

// Kafka consumer shutdown
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 mọi request đều hoàn thành trong vài trăm millisecond. Background job có thể chạy vài phút, WebSocket connection có thể sống hàng giờ. Graceful shutdown cho những trường hợp này phức tạp hơn.

Với background worker (Sidekiq, Bull, Celery), pattern chuẩn là: 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 — hoặc tăng grace period đủ lớn, hoặc implement checkpoint trong job để job có thể dừng giữa chừng và resume sau. Checkpoint thường dùng cho batch processing: ghi lại vị trí đã xử lý đến đâu, lần chạy sau tiếp tục từ đó.

// Bull worker với graceful shutdown
worker.on("closing", () => {
  // Worker sẽ hoàn thành job hiện tại rồi dừng
  console.log("Worker closing, finishing current job");
});

process.on("SIGTERM", async () => {
  await worker.close(); // chờ job hiện tại xong
  process.exit(0);
});

WebSocket connection 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 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 vẫn đang chạy.

// Gửi close frame cho tất cả WebSocket connection
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ệ thống có hàng nghìn WebSocket connection, gửi close frame tuần tự sẽ chậm — 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 có thể xảy ra. Nếu endpoint có request chạy tối đa 60 giây (file upload lớn, report generation), deregistration delay nên ít nhất 60 giây. Đặt quá ngắn thì request dài bị cắt; đặt quá dài thì 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 vẫn 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 kiểm tra đơn giản nhất là dùng load test tool 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 khi deploy xong, kiểm tra output của hey
# Expect: 0 errors, 0 timeouts

Nếu thấy lỗi, log timestamp lỗi và so sánh với timestamp pod termination — xác định lỗi xảy ra vì race condition (cần tăng preStop sleep), vì grace period quá ngắn (cần tăng terminationGracePeriodSeconds), hay vì app không handle SIGTERM.

Một cách kiểm tra chi tiết hơn: gửi request có unique ID, server log request 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. Số request drop phải bằng 0 cho mọi lần deploy.

Đưa test này vào CI/CD pipeline — chạy sau mỗi thay đổi Kubernetes config hoặc shutdown logic. Regression trong graceful shutdown thường không bị phát hiện cho đến khi deploy production dưới load thật.


Sai lầm thường gặp

Sai lầm phổ biến nhất là đơn giản không handle SIGTERM. Rất nhiều ứng dụng Node.js, Python, thậm chí Go deploy lên production mà không có signal handler. Process nhận SIGTERM, hành vi mặc định là exit ngay — mọi in-flight request bị drop. Đây là lỗi mà mọi production service đều phải fix, không có ngoại lệ.

Grace period quá ngắn là sai lầm phổ biến thứ hai. Mặc định 30 giây nghe có vẻ 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 (report generation, file processing), SIGKILL đến trước khi request xong. Tính toán: terminationGracePeriodSeconds > preStop sleep + max request duration + buffer.

Không drain database connection hoặc drain sai thứ tự gây lỗi tinh vi. App đóng connection pool rồi mới chờ in-flight request — request đang query database nhận lỗi “pool closed”, trả 500 cho user. Hoặc app chờ in-flight 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 probe không phản ánh trạng thái shutdown là lỗi hay gặp ở team mới dùng Kubernetes. Readiness probe vẫn trả 200 trong khi app đang shutdown — load balancer vẫn gửi traffic đến, traffic đó bị refuse hoặc timeout. Khi nhận SIGTERM, readiness phải trả 503 ngay lập tức.

Cuối cùng, quên 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 — không phải optional.


Tóm tắ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 — nếu không handle, process chết ngay khi nhận signal, mọi request đang xử lý bị drop.

Trên Kubernetes, race condition giữa SIGTERM và endpoint removal gây 502 khi deploy. 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. terminationGracePeriodSeconds phải lớn hơn tổng preStop sleep cộng thời gian drain thực tế.

Database pool, message consumer, WebSocket — tất cả cần drain đúng thứ tự, ngược với thứ tự khởi tạo. Background worker ngừng nhận job mới, chờ job hiện tại xong. Load balancer cần deregistration delay đủ lớn cho request chậm nhất.

Thiết kế shutdown đúng mà không test dưới load thì vẫn có thể mất request. Gửi traffic liên tục trong khi deploy, đếm số lỗi — con số đó phải bằng 0. Đưa test này vào pipeline để phát hiện regression sớm, không phải khi user báo lỗi lúc 5 giờ chiều thứ Sáu.