mirror of
https://github.com/go-admin-team/go-admin.git
synced 2026-09-22 02:27:57 +00:00
/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.
140 lines
4.8 KiB
Go
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)
|
|
}
|
|
}
|