fix(health): bound health checks that perform I/O - #4985
Open
kimsondrup wants to merge 1 commit into
Open
Conversation
go-sundheit records a result only when a check returns, so a check blocked on an unresponsive dependency records nothing and the last recorded result - a passing one - stands for the whole outage. The process reports itself healthy while unable to serve anything. ExecutionTimeout does not help: it only cancels a context, cancellation is cooperative, and storage/sql never propagates one. Run the check on its own goroutine, stop waiting once it overruns, and report the overrun as a failure. Returning early is what allows the scheduler to proceed to the next tick, so executions are single-flight to keep an unresponsive dependency from accumulating a goroutine per tick. An execution that never returns therefore holds the slot for the life of the process and the check keeps failing; one that returns late releases it and the check recovers. Applied to the storage check, the one check dex performs I/O for. Signed-off-by: Kim Sondrup <kim@sondrup.io>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
Health checks that perform I/O can be blocked indefinitely by the very dependency
they are checking. When that happens the check does not fail — it stops returning,
and the scheduler goes on serving the last result it recorded, which is a passing
one. Dex then reports itself healthy for the whole outage.
This wraps the check so that an execution which overruns is reported as a failure,
and applies it to the storage check.
What this PR does / motivation
We hit this in production. A dex instance became unable to serve any storage
operation and stayed that way for over 13 hours, reporting healthy throughout:
/healthz/readyanswered200in 8ms and the load balancer kept it in rotation.The storage check is a real round trip, so it was blocked by the same condition it
exists to detect — it did not fail, it stopped returning, and a check that never
returns records nothing.
The triggering bug is separate and fixed separately. This PR is about the health
check being unable to report it, or the next one.
Why the existing timeout option is not sufficient
go-sundheitrecords a result when a check returns.checkTask.executeinvokesthe check synchronously, so a check that never returns records nothing and the
previously recorded (passing) result stands.
Setting
gosundheit.ExecutionTimeoutdoes not address this: it only cancels thecontext, and cancellation is cooperative. A check blocked where cancellation is
not observed — waiting on a
database/sqlconnection pool, inside a driver thattakes no context, in a syscall, in cgo — never returns regardless. In our case the
check was parked in a pool wait, and the SQL storage layer does not propagate
context to
database/sqlat all, so there was nothing to cancel.What this changes
storage.NewTimeoutHealthCheckFuncwraps a check function so that:overruns, reporting the overrun as a failure;
unblock promptly;
Single-flight matters because Go cannot stop a goroutine: one that is blocked keeps
running and completes only if the dependency recovers. Starting a fresh one per
scheduler tick would accumulate one goroutine per tick for the length of the outage
(thousands, over hours), each still holding whatever it had acquired. Instead,
while an execution is outstanding, later ones report the failure without starting
another.
One consequence, deliberate: an execution that never returns holds the slot for
the life of the process, so the check reports a failure from then on
even if the dependency later recovers. An execution that cannot be completed
attests to nothing, and a process holding resources it may never release is not
one to report as healthy. Recovery is by restart, which is the outcome such a
process needs anyway. This is documented on the exported function and pinned by a
test.
This bounds the cost of an outage and makes it visible. It does not make the
process any healthier, and is not a substitute for fixing the underlying faults.
Applied to the storage check in
cmd/dex/serve.go— currently the only registeredcheck, and the only one that performs I/O. (
/healthz/liveis static;server.go's/healthzreads the same cached verdict and is fixed by the samechange.)
Tests
storage/health_test.go(12 tests) — a check that never returns is reported as afailure; single-flight is enforced; the check recovers once the outstanding
execution returns; a stuck execution keeps the check failing and is never
restarted; the goroutine releases the slot before delivering its result; the
context is canceled on overrun; caller cancellation releases the caller; a stale
result is never handed to a later caller; details and errors pass through; defaults
are applied; concurrent executions under
-race.storage/health_scheduler_test.go— drives the realgo-sundheitscheduler andasserts it goes unhealthy when the check stops returning. Pointed at the unwrapped
check this test fails, reproducing the symptom above: the scheduler keeps reporting
healthy.
Statement coverage of the wrapper is 100%.
Special notes for reviewers
Defaults.
DefaultHealthCheckTimeoutis 10s against the existing 15sexecution period, so a stalled execution is reported before the next is due. It is
deliberately generous: it bounds a stall, it does not police latency. It is not
currently configurable — happy to expose it in config if you would prefer that
before merge, particularly for larger SQL or etcd deployments where 10s might be
tight under load.
InitiallyPassing(true)is unchanged. It means dex reports healthy for up toone period before the first check completes. Arguably wrong for a readiness probe,
but it is existing behavior and changing it affects every deployment, so it seemed
out of scope here.
Placement. The wrapper makes no assumptions about the check it is given, so
pkg/may be a better home thanstorage. It is instoragebecause that iswhere the only check that needs it lives. Happy to move it.
Wrapping is applied at the registration site, not inside the check, so a future
check that performs I/O must be wrapped explicitly. Wrapping inside
RegisterCheckis not possible without vendoring
go-sundheit. With one check this is fine; attwo it is probably worth a small register-and-wrap helper.
Panics are not recovered.
go-sundheithas norecover(), so a panickingcheck crashes the process both before and after this change. Behavior is unchanged;
converting a panic to a check failure would be a reasonable follow-up but is a
behavior change beyond "do not hang".
Upstream alternative. The cleanest long-term home for this is
go-sundheititself —
checkTask.executecould run the check on a goroutine and record atimeout result on overrun, fixing it for every consumer. Happy to raise that
upstream. This wrapper would still be wanted in the interim, and dex would keep
control of the single-flight policy.
The behavior change to be aware of: a check that correctly starts failing will
now cycle a container configured with a health-gated restart policy, where
previously the outage was silent. That is the intent, but it is a visible
difference for operators.