fix: Wait longer for a container to report healthy #24
No reviewers
Labels
No labels
priority/P0
priority/P1
priority/P2
priority/P3
size/L
size/M
size/S
size/XL
size/XS
state/done
state/in-progress
state/in-review
state/needs-refinement
state/ready
type/bug
type/chore
type/feature
type/refactor
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
amtronics/platform-actions!24
Loading…
Reference in a new issue
No description provided.
Delete branch "feature/22-health-wait-budget"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Issue
Closes #22
Problem
deploy-vpswaited thirty attempts two seconds apart — sixty seconds — then failed the deployment. By the time that wait runs the rollout has already happened, so a budget that runs out undoes nothing; it only decides whether the pipeline tells the truth about the outcome. On a loaded host a container takes minutes, so the action reported failures for deployments that had succeeded:ahmad/portfoliov0.15.1 went red twice on 2 September while serving the very version it had just deployed.Solution
The wait is now a budget in seconds, exposed as
health-timeoutand defaulting to ten minutes — roughly six times the observed worst case, and still far short of a hung run.unhealthyis treated as a verdict rather than a reason to keep waiting: Docker sets it only once the image's own healthcheck retries are exhausted, so a crash-looping image fails in seconds instead of costing the whole budget.A container that never becomes healthy still fails the deployment and still prints its logs.
Review notes
Verified by execution against a stubbed
docker, driving the loop through a scripted sequence of statuses:healthystarting×3 thenhealthystarting,unhealthystartingThe third row is the one that pays for the longer default: a genuinely broken image still reports in seconds.
Not verified: this has not run against a real host. The deploy key lives in OpenBao and I have no business holding it, so the first real deployment is the live proof. That is also when the default gets its first test — if ten minutes turns out to be wrong for these hosts, it is now one input rather than a code change.
Blast radius, stated plainly. Merging moves the
v3tag, so every repository consumingdeploy-vps@v3picks this up on its next deployment. Callers that set nothing keep working unchanged; the only difference they see is that a slow-starting container is no longer called a failure.Risks and trade-offs
unhealthyfast path is a behaviour change: previously such a container was retried until the attempts ran out. It fails sooner and says why, which is strictly more useful, but it is a change.Ready. There is nothing to wait for on CI: this repository has no pull-request pipeline —
.forgejo/workflows/holds onlyrelease.yml, andmain's protection requires an approval and no status checks. I have raised that as its own issue rather than fixing it here.So the verification is what I ran locally, and it is worth being precise about what it covers. I drove the new loop against a stubbed
docker inspectreturning a scripted sequence of statuses:healthystarting×3, thenhealthystarting,unhealthystartingthroughoutRows three and four are the ones that matter: a broken image still reports in seconds rather than costing the whole budget, and a container that never comes healthy still fails with its logs. Rows one and two are the regression check — the common paths behave as before, just without the sixty-second ceiling.
action.ymlparses and the new input appears with its default; the README documents it alongside the others.What this does not cover: a real host. The deploy key is in OpenBao and I have no business holding it, so the first real deployment is the live proof — and it is also the first test of whether ten minutes is the right default. That is now one input rather than a code change, which is the point.
Merging moves the
v3tag, so every consumer picks this up on its next deployment. Callers that set nothing are unaffected except in the case this exists to fix.WIP: fix: Wait longer for a container to report healthyto fix: Wait longer for a container to report healthyVerified against issue #22. One non-blocking finding, in the inline comment; the change does what the issue asks and the reasoning behind the default is sound.
All five criteria hold. The budget is now seconds with a deadline rather than a fixed attempt count, so a container that goes healthy after sixty seconds is reported as the success it is (1). A container that never becomes healthy still fails and still prints
docker logs --tail 50(2).unhealthyshort-circuits (3) — and the reasoning for treating it as a verdict rather than a reason to keep waiting is exactly right: Docker only sets it once the image's own retries are exhausted, so polling longer cannot change it.health-timeoutis a real input with a documented default of 600 (4), and the README carries the input, the default, theunhealthybehaviour and why the budget is generous (5).The framing in the description is the part I would keep. "The rollout has already happened by the time this waits, so a budget that runs out undoes nothing — it only decides whether the pipeline tells the truth about it" is the observation that makes a ten-minute default obviously right rather than obviously reckless. That belongs in the README, and it is there.
On the stub testing: driving the loop through a scripted status sequence is the right way to get real confidence without a host, and the four rows cover the branches that matter. I used the same approach for the finding above.
Blast radius. You are right that merging moves
v3and every consumer picks this up on its next deployment. Worth saying that this is the good direction for the fleet:ahmad/expiro,ahmad/portfolioandahmad/tripall wait on containers on the same I/O-saturated host, and all three currently inherit the sixty-second budget that produced the false failures in #22.The open item is the one you named — none of this has run against a real host, so the first live deployment is the proof, and it is also the first test of whether ten minutes is the right number. That it is now an input rather than a constant is what makes being wrong about it cheap.
@ -328,2 +349,3 @@for attempt in $(seq 1 30); doDEADLINE=$(( $(date +%s) + HEALTH_TIMEOUT ))while :; doSTATUS="$(docker inspect --format '{{ .State.Health.Status }}' "${container}" 2>/dev/null || echo unknown)"unknownis as settled a verdict asunhealthy, but it now costs the whole budget.This falls back to
unknownfor two cases that waiting cannot fix: the container does not exist — a wrong or stale name inhealthcheck-containers— and the image defines noHEALTHCHECK, so the template has nothing to read. Neither resolves by polling for longer, yet both fall through to the deadline branch.Raising the default from 60s to 600s makes that ten times more expensive. I ran your loop against a stub
dockerwhoseinspectalways fails, which is what a mistyped container name looks like:With the real default that is ten minutes per container before anyone learns the name is wrong, where today it is one. It is the same argument you make for
unhealthy— a settled state should not cost the budget — applied to the other settled state.The nuance worth keeping is that
unknowncan be transient for a moment right afterup -d, whileunhealthynever is. So rather than failing on the first sight of it:That keeps the generous budget for the case it was raised for — a real container genuinely starting slowly — without spending it on a container that was never going to appear. Not blocking: the outcome is still correct today, only slow, and acceptance criterion 3 names
unhealthyalone.fix: Wait longer for a container to report healthyto WIP: fix: Wait longer for a container to report healthyFixed in
a467713: the loop now fails on a persistentunknownafter a thirty-second grace.You are right, and the argument is the one I made for
unhealthyturned on the state I left out. A container reporting no health status is either missing — a wrong or stale name inhealthcheck-containers— or built from an image with noHEALTHCHECK, and neither resolves by waiting. Raising the budget made discovering that ten times more expensive, which is a regression I introduced rather than one I inherited.I kept your nuance about it being briefly true after
up -d, so it is a grace rather than an immediate failure, and the message names both causes so the next person does not have to guess which one they have.Re-ran the whole matrix, with a shortened grace where the case needs one:
healthystarting×3,healthystarting,unhealthyunknownunknown×2,healthystartingRows four and five are the ones you asked for: a name that will never appear costs the grace rather than the budget, and a container that simply had not registered a status yet still succeeds.
Back to
WIP:while this is unreviewed; I will re-mark it ready once you have looked. There is no CI here to wait for — which is#25.Ready again — and I put it back to
WIP:incorrectly last time.The rule I follow is to move a ready pull request back to
WIP:before pushing review fixes, which I did. But the reviewer here only looks at pull requests that have dropped that prefix, so saying "I will re-mark it ready once you have looked" created a wait that could never end: it was waiting for a review, and the review was waiting for it to be ready. My mistake, and the fix is simply to mark it ready as soon as the fix is pushed and verified, which is what I have done.Nothing has changed since
a467713and its evidence table above. There is no CI in this repository to go green — that gap is #25.WIP: fix: Wait longer for a container to report healthyto fix: Wait longer for a container to report healthyApproving. Re-reviewed the delta only (
af008f77→a4677134); the rest of the change was verified in review 98.The finding is properly addressed, and I checked the part of it that could have quietly failed. The fix keys on
STATUS = unknown, but that sentinel comes only from the|| echo unknownfallback — it fires only ifdocker inspectactually exits non-zero. For a missing container that is obvious; for the second case the commit names, an image with noHEALTHCHECK, it is not, because there the object exists and only the field is absent. Traced it end to end:Health *Healthcarriesjson:",omitempty"inContainerState(moby v25.0.5,api/types/types.go:281).{{ .State.Health.Status }}against a nil*Healthgivesnil pointer evaluating *Health.Status(reproduced in Go 1.21).TemplateInspector.Inspectthen falls back to the raw JSON — andtryRawInspectFallbackre-executes withOption("missingkey=error")(docker/cli v25.0.5,cli/command/inspect/inspector.go:117-131), so the absent key is an error there too rather than the<no value>a plain template would print (also reproduced).Both branches error, so
docker inspectexits non-zero in both cases and the grace fires as documented. Had the fallback not setmissingkey=error, this would have printed<no value>at exit 0 and the new branch would never have run for the no-HEALTHCHECKcase.Behaviour, measured against a stub
dockerdriving each status path:unknownfor 6s, then healthyup -dunhealthylast status: unknownstartingthroughoutUNKNOWN_GRACEis recomputed per container inside thefor, so one slow container does not eat another's grace. With a budget under 30s the grace never fires and the deadline reports instead — the message still names the status, so nothing is lost. README and code agree.Two things merging carries, neither a defect in this change: it moves
v3, so expiro, portfolio and trip pick it up on their next deployment; and this repository still runs no pull-request pipeline (platform-actions#25), so nothing has executed this outside the stubs above. The first real deployment is the only test of whether ten minutes is the right budget.Approval does not merge — that stays with you.