open atlas
↑ Back to track
Go, zero to senior GO · 12 · 03

Healthchecks and lifecycle: liveness vs readiness vs startup, and the shutdown sequence that drops zero requests

Liveness restarts, readiness routes, startup grants boot grace — checking dependencies in liveness turns a DB blip into a restart storm. Real readiness pings what the service needs, flips off first on SIGTERM, and the whole drain fits inside terminationGracePeriodSeconds.

GO Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

The database failover took 28 seconds. The outage it caused lasted 40 minutes. Every pod in the fleet ran a liveness probe against /health — a handler someone had once improved to ping the database, because more checking sounded safer. When the failover began, two hundred perfectly healthy pods started failing liveness together; three misses later, the kubelets killed them all. Now the incident had a second act: hundreds of restarting processes with cold caches reconnecting simultaneously to a database that had just recovered — a connection stampede that knocked it over again, which failed liveness again, which restarted the fleet again. A 28-second blip, looped into 40 minutes by the very probes meant to add safety. The postmortem fit in one sentence: liveness answers should I be restarted, readiness answers should I receive traffic, and the DB check had been wired to the question where restarting is the response. A different team that quarter had the mirror-image bug — no startup probe, a 90-second schema migration on boot, liveness armed at thirty seconds: CrashLoopBackOff, forever, on every deploy. Kubernetes faithfully executes whichever mistake you encode.

Three probes, three different questions

By the end of this lesson you will know exactly which of the three probes belongs to which question — and why getting that wrong turns a 28-second blip into a 40-minute outage every time.

Liveness asks: is this process broken beyond self-repair — deadlocked, wedged, corrupted? Failure means the kubelet restarts the container, so the handler must test only conditions a restart fixes: the event loop responds, period. It must never check dependencies, because a restart does not repair a database — it just converts every dependency outage into a fleet-wide restart storm, exactly the Hook. Readiness asks: should this pod receive traffic right now? Failure removes the pod from Service endpoints — reversible, cheap, exactly what you want during a dependency outage, an overload, or a drain. This is where dependency checks belong. Startup asks: has the process finished booting? Until it succeeds, liveness and readiness are suspended — the grace budget for migrations and cache warms is failureThreshold times periodSeconds, and without it liveness kills a slow boot at its threshold and loops forever.

livenessProbe:
  httpGet: { path: /healthz, port: 8080 }
  periodSeconds: 10
  failureThreshold: 3        # 30 s of true wedge before restart
readinessProbe:
  httpGet: { path: /readyz, port: 8080 }
  periodSeconds: 5
  failureThreshold: 2        # out of rotation within ~10 s
startupProbe:
  httpGet: { path: /healthz, port: 8080 }
  periodSeconds: 5
  failureThreshold: 36       # up to 180 s to boot before liveness arms
Quiz

Your liveness probe calls a handler that pings the database. The DB has a 30-second failover. What happens to the fleet?

Wiring real readiness in Go

A readiness handler that returns 200 unconditionally is theater — it tells Kubernetes a pod is serviceable when the pod has no idea. Real readiness checks what the service actually needs to serve every request, with a timeout, and carries a drain flag:

var ready atomic.Bool // set true once boot completes

mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusOK) // liveness: process alive — nothing else
})

mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
	if !ready.Load() {
		http.Error(w, "draining", http.StatusServiceUnavailable)
		return
	}
	ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
	defer cancel()
	if err := db.PingContext(ctx); err != nil { // the dependency every request needs
		http.Error(w, "db unreachable", http.StatusServiceUnavailable)
		return
	}
	w.WriteHeader(http.StatusOK)
})

