6 min read

Graceful shutdown: lợi ích và lý do phải có

Graceful shutdown: lợi ích và lý do phải có

Triển khai bản mới, scale xuống pod, hay cần dừng dịch vụ để bảo trì - tất cả đều đụng đến bài toán tắt hệ thống. Nếu ta “giết” tiến trình ngay lập tức, request đang xử lý bị cắt ngang, dữ liệu có thể dở dang, hàng đợi bỏ dở, connection rò rỉ. Graceful shutdown là kỹ thuật giúp dịch vụ dừng có trật tự: ngừng nhận việc mới, chờ nốt việc đang làm, đóng tài nguyên, và báo trạng thái rõ ràng cho hạ tầng xung quanh.

Hãy tưởng tượng quán cà phê đến giờ đóng cửa. Thay vì tắt đèn đột ngột, barista ngừng nhận order mới, pha nốt những ly còn lại, hoàn tiền nếu cần, dọn máy, rồi khóa cửa. Đó chính là tinh thần của graceful shutdown.

Graceful shutdown là gì?

Là quy trình dừng dịch vụ có kiểm soát khi nhận tín hiệu kết thúc (SIGTERM, SIGINT, hook của systemd/Kubernetes):

  1. Không nhận thêm yêu cầu mới (drain).
  2. Chờ hoặc hủy an toàn các tác vụ đang xử lý theo deadline.
  3. Đóng kết nối, flush buffer/log, commit/rollback giao dịch.
  4. Ghi lại metric cuối cùng, báo trạng thái “đã dừng” cho hệ thống giám sát.

Lợi ích cụ thể

1) Bảo toàn dữ liệu và tính đúng

Không có request nào bị “chặt đôi”. Giao dịch đang chạy có cơ hội hoàn tất hoặc rollback. Với hàng đợi, consumer trả lại offset/ack đúng cách, tránh mất hoặc nhân đôi dữ liệu.

2) Trải nghiệm người dùng ổn định

Load balancer dần dần rút traffic khỏi instance sắp chết; người dùng không thấy lỗi 5xx ngẫu nhiên. Ở quán cà phê, khách cuối vẫn nhận được ly đã order.

3) Tài nguyên sạch sẽ, tránh rò rỉ

Connection pool, file descriptor, goroutine/threads được đóng đúng. Khi khởi động lại bản mới, tài nguyên hệ điều hành không bị “kẹt”.

4) Triển khai an toàn, ít gián đoạn

Blue/green, rolling update, autoscaling đều dựa vào khả năng một instance tự rút êm khỏi cụm. Không có graceful, bạn sẽ thấy spike lỗi trong mỗi đợt deploy.

5) Dễ quan sát và vận hành

Quy trình dừng có log/metric rõ ràng: thời gian drain, số request dang dở, số job hủy, v.v. Điều này giúp tối ưu budget thời gian dừng ở lần sau.

Vì sao “phải có” trong môi trường hiện đại

  • Kubernetes gửi SIGTERM và chờ trong terminationGracePeriodSeconds. Nếu ứng dụng bạn không xử lý, nó sẽ bị SIGKILL khi hết hạn - tất cả đang chạy bị cắt ngang.
  • Load balancer (ALB/Nginx/Ingress) cần thời gian để ngừng route tới instance không còn “healthy”. Nếu ứng dụng không tự hạ readiness, traffic vẫn bị ném vào.
  • Job/consumer cần trả offset/ack trước khi dừng. Nếu không, hệ thống hoặc mất thông điệp, hoặc xử lý lặp lại thiếu kiểm soát.

Mẫu triển khai thực dụng (Go)

Ví dụ service HTTP cho app đồ uống. Mục tiêu: nhận SIGTERM, ngừng nhận request mới, chờ nốt request hiện tại tối đa 5 giây, dừng worker nền, đóng DB/Redis.

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/order", orderHandler)

    srv := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  60 * time.Second,
    }

    // chạy worker nền (ví dụ: đồng bộ điểm thưởng)
    bgCtx, bgCancel := context.WithCancel(context.Background())
    go loyaltyWorker(bgCtx)

    // nhận tín hiệu dừng
    stop := make(chan os.Signal, 1)
    signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)

    // chạy HTTP server
    go func() {
        log.Println("HTTP listening on :8080")
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("listen: %v", err)
        }
    }()

    <-stop // chờ tín hiệu
    log.Println("shutting down...")

    // 1) ngừng worker nền
    bgCancel()

    // 2) đặt timeout drain request
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    // 3) tắt HTTP server: stop accept + chờ in-flight
    if err := srv.Shutdown(ctx); err != nil {
        log.Printf("server shutdown error: %v", err)
        // buộc tắt khi quá thời gian
        _ = srv.Close()
    }

    log.Println("done")
}

func orderHandler(w http.ResponseWriter, r *http.Request) {
    // mô phỏng xử lý 1–2 giây
    time.Sleep(time.Second)
    w.Write([]byte("ok"))
}

