Skip to content

feat(role): measure etcd vote fitness and report the role controller's decisions in dry-run - #185

Draft
Mearman wants to merge 16 commits into
mainfrom
feat/role-controller-signals
Draft

Mearman wants to merge 16 commits into
mainfrom
feat/role-controller-signals

Conversation

@Mearman

@Mearman Mearman commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Refs #182

The role controller of #179, in dry-run: it collects the fitness signals and reports what it would do, and changes nothing in the cluster.

fleet-role-probe (a DaemonSet on every Linux node with a kubelet, host network) appends one sample every period to its key of the fleet-role-probe ConfigMap: TCP connect time to each voter's peer port, what a voter's etcd metrics gained since the last sample (WAL fsync, backend commit, per-peer round trip, leader changes, leader/learner/known-member gauges), a fio fdatasync reading once per window on a non-voter (a node that is neither a voter nor marked eligible records nothing, so the ConfigMap grows with the hosts that decide the control plane), the regrade core's own memory readings, and whether the default route leaves by wired, Wi-Fi or an interface whose link can't be seen. fleet-role (Deployment) reads those and the Nodes and publishes fleet-role-status, which the heartbeat republishes beside the autoscaler's. Per host it gives fit, unfit or unknown with every reason and its numbers, and the swap it would make or why it would not.

Same form as node-recovery and psi-reporter: bash, jq and kubectl in an Alpine image built by the release workflow, installed by the ARC role (github_runner_arc_role_controller_enabled, off by default), ClusterRoles that can only get and list nodes, ConfigMap access by name.

Thresholds are derived as the epic states them (a tenth and a twentieth of the election timeout, 10 ms and 25 ms disk, memory against the regrade limit and the storage quota, a window of 100 samples = the fewest that give a p99 its own sample, cooldown = k3s's learner stall time plus a window). The tests hold each to its boundary, the memory limit to the regrade core's is_over, and the probes to the real thing: a sysfs tree whose links loop like the kernel's (and the host's own /sys where there is one), real listeners and curl, pages from a real etcd 3.6.15 three-member cluster (and a live local one when etcd is on the PATH), the real fio.

