Skip to content

feat(arc): taint a stopped node out of service so its pods are replaced - #18

Merged
Mearman merged 2 commits into
mainfrom
fix/dead-node-stuck-pods
Sep 26, 2026
Merged

Mearman merged 2 commits into
mainfrom
fix/dead-node-stuck-pods

Conversation

@Mearman

@Mearman Mearman commented Sep 26, 2026

Copy link
Copy Markdown
Member

Closes #2

When a node stops, its pods get marked for deletion but stay Terminating because only the node's kubelet can confirm it. Deployments replace them anyway, which is why the controller came back on its own, but ARC won't start a listener until the old listener pod is gone, so a listener on the dead node stalls its scale set until someone force-deletes it.

This adds a small watcher Deployment to the ARC role (node-recovery, its own image) that uses Kubernetes' non-graceful node shutdown. Once a node's Ready condition has been Unknown for github_runner_arc_node_recovery_after_seconds (default 120), it adds node.kubernetes.io/out-of-service=nodeshutdown:NoExecute. The control plane then evicts everything on the node and podgc force-deletes the pods already terminating there. When the node reports Ready again the watcher takes the taint off. github_runner_arc_node_recovery_enabled: false removes the watcher and any taint it left.

A few things it deliberately doesn't do. It only acts on Unknown, not on NotReady (False): in that case the kubelet is alive and finishes the deletions itself. It never taints the node it's running on. It only removes taints carrying its own annotation, so an operator's own out-of-service taint is left alone. Each patch tests the node's resourceVersion first, so a node that came back between the read and the write is left for the next poll. RBAC is get/list/patch on nodes, with no delete, so it can't remove a Node object (which on a k3s server would drop its etcd member); the taint itself doesn't touch etcd, and nothing in the cluster role reacts to it. The watcher's own pod tolerates unreachable/not-ready for only 10s, so if it was on the node that died, a replacement starts well before the threshold.

On the 120s default: the node controller only marks a node Unknown after 50s of missed heartbeats (node-monitor-grace-period on current Kubernetes), and the extra 120s leaves room for a k3s container restart or upgrade to come back before anything is given up. Worst case, a stopped node now stalls a listener for about four minutes (grace + threshold + a 15s poll + podgc's 20s sweep + the listener's own restart, which the issue comment puts at under a minute) instead of indefinitely. The trade-off is that runner pods on a node gone longer than that are given up before the five-minute default eviction would do it, but their containers stopped with the node, so those jobs are lost either way.

Shorter tolerations on the controller, listener and platform pods on their own don't help: they only get the pod marked for deletion sooner, and it stays Terminating just the same. The taint evicts regardless of tolerations, so it covers that option too.

Testing: tests/node_recovery/run.sh (new Node recovery workflow) builds the image, renders the role's manifests into a three-node kind cluster and stops a worker container. A single-replica StatefulSet stands in for the listener, since its controller also waits for the old pod to go. With the watcher scaled to zero, the pod stays Terminating and isn't replaced; with the watcher on, it's replaced on a healthy node and the taint comes off once the worker is started again. Then, with default tolerations, it stops the worker again and checks the replacement arrives within grace + threshold + margin and that the taint wasn't applied before the threshold. Unit tests cover the decision logic in scripts/node-recovery.sh against a fake kubectl.

After the first release, ghcr.io/exadev/github-runner-node-recovery will need making public, like the pull-secret renewer, since the watcher pulls without a pull Secret.

When a node's kubelet stops reporting, its pods are marked for deletion
but stay Terminating, since only that kubelet can confirm the deletion.
ARC waits for a listener's old pod to go before starting a new one, so a
listener on a stopped node stalled its scale set until someone
force-deleted the pod.

A small watcher Deployment now gives a node the
node.kubernetes.io/out-of-service=nodeshutdown:NoExecute taint once its
Ready condition has been Unknown for
github_runner_arc_node_recovery_after_seconds (120 by default). The
control plane then evicts the node's pods and force-deletes the
terminating ones. The watcher removes the taint when the node reports
Ready again, only touches taints it applied, never acts on its own node
or on a node reporting NotReady, and needs only get, list and patch on
nodes. Turning it off removes it and any taint it left.

The watcher gets its own image, built and stamped by the release like
the others.

Closes #2
A single-replica StatefulSet stands in for an ARC listener, since its
controller also waits for the old pod to go. With the watcher off, its
pod on the stopped worker stays Terminating; with the watcher on it is
replaced on a healthy node within the grace period plus the threshold,
and the taint is removed once the worker is back.
@Mearman
Mearman marked this pull request as ready for review September 26, 2026 06:46
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review ✅ Completed 2026-09-26T06:53:16.880799Z 0deb82c Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@Mearman
Mearman merged commit 6476a2f into main Sep 26, 2026
12 checks passed
@Mearman
Mearman deleted the fix/dead-node-stuck-pods branch September 26, 2026 06:47
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 1.3.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A node going down stalls its scale set until stuck pods are force-deleted

1 participant