func loyaltyWorker(ctx context.Context) {
    ticker := time.NewTicker(2 * time.Second)
    defer ticker.Stop()
    for {
        select {
        case <-ctx.Done():
            // flush, đóng tài nguyên nếu cần
            return
        case <-ticker.C:
            // làm việc nền
        }
    }
}

Điểm mấu chốt:

  • Dùng Shutdown (không phải Close) để dừng nhận kết nối mớichờ in-flight.
  • Worker nền nghe ctx.Done() để dừng êm.
  • deadline tổng cho toàn bộ quy trình dừng; khi quá hạn, ta dùng phương án buộc tắt.

Tích hợp với Kubernetes, systemd, LB

Kubernetes

  • Readiness: khi nhận SIGTERM, hạ readiness (trả /healthz = lỗi nhẹ hoặc cờ not-ready) để Ingress/Service ngừng route request mới.
  • PreStop hook: gọi một endpoint POST /drain để ứng dụng bật cờ “không nhận việc mới”, sau đó sleep 2–5 giây cho LB rút hẳn traffic trước khi SIGTERM tới.
  • terminationGracePeriodSeconds: đặt đủ lớn so với thời gian xử lý dài nhất (ví dụ 30–60 giây). Nếu job có thể kéo dài hơn, cần cơ chế hủy an toàn hoặc checkpoint.

systemd

  • Thiết lập TimeoutStopSec phù hợp. Ứng dụng bắt SIGTERM để drain; nếu hết hạn, systemd gửi SIGKILL.

Load balancer / API Gateway

  • Bật connection draining/keepalive hợp lý.
  • Với gRPC, bật server-side graceful để hoàn tất stream đang mở.

Quy trình chuẩn trong ứng dụng web

  1. Chặn nhận việc mới: đặt cờ hoặc đổi handler cho các route quan trọng trả về 503/Retry-After. Với hàng đợi, tạm ngưng fetch.
  2. Drain in-flight: chờ tác vụ hiện tại hoàn tất theo ngân sách thời gian (ví dụ 5–10 giây). Với batch dài, hỗ trợ checkpoint/hủy.
  3. Đóng tài nguyên: DB, cache, message broker, file. Thứ tự đóng nên phản ánh thứ tự phụ thuộc (đóng consumer trước, rồi producer, sau cùng network).
  4. Ghi log/metric cuối: số request còn lại, thời gian drain, lỗi khi dừng. Điều này giúp tinh chỉnh cấu hình lần sau.

Tránh các “mùi hôi” thường gặp

  • Dùng os.Exit đột ngột trong bất kỳ chỗ nào → bỏ qua defer, không flush log.
  • Không hạ readiness khi dừng trên Kubernetes → LB vẫn gửi request vào instance sắp chết.
  • Không đặt deadline cho shutdown → treo vô hạn do một goroutine không nghe ctx.Done().
  • Worker không idempotent khi retry sau dừng giữa chừng → dữ liệu lặp.
  • Đóng channel sai chỗ (consumer đóng) → panic và mất dữ liệu.

Kiểm thử graceful shutdown

  • Test tín hiệu: gửi SIGTERM trong lúc đang có request/stream để xác nhận response hoàn tất và server kết thúc đúng thời gian.
  • Chaos: ép thời gian xử lý kéo dài, giảm nhanh termination grace để xem ứng dụng có tự hủy an toàn không.
  • Canary deploy: triển khai 1 pod với cấu hình dừng mới, quan sát lỗi 5xx và thời gian drain trước khi rollout toàn bộ.

Checklist áp dụng ngay

  • Có handler tín hiệu (SIGTERM/SIGINT) và deadline tổng cho quá trình dừng.
  • Hạ readiness hoặc chặn nhận việc mới ngay khi bắt đầu shutdown.
  • Tất cả worker/consumer đều tôn trọng ctx.Done() và hỗ trợ hủy/rollback.
  • Đóng tài nguyên theo thứ tự phụ thuộc; tránh rò rỉ.
  • Ghi log/metric cho thời gian drain, số việc dở dang, lỗi khi dừng.
  • Cấu hình terminationGracePeriodSeconds (K8s) hoặc TimeoutStopSec (systemd) đủ lớn.

Kết luận

Graceful shutdown không phải “nice-to-have” mà là bắt buộc với mọi dịch vụ nghiêm túc. Nó biến một lần tắt hệ thống thành thao tác có thể dự đoán, không gây đau cho người dùng, không làm rối dữ liệu, và không khiến đội vận hành mất ngủ.

Cứ nhớ quán cà phê: ngừng nhận order, pha nốt ly cuối, dọn máy, khóa cửa. Hệ thống của bạn cũng nên làm như vậy - mỗi lần dừng là một lần đóng cửa êm ái. Khi quy trình này được chuẩn hóa, mọi lần deploy, scale, hay sự cố đều trở nên gọn gàng và đáng tin cậy.