roles/etcd_io_priority + playbooks/etcd-io-priority.yml, enabled for [k3s_prod]
only. Mitigation for the 2026-09-07 k3s-prod-1 outage: etcd's WAL fdatasync took
3.4-6.7 s (expects 1 s), the node went NotReady while the host stayed up, and
/proc/pressure/io full avg300 read 11.36 there against 0.75 (node 2, NVMe) and
1.29 (node 3).
WHAT IT DOES, on the SATA nodes only:
1. k3s-etcd-io-priority.service (oneshot, Before=k3s.service) switches the
shared device to BFQ and drops nr_requests 64 -> 32.
2. IOWeight=10000 drop-ins on system.slice AND k3s.service, which BFQ turns
into io.bfq.weight=1000 — a 10:1 share against the default-weighted
kubepods.slice where Longhorn's instance-managers live.
3. Reads all of it back out of the kernel and ASSERTS. If the scheduler did
not switch or the weight did not land, the play fails.
THE THREE OBVIOUS ANSWERS ARE INERT ON THESE NODES, ALL CHECKED LIVE, ALL
DELIBERATELY ABSENT:
* IOSchedulingClass=realtime on k3s.service. mq-deadline DOES honour the
priority class, so this looks right — but ioprio is inherited across fork
and kept across exec (block/blk-ioc.c __copy_io copies ioc->ioprio) and
CONTAINERD IS A CHILD OF THE K3S SERVER PROCESS. Verified on k3s-prod-1:
containerd pid 84553 has ppid 84333 = k3s-server, and
ionice -c2 -n0 -- sh -c 'ionice -p $$; sh -c "ionice -p \$\$"'
prints best-effort prio 0 at both levels. Realtime for k3s is realtime for
every container on the node, Longhorn included. No relative gain.
* IOSchedulingPriority (the level inside a class): mq-deadline buckets by
CLASS only, so BE-0 vs BE-4 is a no-op.
* io.latency, which would be the ideal scheduler-independent protection:
CONFIG_BLK_CGROUP_IOLATENCY is not built in Debian trixie's 7.1.8 kernel
(only IOCOST is) and no io.latency file exists under /sys/fs/cgroup.
AND ONE NON-OBVIOUS TRAP: bare IOWeight= is DECORATION here. systemd writes it
to io.weight (blk-iocost — compiled in but disabled, io.cost.qos is empty) and
io.bfq.weight (BFQ — not loaded). Neither reader exists on the stock nodes, so
the scheduler change and the weights have to be one role with one verification.
The value is 10000 rather than a "reasonable" 1000 for the same reason: systemd
rescales 1..10000 onto io.bfq.weight's 1..1000, so 1000 maps to ~101 against
BFQ's unweighted default of 100. The role asserts the result is >= 500.
k3s-prod-2 IS SKIPPED ON PURPOSE. The role discovers the device under
k3s_data_dir per node (verified: prod-1/3 -> /dev/sda5 -> sda, prod-2 ->
/dev/nvme0n1p5 -> nvme0n1) and writes NOTHING on NVMe rather than leaving inert
files behind — BFQ is a poor fit for NVMe and node 2 measured 0.75 IO PSI.
DEV IS UNTOUCHED. Master switch defaults false and is set true only in
group_vars/k3s_prod; dev has a dedicated etcd device (docs/plans/ssd-etcd-
isolation.md) and nothing to arbitrate. Verified with --list-hosts: no extra
vars -> the three dev hosts (where the role is a no-op); -e k3s_hosts=k3s_prod
-> the three prod hosts; --limit k3s-prod-3 -> one.
NOTHING IS RESTARTED. The oneshot writes two sysfs files; the weights go in
through daemon-reload. If daemon-reload turns out not to re-realize a running
k3s.service's cgroup, the verification FAILS and names the rolling restart as
the fix rather than quietly leaving the weight unapplied.
WHAT IT CANNOT DO, stated in the role README and worth repeating: it does not
separate the two tenants. Both still write one ext4 filesystem, and no cgroup
control arbitrates the shared jbd2 journal that etcd's fdatasync waits on. It is
also UNPROVEN on this hardware — it fails loudly if the settings do not land, it
cannot tell you they helped. Apply to k3s-prod-3 first and watch
NodeIOPressureHigh. Replacing the C400 / giving etcd its own device remains the
real fix and the operator's (PROD-PROVISIONING.md §2 exit condition).
Syntax-checked, and the fact-derivation and assert expressions were exercised
offline against all three node shapes plus negative controls (a node still on
mq-deadline fails the gate; IOWeight=1000 -> 101 fails the floor).
Co-Authored-By: Claude Opus 5 <[email protected]>
(cherry picked from commit de8c1f832724c011b03466842e18296de07a6776)
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