Files
go-admin/cmd/api/shutdown_test.go
T
zhangwenjian a442eadb96 feat: keep serving for a configurable window before the listener closes
/ready has failed from the moment shutdown begins since the readiness probe was
added, and the order it does that in is right: reversed, the state would be
reported after the connections were already cut. But order alone does not
produce a window. Nothing waited between the flip and Shutdown, so the two were
microseconds apart, and a poller on a multi-second interval never saw the 503 -
it saw a refused connection, which is the thing the probe was supposed to
avoid. Polling a container through a SIGTERM on the demo host recorded exactly
that: 200, then connection refused, and no 503 in between.

extend.shutdown.drain is that wait. The process keeps serving normally for it -
answering requests, not refusing them, because refusing them would move the
outage earlier rather than avoid it - and only then closes the listener.

It is zero by default, so nothing changes for a deployment that does not ask
for it. That is not timidity: the budgets are spent one after another, and a
non-zero default would push every existing shutdown closer to the orchestrator's
grace period, where being cut off part-way through the cleanup callbacks is
worse than never draining at all.

Keep-alive is switched off with the flip. The server keeps connections alive
until Shutdown sets shuttingDown() itself, so without this the pooled
connections a balancer holds would sit untouched for the whole window and be
cut at the end of it anyway - the cost of the window without its benefit. This
is the switch Shutdown flips, moved earlier by the window's length.

The signal disposition is restored after the window rather than on the first
signal. Before there was a window, the interval where a second signal killed
the process outright was only reachable while a cleanup callback hung; putting
a multi-second wait inside it would have made every ordinary shutdown
interruptible for the length of the drain. A second signal during the window is
taken by the channel and ends the window early instead - somebody sending
another kill wants this over with sooner - and the escape hatch comes back the
moment the window does.

What the window is worth depends on who removes this instance. A balancer that
polls /ready acts on the 503 and needs the window to cover its check interval
times its failure threshold; a Kubernetes Service withdraws the endpoint when
the Pod is deleted, concurrently with SIGTERM and regardless of what the probe
returns, and there the window covers the delay in that removal reaching every
node. The three comments that used to say a balancer "has a chance to" take the
instance out said it without either qualification, which is how a claim comes to
be repeated after a live test has refuted it.

The subprocess test polls the real probes on a connection it opens after the
signal - a reused one can be served after the listener is closed, which would
let this pass against a shutdown that had already broken it - and asserts on the
draining answer in the body, not on the status code. With no database the status
is 503 from start-up, so a status-code assertion would hold even with
BeginDraining deleted. Two window lengths, because one proves only that
something takes that long.
2026-09-06 21:49:17 +08:00

140 lines
4.8 KiB
Go

package api
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
otherrouter "go-admin/app/other/router"
"go-admin/common/health"
ext "go-admin/config"
)
// The seconds in the configuration and the durations the sequence waits on are
// two spellings of one budget, and only one of them is printed at start-up.
func TestBudgetFromSeconds(t *testing.T) {
got := budgetFrom(ext.ShutdownBudget{Drain: 10, Server: 5, Cleanup: 3})
want := budget{
drain: 10 * time.Second,
server: 5 * time.Second,
cleanup: 3 * time.Second,
}
if got != want {
t.Errorf("budgetFrom = %+v, want %+v", got, want)
}
}
// The package variables and config.Default*Seconds have to say the same thing.
// They are the same default written twice - once as durations for the shutdown
// and once as seconds for the fallback - and a deployment that configures
// nothing is entitled to one answer, not two.
func TestDefaultBudgetIsTheConfiguredFallback(t *testing.T) {
unconfigured, err := ext.Shutdown{}.Budget()
if err != nil {
t.Fatalf("the empty section did not resolve: %v", err)
}
if got, want := defaultBudget(), budgetFrom(unconfigured); got != want {
t.Errorf("defaultBudget = %+v, want the unconfigured budget %+v", got, want)
}
}
// The rate limiter must not answer for the probes.
//
// It is installed on the engine, so without this the probes are limited like
// any other route and answer 429 above the threshold. A liveness probe that
// collects 429s fails its threshold and the container is restarted - taking
// capacity out of a deployment that is already short of it and pushing the
// rest closer to the threshold. The limiter working exactly as designed is
// what would cause it.
//
// The stand-in rejects everything rather than being a real limiter: what is
// under test is which requests reach it, and a real one would need the traffic
// to cross a threshold before it said anything.
func TestTheProbesSkipTheRateLimiter(t *testing.T) {
gin.SetMode(gin.TestMode)
var reached []string
r := gin.New()
r.Use(exemptProbes(func(c *gin.Context) {
reached = append(reached, c.FullPath())
c.AbortWithStatus(http.StatusTooManyRequests)
}))
v1 := r.Group(otherrouter.APIPrefix)
otherrouter.RegisterMonitorRouter(v1)
v1.GET("/business", func(c *gin.Context) { c.Status(http.StatusOK) })
for _, tc := range []struct {
path string
limited bool
}{
{otherrouter.APIPrefix + otherrouter.HealthPath, false},
{otherrouter.APIPrefix + otherrouter.ReadyPath, false},
// Not a probe, and deliberately not exempt: the exemption is for the
// two routes an orchestrator acts on, not for everything under
// /api/v1 that happens to be unauthenticated.
{otherrouter.APIPrefix + "/metrics", true},
{otherrouter.APIPrefix + "/business", true},
} {
t.Run(tc.path, func(t *testing.T) {
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, tc.path, nil))
if tc.limited {
if w.Code != http.StatusTooManyRequests {
t.Errorf("answered %d, want the middleware's 429 - it was skipped for a route that is not a probe", w.Code)
}
return
}
if w.Code == http.StatusTooManyRequests {
t.Errorf("answered 429; a probe that can be rate-limited gets the container restarted under load")
}
})
}
// Said separately, because a probe could also answer 429 by itself: what
// has to be true is that the middleware never saw the request.
for _, p := range reached {
if probePaths[p] {
t.Errorf("the middleware ran for %s", p)
}
}
}
// /health has to stay 200 while draining, and it is the assertion most easily
// lost by accident: making the liveness probe follow the readiness flag reads
// like tidying up, and it turns every rolling restart into a kubelet-issued
// kill part-way through the drain.
func TestHealthStaysUpWhileDraining(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
v1 := r.Group(otherrouter.APIPrefix)
otherrouter.RegisterMonitorRouter(v1)
ask := func(path string) int {
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
return w.Code
}
if got := ask(otherrouter.APIPrefix + otherrouter.HealthPath); got != http.StatusOK {
t.Fatalf("/health answered %d before draining, want 200", got)
}
// Process-wide and one-way - nothing clears it - so this is the last thing
// in this package that may run in-process and care. Everything else that
// exercises draining does so in a child process of its own.
health.BeginDraining()
if got := ask(otherrouter.APIPrefix + otherrouter.HealthPath); got != http.StatusOK {
t.Errorf("/health answered %d while draining, want 200 - liveness is "+
"\"should I restart you\", and the answer during a drain is no", got)
}
if got := ask(otherrouter.APIPrefix + otherrouter.ReadyPath); got != http.StatusServiceUnavailable {
t.Errorf("/ready answered %d while draining, want 503", got)
}
}