feat: drain readiness before graceful shutdown

This commit is contained in:
2026-09-20 19:41:28 +08:00
parent 656a81251e
commit 00d4156b25
5 changed files with 66 additions and 9 deletions

View File

@@ -4,7 +4,9 @@ package httpserver
import (
"context"
"encoding/json"
"errors"
"net/http"
"sync/atomic"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/buildinfo"
@@ -14,6 +16,37 @@ import (
type Checker func(context.Context) error
// Readiness 将数据库/结构检查与进程排空状态合并为一个就绪门控。
// 收到终止信号后先调用 BeginDrain,再停止接收新请求;存量请求仍由
// http.Server.Shutdown 在限定时间内完成。这样编排平台有机会先看到 503。
type Readiness struct {
checker Checker
draining atomic.Bool
}
// NewReadiness 创建可在优雅下线期间切换为未就绪的门控。
func NewReadiness(checker Checker) *Readiness {
return &Readiness{checker: checker}
}
// Check 执行就绪检查;排空开始后始终返回内部错误,由 HTTP 层统一隐藏细节。
func (r *Readiness) Check(ctx context.Context) error {
if r == nil || r.draining.Load() {
return errors.New("server is draining")
}
if r.checker == nil {
return errors.New("readiness checker is unavailable")
}
return r.checker(ctx)
}
// BeginDrain 先于 http.Server.Shutdown 调用,阻止排空窗口内的新流量。
func (r *Readiness) BeginDrain() {
if r != nil {
r.draining.Store(true)
}
}
func New(addr string, ready Checker, tasks ...*service.Service) *http.Server {
mux := http.NewServeMux()
write := func(w http.ResponseWriter, status int, body map[string]string) {

View File

@@ -22,6 +22,26 @@ func TestHealthSeparatesLivenessAndReadiness(t *testing.T) {
}
}
func TestReadinessEntersDrainBeforeShutdown(t *testing.T) {
checks := 0
readiness := NewReadiness(func(context.Context) error {
checks++
return nil
})
server := New("127.0.0.1:0", readiness.Check)
ready := httptest.NewRecorder()
server.Handler.ServeHTTP(ready, httptest.NewRequest(http.MethodGet, "/health/ready", nil))
if ready.Code != http.StatusOK || checks != 1 {
t.Fatalf("before drain: status=%d checks=%d", ready.Code, checks)
}
readiness.BeginDrain()
draining := httptest.NewRecorder()
server.Handler.ServeHTTP(draining, httptest.NewRequest(http.MethodGet, "/health/ready", nil))
if draining.Code != http.StatusServiceUnavailable || checks != 1 || strings.Contains(draining.Body.String(), "server is draining") {
t.Fatalf("during drain: status=%d checks=%d body=%s", draining.Code, checks, draining.Body.String())
}
}
func TestVersionIsPublicAndBounded(t *testing.T) {
server := New("127.0.0.1:0", nil)
rr := httptest.NewRecorder()