Not in this PR: the inventory variables that publish the eligible and pinned labels (they touch the cluster role files #180 is changing, so the controller reads two Node labels and the target from the ARC role until then), the mesh scenario with tc netem, and the readings compared with hand readings on real hosts, which are below.

Real-host checks (read-only)

Each reading alone, from inside the probe pod on the node, with nothing written to the cluster (fsync writes and unlinks a small file in the probe's own directory). Compare each with the hand reading beside it. Pod for a node: kubectl -n github-runner-platform get pod -l app.kubernetes.io/name=fleet-role-probe --field-selector spec.nodeName=NODE -o name.

probe="kubectl -n github-runner-platform exec POD -- /app/scripts/fleet-role-probe.sh check"

# link: the interface and wired/wifi/unknown
$probe link
ip -o route show default                       # the lowest metric decides
readlink -f /sys/class/net/IFACE               # under /devices/virtual/ means unknown
ls -d /sys/class/net/IFACE/phy80211 /sys/class/net/IFACE/wireless; grep DEVTYPE /sys/class/net/IFACE/uevent

# connect: ms to a voter's peer port (a number under the election timeout, or the timeout itself when it fails)
$probe connect PEER_MESH_IP
curl -s -o /dev/null -w '%{time_connect}\n' --connect-timeout 1 http://PEER_MESH_IP:2380/
ping -c 20 PEER_MESH_IP; tailscale ping --c 10 PEER_MESH_IP

# memory
$probe memory
cat /proc/pressure/memory; grep -E '^(MemTotal|MemAvailable):' /proc/meminfo

# etcd, on a voter: gauges and the tails of etcd's cumulative histograms
$probe etcd
curl -s http://127.0.0.1:2381/metrics | grep -E '^etcd_(server_id|server_has_leader|server_is_learner|network_known_peers|network_active_peers|server_leader_changes_seen_total|disk_wal_fsync_duration_seconds_(sum|count)|disk_backend_commit_duration_seconds_(sum|count))'
etcdctl --cacert=/var/lib/rancher/k3s/server/tls/etcd/server-ca.crt --cert=/var/lib/rancher/k3s/server/tls/etcd/server-client.crt --key=/var/lib/rancher/k3s/server/tls/etcd/server-client.key --endpoints=https://127.0.0.1:2379 member list -w table

# fsync, on a node that is not a voter
$probe fsync
fio --rw=write --ioengine=sync --fdatasync=1 --directory=/var/lib/rancher/k3s/fleet-role-probe --size=230000 --bs=2300 --name=hand --unlink=1 --output-format=json | jq '.jobs[0].sync.lat_ns.percentile["99.000000"] / 1e6'

Then, with the role controller on for at least one window, kubectl -n github-runner-platform get cm fleet-role-status -o jsonpath='{.data.status\.json}' | jq . against the hosts, and kubectl -n github-runner-platform logs deploy/fleet-role.

Reads each node's probe entry and the Nodes, and reports per host whether it is fit, unfit or
unknown with the numbers behind it, and the swap of an unfit voter for a fit standby that it
would make. It changes nothing in the cluster: its only write is its own status ConfigMap.

Every limit derives from the election timeout, the tail percentile or a named fact about etcd
or k3s. The unit tests hold each to its boundary, and the memory limit to the regrade core's
own is_over.
…lth for the controller

A per-node probe appends one sample per period to its key of a shared ConfigMap and keeps the
last window of them: the TCP connect time to every voter's peer port, what a voter's etcd
metrics gained since the last sample, an fdatasync reading on a node that is not a voter, the
memory signals the regrade core reads, and whether the default route leaves by wired, Wi-Fi or
an interface whose link cannot be seen.

The tests read the link from a sysfs tree whose links loop as the kernel's do and from the
host's own sysfs where there is one, the connect times from real listeners, etcd's metrics from
pages a real three-member cluster served (and a live local cluster when an etcd binary is
available), and the disk reading from the real fio.
One image holds the controller, which its entrypoint runs on an interval, and the probe, which the
DaemonSet starts by name. It carries fio, the disk check etcd's own documentation uses, and the
regrade core whose functions the probe reads memory with. The unit job installs fio so that the
probe's fdatasync reading is tested with the real tool.
…n by hand

`fleet-role-probe.sh check link|connect|memory|etcd|fsync` prints one reading and exits without
touching the cluster, so that every probe can be run on a real host and its output set beside a
reading taken there by hand.
A fleet-role Deployment and a fleet-role-probe DaemonSet in the platform namespace, each with
a ServiceAccount that can get and list nodes and reach only its own ConfigMaps by name, and
neither able to change a node. The controller's settings follow the cluster role's own where
that role defines them, with fallbacks the tests hold to its defaults, and the target is
refused unless it is odd, at least three and a place below the maximum number of servers.
…toscaler's

The heartbeat reads the controller's status ConfigMap, and is allowed that one by name, only when
the role controller is installed, and publishes it to the gist while the fleet is vouched for,
on the same terms as the autoscaler's status.
…he controller

The role works out the cluster's election timeout once and gives the same figure to the controller
and the probe, so neither script holds a default of its own that could differ from the other.
@Mearman Mearman changed the title feat(role): collect the etcd vote fitness signals and report the role controller's decisions in dry-run feat(role): measure etcd vote fitness and report the role controller's decisions in dry-run Nov 3, 2026
A probe on any other node recorded a window of samples nobody reads, and the shared ConfigMap,
which holds at most a mebibyte, would have filled with the size of the fleet. Such a node now
records nothing and loses an entry it had, so the ConfigMap grows with the hosts that decide the
control plane.
…to act

A voter that is paused or unreachable stops reporting while its last sample still looks healthy,
so the gate on etcd's state read stale gauges. It now says the probe has not reported, as the
reason nothing would change.
Linux limits one argument to a process to 128 KiB, and the entries of a few voters together pass
it, so the controller failed with an argument list too long on a runner while it passed on
macOS. The tests now run with a jq that refuses such an argument, and hold both scripts to it
with inputs the size of a full ConfigMap.
…runs

The role controller's probe runs from its own image, whose Dockerfile contents are held by the
role controller's tests instead.
…er_role_controller

The opt-in is the cluster role's variable, off by default, which the ARC role takes as the default
of its own switch, so one inventory setting turns on the probe and the controller for the group.
The tests hold that switch and the names the status is published under.
…on pull requests

A build-only job builds fleet-role/Dockerfile as the release does and runs what the pods run from
the image as built. The unit job installs the etcd v3.6.15 release, checked against the SHA-256
its SHA256SUMS asset publishes before it is extracted, for the probe's live test.
…the voter it replaces

A slow link makes both its ends unfit, so ranking unfit voters alone could send a healthy peer
out in place of the one on the poor path, and a standby that measures the slow voter slow was
never fit. The voter on the most slow links is now the one to replace, nothing is swapped when
it is pinned, and a candidate is judged against the voters that would remain.
…o it is up

etcd keeps one gauge per peer that counts its active streams, up to two, so a follower with both
streams to the leader and one to the other follower read as connected to one peer and the gate
held every swap back. The fixture is a page a follower of a real three-server k3s cluster served.
…refuses one

A probe on a poor path made four sequential calls a sample and went stale, and its process exited on
the first refusal and came back as a restart. It now reads its own entry once, reads the nodes once
a sample, and a patch the API will not take is tried again next period with the first-sample restart
mark kept until a sample is recorded.
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