Mẫu thực tiễn để triển khai Graceful Shutdown trong ngôn ngữ Go
(victoriametrics.com)- Graceful Shutdown trong ứng dụng Go là quy trình dừng, trong đó chặn yêu cầu mới, chờ các tác vụ đang chạy hoàn tất, rồi dọn dẹp tài nguyên như kết nối cơ sở dữ liệu, khóa tệp và network listener
- Xử lý dừng bắt đầu từ việc nhận tín hiệu kết thúc như
SIGTERMvàSIGINTbằngos/signalhoặcsignal.NotifyContexttừ Go 1.16 trở lên để thay thế hành vi thoát ngay mặc định - Trong Kubernetes, quá trình dừng phải hoàn tất trong grace period 30 giây mặc định; cần dành thời gian để trạng thái ngừng nhận traffic được lan truyền tới cả load balancer bên ngoài thông qua độ trễ
preStophoặc readiness probe thất bại http.Server.Shutdownchặn kết nối mới và chờ yêu cầu đang hoạt động hoàn tất, nhưng nếu handler không tuân theo context cancellation thì có thể phát sinh ghi dở dang, mất dữ liệu hoặc giao dịch còn mở- Tài nguyên quan trọng không nên được giải phóng ngay sau tín hiệu dừng mà nên dọn sau khi yêu cầu kết thúc hoặc khi hết thời gian giới hạn; dừng theo thứ tự ngược với lúc khởi tạo giúp dễ giữ đúng phụ thuộc giữa các thành phần
Điều kiện tối thiểu của Graceful Shutdown
- Graceful Shutdown thường phải đáp ứng ba điều kiện
- Không nhận thêm yêu cầu hoặc thông điệp mới từ các điểm vào như HTTP hay pub/sub
- Chờ các yêu cầu đang xử lý kết thúc, và nếu mất quá lâu thì phản hồi bằng lỗi graceful
- Giải phóng tài nguyên quan trọng như kết nối cơ sở dữ liệu, khóa tệp, network listener và thực hiện dọn dẹp cuối cùng
- Các kết nối ra ngoài như cơ sở dữ liệu hoặc cache không nên bị cắt ngay ở bước chặn yêu cầu mới
- Bài viết tập trung vào HTTP server và ứng dụng container, nhưng nguyên lý cốt lõi cũng áp dụng cho các ứng dụng khác
Xử lý tín hiệu kết thúc
- Trên các hệ thống kiểu Unix, signal là software interrupt báo cho tiến trình biết rằng đã xảy ra một tình huống cụ thể
- Tiến trình có thể đăng ký handler cho một signal cụ thể; nếu không có handler thì sẽ theo hành vi mặc định
- Hành vi mặc định có thể là thoát, dừng, tiếp tục chạy hoặc bỏ qua
- Một số signal như
SIGKILLkhông thể bị bắt hay bỏ qua và sẽ kết thúc tiến trình
- Go runtime tự động đăng ký nhiều signal handler như
SIGTERM,SIGQUIT,SIGILL,SIGTRAPngay từ trước khi hàmmainchạy - Trong Graceful Shutdown, ba tín hiệu kết thúc quan trọng nhất thường là
SIGTERM: cách tiêu chuẩn và mềm để yêu cầu tiến trình kết thúc; đây là signal mà Kubernetes gửi cho ứng dụng trước khi cưỡng bức dừngSIGINT: được gửi khi người dùng nhấnCtrl+Ctrong terminal để dừng tiến trìnhSIGHUP: ban đầu dùng cho việc ngắt kết nối terminal, hiện cũng thường được dùng làm tín hiệu reload cấu hình
- Nếu không xử lý riêng, khi nhận
SIGTERM,SIGINThoặcSIGHUP, Go runtime sẽ kết thúc ứng dụng
os/signal và NotifyContext
signal.Notifyyêu cầu Go runtime chuyển các signal chỉ định vào channel thay vì thực hiện hành vi mặc định- Channel nhận signal nên có buffer kích thước 1 để ổn định hơn
- Nội bộ Go dùng
selectvàdefaultkhi gửi vào channel - Nếu còn chỗ trong buffer thì signal được chuyển tới, còn nếu buffer đầy thì signal sẽ bị bỏ
- Với channel không buffer, nếu không có goroutine đang nhận thì có thể bị lỡ signal
- Nội bộ Go dùng
signal.Notifycó thể được gọi nhiều lần cho cùng một signal, và Go sẽ gửi signal đó tới tất cả channel đã đăng ký- Dù nhấn
Ctrl+Cnhiều lần, thông thường lần nhấn thứ hai không tự động bị nâng thànhSIGKILL- Phần lớn shell bash hoặc Linux không tự động nâng cấp như vậy
- Muốn cưỡng bức kết thúc thì phải gửi trực tiếp
SIGKILLbằngkill -9
- Trong quá trình phát triển cục bộ, nếu muốn lần nhấn
Ctrl+Cthứ hai dẫn tới cưỡng bức thoát, có thể gọisignal.Stopngay sau khi nhận signal đầu tiên để ngừng nhận các signal tiếp theo - Từ Go 1.16, có thể dùng
signal.NotifyContextđể gắn xử lý signal với context cancellation- Ngay cả sau khi
ctx.Done()xảy ra, vẫn phải gọistop()thì lần nhấnCtrl+Cthứ hai mới có thể buộc ứng dụng kết thúc
- Ngay cả sau khi
Thời hạn dừng và cách Kubernetes hoạt động
- Trước tiên cần biết ứng dụng thực sự có bao nhiêu thời gian để dừng sau khi nhận signal kết thúc
- Grace period mặc định của Kubernetes là 30 giây nếu không chỉ định riêng
terminationGracePeriodSeconds - Khi quá thời gian này, Kubernetes sẽ gửi
SIGKILLđể cưỡng bức dừng ứng dụngSIGKILLkhông thể bị bắt hoặc xử lý
- Toàn bộ logic dừng, bao gồm xử lý các yêu cầu còn lại và giải phóng tài nguyên, phải hoàn tất trong khoảng thời gian này
- Với mốc mặc định 30 giây, nếu chừa khoảng 20% làm biên an toàn thì nên cố gắng hoàn tất toàn bộ quá trình dừng trong 25 giây
Chặn yêu cầu mới và xử lý readiness
- Trong
net/httpcủa Go, có thể thực hiện Graceful Shutdown bằnghttp.Server.Shutdown- Ngừng chấp nhận kết nối mới
- Chờ các yêu cầu đang hoạt động hoàn tất
- Sau đó đóng các idle connection
- Các yêu cầu đang chạy có thể hoàn tất; sau khi xong, kết nối tương ứng sẽ chuyển sang trạng thái idle rồi bị đóng
- Client cố tạo kết nối mới trong lúc đang dừng thường sẽ nhận lỗi
connection refusedvì listener đã bị đóng - Trong môi trường container hoặc hệ thống orchestration có load balancer bên ngoài, việc không dừng nhận yêu cầu mới ngay lập tức là rất quan trọng
- Pod có thể vẫn nhận traffic trong một khoảng ngắn sau khi đã bị đánh dấu là đối tượng cần dừng
kube-proxy, thành phần nội bộ của Kubernetes, nhận biết khá nhanh việc trạng thái pod chuyển sangTerminating- Load balancer bên ngoài hoạt động độc lập với Kubernetes và dùng health check riêng, nên cần thời gian để trạng thái được lan truyền
- Có hai cách để chờ việc chặn traffic được lan truyền
sleepmột lúc trong hookpreStopđể load balancer bên ngoài có thời gian nhận ra pod đang dừng- Thời gian trong
preStopđược tính vàoterminationGracePeriodSeconds
- Thời gian trong
- Ở mức mã nguồn, làm cho readiness probe thất bại rồi chờ một lúc
- Cách này áp dụng được không chỉ cho Kubernetes mà còn cho các môi trường khác nơi load balancer cần biết trạng thái sẵn sàng
- Readiness probe định kỳ kiểm tra xem container đã sẵn sàng nhận traffic hay chưa
- Có thể health check bằng HTTP request, kết nối TCP hoặc chạy lệnh
- Nếu probe thất bại, Kubernetes sẽ loại pod khỏi service endpoint để nó không còn nhận traffic
- Khi chuẩn bị dừng, có thể dùng
atomic.BoolnhưisShuttingDownđể/healthztrả về HTTP 503 - Sau khi chuyển readiness sang trạng thái thất bại, cần chờ vài giây để thay đổi được lan truyền
- Ví dụ cấu hình là
periodSeconds: 5, và ví dụ trong bài dùng chờ 5 giây - Thời gian chờ chính xác phụ thuộc vào cấu hình readiness probe
- Ví dụ cấu hình là
Xử lý các yêu cầu đang chạy
- Tạo thời hạn bằng
context.WithTimeouttheo shutdown budget rồi truyền vàoserver.Shutdown(ctx) - Có hai trường hợp
server.Shutdowntrả về- Tất cả kết nối đang hoạt động đã đóng và toàn bộ handler đã xử lý xong
- Context được truyền vào hết hạn trước khi handler hoàn tất, khiến server từ bỏ việc chờ
- Dù ở trường hợp nào,
Shutdownchỉ trả về sau khi server đã ngừng xử lý yêu cầu hoàn toàn - Handler cần chạy nhanh và nhận biết context
- Nếu không, khi hết thời gian giới hạn thì tác vụ có thể bị ngắt giữa chừng
- Có thể dẫn tới ghi dở dang, mất dữ liệu, trạng thái không nhất quán, giao dịch còn mở hoặc dữ liệu hỏng
- Có hai cách phổ biến để truyền tín hiệu dừng vào handler
- Tiêm logic hủy vào context của từng request thông qua middleware
- Cung cấp một context toàn cục dùng chung cho mọi kết nối qua
BaseContextcủahttp.Server
- Trong HTTP server, hai context có thể tùy biến là
BaseContextvàConnContext- Với Graceful Shutdown,
BaseContextphù hợp hơn vì có thể tạo một context toàn cục có thể hủy áp dụng cho toàn bộ server
- Với Graceful Shutdown,
- Graceful Shutdown chỉ thực sự hiệu quả khi các hàm tôn trọng việc hủy context
- Nên tránh dùng
context.Background()hoặctime.Sleep()theo cách bỏ qua cơ chế hủy - Có thể thay
time.Sleep(duration)bằng cách dùngselectđể chờ đồng thờitime.After(duration)vàctx.Done()
- Nên tránh dùng
- Ở các phiên bản Go cũ,
time.Aftercó thể gây rò rỉ bộ nhớ cho tới khi timer chạy xong- Vấn đề này đã được sửa từ Go 1.23 trở lên
- Nếu không chắc về phiên bản, có thể dùng
time.NewTimer,Stopvà khi cần thì kiểm tra<-t.C - Vấn đề liên quan: time: stop requiring Timer/Ticker.Stop for prompt GC
Khác biệt giữa Shutdown và Close
- Nguyên lý tương tự không chỉ áp dụng cho HTTP server mà còn cho cả các dịch vụ bên thứ ba
DB.Closecủadatabase/sqlsẽ đóng kết nối cơ sở dữ liệu, ngăn truy vấn mới bắt đầu và chờ các truy vấn đang chạy kết thúc- Điểm cốt lõi là không nhận thêm yêu cầu hay thông điệp mới, đồng thời cho các công việc đang tồn tại thời gian để hoàn tất trong grace period đã định nghĩa
server.Close()sẽ dừng ngay mà không chờ các kết nối đang hoạt động- Các handler đang dùng mạng sẽ gặp lỗi khi đọc hoặc ghi
- Client có thể nhận ngay lỗi kết nối như
ECONNRESEThoặcsocket hang up - Các handler chạy dài không tương tác mạng có thể vẫn tiếp tục chạy dưới nền
- Có thể dùng
server.Close()sau khiserver.Shutdown()trả về lỗi, nhưng điều này phụ thuộc vào chiến lược dừng - Truyền tín hiệu dừng bằng context là cách tiếp cận đáng tin cậy và graceful hơn
Thứ tự giải phóng tài nguyên quan trọng
- Sai lầm thường gặp là giải phóng tài nguyên quan trọng ngay khi nhận tín hiệu dừng
- Ở thời điểm đó, các handler và in-flight request có thể vẫn đang dùng những tài nguyên đó, vì vậy việc dọn dẹp nên được hoãn đến sau khi hết shutdown timeout hoặc sau khi mọi yêu cầu đã kết thúc
- Trong nhiều trường hợp, chỉ cần tiến trình thoát là hệ điều hành sẽ tự thu hồi tài nguyên
- Bộ nhớ do Go cấp phát sẽ được giải phóng khi tiến trình kết thúc
- File descriptor sẽ do hệ điều hành đóng
- Các tài nguyên cấp hệ điều hành như process handle cũng được thu hồi
- Tuy nhiên, vẫn có những trường hợp cần dọn dẹp tường minh
- Kết nối cơ sở dữ liệu phải được đóng đúng cách, và các giao dịch đang mở cần được commit hoặc rollback
- Message queue và broker có thể cần flush message, commit offset hoặc thông báo cho client rằng đang đóng
- Dịch vụ bên ngoài có thể không phát hiện việc mất kết nối ngay, nên tự đóng kết nối sẽ giúp dọn nhanh hơn thay vì chờ TCP timeout
- Một quy tắc tốt là dừng các thành phần theo thứ tự ngược với lúc khởi tạo
defertrong Go rất hợp với mẫu này vì hàm đăng ký sau sẽ chạy trước
- Một số thành phần cần thiết kế shutdown routine riêng, chẳng hạn như khi phải ghi dữ liệu từ memory cache xuống đĩa
Luồng của ví dụ hoàn chỉnh
- Ví dụ đầy đủ tạo root context nhận
SIGINTvàSIGTERMbằngsignal.NotifyContext - Endpoint
/healthztrả về HTTP 503 cùngShutting downnếuisShuttingDownlà true, ngược lại trả vềOK - Handler mẫu sẽ trả về
Hello, world!sau 2 giây, hoặc phản hồi timeout HTTP request nếu request context bị hủy BaseContextgắn vớiongoingCtxđể các in-flight request không bị hủy ngay lập tức khi vừa nhậnSIGTERM- Khi nhận tín hiệu dừng, quy trình diễn ra theo thứ tự sau
- Gọi
stop()để cho phép xử lý mặc định bổ sung - Gọi
isShuttingDown.Store(true)để chuyển readiness sang trạng thái thất bại - Chờ 5 giây là
_readinessDrainDelayđể readiness check được lan truyền - Gọi
server.Shutdownvới thời hạn 15 giây là_shutdownPeriod - Gọi
stopOngoingGracefully()để hủy context đang chạy - Nếu
Shutdownthất bại, chờ cưỡng bức hủy trong 3 giây là_shutdownHardPeriod
- Gọi
1 bình luận
Ý kiến trên Hacker News
Từng bị dính vì trong một số cấu hình, Kubernetes mất lâu hơn tưởng để cập nhật IP đích của load balancer. Với tôi, 90% graceful shutdown là bảo đảm lưu lượng thực sự được drain trước khi pod kết thúc
Thêm sleep 15 giây vào hook
preStoptoàn cục đã làm tỷ lệ HTTP 503 giảm mạnh, đồng thời câu giờ từ lúc bắt đầu hủy đăng ký khỏi load balancer cho đến khiSIGTERMđược chuyển đến ứng dụng, khiến phần xử lý phía ứng dụng đơn giản hơn nhiềupreStopsleep là một giải pháp kỳ diệu để giữ SLO trong rolling deployment chất lượng caoTôi nghĩ Kubernetes có thể cải thiện hai điểm. Pod nên được loại khỏi Endpoints trước khi bắt đầu trình tự termination, và nên có tùy chọn termination delay giống như termination grace. Ngoài ra PDB nên có tùy chọn cho phép tái tạo trước khi eviction
Nếu scrape endpoint Prometheus
/metricsthông thường mỗi N giây, sẽ có một khoảng mà các metric được ghi giữa lần scrape cuối cùng và lúc tiến trình thực sự thoát không được truyền đi. Vì vậy bạn có thể có ấn tượng sai về việc có lỗi trong trình tự shutdown hay khôngNếu không cẩn thận, bạn cũng có thể mất log của vài giây ngay trước khi dịch vụ dừng. Ví dụ, nếu một sidecar như Promtail hoặc Vector theo dõi file log, còn dịch vụ khi khởi động lại truncate cùng đường dẫn đó rồi ghi tiếp, sẽ xuất hiện race condition làm log trong lúc shutdown biến mất
Dù bỏ ra nhiều công sức như vậy, phần lớn dữ liệu vẫn bị bỏ qua hoàn toàn, còn insight kinh doanh hiếm khi tốt hơn đáng kể so với phiên bản “khu ổ chuột” là
sshvào server rồigrepfile log. Tôi không chắc nỗ lực đổ vào hệ sinh thái này đã cải thiện uptime, hiệu năng hay khả dụng một cách đáng kể hay chưaTôi dự định sẽ xử lý những thứ như “đồng bộ log”, “đợi cho ingress bắt kịp liveness handler”
https://github.com/utrack/caisson-go/blob/main/caiapp/caiapp...
https://github.com/utrack/caisson-go/tree/main/closer
Tài liệu hiện vẫn còn thiếu và còn vài thứ chưa xong, nhưng khi đi nghỉ về tôi dự định phát hành bản đầu tiên. Cuối cùng nó sẽ trở thành một meta-platform và thư viện platform tham chiếu để xử lý hạ tầng k8s/otel/grpc+http phổ biến
Vì hành vi này mà dịch vụ của chúng tôi đến giờ vẫn dùng statsd. Mô hình dựa trên push không gặp vấn đề này
Có một cái bẫy nhỏ tôi thường thấy: nhiều người nghĩ rằng gọi
log.Fatalthìdefervẫn được chạy. Thực tế là khônglog.Fatal("fatal")gọios.Exitở bên trong, nên thoát ngay lập tức vàdeferkhông chạy. Ngược lại,panic("fatal")sẽ hiển thị cảfatallẫnin deferNếu một hệ thống phân tán muốn hoạt động đúng lại phụ thuộc vào giả định rằng client phải shutdown một cách graceful, thì sớm muộn gì nó cũng sẽ hỏng nặng
Cách duy nhất để biết hệ thống có chịu được hard crash của thành phần hay không là biến hard crash thành chuyện bình thường luôn xảy ra. Vinh quang thuộc về chaos monkey
Ứng dụng bị hạ bằng
sig intkhác xa với bị giết bằngkill. Ví dụ, blue-green migration cần hành vi shutdown gracefulNghĩ lại thì có khi lại cần thật. Đó có thể là cách duy nhất để bảo đảm giả định ấy là đúng. Giống cách chaos monkey của Netflix vài năm trước
Tôi đã tưởng bài sẽ nói đến cách instance dịch vụ mới tiếp quản listening socket từ instance cũ, để khởi động lại ứng dụng mà không làm rớt bất kỳ kết nối đến nào
Với systemd thì triển khai tương đối đơn giản, và nginx cũng đã hỗ trợ hơn 20 năm. Đáng tiếc là Kubernetes và Docker không hỗ trợ vì giả định việc này được xử lý ở load balancer hoặc reverse proxy
Đồng nghiệp của tôi luôn nói rằng nếu chương trình không xử lý gọn gàng
ctrl cvà vài lệnh thoát khác thì đó là chương trình viết saiTôi thấy Elixir xử lý những phần như thế này rất thông minh. Kinh nghiệm của tôi chưa nhiều, nhưng vì nó được thiết kế để các tiến trình VM nhỏ có thể panic, thoát rồi được tạo lại, nên có vẻ nhu cầu phải cố ý viết routine graceful shutdown giảm đi
Vì đặc tính này đã được tích hợp sẵn vào kiến trúc ứng dụng
Tôi đã làm một thư viện nhỏ để xử lý graceful shutdown trong dự án của mình: https://github.com/eberkund/graceful
Thường thì có vài service cần khởi động, và mỗi service lại có cách khởi động và dừng khác nhau. Có lúc phải instantiate object trước, có lúc có context muốn cancel, cũng có lúc có phương thức
Stopcần gọi. Tôi thiết kế nó để gom tất cả những thứ này vào một chỗ bằng một API thống nhấthttps://pkg.go.dev/git.sr.ht/~mariusor/wrapper#example-Regis...
Pod đang trong quá trình shutdown thì theo định nghĩa là không ở trạng thái ready. Service cũng đánh dấu endpoint là terminating và not ready. Điều này xảy ra khi chuyển sang trạng thái Terminating, nên không nhất thiết phải làm readiness check thất bại
Tôi không biết chính xác thứ tự giữa
SIGTERMvà việc cập nhật các object nhưPod.statushay endpoint slice. Có thể vẫn có một khoảng cửa sổ nhỏ mà kết nối còn đi vào sauSIGTERM, nhưng không phải một khoảng lớn kéo dài “cho đến khi readiness check thất bại” như bài viết ngụ ý. Từ góc nhìn người quản lý cluster, khoảng cửa sổ cực nhỏ đó không quá quan trọng. Chỉ cần đừng nhận kết nối mới, đóng các kết nối hiện có một cách graceful, và thoát trong thời gian hợp lý. Tuy vậy, một nửa số app tôi xử lý thuộc kiểu có xử lýSIGTERMnhưng shutdown rất lâu, hoặc thậm chí không xử lýSIGTERMmà vẫn shutdown rất lâuChúng tôi đã áp dụng Google Wire trong một số dự án của JustWatch và nó đã thay đổi cục diện. Công cụ này ít được biết đến một cách đáng ngạc nhiên, nhưng giúp loại bỏ logic shutdown lộn xộn trong Kubernetes
Wire buộc phải có dependency injection gọn gàng, nên giờ mọi thứ shutdown theo thứ tự xác định thay vì theo thứ tự không rõ ràng
https://go.dev/blog/wire
https://github.com/google/wire