Skip to content

fix(ci): make leaked CI containers visible and reapable on the shared Docker host - #563

Merged
smunini merged 2 commits into
mainfrom
fix/ci-leaked-playwright-containers
Aug 18, 2026
Merged

fix(ci): make leaked CI containers visible and reapable on the shared Docker host#563
smunini merged 2 commits into
mainfrom
fix/ci-leaked-playwright-containers

Conversation

@smunini

@smunini smunini commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

What happened

docker ps on the shared Docker host showed a Playwright container still running two weeks after its job ended:

bd93c30ab654  mcr.microsoft.com/playwright:v1.49.1-noble  "sleep 3600"  2 weeks ago  Up 2 weeks  ui-e2e-sqlite-30553500384-1

It is from run 30553500384 (UI Tests (backend matrix), sqlite leg, 2026-07-30) — whose Cleanup step reported success.

Root cause

The host was over its ZFS quota that afternoon. Sibling legs in the same run died with:

docker: Error response from daemon: write /var/lib/containerd/io.containerd.metadata.v1.bolt/meta.db: disk quota exceeded
docker: Error response from daemon: mount callback failed on /tmp/containerd-mount…: mkdir …/dev/pts: disk quota exceeded

docker rm -f needs that same meta.db write, so the cleanup failed too — but it was written:

docker rm -fv "$PW_CONTAINER" 2>/dev/null || true

stderr discarded, || true on the end. The step went green. The job log shows Removing Playwright container... at 14:58:38.654 and the next echo 93 ms later, with no container ID echoed back — it never removed anything. The container still shows Up despite its command being sleep 3600 because containerd could not persist the exit state either.

Why nothing swept it in two weeks

Four independent gaps, all of which had to hold — and did:

  1. No labels. ui-tests.yml / ui-tests-matrix.yml start containers with no --label, so ci.yml's "Reap this run's testcontainers" (label=github.run_id=…) and "Sweep orphaned testcontainers from dead runs" (label=org.testcontainers.managed-by=testcontainers) were both blind to it. Those only see testcontainers-rs containers.
  2. docker-host-gc never pruned containers — volumes, images, build cache, networks only.
  3. Names are run-id-scoped, so a later run's docker rm -f "$PW_CONTAINER" could never match an older leak.
  4. The leak degraded the GC that did run — a running container pins its image, so docker image prune -af --filter until=48h could not reclaim the ~2 GB Playwright image.

Net: on a healthy host cleanup works; the moment the host is sick, it fails silently and the leak becomes permanently unreapable.

Changes

  • Labels — every container the UI workflows start now carries hfs-ci=true, github.run_id and github.workflow.
  • docker-host-gc orphan reap — new age-guarded sweep (new container-max-age-min input, default 180m) scoped to hfs-ci=true. Placed after the volume sweep (which is what unwedges a quota-choked host) and before the image prune (which a surviving container blocks). Label-scoped rather than a blanket docker container prune, following the same defence-in-depth reasoning as the existing anonymous-volume filter: the host runs long-lived services, and a stopped container a human intends to restart must never be collected.
  • No more silent cleanup2>/dev/null || true replaced with a reap() that verifies by docker inspect and emits ::warning:: when a container survives. Deliberately still non-fatal: a leak should not turn a passing suite red. reap() verifies by inspect rather than trusting the exit code, because docker rm -f exits 0 for a container that does not exist and a wedged daemon can report success without removing anything.
  • --keep-storage--reserved-space — the old spelling is deprecated and now warns. The flag is parsed by the client, not the remote daemon, so the runner pool decides which spelling works and the pool is not uniform; the action probes --help rather than pinning either name.

Verification

The host itself has been cleaned manually (zombie removed, ~1.2 GB reclaimed, now 0 B reclaimable).

For the code: the shell was extracted straight out of the YAML and replayed against the real daemon and against fake daemons reproducing each failure mode, under GitHub's actual bash --noprofile --norc -eo pipefail.

Scenario Result
quota exceeded (the real 2026-07-30 failure) prints daemon error + ::warning::, step continues
rm exits 0 but container survives (wedged daemon) ::warning::, step continues
unset ES_CONTAINER (empty arg) silent no-op — also fixes the old :-none default, which asked the daemon to remove a container literally named none
container absent already gone
container present Removed, confirmed gone
sweep, threshold 180m, fresh containers reaps nothing (concurrent runs safe)
sweep, threshold 0m reaps only the hfs-ci=true container; unlabelled one untouched
sweep, daemon unreachable ::warning:: + skip, rather than reporting a clean "Reaped 0"
flag probe on Docker 27.2.1 (old spelling only) correctly selects --keep-storage; probe works with no daemon reachable