Discipline cuts both ways: only hard dependencies belong here — the ones whose absence fails every request. Wiring an optional cache or a non-critical downstream into readiness means one flaky sidecar removes your whole fleet from rotation; optional dependencies degrade responses, they do not unready pods. And keep the endpoint taxonomy straight: /healthz is trivial and for the kubelet, /readyz checks dependencies and gates traffic, /debug/pprof is for engineers and never public — importing net/http/pprof registers handlers on DefaultServeMux as a side effect, which is how profilers end up internet-facing; serve it on a separate localhost or cluster-internal listener.

Why this works

Why does readiness get the dependency checks and liveness never? Because the two responses differ in reversibility. Unready is a no-op state change: the pod stays warm, keeps its caches and connections, and rejoins the instant the dependency returns. A restart destroys process state and adds reconnect load at exactly the moment the dependency is weakest — it converts a partial failure into a bigger one. Check dependencies where the remedy is to wait, not where the remedy is to kill.

The shutdown sequence and the grace budget

Termination is a distributed handshake, and the probes are half of it. On SIGTERM, endpoint removal propagates asynchronously — load balancers keep sending new requests for several seconds. A server that stops accepting immediately serves connection-refused to live traffic on every deploy. The correct order: fail readiness first, wait out propagation, then drain — this spirals straight into the go/03 graceful-shutdown lesson, now with the cluster choreography attached:

<-ctx.Done()                 // SIGTERM from the kubelet
ready.Store(false)           // 1. /readyz now 503 — endpoint removal begins
time.Sleep(5 * time.Second)  // 2. absorb endpoint propagation lag
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx) // 3. drain in-flight requests

The arithmetic is non-negotiable: terminationGracePeriodSeconds (default 30) must cover the propagation sleep plus the drain deadline plus headroom, because when it expires SIGKILL arrives unconditionally and takes the unfinished drain with it. Five plus twenty leaves five seconds of headroom inside the default — and if your slowest legitimate request runs 25 seconds, the default budget is already broken: raise the grace period to 40, do not shave the sleep. Probe timing folds into the same arithmetic from the other side: readiness flips within failureThreshold times periodSeconds, so 2 x 5 s means the LB starts removing you about ten seconds after the flip in the worst case — the sleep covers the controller-to-dataplane lag, the probe cadence covers detection.

Quiz

On SIGTERM, why must the readiness flip happen before srv.Shutdown, with a sleep between them?

Recall before you leave
  1. 01
    Give the exact semantics of the three probes and the production failure story behind each wiring rule.
  2. 02
    Walk the SIGTERM-to-exit sequence with the grace-budget arithmetic for a service whose slowest legitimate request takes 20 seconds.
Recap

The three probes are three different questions, and every production story in this lesson comes from answering the wrong one. Liveness asks whether a restart would help: keep its handler trivial — a 200 that proves the process schedules requests — because the kubelet’s only response is a kill, and a kill does not fix a database; wired to a dependency, liveness converts a 28-second blip into a self-feeding fleet restart storm. Readiness asks whether to send traffic now: this is where the DB pool ping with a one-second timeout belongs, because the response — removal from endpoints — is reversible, keeps the process warm, and is exactly right during outages, overload, and drains; restrict it to hard dependencies or one flaky optional sidecar will unready your fleet. Startup asks whether boot has finished, suspending the other probes within its failureThreshold-times-period budget — the missing piece in every slow-migration CrashLoopBackOff. Endpoint taxonomy follows the questions: /healthz trivial, /readyz honest, /debug/pprof never public — pprof self-registers on DefaultServeMux. Termination is the readiness flag doing double duty: SIGTERM, flip /readyz to 503, sleep roughly five seconds while endpoint removal propagates through detection cadence and dataplane lag, then srv.Shutdown bounded by your slowest legitimate request — the go/03 graceful-shutdown choreography, now cluster-aware. Sum the sequence against terminationGracePeriodSeconds with headroom, and raise the grace period rather than shaving steps: SIGKILL arrives on schedule and does not negotiate. Now when you review a probe config in a PR, you will check one thing first: does the liveness handler touch any external dependency — and if it does, you know exactly what to move and where.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.