fix: drain the runner before replacing its container mid-job - #54
Merged
Conversation
Replacing a runner container while its runner is executing a CI job destroys the job: GitHub waits out the runner heartbeat (~10 min), then fails every remaining step (observed on BumpApp/bump 2026-08-10, two instrumentation suites killed by one deploy — BumpApp/bump#5223). - Split the image pull into its own docker_image_pull task (safe while a job runs) and run the container task with pull: false — a new digest still triggers the replace via image-ID comparison. - Add a check-mode pass of the container task to detect config-driven recreation without touching anything. - When either signals a replace and the container exists, poll the runners API (org or repo scope, same PAT used for registration) until no runner matching ^name(-|$) is busy; bounded by github_runner_drain_timeout_minutes (45) at github_runner_drain_poll_seconds (30) intervals. Timeout fails the deploy with guidance instead of killing the job. - github_runner_drain_before_replace=false restores old behavior; github_runner_force_replace=true is the emergency override. - Shared container args move to vars/main.yml (role-prefixed for ansible-lint); community.docker floor raised to >=3.6.0 for docker_image_pull.
There was a problem hiding this comment.
Pull request overview
This PR prevents destructive runner container replacement during active GitHub Actions jobs by adding a “drain before replace” gate that waits for the runner to go idle when a replacement is imminent (image digest change or config-driven recreation).
Changes:
- Split image pulling into a separate
docker_image_pullstep and run the container task withpull: falseto safely detect “replace imminent”. - Add a drain gate that polls the GitHub runners API and blocks replacement while the matching runner is
busy, failing loudly on timeout (with escape hatches). - Centralize shared container arguments in
roles/github_runner/vars/main.ymland document new variables in defaults/README/CHANGELOG; raisecommunity.dockerminimum version.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| roles/github_runner/vars/main.yml | Adds internal computed vars for container args and constructs the runners API URL/name-matching regex used by the drain gate. |
| roles/github_runner/tasks/main.yml | Adds image pull + “plan” pass and introduces the drain-before-replace polling gate before deploying the container. |
| roles/github_runner/defaults/main.yml | Introduces new drain/force-replace/API-base configuration defaults. |
| README.md | Documents newly added role variables for drain behavior and API base configuration. |
| galaxy.yml | Raises community.docker dependency floor to support docker_image_pull. |
| CHANGELOG.md | Records the drain-before-replace behavior, new vars, and dependency change for 0.1.4. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…clamp - Probe the runners listing before polling and fail loudly if it is paginated beyond one page: a truncated listing could hide the target runner and let the drain conclude idle while a job is mid-flight. - Guard the until expression on response shape so a rate-limit or transient non-JSON body counts as still-waiting (retry) instead of aborting the drain with an undefined-key error. - Clamp the poll interval to >=1s so drain_poll_seconds: 0 cannot divide the retry computation by zero. - Rescue message now includes the underlying failure so a pagination trip is distinguishable from a busy-timeout.
0.1.4 is already released on Galaxy (tagged at the docker-timeouts head), so the drain-before-replace work ships as 0.1.5; changelog split accordingly.
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.
Problem
Replacing a runner container while its runner is executing a CI job destroys the job: GitHub waits out the runner heartbeat (~10 min), then fails every remaining step. Observed concretely on BumpApp/bump 2026-08-10, where one deploy replaced two runners mid-run and killed both instrumentation suites (BumpApp/bump#5223).
Design
docker_image_pullruns first — always safe while a job runs, and itschangedis half the "replace imminent" signal. The container task now runspull: false; a fresh digest still triggers the replace via the module's image-ID comparison.pull: false) detects config-driven recreation (env, volumes, network mode, …) without touching anything. Idempotent deploys skip the drain entirely — unchanged from today, they remain safe to run concurrently with CI.^<name>(-|$)isbusy(the image registers<RUNNER_NAME_PREFIX>-<random>; the bare-name alternative covers a suffix-less future). Org and repo scopes both supported; the busy-check uses the same PAT registration already requires, so no new credentials. On timeout the deploy fails loudly with guidance instead of killing the job.github_runner_drain_before_replace: falserestores the old always-replace behavior;github_runner_force_replace: truereplaces immediately in an emergency.New variables (documented in README + CHANGELOG, folded into the pending 0.1.4):
github_runner_drain_before_replace(true),github_runner_drain_timeout_minutes(45),github_runner_drain_poll_seconds(30),github_runner_force_replace(false),github_runner_api_base(derived for github.com/GHES).Shared container args moved to
vars/main.ymlso the plan and deploy passes can't drift; renamed with the role prefix per ansible-lint'svar-naming[no-role-prefix].community.dockerfloor raised to>=3.6.0fordocker_image_pull.Testing
ansible-lint roles/github_runner: 0 failures, 0 warnings (production profile).molecule test --scenario-name default: converge, idempotence, verify, destroy all pass. The drain gate is exercised as "skipped" in molecule (no replace imminent on the idempotence pass, which is the correct behavior); live busy-path verification needs a real busy runner — suggested check after release: run a deploy during a long bump instrumentation suite and watch the drain task poll.🤖 Generated with Claude Code