All three files pass YAML parse and bash -n on every run: block.

Note

ui-tests-matrix.yml is nightly + workflow_dispatch only, so this PR will not exercise it. ui-tests.yml runs on PRs touching crates/ui/**, which this PR does not — worth a manual workflow_dispatch of both after merge to see the new labels land.

https://claude.ai/code/session_01XVgTcegkXoCzz1Y2LRan9i

… Docker host

A Playwright container from run 30553500384 (ui-tests-matrix, sqlite leg,
2026-07-30) was still running on the shared Docker host two weeks later,
even though its `Cleanup` step reported success.

Root cause: the host was over its ZFS quota that afternoon — sibling legs
in the same run died with "disk quota exceeded" from containerd's meta.db.
`docker rm -f` needs that same write, so cleanup failed too. But it was
written `docker rm -fv "$X" 2>/dev/null || true`, so the error was
discarded and the step went green.

Nothing could collect it afterwards, for four independent reasons:

  - the container carried no labels, so ci.yml's run_id reap and its
    `org.testcontainers.managed-by` orphan sweep were both blind to it;
  - docker-host-gc pruned volumes, images, build cache and networks, but
    never containers;
  - container names are run-id-scoped, so no later run could match it;
  - it pinned the ~2GB Playwright image against `image prune`, so the leak
    actively degraded the one sweep that did run.

Fixes:

  - Label every container the UI workflows start with `hfs-ci=true`,
    `github.run_id` and `github.workflow`.
  - Add an age-guarded orphan reap to docker-host-gc, scoped to the
    `hfs-ci=true` label (default 180m). Runs after the volume sweep, which
    is what unwedges a quota-choked host, and before the image prune, which
    a surviving container would otherwise block. Label-scoped rather than a
    blanket `container prune` so it cannot collect a stopped container a
    human intends to restart.
  - Replace the silencing `2>/dev/null || true` removals with a `reap()`
    that verifies by inspect and emits `::warning::` when a container
    survives. Non-fatal: a leak should not turn a passing suite red.
  - `docker builder prune --keep-storage` is deprecated in favour of
    `--reserved-space`. The flag is parsed by the client, so the runner pool
    decides which name works and the pool is not uniform — probe for it.

Verified by extracting the shell out of the YAML and replaying it against
the real daemon and against fake daemons reproducing the quota error, a
daemon that exits 0 without removing, and an unreachable daemon.

Claude-Session: https://claude.ai/code/session_01XVgTcegkXoCzz1Y2LRan9i
…copy

RUSTSEC-2026-0258 (published 2026-08-17) flags unbounded buffering of
empty HTTP/2 DATA frames as a memory-exhaustion DoS, turning the Security
Audit job red on every branch. The lock carried two h2 copies:

  * 0.4.13, the one our own server actually speaks HTTP/2 through
    (axum -> hyper 1.x, plus tonic and reqwest). Bumped to the patched
    0.4.16 -- this is the copy that matters, since it is the one facing
    untrusted FHIR clients.
  * 0.3.27, reached only via the legacy hyper 0.14 fallback client inside
    aws-smithy-http-client 1.1.10. The 0.3 line is unsupported upstream
    (0.3.27 is its final release; the fix lands in >= 0.4.16 only), so no
    upgrade exists. `cargo tree -i hyper@0.14.32` finds nothing under the
    default feature set -- it links only under `--all-features`, the same
    legacy smithy path already covered by the rustls-webpki ignores -- and
    it is a client talking to AWS endpoints, so reaching the bug would
    require AWS itself to send the hostile frame stream. Ignored with that
    rationale recorded next to the existing entries.

The lock edit is deliberately confined to the four h2 references and the
package block: `cargo update --precise` also re-resolves unrelated entries
(it downgraded socket2 0.6.3 -> 0.5.10 and windows-sys 0.61.2 -> 0.52.0,
neither of which is yanked or advisory-affected), and that churn does not
belong in a security fix. `cargo check --workspace --all-targets --locked`
and `cargo metadata --all-features --locked` both pass on the result.

Claude-Session: https://claude.ai/code/session_011VbASUJbXY1kDoPLgUMgkH
@smunini
smunini merged commit da4ae12 into main Aug 18, 2026
10 checks passed
@smunini
smunini deleted the fix/ci-leaked-playwright-containers branch August 18, 2026 14:38
@codecov

codecov Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant