Self-review of the role I had just written. On k3s-prod-2 the scheduler path is
deliberately skipped, so the `stat` on /sys/fs/cgroup/system.slice/k3s.service is
skipped too — and a SKIPPED registration has no `.stat` key at all. Ansible
templates `loop:` BEFORE it evaluates `when:`, so
loop: "{{ ['system.slice'] + (['system.slice/k3s.service']
if _eio_k3s_cgroup.stat.exists else []) }}"
raises on that host even though the task would never have run. The role would have
failed on the one node it is designed to leave alone — and it would have failed at
the very end, after doing nothing, which reads like a broken mitigation rather than
an intentional skip.
`| default(false)` on both uses. Exercised offline against a simulated skipped
registration; `--syntax-check` re-run.
Co-Authored-By: Claude Opus 5 <[email protected]>
(cherry picked from commit 5ccf5111a54857abb934a64c41332b7aa5046323)
roles/etcd_io_priority
Give the k3s server process — and therefore the embedded etcd it hosts — a larger share of the block device it shares with Longhorn.
The incident
2026-09-07 06:05:21Z, k3s-prod-1 went NotReady (Kubelet stopped posting node status) while
the host stayed up, pingable and SSH-able. From the k3s journal:
slow fdatasync took=3.56s / 6.71s / 4.95s / 3.40s (etcd expects 1s)
apply request took too long took=8.30s (expects 100ms)
etcdserver: too many requests
Failed to check local etcd status for learner management: context deadline exceeded
No OOM, no MCE, no hung task. All three prod nodes put /data/k3s (etcd's WAL and the
containerd image store) and /data/longhorn (every replica) in two directories on one
partition:
| node | device | drive | /proc/pressure/io full avg300 |
|---|---|---|---|
| k3s-prod-1 | /dev/sda5 |
C400-MTFDDAC512MAM — 2011 consumer SATA SSD, no PLP, 61,440 reallocated sectors | 11.36 |
| k3s-prod-2 | /dev/nvme0n1p5 |
Samsung PM9A1 NVMe | 0.75 |
| k3s-prod-3 | /dev/sda5 |
WDC WDS500G2B0A (WD Blue SATA) | 1.29 |
Recovery took a manual systemctl restart k3s; 18 Longhorn volumes then rebuilt onto the same
disk etcd was trying to fsync on. The failure is self-reinforcing.
What this role does
Two things, both instantly reversible, neither requiring a k3s restart:
k3s-etcd-io-priority.service(oneshot, orderedBefore=k3s.service) switches the shared device to the BFQ I/O scheduler and lowersnr_requestsfrom 64 to 32.IOWeight=10000drop-ins onsystem.sliceandk3s.service, which BFQ turns intoio.bfq.weight=1000— a 10:1 share against the default-weightedkubepods.slicewhere Longhorn's instance-managers live.
Then it reads every one of those values back out of the kernel and asserts on them. If the scheduler did not switch, or the weight did not land, the play fails. A mitigation that looks applied and is not is worse than none.
Why not the obvious things — all checked on the live nodes, all inert
IOSchedulingClass=realtime on k3s.service. The scheduler here (mq-deadline, and BFQ
too) does honour the I/O priority class, so this looks like the answer. It is not: I/O
priority is inherited across fork and preserved across exec — block/blk-ioc.c's
__copy_io() copies ioc->ioprio into the child — and containerd is a child of the k3s
server process. Verified on k3s-prod-1: ps -eo pid,ppid,comm shows containerd's PPID is the
k3s-server PID (84553 → 84333), and
$ ionice -c2 -n0 -- sh -c 'ionice -p $$; sh -c "ionice -p \$\$"'
best-effort: prio 0
best-effort: prio 0
confirms inheritance through two levels of fork+exec on that node. Putting k3s in the realtime
class therefore puts every container on the node in the realtime class, Longhorn included —
no relative gain, and it demotes the rest of system.slice (sshd, journald) for nothing.
Getting this to work would need containerd out of k3s's process tree, i.e. an external
containerd via --container-runtime-endpoint. That is an architecture change, not a tweak.
IOSchedulingPriority (the level within a class). mq-deadline buckets requests by
priority class only — IOPRIO_CLASS_RT/BE/IDLE map to three dd_per_prio queues and the
level inside a class is not read. So best-effort 0 versus best-effort 4 is a no-op here.
io.latency (blk-iolatency). The ideal mechanism for this problem — scheduler-independent,
protects one cgroup's latency target by throttling its siblings. Not available: Debian
trixie's kernel does not build it. grep CONFIG_BLK_CGROUP /boot/config-7.1.8+deb13-amd64
returns CONFIG_BLK_CGROUP_IOCOST=y and no IOLATENCY, and there is no io.latency file
anywhere under /sys/fs/cgroup.
IOWeight= on the stock configuration. systemd writes IOWeight= to both io.weight
(blk-iocost) and io.bfq.weight (BFQ). On these nodes as shipped, neither reader exists:
the scheduler is mq-deadline, which has no cgroup awareness, and blk-iocost is compiled in but
disabled (/sys/fs/cgroup/io.cost.qos is empty). Adding a drop-in without switching the
scheduler would be pure decoration. That is why the scheduler change and the weights are one
role and one verification, not two independent knobs.
IOWeight=1000 instead of 10000. systemd rescales IOWeight's 1..10000 range onto
io.bfq.weight's 1..1000, so 1000 maps to roughly 101 — BFQ's default for an unweighted cgroup
is 100. Only the top of the range separates anything. The role asserts the resulting
io.bfq.weight is ≥ etcd_io_priority_min_bfq_weight (500) rather than trusting that mapping.
A udev rule instead of a unit. ATTR{queue/scheduler}="bfq" on the boot disk fires its
add event in the initramfs, where /etc/udev/rules.d is absent and bfq.ko is not loaded —
it silently does nothing and the disk comes up on mq-deadline with no error anywhere.
A drop-in on kubepods.slice (to lower Longhorn instead of raising etcd). kubepods.slice
is a transient unit the kubelet creates through the systemd API (systemctl cat kubepods.slice shows /run/systemd/transient/kubepods.slice), and whether systemd merges an
/etc drop-in into a transient unit was not verified on these nodes. Raising system.slice
gets the same ratio through a unit systemd definitely owns.
What this role does NOT fix
- It does not separate etcd from Longhorn. Both still write to one ext4 filesystem on one
partition. Weights arbitrate the device queue; they cannot arbitrate the shared ext4
journal.
fdatasyncon the WAL waits on a jbd2 commit, jbd2 is a kernel thread in the root cgroup, and Longhorn's replica writes dirty the same journal. That serialization point is invisible to every cgroup control in this file and is a hard limit of co-tenancy on one filesystem. - It is unproven on this hardware. BFQ is the right mechanism on paper and it is what
systemd's
IOWeightwas built for, but nothing here has been measured on a C400 under a real Longhorn rebuild. The role fails loudly if the settings do not land; it cannot tell you they helped. Apply tok3s-prod-3first and watch theNodeIOPressureHighalert and the "Nodes — CPU / Memory / I-O Pressure" dashboard before touching node 1. - BFQ has costs. It disables writeback throttling (
wbt_lat_usecis 2000 on sda today), spends more CPU per request, and gives up some peak throughput. On a device that is already the bottleneck this is the right trade; it is still a trade. - k3s-prod-2 gets nothing. Its data dir is on NVMe, BFQ is a poor fit for NVMe, and its IO PSI was 0.75 during the incident. The role detects this and installs nothing rather than leaving inert files behind.
- The real fix is hardware and is the operator's: replace the C400, or give etcd its own
device and flip
node_baseline_etcd_sourceback todevice— the pathroles/node_baselinestill supports unchanged, and the exit condition already written intoinfra/PROD-PROVISIONING.md§2.
Running it
cd infra/ansible
# ALWAYS confirm the target first — a prior mistake in this repo silently hit dev.
ansible-playbook playbooks/etcd-io-priority.yml -e k3s_hosts=k3s_prod \
--limit k3s-prod-3 --list-hosts
ansible-playbook playbooks/etcd-io-priority.yml -e k3s_hosts=k3s_prod --limit k3s-prod-3
Rollback, per node, immediate:
systemctl disable --now k3s-etcd-io-priority.service
printf 'mq-deadline\n' > /sys/block/sda/queue/scheduler
printf '64\n' > /sys/block/sda/queue/nr_requests
rm -f /etc/systemd/system/{system.slice,k3s.service}.d/10-etcd-io-weight.conf
systemctl daemon-reload