Bài viết được dịch từ https://victoriametrics.com/blog/go-graceful-shutdown/
"Graceful shutdown" trong bất kỳ ứng dụng nào thường đáp ứng ba điều kiện tối thiểu:
- Đóng các entry points bằng cách ngừng nhận các requests hoặc messages mới từ các nguồn như HTTP, Pub/sub system, v.v. Tuy nhiên, vẫn giữ các kết nối đi đến các dịch vụ bên thứ ba như database hoặc cache đang hoạt động.
- Chờ cho tất cả các yêu cầu đang xử lý hoàn tất. Nếu một yêu cầu mất quá nhiều thời gian, hãy response với lỗi bằng một cách "graceful" nhất có thể.
- Giải phóng các tài nguyên quan trọng như kết nối cơ sở dữ liệu, khóa tệp (file locks), hoặc network listeners. Thực hiện mọi công việc dọn dẹp cuối cùng.
Bài viết này tập trung vào các HTTP server và ứng dụng được đóng gói trong container (containerized applications), nhưng các ý tưởng cốt lõi có thể áp dụng cho mọi loại ứng dụng.
1. Bắt Tín Hiệu (Catching the Signal)
Trước khi xử lý graceful shutdown, chúng ta cần bắt các signals liên quan tới shutdown service (termination signals). Những signals này báo cho service của chúng ta biết đã đến lúc exit và bắt đầu quá trình shutdown.
Vậy, signal là gì?
Trong các hệ thống giống Unix, signal là các ngắt phần mềm (software interrupts). Chúng thông báo cho một process rằng có điều gì đó đã xảy ra và nó nên dừng việc đang làm để xử lý signal.
Dưới đây là một vài hành vi có thể xảy ra:
- Signal handler: Một process có thể đăng ký một handler (một hàm) cho một signal cụ thể. Hàm này sẽ chạy khi signal đó được nhận.
- Hành động mặc định: Nếu không có handler nào được đăng ký, process sẽ tuân theo hành vi mặc định cho signal đó. Điều này có thể có nghĩa là завершения, dừng, tiếp tục, hoặc bỏ qua process.
- Signal không thể chặn (Unblockable signals): Một số signal, như
SIGKILL
(số signal 9), không thể bị bắt hoặc bỏ qua. Chúng có thể завершения process.
Khi ứng dụng Go của bạn khởi động, ngay cả trước khi hàm main
của bạn chạy, Go runtime sẽ tự động đăng ký các signal handler cho nhiều signal (SIGTERM
, SIGQUIT
, SIGILL
, SIGTRAP
, và các signal khác). Tuy nhiên, đối với graceful shutdown, thường chỉ có ba signal завершения quan trọng:
SIGTERM
(Termination): Một cách lịch sự để yêu cầu một process terminate. Nó không ép buộc process phải dừng. Kubernetes gửi signal này khi muốn ứng dụng của bạn thoát trước khi nó bị "force kill" bằng SIGKILL.SIGINT
(Interrupt): Được gửi khi người dùng muốn dừng một process từ terminal, thường bằng cách nhấnCtrl+C
.SIGHUP
(Hang up): Ban đầu được sử dụng khi một terminal bị ngắt kết nối. Hiện nay, nó thường được sử dụng để báo hiệu cho một ứng dụng reload lại configuration của nó.
Mọi người chủ yếu quan tâm đến SIGTERM
và SIGINT
. Trong khi SIGHUP
ngày nay ít được sử dụng cho việc shutdown hơn mà chủ yếu dùng để reload lại configuration. Bạn có thể tìm hiểu thêm về điều SIGHUP trong SIGHUP Signal for Configuration Reloads.
Mặc định, khi ứng dụng của bạn nhận được SIGTERM
, SIGINT
, hoặc SIGHUP
, Go runtime sẽ bắt đầu chấm dứt ứng dụng.
Insight: Cách Go Chấm Dứt Ứng Dụng Của Bạn
Khi ứng dụng Go của bạn nhận được mộtSIGTERM
, runtime trước tiên sẽ bắt nó bằng một handler tích hợp sẵn gọi là signal handler. Nó kiểm tra xem có custom handler nào được đăng ký không. Nếu không, runtime sẽ tạm thời vô hiệu hóa handler của chính nó, và gửi lại cùng một signal (SIGTERM
) cho ứng dụng. Lần này, hệ điều hành sẽ xử lý nó bằng hành vi mặc định, đó là terminate process.
Trong Go, bạn có thể ghi đè hành vi này bằng cách đăng ký signal handler của riêng mình với package os/signal
:
func main() { signalChan := make(chan os.Signal, 1) signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM) // Công việc cài đặt ở đây <-signalChan fmt.Println("Received termination signal, shutting down...")
}
signal.Notify
yêu cầu Go runtime chuyển các signal được chỉ định đến một channel thay vì sử dụng hành vi mặc định. Điều này cho phép bạn xử lý chúng một cách thủ công và ngăn ứng dụng tự động termination.
Một channel có buffer với capacity 1 là một lựa chọn tốt để xử lý termination signal. Khi Go gửi signal đến channel này bằng cách sử dụng một câu lệnh select
với một default
case:
select {
case c <- sig:
default:
}
Điều này khác với select
thông thường được sử dụng với các receiver channel. Khi được sử dụng để gửi thì:
- Nếu buffer còn chỗ trống, signal được gửi
c <- sig
. - Nếu buffer đầy, signal bị loại bỏ, và
default
case chạy. Nếu bạn đang sử dụng một channel không có buffer và không có goroutine nào đang tích cực nhận, signal sẽ bị bỏ lỡ.
Mặc dù nó chỉ có thể chứa một signal, channel có buffer này giúp tránh bỏ lỡ signal đầu tiên đó trong khi ứng dụng của bạn vẫn đang khởi tạo và chưa lắng nghe.
Lưu ý: Bạn có thể gọi
Notify
nhiều lần cho cùng một signal. Go sẽ gửi signal đó đến tất cả các channels đã đăng ký.
Một điều khá thú vị, khi bạn nhấn Ctrl+C
nhiều lần, nó không tự động "kill" ứng dụng. Lần Ctrl+C
đầu tiên gửi một SIGINT
đến process ở foreground. Nhấn lại lần nữa thường gửi một SIGINT
khác, chứ không phải SIGKILL
. Hầu hết các terminal, như bash hoặc các shell Linux khác, không tự động 'leo thang' signal. Nếu bạn muốn ép dừng, bạn phải gửi SIGKILL
thủ công bằng lệnh kill -9
.
Điều này không lý tưởng cho việc code ở local, khi bạn có thể muốn lần Ctrl+C
thứ hai sẽ force terminate ứng dụng. Để làm được điều này, bạn có thể ngăn ứng dụng lắng nghe các signal tiếp theo bằng cách sử dụng signal.Stop
ngay sau khi signal đầu tiên được nhận:
func main() { signalChan := make(chan os.Signal, 1) signal.Notify(signalChan, syscall.SIGINT) <-signalChan signal.Stop(signalChan) // <--- select {} }
Bắt đầu từ Go 1.16, bạn có thể đơn giản hóa việc xử lý signal bằng cách sử dụng signal.NotifyContext
, để liên kết việc xử lý signal với việc hủy context (context cancellation):
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop() // ... <-ctx.Done() // Chờ cho đến khi context bị hủy (do nhận signal)
stop() // Ngừng lắng nghe signal để lần Ctrl+C tiếp theo có thể dừng chương trình
Bạn vẫn nên gọi stop()
sau ctx.Done()
để cho phép lần Ctrl+C
thứ hai force terminate ứng dụng.
2. Nhận Biết Timeout (Timeout Awareness)
Điều quan trọng thứ hai là phải biết ứng dụng của bạn có bao lâu để shutdown sau khi nhận được termination signal. Ví dụ, trong Kubernetes, thời gian gia hạn (grace period) mặc định là 30 giây, trừ khi được chỉ định khác bằng field terminationGracePeriodSeconds
. Sau khoảng thời gian này, Kubernetes sẽ gửi một SIGKILL
để force dừng ứng dụng.
Logic shutdown của bạn phải hoàn thành trong khoảng thời gian này, bao gồm việc xử lý bất kỳ yêu cầu còn lại nào và giải phóng tài nguyên.
Giả sử mặc định là 30 giây. Bạn có thể dành khoảng 20 phần trăm thời gian làm biên độ an toàn để tránh bị "kill" trước khi dọn dẹp xong. Điều này có nghĩa là cố gắng hoàn thành mọi thứ trong vòng 25 giây để tránh mất mát dữ liệu hoặc dừng khi đang thực hiện tác vụ quan trọng.
3. Ngừng Chấp Nhận Yêu Cầu Mới
Khi sử dụng net/http
, bạn có thể xử lý graceful shutdown bằng cách gọi phương thức http.Server.Shutdown
. Phương thức này dừng server chấp nhận các connections mới và chờ cho tất cả các connections đang hoạt động hoàn thành trước khi đóng các connections không hoạt động (idle connections).
Cách nó hoạt động:
- Nếu một yêu cầu đã đang được xử lý trên một kết nối hiện có, server sẽ cho phép nó hoàn thành. Sau đó, kết nối được đánh dấu là không hoạt động (idle) và bị đóng.
- Nếu một client cố gắng tạo một kết nối mới trong quá trình shutdown, nó sẽ thất bại vì các listener của server đã bị đóng. Thường dẫn đến lỗi "connection refused".
Trong môi trường containerized (và nhiều môi trường được điều phối khác với các external load balancers), đừng dừng chấp nhận connections mới ngay lập tức sau khi nhận SIGTERM. Ngay cả sau khi một pod được đánh dấu để termination, nó vẫn có thể nhận traffic trong một khoảng thời gian nhỏ sau đó.
Các thành phần nội bộ của Kubernetes như kube-proxy
nhận biết được sự thay đổi trạng thái của pod thành "Terminating" rất nhanh. Sau đó, chúng ưu tiên định tuyến traffic nội bộ đến các endpoint Ready,Serving
hơn là Terminating,Serving
.
Tuy nhiên, external load balancers hoạt động độc lập với Kubernetes. Nó thường sử dụng các cơ chế health check của riêng mình để xác định các backend nodes nào nên nhận traffic. Health check này cho biết liệu có các pod khỏe mạnh (Ready
) và không đang terminating trên node hay không. Tuy nhiên, health check này cần một chút thời gian để lan truyền (propagation).
Có 2 cách để xử lý việc này:
-
Sử dụng một
preStop
hook để sleep một lúc, để external load balancer có thời gian nhận ra rằng pod đang termination.lifecycle: preStop: exec: command: ["/bin/sh", "-c", "sleep 10"]
Và điều thực sự quan trọng là thời gian thực hiện bởi
preStop
hook được tính vàoterminationGracePeriodSeconds
, thời gian mà pod của bạn shutdown. -
Làm cho readiness probe thất bại và sleep ở trong code logic. Cách này không chỉ áp dụng cho môi trường Kubernetes, mà còn cho các môi trường khác có load balancer cần biết pod không sẵn sàng.
[!NOTE] What is readiness probe?
Một "readiness probe" xác định khi nào một container sẵn sàng chấp nhận traffic bằng cách kiểm tra định kỳ tình trạng của nó thông qua các phương thức được cấu hình như HTTP requests, TCP connections, hoặc command executions. Nếu probe thất bại, Kubernetes sẽ loại bỏ pod khỏi các endpoint của service, ngăn nó nhận traffic cho đến khi nó sẵn sàng trở lại.
Để tránh lỗi kết nối trong khoảng thời gian ngắn này, chiến lược đúng đắn là làm cho readiness probe thất bại trước. Điều này báo cho bộ điều phối (orchestrator) biết rằng pod của bạn không nên nhận traffic nữa:
var isShuttingDown atomic.Bool func readinessHandler(w http.ResponseWriter, r *http.Request) { if isShuttingDown.Load() { w.WriteHeader(http.StatusServiceUnavailable) w.Write([]byte("shutting down")) return } w.WriteHeader(http.StatusOK) w.Write([]byte("ok"))
}
Pattern này cũng được sử dụng làm ví dụ code trong các test images. Trong phần implementation của họ, một channel đã đóng được sử dụng để báo hiệu cho readiness probe trả về HTTP 503 khi ứng dụng đang chuẩn bị shutdown.
Sau khi cập nhật readiness probe để cho biết pod không còn sẵn sàng, hãy đợi vài giây để cho phép hệ thống có thời gian lan truyền thay đổi.
Thời gian chờ chính xác phụ thuộc vào cấu hình readiness probe của bạn; chúng ta sẽ sử dụng 5 giây cho bài viết này với cấu hình đơn giản sau:
readinessProbe: httpGet: path: /healthz port: 8080 periodSeconds: 5
Hướng dẫn này chỉ cung cấp cho bạn ý tưởng đằng sau graceful shutdown. Việc lập kế hoạch chiến lược graceful shutdown của bạn phụ thuộc vào đặc điểm ứng dụng của bạn.
4. Xử Lý Các Yêu Cầu Đang Chờ (Handle Pending Requests)
Bây giờ chúng ta đang shutdown server một cách "graceful", chúng ta cần chọn một timeout dựa trên ngân sách shutdown của bạn:
ctx, cancelFn := context.WithTimeout(context.Background(), timeout)
err := server.Shutdown(ctx)
Hàm server.Shutdown
chỉ trả về trong hai tình huống:
- Tất cả các kết nối đang hoạt động đã được đóng và tất cả các handler đã xử lý xong.
- Context được truyền cho
Shutdown(ctx)
hết hạn trước khi các handler hoàn thành. Trong trường hợp này, server từ bỏ việc chờ đợi.
Trong cả hai trường hợp, Shutdown
chỉ trả về sau khi server đã hoàn toàn ngừng xử lý các yêu cầu. Đây là lý do tại sao các handler của bạn phải nhận biết được context (context-aware). Nếu không, chúng có thể bị cắt ngang giữa chừng trong trường hợp 2, điều này có thể gây ra các vấn đề như ghi một phần (partial writes), mất dữ liệu (loss data), trạng thái không nhất quán (inconsistency), giao dịch đang mở, hoặc dữ liệu bị hỏng (corrupted data).
Một vấn đề phổ biến là các handler không tự động nhận biết khi server đang shutdown.
Vậy, làm thế nào chúng ta có thể thông báo cho các handler của mình rằng server đang shutdown? Câu trả lời là sử dụng context. Có hai cách chính để làm điều này:
a. Sử dụng context middleware để chèn logic hủy (cancellation logic)
Middleware này wraps mỗi yêu cầu với một context lắng nghe một signal shutdown:
func WithGracefulShutdown(next http.Handler, cancelCh <-chan struct{}) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx, cancel := WithCancellation(r.Context(), cancelCh) defer cancel() r = r.WithContext(ctx) next.ServeHTTP(w, r) })
}
b. Sử dụng BaseContext
để cung cấp một global context cho tất cả các kết nối
Ở đây, chúng ta tạo một server với một BaseContext
tùy chỉnh có thể bị hủy trong quá trình shutdown. Context này được chia sẻ trên tất cả các connections:
ongoingCtx, cancelFn := context.WithCancel(context.Background())
server := &http.Server{ Addr: ":8080", Handler: yourHandler, BaseContext: func(l net.Listener) context.Context { return ongoingCtx },
} // Sau khi thử graceful shutdown:
cancelFn()
time.Sleep(5 * time.Second) // độ trễ tùy chọn để cho phép context lan truyền
Trong một HTTP server, bạn có thể tùy chỉnh hai loại context: BaseContext
và ConnContext
. Đối với graceful shutdown, BaseContext
phù hợp hơn. Nó cho phép bạn tạo một global context với khả năng hủy áp dụng cho toàn bộ server, và bạn có thể hủy nó để báo hiệu cho tất cả các connections đang hoạt động rằng server đang shutdown.
Tất cả công việc xung quanh graceful shutdown này sẽ không giúp ích gì nếu các hàm của bạn không tôn trọng việc hủy context. Cố gắng tránh sử dụng context.Background()
, time.Sleep()
, hoặc bất kỳ hàm nào khác bỏ qua context.
Ví dụ, time.Sleep(duration)
có thể được thay thế bằng một phiên bản nhận biết context như sau:
func Sleep(ctx context.Context, duration time.Duration) error { select { case <-time.After(duration): return nil case <-ctx.Done(): return ctx.Err() }
}
Rò rỉ tài nguyên (Leaking Resources)
Trong các phiên bản Go cũ hơn,time.After
có thể làm rò rỉ bộ nhớ cho đến khi timer kích hoạt. Điều này đã được sửa trong Go 1.23 và mới hơn. Nếu bạn không chắc mình đang sử dụng phiên bản nào, hãy xem xét sử dụngtime.NewTimer
cùng vớiStop
và một kiểm tra<-t.C
tùy chọn nếuStop
trả về false.
Mặc dù bài viết này tập trung vào HTTP server, khái niệm tương tự cũng áp dụng cho các dịch vụ của bên thứ ba. Ví dụ, package database/sql
có phương thức DB.Close
. Nó đóng kết nối cơ sở dữ liệu và ngăn các truy vấn mới bắt đầu. Nó cũng chờ cho bất kỳ truy vấn đang diễn ra nào hoàn thành trước khi shutdown hoàn toàn.
Nguyên tắc cốt lõi của graceful shutdown là như nhau trên tất cả các hệ thống: Ngừng chấp nhận các requests hoặc messages mới, và cho các hoạt động hiện tại thời gian để hoàn thành trong một khoảng thời gian gia hạn (grace period) được xác định.
Một số người có thể thắc mắc về phương thức server.Close()
của package net/http
, phương thức này đóng các kết nối đang diễn ra ngay lập tức mà không chờ các yêu cầu hoàn thành. Liệu nó có thể được sử dụng sau khi server.Shutdown()
trả về lỗi không?
Câu trả lời ngắn gọn là có, nhưng nó phụ thuộc vào chiến lược shutdown của bạn. Phương thức Close
đóng tất cả các listener và kết nối đang hoạt động:
- Các handler đang sử dụng mạng sẽ nhận được lỗi khi chúng cố gắng đọc hoặc ghi vào connection.
- Client sẽ ngay lập tức nhận được lỗi kết nối, chẳng hạn như
ECONNRESET
('socket hang up'). - Tuy nhiên, các handler chạy mà không tương tác với connection có thể tiếp tục chạy.
Đây là lý do tại sao việc sử dụng context để lan truyền signal shutdown vẫn là cách tiếp cận đáng tin cậy và "graceful" hơn.
5. Giải Phóng Các Tài Nguyên Quan Trọng
Một lỗi phổ biến là giải phóng các tài nguyên quan trọng ngay khi nhận được termination signal. Tại thời điểm đó, các handler và các yêu cầu đang trong quá trình xử lý (in-flight requests) của bạn có thể vẫn đang sử dụng những tài nguyên đó. Bạn nên trì hoãn việc dọn dẹp tài nguyên cho đến khi timeout của shutdown đã qua hoặc tất cả các yêu cầu đã hoàn tất.
Trong nhiều trường hợp, chỉ cần để process thoát là đủ. Hệ điều hành sẽ tự động thu hồi tài nguyên. Ví dụ:
- Bộ nhớ được cấp phát bởi Go sẽ tự động được giải phóng khi terminate process.
- Các file descriptor (file, network,...) được đóng bởi hệ điều hành.
- Các tài nguyên cấp hệ điều hành như process handles được thu hồi.
Tuy nhiên, có những trường hợp quan trọng mà việc dọn dẹp rõ ràng vẫn cần thiết trong quá trình shutdown:
- Kết nối cơ sở dữ liệu nên được đóng đúng cách. Nếu có bất kỳ giao dịch nào vẫn đang mở, chúng cần được commit hoặc rollback. Nếu không có shutdown đúng cách, cơ sở dữ liệu phải dựa vào timeout của kết nối.
- Hàng đợi tin nhắn (Message queues) và broker thường yêu cầu graceful shutdown. Điều này có thể bao gồm flusing messages, commit offset, hoặc báo hiệu cho broker rằng client đang thoát. Nếu không, có thể xảy ra các vấn đề về tái cân bằng (rebalancing) hoặc mất messages.
- Các dịch vụ bên ngoài có thể không phát hiện việc ngắt kết nối ngay lập tức. Việc đóng kết nối thủ công cho phép các hệ thống đó dọn dẹp nhanh hơn là chờ TCP timeout.
Một quy tắc khá tốt là shutdown các thành phần theo thứ tự ngược lại với cách chúng được khởi tạo. Điều này tôn trọng sự phụ thuộc giữa các thành phần. Câu lệnh defer
của Go giúp việc này dễ dàng hơn vì hàm được defer
cuối cùng sẽ được thực thi đầu tiên:
db := connectDB()
defer db.Close() cache := connectCache()
defer cache.Close()
Một số thành phần yêu cầu xử lý đặc biệt. Ví dụ, nếu bạn cache dữ liệu trong bộ nhớ, bạn có thể cần ghi dữ liệu đó ra đĩa trước khi termination. Trong những trường hợp đó, hãy thiết kế một quy trình shutdown cụ thể cho thành phần đó để xử lý việc dọn dẹp đúng cách.
Tóm Tắt (Summary)
Đây là một ví dụ hoàn chỉnh về cơ chế graceful shutdown. Nó được viết dễ hiểu để giúp bạn dễ nắm bắt hơn. Bạn có thể tùy chỉnh nó để phù hợp với ứng dụng của mình, sử dụng errgroup
, WaitGroup
, hoặc bất kỳ pattern nào khác:
const ( _shutdownPeriod = 15 * time.Second _shutdownHardPeriod = 3 * time.Second _readinessDrainDelay = 5 * time.Second
) var isShuttingDown atomic.Bool func main() { // Thiết lập context signal rootCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() // Endpoint cho readiness http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { if isShuttingDown.Load() { http.Error(w, "Shutting down", http.StatusServiceUnavailable) return } fmt.Fprintln(w, "OK") }) // Logic nghiệp vụ mẫu http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { select { case <-time.After(2 * time.Second): fmt.Fprintln(w, "Hello, world!") case <-r.Context().Done(): // Lắng nghe hủy context của request http.Error(w, "Request cancelled.", http.StatusRequestTimeout) } }) // Đảm bảo các yêu cầu đang xử lý không bị hủy ngay lập tức khi nhận SIGTERM ongoingCtx, stopOngoingGracefully := context.WithCancel(context.Background()) server := &http.Server{ Addr: ":8080", BaseContext: func(_ net.Listener) context.Context { return ongoingCtx // Cung cấp context này cho tất cả các request }, } go func() { log.Println("Server starting on :8080.") if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { panic(err) } }() // Chờ signal <-rootCtx.Done() stop() // Ngừng nhận signal từ rootCtx isShuttingDown.Store(true) log.Println("Received shutdown signal, shutting down.") // Cho thời gian để readiness check lan truyền time.Sleep(_readinessDrainDelay) log.Println("Readiness check propagated, now waiting for ongoing requests to finish.") // Timeout cho việc chờ các request hoàn thành shutdownCtx, cancel := context.WithTimeout(context.Background(), _shutdownPeriod) defer cancel() err := server.Shutdown(shutdownCtx) // Thử graceful shutdown stopOngoingGracefully() // Hủy ongoingCtx để báo hiệu cho các handlers đang chạy if err != nil { log.Println("Failed to wait for ongoing requests to finish, waiting for forced cancellation.") time.Sleep(_shutdownHardPeriod) // Chờ thêm một chút trước khi force thoát } log.Println("Server shut down gracefully.")
}
Who We Are
Nếu bạn muốn monitor các dịch vụ của mình, theo dõi số liệu và xem mọi thứ hoạt động như thế nào, bạn có thể muốn xem qua VictoriaMetrics. VictoriaMetrics là một monitoring system, mã nguồn mở, và tiết kiệm chi phí để theo dõi cơ sở hạ tầng của bạn thay cho Prometheus khá nặng nề.
Các bài viết liên quan: