3 điểm bởi GN⁺ 2025-05-06 | 1 bình luận | Chia sẻ qua WhatsApp
  • 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ư SIGTERMSIGINT bằng os/signal hoặc signal.NotifyContext từ 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ễ preStop hoặc readiness probe thất bại
  • http.Server.Shutdown chặ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ư SIGKILL khô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, SIGTRAP ngay từ trước khi hàm main chạ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ừng
    • SIGINT: được gửi khi người dùng nhấn Ctrl+C trong terminal để dừng tiến trình
    • SIGHUP: 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, SIGINT hoặc SIGHUP, Go runtime sẽ kết thúc ứng dụng

os/signalNotifyContext

  • signal.Notify yê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 selectdefault khi 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
  • signal.Notify có 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+C nhiều lần, thông thường lần nhấn thứ hai không tự động bị nâng thành SIGKILL
    • 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 SIGKILL bằng kill -9
  • Trong quá trình phát triển cục bộ, nếu muốn lần nhấn Ctrl+C thứ hai dẫn tới cưỡng bức thoát, có thể gọi signal.Stop ngay 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ọi stop() thì lần nhấn Ctrl+C thứ hai mới có thể buộc ứng dụng kết thúc

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ụng
    • SIGKILL khô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/http của Go, có thể thực hiện Graceful Shutdown bằng http.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 refused vì 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 sang Terminating
    • 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
    • sleep một lúc trong hook preStop để 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ào terminationGracePeriodSeconds
    • Ở 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.Bool như isShuttingDown để /healthz trả 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

Xử lý các yêu cầu đang chạy

  • Tạo thời hạn bằng context.WithTimeout theo shutdown budget rồi truyền vào server.Shutdown(ctx)
  • Có hai trường hợp server.Shutdown trả 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, Shutdown chỉ 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 BaseContext của http.Server
  • Trong HTTP server, hai context có thể tùy biến là BaseContextConnContext
    • Với Graceful Shutdown, BaseContext phù 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
  • 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ặc time.Sleep() theo cách bỏ qua cơ chế hủy
    • Có thể thay time.Sleep(duration) bằng cách dùng select để chờ đồng thời time.After(duration)ctx.Done()
  • Ở các phiên bản Go cũ, time.After có thể gây rò rỉ bộ nhớ cho tới khi timer chạy xong

Khác biệt giữa ShutdownClose

  • 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.Close của database/sql sẽ đó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ư ECONNRESET hoặc socket 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 khi server.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
    • defer trong 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 SIGINTSIGTERM bằng signal.NotifyContext
  • Endpoint /healthz trả về HTTP 503 cùng Shutting down nếu isShuttingDown là 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
  • BaseContext gắn với ongoingCtx để các in-flight request không bị hủy ngay lập tức khi vừa nhận SIGTERM
  • 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_readinessDrainDelay để readiness check được lan truyền
    • Gọi server.Shutdown với thời hạn 15 giây_shutdownPeriod
    • Gọi stopOngoingGracefully() để hủy context đang chạy
    • Nếu Shutdown thất bại, chờ cưỡng bức hủy trong 3 giây_shutdownHardPeriod

1 bình luận

 
GN⁺ 2025-05-06
Ý 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 preStop toà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 khi SIGTERM được chuyển đến ứng dụng, khiến phần xử lý phía ứng dụng đơn giản hơn nhiều

    • Đúng vậy. preStop sleep là một giải pháp kỳ diệu để giữ SLO trong rolling deployment chất lượng cao
      Tô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 /metrics thô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ông
    Nế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

    • Stack observability trông hơi phi lý. Log, metric, trace mỗi thứ có database, sidecar và stack trực quan hóa riêng; thư viện tích hợp cho từng ngôn ngữ thì mỗi nơi một kiểu; và chi phí cloud cũng khổng lồ
      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à ssh vào server rồi grep file 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ưa
    • Hơn 8 năm qua khi xử lý các ứng dụng Go tải cao, tôi đã gặp đúng những vấn đề này và đang xử lý chúng trong thư viện nền tảng theo đúng kiểu như vậy. Việc phát triển và cải tiến platform cùng rolling deployment ở mỗi công ty gần như là sở thích của tôi
      Tô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
    • Tôi luôn không hiểu vì sao Prometheus và các công cụ liên quan lại dùng mô hình pull. Trong khi hầu hết đều dùng mô hình push
    • Tôi tò mò không biết có ai từng thấy giải pháp tiện lợi cho vấn đề này chưa. Nếu chu kỳ scrape là 15 giây thì không thể đợi 30 giây chỉ để ghi metric hai lầ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.Fatal thì defer vẫn được chạy. Thực tế là không
    log.Fatal("fatal") gọi os.Exit ở bên trong, nên thoát ngay lập tức và defer không chạy. Ngược lại, panic("fatal") sẽ hiển thị cả fatal lẫn in defer

  • Nế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

    • Tôi tin mạnh điều đó đến mức khi thiết kế không cân nhắc graceful shutdown. Các thành phần phải có thể hard crash một cách an toàn, thậm chí thường xuyên; và nếu một tỷ lệ quan trọng của hệ thống vẫn đang hoạt động như dự định thì toàn hệ thống không được bị ảnh hưởng đáng kể
      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
    • Graceful shutdown để tử tế với client hoặc workflow là một chuyện; còn hệ thống muốn hoạt động thì client phải dựa vào nó lại là chuyện rất khác
    • Thời máy chủ vật lý ngày xưa, chúng tôi dùng STONITH cho việc đó: https://smcleod.net/2015/07/delayed-serial-stonith/
    • Ngay cả với tình huống có thể phục hồi, vẫn có lý do chính đáng để việc shutdown thông thường không trông giống như một lần shutdown thảm họa
      Ứng dụng bị hạ bằng sig int khác xa với bị giết bằng kill. Ví dụ, blue-green migration cần hành vi shutdown graceful
    • Đúng. Nhưng dù phần mềm được thiết kế để chịu được việc rút phích cắm, không có nghĩa là khi shutdown ta cần thực sự rút phích
      Nghĩ 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 c và vài lệnh thoát khác thì đó là chương trình viết sai

    • Ctrl-C được dành riêng để sao chép vào clipboard. Dùng nó làm thao tác dừng chương trình là rất phản trực giác và sẽ khiến người dùng tức giận
  • Tô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 tò mò điều đó loại bỏ nhu cầu graceful shutdown mà tác giả đề cập như thế nào
  • 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 Stop cầ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ất

  • 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 SIGTERM và việc cập nhật các object như Pod.status hay 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 sau SIGTERM, 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ý SIGTERM nhưng shutdown rất lâu, hoặc thậm chí không xử lý SIGTERM mà vẫn shutdown rất lâu

  • Chú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