ci(benchmark): stop the FHIR benchmark from exhausting the shared Docker host - #453
ci(benchmark): stop the FHIR benchmark from exhausting the shared Docker host#453mauripunzueta wants to merge 3 commits into
Conversation
…ker host
Run 30550776427 did not just fail — it took every other job on the Docker
host down with it for about three hours, and destroyed the evidence needed
to explain itself.
What happened, from the log:
14:30:12 import starts
14:52:20 497/1000 bundles, steady ~23/min
14:52:21 503 remaining iterations "complete" in 19 seconds
14:58:38 search setup logs the cause:
HTTP 503 "error connecting to server"
The `max` request duration was exactly 15m0s — `HFS_REQUEST_TIMEOUT=900` —
so the in-flight requests all hit the server-side ceiling at once and the
rest failed on arrival. HFS was alive and returning well-formed
OperationOutcomes; PostgreSQL was gone. That also explains the search
suite's 4.8M requests at 40k/s with sub-millisecond latency and 100%
failures: fast 503s from a server that never reached a database.
Postgres was started with no volume, so its data lived in the container's
writable layer on the daemon's own storage — the filesystem that then
reported `disk quota exceeded`. The runner was killed at 15:04:28, and a
killed *runner* does not run `if: always()` steps, so the cleanup never
executed and the containers were orphaned holding the whole import.
Four changes:
1. Postgres data moves to a named, labelled volume, mounted at the image's
declared VOLUME root so it survives PGDATA relocations across postgres
image versions. Container-layer data cannot be reclaimed independently
of the container; a named volume can be dropped by label even after the
container is gone.
2. A reaper at the START of the job removes `hfs-bench-*` containers and
dangling labelled volumes left by earlier runs. A cleanup step cannot
defend against itself never running, so this is the compensating control
for the killed-runner case. Both the name prefix and the run-id
exclusion are re-checked in the loop rather than trusting
`--filter name=` to anchor — a reaper that deletes someone else's
container would be worse than the leak it fixes. The run-id exclusion is
also what stops the concurrent sqlite and postgres matrix legs, which
share a run id, from killing each other.
3. A canary creates a container and writes 256 MiB before anything is
allocated. It catches an exhausted host in ~10 seconds with the remedy
printed, instead of 20 minutes in with a confusing symptom and more
debris. It does not catch a merely tight host; that trade is documented
at the call site.
4. The artifact uploads move ahead of per-shape attribution and summary
generation. Those steps are where this run died, so the server log and
raw results — the only non-recomputable outputs — were lost for exactly
the run that needed them. Upload first, analyse second.
Not addressed here: whether the Postgres import path is genuinely slow. The
median request was 120ms but 20 VUs over 22 minutes yielded 497 bundles, and
"HFS is slow" cannot be separated from "the host was already degrading"
without a clean run. That measurement is what these changes make possible.
Also unaddressed: `crud.js:47` derefs the response body unguarded, turning a
dead backend into 12,139 stack traces — that file is vendored from
HealthSamurai, so it needs an upstream fix or a local patch.
…eap each other The reaper excluded this run's own containers by run id, which covers the sqlite/postgres matrix legs (they share a run id). It did not cover two *different* runs: the `concurrency` group only cancels in-progress runs on the same ref, so a dispatch on main and one on a PR branch run side by side with different run ids — and each would have reaped the other's containers mid-benchmark. Adds the age guard ci.yml already uses for the same reason. 120 minutes is comfortably longer than a full leg (import alone is ~22 min), so a running benchmark is never in range. The canary covers the window where an orphan is younger than the guard but still holding the host's space. Found by reading #452, which fixes the same class of leak repo-wide.
|
Overlaps #452 ( Same conclusion reached independently. #452 supplies a fact I didn't have and have now incorporated: the shared host runs a 200G ZFS quota, and the leaking objects are anonymous volumes, because the postgres/Elasticsearch/mongo images declare Merge order: #452 first, then rebase this. For Reading #452 also surfaced a real bug here, now fixed in |
Verified end to end — dispatched on this branchRun 30576511473, Ran prewarm-only deliberately: it exercises every change in this PR (reaper → canary → volume → suites → uploads → cleanup) while writing almost nothing, so it was safe to run alongside in-flight CI on the shared host. The multi-GB import run is the one that needs a quiet host. The reaper found the orphans from the failed run — they were still there. Those are exactly the two containers orphaned when run 30550776427's runner was killed, still holding the Synthea import 5½ hours later. CI going green earlier did not mean the host had been cleaned — enough space was freed elsewhere to unblock it, but the benchmark's own debris survived until this reaper removed it. That is the failure mode this PR exists to close, reproduced and fixed on the first real run. Canary: passed, host has writable space. Named volume, full lifecycle: Artifacts, which is the point of the reordering:
Run 30550776427 produced only the binary artifact — the log and results were lost because the uploads sat behind the step that died. Both now exist. One finding for #452
22 GB of reclaimable anonymous volumes against a 200 G quota. That is #452's leak, quantified — and it is untouched by this PR, which only manages its own named volume. Worth pruning on the host now, independently of either PR. Still not doneThe full import run, for an actual measurement — it needs a quiet host and ~50 minutes. Say the word and I'll dispatch it. |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
#452 landed first, as suggested — it changed `docker rm -f` → `docker rm -fv` across 16 workflows, including the four sites in fhir-benchmark.yml this branch rewrites. All four conflicts were the same shape: identical semantics, different spelling, with this branch adding lines around them. Resolved by keeping this branch's version (a strict superset for this file: the same `-v` plus the named PG data volume, the labels the reaper matches on, and the volume teardown) while adopting main's `-fv` spelling, so all five `docker rm` sites here read the same as the other fifteen workflows. Verified after resolution: YAML parses, every `run:` block passes `bash -n`, the step order is unchanged, and the net diff against the new main is purely additive — no `-fv` regressed back to `-f`.
Code reviewFound 3 high-confidence issues, all in the new "Reap stale benchmark containers and verify Docker host storage" step. (Note: inline per-line comments were not available in this environment, so all findings are consolidated here with direct line links.) 1. Named Postgres volume is never actually pruned by the reaper hfs/.github/workflows/fhir-benchmark.yml Lines 304 to 308 in 43fd3c6 Since Docker Engine 23.0, Fix: 2. Canary cannot detect a genuinely full disk hfs/.github/workflows/fhir-benchmark.yml Lines 321 to 326 in 43fd3c6
Fix: drop the pipe so 3. Canary container name is not scoped per matrix leg — concurrent legs can collide hfs/.github/workflows/fhir-benchmark.yml Lines 321 to 326 in 43fd3c6 The Fix: scope the name by backend (e.g. |
Hardens
fhir-benchmark.ymlso a failing benchmark can no longer take out the shared Docker host — and so the next failure is diagnosable.Relates to #190 (the benchmark harness itself).
What happened on run 30550776427
That run did not merely fail. It exhausted the Docker host, which then failed every testcontainers-based job in the repo with
disk quota exceededfor roughly three hours —Test Ruston unrelated PRs,UI Testsonfix/449-es-nightly,HFS Redis Cluster Testsonfeat/cluster-capable-state. And it destroyed the evidence needed to explain itself.The timeline is exact:
That is not a speed-up; it is every remaining request failing instantly, and 503 ≈ the 504 failed checks. The
maxrequest duration was exactly 15m0s =HFS_REQUEST_TIMEOUT=900, so the 20 in-flight requests all hit the server-side ceiling simultaneously.The search suite's
setup()had the answer all along:HFS was up, returning well-formed OperationOutcomes. PostgreSQL was gone. That also resolves the one number I could not explain from the summary alone: the search suite's 4,796,883 requests at ~40k/s with 699µs average latency and 100% failures were fast 503s from a server that never touched a database. Likewise crud's 12,139
TypeErrors — those were error bodies, not resources.Root cause: Postgres was started with no volume, so its data lived in the container's writable layer on the daemon's own storage — precisely the filesystem that reported
disk quota exceeded. The runner was then killed at 15:04:28, and a killed runner does not runif: always()steps, so the cleanup at the end of the job never executed and the containers were orphaned still holding the import.The four changes
1. Postgres data on a named, labelled volume (
-v "$PG_VOLUME:/var/lib/postgresql").Mounted at the image's declared VOLUME root rather than at
$PGDATA, so it keeps working across postgres image versions that relocate PGDATA within that tree. The point is reclaimability: container-layer data cannot be freed independently of the container, so an orphan pins the space forever; a named volume can be dropped by label long after the container is gone.2. A reaper at the START of the job.
Removes
hfs-bench-*containers and dangling labelled volumes left by earlier runs. This is the load-bearing change — a cleanup step cannot defend against its own step never running, so the durable control has to be at the start.Both conditions are re-checked in the loop rather than trusting
--filter name=to anchor, because a reaper that deletes someone else's container would be worse than the leak it fixes:The run-id exclusion is also what stops the concurrent
sqliteandpostgresmatrix legs — which share a run id — from killing each other. I verified the filter against a hostile candidate list includingsome-other-teams-postgres,my-hfs-bench-lookalike,prod-db, and this run's own three containers: only the two genuinely stale ones are reaped, even if the docker filter returns everything.3. A disk canary before anything is allocated.
docker system dfreports image and volume usage but not a filesystem quota, which is what actually bit us. The only reliable check is to ask the daemon to do the thing we need it to do — create a container and write to its layer. Catches an exhausted host in ~10 seconds with the remedy printed, rather than 20 minutes in with a confusing symptom and more debris.It does not catch a merely tight host — 256 MiB is a floor, and a host with 1 GB free would pass here and still fail during the import. That trade is documented at the call site.
4. Artifact uploads move ahead of the post-processing steps.
Upload resultsandUpload server logsat after per-shape attribution and summary generation — which is exactly where this run died. So the server log and raw results, the only outputs that cannot be recomputed, were lost for precisely the run that needed them. I could not retrieve HFS's own log for this investigation because of that ordering. Everything after the uploads is derived data; upload first, analyse second.Scope — what this does not do
crud.js:47still derefs the response body unguarded (getRtId), turning a dead backend into 12,139 stack traces instead of a clean failure count. That file is vendored fromHealthSamurai/fhir-server-performance-benchmark, so it needs an upstream fix or a deliberate local patch — out of scope here.Verification
Workflow-only; no Rust touched.
yaml.safe_loadparses; every modifiedrun:block passesbash -nwith${{ }}expressions stubbed; the reaper's filter logic was unit-tested against an adversarial name list as described above.The real proof is a manual dispatch on a healthy host, which I have not run — it consumes the Docker host for ~50 minutes and I did not want to take it out from under in-flight CI without asking. Worth doing before merge, ideally after confirming the orphaned
hfs-bench-*containers from run 30550776427 are actually gone (CI went green again at 17:53, so the host was reclaimed, but I cannot see whether that was a clean prune or a partial one).