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

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

Table of Contents

Có lần team mình deploy bản mới lúc 11h đêm. kubectl rollout restart xong, 30 giây sau PagerDuty réo: 200 cái 502 trong 5 giây. Khách hàng đang đặt đồ ăn thì timeout. Mình lật đật rollout undo, xong ngồi check log - hóa ra pod cũ bị SIGTERM, HTTP server tắt ngay lập tức, 50 request đang xử lý dở bị cắt ngang hết.

Lỗi không phải ở Kubernetes. Lỗi ở service mình: không có graceful shutdown.

Sau đêm đó, mình bỏ 2 ngày implement graceful shutdown cho toàn bộ service. Dưới đây là pattern mình dùng, kèm code Go chạy được và những thứ Kubernetes cần sync.

![Graceful Shutdown(https://blog-bucket.luandnh.com/images/covers/graceful-shutdown.jpg)

Chuyện gì xảy ra khi bạn kill -9?

Trước khi vào code, hiểu cái chết của process:

  1. Request đang xử lý bị cắt ngang → client nhận 502/503, dữ liệu không nhất quán
  2. Connection DB/Redis không đóng → connection leak, pool exhausted
  3. Message queue bỏ dở → message mất hoặc duplicate
  4. LB chưa kịp cập nhật → traffic vẫn gửi đến pod đã chết

Nói đơn giản: graceful shutdown là cách bạn đóng cửa quán cà phê - không tiếp khách mới, pha nốt ly cuối cùng, rửa ly, tắt máy, rồi mới khóa cửa. Còn kill -9 là tắt đèn bỏ chạy.

Triển khai trong Go: từ cơ bản đến production

Bước 1: Signal handling

package main

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

func main() {
    srv := &http.Server{
        Addr:    ":8080",
        Handler: router,
    }

    // Start server trong goroutine
    go func() {
        log.Println("Server starting on :8080")
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("listen: %v", err)
        }
    }()

    // Block cho đến khi nhận signal
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    sig := <-quit
    log.Printf("Received signal: %v. Shutting down...", sig)

    // Cho server 30s để finish request đang xử lý
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := srv.Shutdown(ctx); err != nil {
        log.Fatalf("Server forced to shutdown: %v", err)
    }

    log.Println("Server exited gracefully")
}

server.Shutdown(ctx) làm 3 việc: đóng listener (không nhận request mới), chờ request đang xử lý finish, rồi thoát. Nếu quá timeout, force shutdown.

Bước 2: Cleanup nhiều tài nguyên - theo thứ tự

Trong thực tế, bạn không chỉ có HTTP server. Còn DB, Redis, RabbitMQ, background workers. Tất cả cần cleanup đúng thứ tự:

type Closer func(ctx context.Context) error

func GracefulShutdown(timeout time.Duration, closers ...Closer) {
    ctx, cancel := context.WithTimeout(context.Background(), timeout)
    defer cancel()

    for _, close := range closers {
        if err := close(ctx); err != nil {
            log.Printf("cleanup error: %v", err)
        }
    }
}

func main() {
    // ... init resources ...

    GracefulShutdown(30*time.Second,
        httpserver.Shutdown,    // 1. Stop nhận request mới
        worker.Stop,            // 2. Dừng background job
        db.Close,               // 3. Đóng DB pool
        redis.Close,            // 4. Đóng Redis
        mq.Close,               // 5. Đóng message queue
    )
}

Mình từng làm ngược: đóng DB trước HTTP server. Kết quả: request đang process gọi DB → connection closed error → 500. Thứ tự cleanup quan trọng hơn bạn nghĩ.

Tích hợp với Kubernetes

Graceful shutdown trong Go mới là một nửa. Kubernetes cần được cấu hình đồng bộ.

PreStop hook - chìa khóa sống còn

lifecycle:
  preStop:
    exec:
      command: ["/bin/sh", "-c", "sleep 5"

preStop chạy TRƯỚC khi SIGTERM đến. Mục đích: để LB/Ingress kịp cập nhật routing table, rút pod khỏi pool. Không có PreStop → LB vẫn gửi traffic trong lúc pod đang shutdown → 502.

terminationGracePeriodSeconds

terminationGracePeriodSeconds: 45

Phải lớn hơn timeout trong code Go + PreStop sleep. Ví dụ:

  • PreStop: 5s
  • Go shutdown timeout: 30s
  • terminationGracePeriodSeconds tối thiểu: 40s (mình set 45 cho buffer)

Nếu pod không thoát trong 45s, Kubernetes force kill bằng SIGKILL. Không kịp cleanup thì lỗi là đúng rồi.

Readiness probe - rút pod khỏi service trước khi shutdown

readinessProbe:
  httpGet:
    path: /ready
    port: 8080
  initialDelaySeconds: 5
  periodSeconds: 5

Trong code Go, khi nhận SIGTERM, set readiness thành false:

var isReady atomic.Bool

func readinessHandler(w http.ResponseWriter, r *http.Request) {
    if isReady.Load() {
        w.WriteHeader(http.StatusOK)
        return
    }
    w.WriteHeader(http.StatusServiceUnavailable)
}

// Trong signal handler:
isReady.Store(false)
time.Sleep(2 * time.Second) // đợi LB cập nhật
srv.Shutdown(ctx)

Set readiness → false → LB ngừng gửi traffic → 2s buffer → shutdown. Đây là pattern mình dùng cho tất cả service production.

Checklist graceful shutdown

  • [ Capture SIGTERM/SIGINT
  • [ Gọi server.Shutdown(ctx) với timeout
  • [ Cleanup tài nguyên theo thứ tự: HTTP → workers → DB → Redis → MQ
  • [ Kubernetes: PreStop hook ≥ 5s
  • [ Kubernetes: terminationGracePeriodSeconds > shutdown timeout
  • [ Readiness probe fail khi đang shutdown
  • [ Log đầy đủ signal nhận được + từng bước cleanup
  • [ Dùng atomic.Bool cho readiness flag, không dùng mutex (không cần)

Bonus: verify graceful shutdown

# Start service
go run main.go &

# Gửi request liên tục
for i in $(seq 1 100); do
  curl -s http://localhost:8080/api/health &
done

# Gửi SIGTERM
kill -TERM $(pgrep main)

# Kiểm tra: không có lỗi connection refused hay 502

Bottom line

Graceful shutdown là thứ “chả ai care cho đến khi 200 cái 502 đập vào mặt lúc 11h đêm.” Mất 30 phút implement, tiết kiệm hàng chục giờ debug incident. Nếu service bạn chưa có - dừng đọc blog, đi implement ngay. Xong rồi quay lại đây đọc tiếp.

Bạn đã từng dính incident vì thiếu graceful shutdown chưa? Có pattern nào hay hơn mình chưa thử không? Kể mình nghe câu chuyện deploy thảm họa của bạn đi - mấy bài học từ incident bao giờ cũng nhớ lâu nhất. 🛑

Share :

Related Posts

Khi nào nên dùng cache (và khi nào không)

Khi nào nên dùng cache (và khi nào không)

Có một bug production mà đến giờ mình vẫn nhớ như in: user A hủy đơn hàng, nhưng app vẫn hiển thị “đang giao” trong 10 phút tiếp theo. Support nhận 30 ticket trong 1 buổi sáng. Nguyên nhân: cache TTL 10 phút, nhưng không ai invalidate khi order status thay đổi.

Read More
Quản lý goroutine: errgroup, leak-proof, backpressure

Quản lý goroutine: errgroup, leak-proof, backpressure

Tháng trước, một service của team mình đột nhiên chậm như rùa. p50 latency từ 50ms nhảy lên 3 giây. Mở Grafana lên - 120,000 goroutine đang chạy. Bình thường chỉ 200. Ai đó vừa push code quên defer cancel() trong một vòng lặp xử lý batch.

Read More
Context trong Go: truyền dữ liệu, deadline, cancel - dùng đúng hay chết hiệu năng

Context trong Go: truyền dữ liệu, deadline, cancel - dùng đúng hay chết hiệu năng

Hồi mới code Go, mình có một bug production kỳ lạ: cứ sau 3-4 ngày chạy, service tự dưng ngốn 8GB RAM rồi OOM. Mò suốt chiều thứ Sáu mới phát hiện: một cái goroutine không bao giờ kết thúc vì mình quên truyền context xuống gRPC call. Mỗi request leak một goroutine, sau 100K request thì server “đội mồ” luôn.

Read More