Kubernetes cgroup v2 Migration: Your Distro Sets the Deadline, Not v1.38
Two official Kubernetes sources disagree on when cgroup v1 stops working, and neither date is the one that binds. systemd v258 already removed cgroup v1, and the behavior changes on the other side are what break workloads.
By VVV Ops ·
Two official Kubernetes sources give different answers about when cgroup v1 stops working. A Kubernetes cgroup v2 migration planned against the wrong one burns a quarter. The FAQ published on 6 October 2026 says the fallback goes away in v1.38. The KEP that owns the removal says there is no timeline. Both are accurate. Neither is the date that should drive your plan. We run these node migrations for clients on EKS and on self-managed clusters, and the expensive part is never the kubelet refusing to start. That failure is loud, documented, and fixed in an afternoon. The cost sits in what changes on the nodes after they come back up.
The removal date two official sources disagree on
The Kubernetes blog The Shift to cgroup v2 in Kubernetes states that the cgroup v1 fallback "is scheduled for removal in Kubernetes v1.38". The KEP it links to, KEP-5573, says something weaker:
We will commit to removing cgroup v1 code but there is not yet a timeline for removal. The removal will be done no earlier than 1.38 to maintain the k8s deprecation policy.
"No earlier than 1.38" is a floor, not a date. Three other signals point the same way. The KEP issue still carries the stage/beta label and lists its last delivered milestone as beta in v1.35. SIG Node's 1.38 planning board lists the KEP as "Not for release", the same status it carried for 1.36 and 1.37. And the v1.38 schedule put enhancements freeze at 30 September 2026, 12:00 UTC, with the release on 16 December 2026. An enhancement that was not tracked by freeze does not ship in that release.
The KEP's own gate for starting removal is also unfinished. It reads "Once all supported releases of Kubernetes have FailCgroupV1 set to true, we can begin the removal of the cgroup v1 support", and that section is still marked unresolved in the document.
One more detail tells you how to read the October blog: it calls 1.36 "the current release", though v1.37 shipped on 26 August 2026. It is a good FAQ that sat in draft for a while, not a release announcement.
So here is the call we give clients. Assume the failCgroupV1: false override still works in v1.38. Do not assume it works in v1.40. And do not use either assumption as your planning constraint, because something else binds first.
Your systemd version is the deadline that actually binds
The kubelet override only matters if the host can boot cgroup v1 at all. On current distributions it increasingly cannot, and no kubelet setting changes that.
systemd v256 made cgroup v1 obsolete and refuses to boot under it unless SYSTEMD_CGROUP_ENABLE_LEGACY_FORCE=1 is set on the kernel command line. systemd v258, released 17 September 2025, closed the door:
Support for cgroup v1 ('legacy' and 'hybrid' hierarchies) has been removed.
That is the real clock, and it is further along than the Kubernetes one.
| Node OS | cgroup v2 by default | What forces the move | |---|---|---| | Container Optimized OS | Since M97 | Already v2 | | Ubuntu | Since 21.10, 22.04+ recommended | Already v2 on supported LTS | | Debian 11 and later | Since bullseye | Trixie ships systemd 257, so v1 needs the force variable | | Fedora | Since 31 | 41 and 42 inherit systemd v256; 43 inherits the removal | | RHEL and RHEL-like | Since 9 | v1 deprecated in RHEL 9.4; RHEL 10 systemd will not boot v1 | | RHEL 8 | No, v2 is opt-in | Needs a boot parameter or a node rebuild | | Amazon Linux 2 | No, AWS documents cgroup v1 support only | The v2 path is AL2023, not a boot flag | | Amazon Linux 2023 | Yes | v1 code "will be completely removed in a future major release" |
The distribution list comes from the Kubernetes cgroup v2 documentation; the vendor positions are collected in KEP-5573, with the Amazon Linux wording taken from the AL2023 cgroup v2 page.
Read the table and the conclusion writes itself. If your nodes run RHEL 8 or Amazon Linux 2, cgroup v1 is not really your problem. The node image is. Fix that and the cgroup question resolves on its own.
Why this is a node replacement, not a configuration change
The cgroup version is decided by the kernel command line at boot, which is why the documented way to change it is a bootloader edit and a reboot rather than a running-node operation. On a distribution that uses GRUB you add systemd.unified_cgroup_hierarchy=1 to GRUB_CMDLINE_LINUX in /etc/default/grub and run sudo update-grub, but the Kubernetes docs recommend what we recommend: pick an OS that already defaults to v2 instead of carrying a boot override.
In practice that makes the unit of work a node image change and a rolling replacement. Teams that plan this as a kubelet configuration rollout get the sequencing wrong and then wonder why nothing changed after the restart.
Start with an audit that reads the nodes rather than your inventory spreadsheet:
# Fleet audit: one pod per node, prints the cgroup filesystem type, then idles.
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: cgroup-audit
namespace: default
spec:
selector:
matchLabels:
app: cgroup-audit
template:
metadata:
labels:
app: cgroup-audit
spec:
tolerations:
- operator: Exists
containers:
- name: probe
image: busybox:1.37.0
command: ["sh", "-c"]
args: ["stat -fc %T /host/sys/fs/cgroup/; sleep 3600"]
volumeMounts:
- name: cgroup
mountPath: /host/sys/fs/cgroup
readOnly: true
volumes:
- name: cgroup
hostPath:
path: /sys/fs/cgroup
kubectl rollout status ds/cgroup-audit --timeout=180s
for pod in $(kubectl get pods -l app=cgroup-audit \
-o jsonpath='{.items[*].metadata.name}'); do
node=$(kubectl get pod "$pod" -o jsonpath='{.spec.nodeName}')
printf '%s\t%s\n' "$node" "$(kubectl logs "$pod" | tr -d '\r')"
done
kubectl delete ds/cgroup-audit
cgroup2fs means v2, tmpfs means v1. Those are the two outputs the Kubernetes docs specify for stat -fc %T /sys/fs/cgroup/, and they are the only check worth trusting, because an AMI name tells you what was intended rather than what booted.
Once a node is on v2 the kubelet needs nothing from you. The docs put it plainly: the kubelet detects cgroup v2 and acts accordingly with no additional configuration. What does need attention is the cgroup driver pairing. Driver autodiscovery over the CRI (KEP-4033) went stable in v1.34 and requires a runtime that implements the RuntimeConfig RPC, which means containerd v2.0 or later or CRI-O v1.28 or later. Older runtimes fall back to the kubelet's configured value, so set it explicitly and use systemd:
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cgroupDriver: systemd
# Leave failCgroupV1 unset. The v1.35 default of true is the behavior you want.
The minimum bar for v2 is a 5.8 kernel plus a runtime that supports it, which means containerd v1.4 or later, or CRI-O v1.20 or later. If your fleet is also moving off containerd 1.x in the same window, read our containerd 1.7 end of life migration notes first. That post covers the runtime version choice and the config format change, and it documents two EKS details that hide cgroup problems: Bottlerocket ships failCgroupV1: false in its kubelet configuration, and Fargate stays on cgroup v1.
OOM kills change shape on the other side
Here is the first behavior change that looks like a regression in your dashboards.
On cgroup v2 nodes the kubelet defaults singleProcessOOMKill to false, which means it sets memory.oom.group on each container cgroup. An OOM event then kills every process in that container at once. On cgroup v1 the kernel could kill a single process and leave a multi-process container running in a degraded state, answering health checks while doing nothing useful.
The v2 behavior is correct and you should keep it. Expect the graphs to disagree. Containers that used to limp along now restart cleanly, so restart counts rise while the silent-failure class disappears. If somebody files that as a cgroup v2 bug, this is the paragraph to send them. You can restore the old behavior with singleProcessOOMKill: true, and we have not yet seen a workload where that was the right trade.
Two limits worth knowing before you promise improvements. The setting is scoped to the container cgroup rather than the pod, so a sidecar dying does not take the pod's main container with it. And the kubelet still treats active_file memory as unreclaimable, so an I/O-heavy workload with a large page cache can trigger memory pressure and eviction. That is kubernetes#43916, it predates this migration, and cgroup v2 does not change the calculation. The documented workaround is still to set equal memory requests and limits on containers that do heavy I/O, after measuring.
The CPU weight conversion that reprioritizes every container
cgroup v1 expresses CPU priority as cpu.shares on a range of 2 to 262144. cgroup v2 uses cpu.weight on a range of 1 to 10000. Kubernetes derives shares from the request with cpu.shares = milliCPU × 1024 / 1000, and the OCI runtime converts shares to weight.
The original mapping from KEP-2254 was linear: cpu.weight = (1 + ((cpu.shares - 2) * 9999) / 262142). It had a real problem. A container requesting 1 CPU produced 1024 shares, exactly the cgroup v1 default, but landed on weight 39 against a cgroup v2 default of 100. Every Kubernetes workload on the node quietly lost priority to the node daemons outside Kubernetes.
The replacement formula is quadratic in log space, cpu.weight = ceil(10^(L²/612 + 125L/612 - 7/34)) where L = log2(cpu.shares), anchored on (2, 1), (1024, 100) and (262144, 10000). The announcement's own worked examples:
| CPU request | cpu.shares | Old weight | New weight | |---|---|---|---| | 100m | 102 | 4 | 17 | | 1000m | 1024 | 39 | 102 |
The 1 CPU row lands at 102 rather than exactly on the anchor, which the announcement describes as close to the default of 100. Close is what matters here: Kubernetes containers and the node's own daemons compete on roughly equal terms again. The same post notes that every request from 90m to 109m maps to the same weight of 17, so the mapping is coarse at the low end.
The change lives in the OCI runtime, not in Kubernetes, so it arrives with runc 1.3.2 or crun 1.23 and not with a control plane upgrade. That decoupling is the part teams miss. A node that moves to cgroup v2 on an older runc gets the bad weights. The same node later picks up a runtime bump and gets different weights again. Two priority shifts, in two separate change windows, neither of them announced by anything in your cluster.
Audit for tooling that predicts weight values before either shift. Custom schedulers, admission policies that validate cgroup settings, and monitoring that derives CPU requests from cpu.weight all break on the new formula. Reversing weight back to milliCPU was always lossy and is more so now, because the mapping is many-to-one. Read requests from the pod spec.
Memory QoS turned itself on in v1.37
Memory QoS has been alpha since v1.22. In v1.37 it graduated to Beta and the MemoryQoS gate is enabled by default, under KEP-2570. It works only on cgroup v2 nodes, because it drives memory.high for throttling and memory.min and memory.low for protection.
A feature that is on by default and only active on v2 nodes means your migration flips it on, node by node, as a side effect. Worth knowing before you start.
The beta announcement says the defaults were chosen so that upgrading to v1.37 does not change runtime behaviour for existing clusters, and they hold up. memoryReservationPolicy defaults to None, which writes neither memory.min nor memory.low. memoryThrottlingFactor now defaults to null, so the kubelet does not write memory.high either. In the alpha releases it defaulted to 0.9 and turning the gate on throttled containers by itself, which is the behaviour the beta removed. Our advice is to leave both alone until the fleet is fully on v2. Changing the memory model and the cgroup hierarchy in one window makes any regression unattributable.
When you do opt in, memoryReservationPolicy: TieredReservation maps Guaranteed pod requests to memory.min for hard protection and Burstable requests to memory.low for soft protection. BestEffort pods have no requests and get neither. There is no per-pod opt-out, so account for the hard reservation across the node before you enable it. Guaranteed memory under memory.min is never reclaimed by the kernel.
The upside of migrating that nobody puts in the migration ticket is Pressure Stall Information. KubeletPSI has been stable since v1.36 and is locked on. PSI needs cgroup v2, Linux 4.20 or later, a kernel built with CONFIG_PSI=y, and psi=1 on the kernel command line for distributions that compile it in but ship it off. Once those hold you get real CPU, memory and I/O contention data at node, pod and container level through the Summary API and /metrics/cadvisor. If you have been guessing at noisy-neighbour problems from CPU throttling counters, this is the better signal. The same goes for in-place pod resize, where accurate enforcement of pod-level resources depends on cgroup v2.
The applications that read cgroupfs and get the limit wrong
This section is where the incidents come from. The node is healthy, the kubelet is satisfied, and a Java service starts OOMing under load two days later.
Anything that reads the cgroup filesystem directly sees a different API on v2. A JVM below the version threshold on a v2-only host does not fail. It skips container detection and sizes its heap from the host's total memory, which on a 64 GiB node with a 2 GiB limit ends exactly as you would expect. The Kubernetes cgroup documentation lists the thresholds:
| Component | Minimum version for cgroup v2 | Failure mode if older | |---|---|---| | OpenJDK / HotSpot | jdk8u372, 11.0.16, 15 and later | Heap sized from host memory, then OOMKilled | | IBM Semeru Runtimes | 8.0.382.0, 11.0.20.0, 17.0.8.0 and later | Same | | IBM Java | 8.0.8.6 and later | Same | | Node.js | v20.3.0 and later, via libuv | v18 line misreads the limit; set --max-old-space-size | | uber-go/automaxprocs | v1.5.1 and later | GOMAXPROCS set from host CPU count | | cAdvisor, standalone DaemonSet | v0.43.0 and later | Container metrics stop resolving |
Third-party monitoring and security agents belong on the same list. They are the ones you will forget, because they arrive as a Helm chart someone installed in 2023. Grep your charts for /sys/fs/cgroup host mounts and check each one against its vendor's cgroup v2 notes.
Two smaller traps. Cilium attaches BPF programs for socket load balancing and defaults its cgroup root to /run/cilium/cgroupv2, so a custom cgroup root in your Cilium values needs re-checking. And if you inspect containers with crictl, do not treat info.runtimeSpec.linux.cgroupsPath as a filesystem path under the systemd driver. It is a systemd unit path in slice:runtime:id form, not a directory under /sys/fs/cgroup. Resolve the real path from /proc/<pid>/cgroup instead.
Our sequencing for a fleet, in the order we actually run it: audit node cgroup versions and application runtime versions in the same pass, fix the application versions first while the nodes are still on v1, then replace node images one pool at a time, starting with a pool that runs no JVM and no custom monitoring agent. The application fixes are the long pole and they are safe to do early. Node replacement is reversible; a heap sized from host memory in production is not.
When to Get Help
If you know every node's cgroup version and every container's runtime version, this migration is a node pool rollout with a short soak. Most teams we meet do not have the second list, and that is the one that generates pages.
We do this work as a fixed-scope engagement: an audit of node cgroup versions and application runtime compatibility, a pool-by-pool replacement plan sequenced against your upgrade window, and the kubelet and runtime configuration landed in your own Terraform or node image build. If you are also dealing with containerd 1.x, kube-proxy IPVS removal, or a version you are already paying extended support for, these land in the same window and should be planned together rather than serially.
Tell us what your nodes are running and we will tell you whether this is a two-week job or a quarter.