
Cover image credit: Photo by Unsplash on Denny Müller
Disk I/O QoS for Kubernetes with cgroup v2 io.weight
Implementing and validating node-local disk-I/O prioritization for Kubernetes Pods with cgroup v2 io.weight.
Table of Contents
Kubernetes can request and limit CPU, memory, and ephemeral-storage capacity. It has no native Pod-level resource semantics for runtime disk-I/O bandwidth, IOPS, or latency QoS.
CPU has requests and limits. Memory has requests, limits, and OOM eviction. ResourceQuota can account for requests.ephemeral-storage and limits.ephemeral-storage, but that is disk capacity, not disk performance. A backup job can stay inside its storage quota and still consume service from the same device as a database. Neither the scheduler nor ResourceQuota arbitrates that contention.
The Linux cgroup v2 I/O controller can. This prototype uses io.weight to give two Pods an explicit, node-local priority policy: low tier gets 10; high tier gets 500. It answers a narrow question: when device service is constrained, which workload should be favored? It does not reserve bandwidth or guarantee a latency target.
The control I started with was not available
The first mechanism I checked was io.latency. It accepts a target completion latency for a cgroup and protects it by throttling peer cgroups whose own target is higher. That is closer to the usual database-versus-backup problem: protect a latency-sensitive workload from bulk I/O. It is still not a hard SLA, but it is a latency-oriented control.
It was absent from this node hierarchy. find /sys/fs/cgroup -name io.latency returned no result anywhere on the kind node, so this implementation could not use it. io.latency and iocost are separate kernel facilities; enabling iocost does not make io.latency appear.
That left io.weight. This article validates its control path and active iocost accounting. The conclusion is deliberately narrower than a benchmark claim: io.weight expresses relative I/O priority, not a reservation.
What io.weight actually is
io.weight is a relative share. Pod cgroups expose it only when their parent enables io in cgroup.subtree_control. The default is 100; valid values are 1 through 10000. The interface accepts a cgroup-wide default value and reports the effective value on readback:
$ cat .../pod-<uid>.slice/io.weightdefault 100$ printf 'default 500\n' > .../pod-<uid>.slice/io.weight$ cat .../pod-<uid>.slice/io.weightdefault 500A 500:10 policy favors the high-tier cgroup over the low-tier cgroup when both compete through the same active I/O controller. It does not promise 500 IOPS, a bandwidth floor, or a completion-latency bound. The workload, request mix, backing device, and device model still determine the observed result.
The controller has separate mechanisms for separate policies:
io.weightis proportional arbitration.io.maxis a per-device rate limit. Temporary bursts are allowed.io.latencyis the latency-target mechanism.
Those distinctions are operational, not semantic trivia. A 50:1
weight ratio changes relative priority. It does not give a bulk writer a fixed latency bound. Check io.latency on the actual node image before designing around it; it is not universally available.
For iocost-backed weighting to operate on a device, the root cgroup must report that device in io.cost.qos with enable=1. Writing io.weight proves configuration. Reading io.cost.qos for enable=1 and cgroup io.stat for cost fields verifies active accounting; inspect io.cost.model to understand the model it is using.
The cgroup path is configuration, not an API
The common examples use /sys/fs/cgroup/kubepods.slice/.... That was not the path on this node. The kind node used the systemd cgroup driver with kubelet cgroupRoot: /kubelet, producing:
/sys/fs/cgroup/kubelet.slice/ kubelet-kubepods.slice/ kubelet-kubepods-burstable.slice/ kubelet-kubepods-burstable-pod<uid_with_underscores>.sliceGuaranteed Pods are direct children of kubelet-kubepods.slice. Burstable and BestEffort Pods have an extra QoS-class slice. The Pod UID is part of the leaf name, with dashes replaced by underscores.
The exact path is not portable. It depends on the cgroup driver, kubelet configuration, and Pod QoS class. A cgroupfs-driver cluster uses a different directory layout. That is why the daemon accepts cgroupRoot as configuration instead of hardcoding a path.
This helper builds the target path for the systemd layout:
func PodSlicePath(cgroupRoot, qosClass, uid string) (string, error) { if uid == "" { return "", fmt.Errorf("cgroup: empty pod UID") } base, err := kubepodsBase(cgroupRoot) if err != nil { return "", err }
normalizedUID := strings.ReplaceAll(uid, "-", "_") lowerQOS := strings.ToLower(qosClass)
switch lowerQOS { case "guaranteed": return fmt.Sprintf("%s/kubelet-kubepods-pod%s.slice", base, normalizedUID), nil case "burstable", "besteffort": qosSlice := fmt.Sprintf("kubelet-kubepods-%s.slice", lowerQOS) leaf := fmt.Sprintf("kubelet-kubepods-%s-pod%s.slice", lowerQOS, normalizedUID) return base + "/" + qosSlice + "/" + leaf, nil default: return "", fmt.Errorf("cgroup: unknown QOS class %q", qosClass) }}A per-device io.weight write was rejected
The first write form I tried was the per-device form documented by the interface:
$ printf '253:16 200\n' > .../low-priority.../io.weightsh: 7: printf: I/O error$ cat .../low-priority.../io.weightdefault 10The write failed and the file retained its prior value. The cgroup-wide form succeeded on the same cgroup:
$ printf 'default 150\n' > .../pod-<uid>.slice/io.weight$ cat .../pod-<uid>.slice/io.weightdefault 150The daemon therefore writes only the cgroup-wide form. The node used mq-deadline, not BFQ, but that alone does not explain why the kernel returned EIO. The useful fact is simpler: on this node and device, per-device syntax was rejected while the cgroup-wide syntax applied and read back correctly.
The daemon
The prototype polls two named Pods. It reads an io-qos.demo/tier annotation (high or low), maps it to a weight, resolves the cgroup path, writes the value, and reads it back.
func (d *Daemon) reconcileOne(ctx context.Context, t Target) error { pod, err := d.Client.CoreV1().Pods(t.Namespace).Get(ctx, t.Name, metav1.GetOptions{}) if err != nil { return fmt.Errorf("get pod: %w", err) }
if pod.Status.Phase != corev1.PodRunning { log.Printf("qosd: %s/%s: phase=%s, waiting", t.Namespace, t.Name, pod.Status.Phase) return nil }
tier, ok := pod.Annotations[TierAnnotation] if !ok { return fmt.Errorf("missing annotation %s", TierAnnotation) } weight, ok := TierWeight[tier] if !ok { return fmt.Errorf("unknown tier %q", tier) } if applied, ok := d.applied[string(pod.UID)]; ok && applied == weight { return nil }
slicePath, err := cgroup.PodSlicePath(d.CgroupRoot, string(pod.Status.QOSClass), string(pod.UID)) if err != nil { return fmt.Errorf("resolve cgroup path: %w", err) } written, readback, err := cgroup.WriteWeight(slicePath, weight) if err != nil { return fmt.Errorf("write weight: %w", err) } log.Printf("qosd: %s/%s uid=%s tier=%s qos=%s path=%s wrote=%q readback=%q", t.Namespace, t.Name, pod.UID, tier, pod.Status.QOSClass, slicePath+"/io.weight", trimNL(written), trimNL(readback)) d.applied[string(pod.UID)] = weight return nil}The write helper is intentionally boring. The readback matters because a successful-looking control loop is useless if the kernel rejects the requested form.
func WriteWeight(slicePath string, weight int) (written string, readback string, err error) { line, err := WeightLine(weight) if err != nil { return "", "", err }
weightFile := slicePath + "/io.weight" if err := os.WriteFile(weightFile, []byte(line), 0644); err != nil { return line, "", fmt.Errorf("cgroup: write %s: %w", weightFile, err) }
data, err := os.ReadFile(weightFile) if err != nil { return line, "", fmt.Errorf("cgroup: read back %s: %w", weightFile, err) } return line, string(data), nil}The live daemon runs inside the kind control-plane node. That placement gives it access to the node cgroup hierarchy and the Kubernetes API credentials it needs to resolve Pods. It is not a DaemonSet or a general cluster controller; this is a focused one-node prototype.
Validating the policy path
The test used two Burstable Pods with direct-I/O fio jobs against a shared hostPath on the kind node. Low tier wrote sequential 1 MiB requests with four jobs. High tier issued 4 KiB random writes with one job. The request sizes differ by 256:1
, so their IOPS and bandwidth are not directly comparable. They are different workload shapes sharing the same target.
The test sequence was equal weights, weighted policy, equal weights again, then weighted policy again. Before and after each arm it captured the two io.weight files and io.stat. The test VM root cgroup reported iocost enabled for the device.
Here, charged-cost share means the high-tier cgroup’s cost.usage delta divided by the combined high-tier and low-tier deltas. I use it because this test validates controller behavior, not storage performance. It is controller accounting, not bandwidth, IOPS, or latency.
| Arm | High / low io.weight | High-tier charged-cost share |
|---|---|---|
| Equal weights, pair 1 | 100 / 100 | 48.90% |
| Weighted, pair 1 | 500 / 10 | 60.10% |
| Equal weights, pair 2 | 100 / 100 | 5.87% |
| Weighted, pair 2 | 500 / 10 | 17.52% |
Across the two ordered pairs, the high-tier share moved upward after the 500:10 setting. That is consistent with the configured priority policy. It is not a throughput or latency result.
The test found the live Pod cgroups, applied each weight, read it back, and captured iocost accounting before and after the workload. The daemon log separately records the same resolution and write/readback path. That distinction matters: the workload capture shows the cgroup control state; the daemon log shows the daemon’s own reconciliation path.
The diagram summarizes configured policy, not measured throughput:
sequenceDiagram
participant Low as low-tier Pod<br/>(1 MiB sequential write)
participant Dev as shared block device
participant High as high-tier Pod<br/>(4 KiB random write)
Note over Low,High: equal weights: 100 / 100
Low->>Dev: bulk I/O
High->>Dev: competing I/O
Note over Low,High: weighted policy: 10 / 500
Low->>Dev: lower configured share
High->>Dev: proportionally favored when constrained
The point of the test is the control boundary. io.weight changes relative priority. It does not establish a throughput number that transfers to another device, filesystem, workload mix, or iocost model. A capacity decision needs repeated randomized arms, synchronized starts, a calibrated model, and the storage stack where the workload will run.
io.max is a second lever
io.max handles a different policy. It takes a backing device major:minor
and BPS or IOPS limits:
$ printf '253:16 wiops=200\n' > .../low-priority.../io.max$ cat .../low-priority.../io.max253:16 rbps=max wbps=max riops=max wiops=200253:16 is the device identifier from this node. Derive it from the workload storage path before applying a cap on another node.
This is useful for a known offender, such as a bulk job whose write rate must be contained. It can be combined with io.weight: use a rate limit to contain the bulk job, then use weights to arbitrate remaining contention. Neither one creates a latency guarantee.
Scope and production requirements
This prototype runs on one kind node, one virtual block device, and two Pods. It does not coordinate Pods across nodes. It does not establish what a network filesystem, NFS share, or iSCSI volume will do. Tier assignment is annotation-driven and targets two named Pods, not a cluster-wide policy API.
Turning this into a platform feature requires more than moving the code into a DaemonSet:
- Verify cgroup v2 and
iodelegation on every node type. - Resolve the actual Pod cgroup layout instead of assuming this systemd path.
- Map the workload storage path to its backing device.
- Check that iocost is active for that device where proportional enforcement is required.
- Select the control by policy: arbitration (
io.weight), rate limit (io.max), or latency target (io.latency). - Validate the chosen policy against the real storage stack and workload mix.
The useful conclusion is narrow. Kubernetes does not provide disk-I/O QoS as a Pod resource, but a node agent can use cgroup v2 io.weight to impose explicit proportional priority on live Pod cgroups. Use it for arbitration. Use io.max when a workload needs a rate limit. Use io.latency only on nodes where that latency control exists.