cgroups in Practice (Docker & K8s)
How container platforms translate user-facing flags to cgroup files
Docker Flag to cgroup File Mapping
Every Docker resource flag is a thin wrapper around writing a value to a cgroup file. There is no magic — Docker computes the value, writes it to the correct cgroup file, and the kernel enforces it.
CPU Mappings
--cpus 2 --> cpu.max = 200000 100000
--cpus 0.5 --> cpu.max = 50000 100000
--cpu-shares 512 --> cpu.weight ~= 50 (converted: shares/1024*100)
--cpuset-cpus "0,1" --> cpuset.cpus = 0,1
Memory Mappings
--memory 512m --> memory.max = 536870912 (512 * 1024 * 1024)
--memory-reservation 256m --> memory.low = 268435456
--memory-swap 1g --> memory.swap.max = 536870912 (1G total - 512M RAM = 512M swap)
I/O and PIDs Mappings
--pids-limit 100 --> pids.max = 100
--device-read-bps /dev/sda:10mb --> io.max = 8:0 rbps=10485760
--device-write-iops /dev/sda:1000 --> io.max = 8:0 wiops=1000
Tip
Verify it yourself: Run a container with --cpus 2 --memory 512m, then inspect the cgroup files at /sys/fs/cgroup/system.slice/docker-<container-id>.scope/ and you will see exactly these values.
cgroup Driver: systemd vs cgroupfs
Container runtimes need to create cgroup directories and write to files. There are two approaches to managing the cgroup hierarchy:
How it works: The container runtime directly creates directories under /sys/fs/cgroup/ and writes to the files itself.
- Simple implementation
- Runtime has full control over the hierarchy
- Can conflict with systemd (both try to manage the same tree)
- systemd may "clean up" cgroups it did not create
# cgroupfs path
/sys/fs/cgroup/docker/
<container-id>/
cpu.max
memory.maxHow it works: The container runtime asks systemd to create transient scopes/slices via D-Bus API. systemd manages the hierarchy.
- Cooperates with systemd (no conflicts)
- Uses proper systemd unit management
- Kubernetes strongly recommends this driver
- kubelet and runtime must use the same driver
# systemd path
/sys/fs/cgroup/system.slice/
docker-<container-id>.scope/
cpu.max
memory.maxWarning
The cgroupfs driver is deprecated for Kubernetes. If kubelet uses systemd but the container runtime uses cgroupfs (or vice versa), you get two cgroup managers fighting over the same hierarchy. This leads to unstable resource management. Always match: both must use systemd.
Kubernetes Resource Model
Kubernetes has its own abstraction layer over cgroups: requests and limits. These map directly to cgroup files, but serve different purposes.
Requests vs Limits
docker run \
--cpus 2 \
--memory 512m \
my-appDocker only has limits — there is no concept of "minimum guaranteed." A container gets what it can until it hits the ceiling.
resources:
requests:
cpu: "500m"
memory: "256Mi"
limits:
cpu: "2"
memory: "512Mi"K8s has requests (minimum guaranteed floor) AND limits (maximum ceiling). This enables better scheduling and resource protection.
K8s Resource to cgroup File Mapping
| K8s Resource | cgroup File | Behavior |
|---|---|---|
requests.cpu: "500m" |
cpu.weight |
Proportional sharing. 500m = ~50 weight (converted from millicores). Guarantees fair CPU share under contention. |
limits.cpu: "2" |
cpu.max = 200000 100000 |
Hard throttle. Process is paused when it exceeds 2 CPUs per period. |
requests.memory: "256Mi" |
memory.low = 268435456 |
Protection from reclaim. This memory is "reserved" — kernel avoids reclaiming it. |
limits.memory: "512Mi" |
memory.max = 536870912 |
Hard limit. OOM kill if exceeded. No throttling — it is immediate death. |
Warning
CPU vs Memory limits behave very differently. Exceeding a CPU limit causes throttling (the process slows down but keeps running). Exceeding a memory limit causes OOM kill (the process dies). This is why setting memory limits too low is more dangerous than setting CPU limits too low.
QoS Classes
Kubernetes classifies every pod into one of three Quality of Service classes based on how requests and limits are configured. This classification determines eviction priority when the node runs out of resources.
Eviction Priority (first evicted at the top)
resources: {} # nothing specifiedresources:
requests: { cpu: "250m", memory: "128Mi" }
limits: { cpu: "1", memory: "512Mi" }resources:
requests: { cpu: "1", memory: "512Mi" }
limits: { cpu: "1", memory: "512Mi" }Note
Scheduling implication: Requests are what the scheduler uses to place pods. If a pod requests 2 CPUs and a node only has 1 CPU allocatable, the pod will not be scheduled there. Limits are enforced at runtime by cgroups. A node can be overcommitted on limits (sum of all limits > node capacity) but not on requests.
Pod cgroup Hierarchy
Kubernetes organizes cgroups in a two-level hierarchy: pod-level cgroups enforce total pod limits, and container-level cgroups enforce per-container limits within the pod.
Guaranteed QoS pods live at this level
Pod-level cgroup (total pod limits)
nginx
app
BestEffort pods — evicted first
Two-Level Enforcement
- Pod-level cgroup: Enforces the sum of all container limits. Prevents the pod as a whole from exceeding its allocation, even if individual containers are within their limits.
- Container-level cgroups: Enforce per-container limits. A single container cannot exceed its own limit even if the pod has headroom.
- This means a 2-container pod with
memory: 256Mieach has a pod-level cgroup withmemory.max = 536870912(512Mi total), and each container cgroup hasmemory.max = 268435456(256Mi).
ResourceQuota & LimitRange
These are admission-time controls — they are enforced by the Kubernetes API server when pods are created, not at runtime by the kernel. They determine which cgroup values kubelet will configure.
Namespace-level totals. Limits the aggregate resources across all pods in a namespace.
apiVersion: v1
kind: ResourceQuota
metadata:
name: team-quota
namespace: team-a
spec:
hard:
requests.cpu: "10"
requests.memory: "20Gi"
limits.cpu: "20"
limits.memory: "40Gi"
pods: "50"Prevents team-a from deploying more than 50 pods or requesting more than 10 CPUs total.
Per-pod/container defaults and constraints. Sets defaults and min/max per container.
apiVersion: v1
kind: LimitRange
metadata:
name: container-limits
spec:
limits:
- type: Container
default:
cpu: "500m"
memory: "256Mi"
defaultRequest:
cpu: "100m"
memory: "128Mi"
max:
cpu: "2"
memory: "1Gi"If a container does not specify requests/limits, these defaults are injected. No container can request more than 2 CPUs.
kubectl apply
User submits pod spec
API Server
LimitRange injects defaults, ResourceQuota checks totals
Scheduler
Places pod based on requests
kubelet
Creates cgroups with the computed values
Note
Key distinction: ResourceQuota and LimitRange are admission-time controls. They reject the pod at creation if it would exceed limits. They do not enforce anything at runtime — that is the kernel's job via cgroups. They merely ensure that cgroup values are always set.
HPA and VPA
Autoscalers use cgroup accounting data as their input signal. The entire metrics pipeline is built on reading cgroup files.
The Metrics Pipeline
cgroup Files
cpu.stat, memory.current, etc.
kubelet
Reads cgroup files, exposes /stats/summary
metrics-server
Scrapes kubelet, aggregates into metrics API
HPA / VPA
Reads metrics, adjusts replicas or resources
Scales the number of pod replicas based on observed CPU/memory utilization.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70- If average CPU > 70% of request, scale up
- If average CPU < 70% of request, scale down
- Utilization =
cpu.stat usage / cpu.weight allocation
Adjusts resource requests and limits based on observed usage history.
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
spec:
targetRef:
kind: Deployment
name: my-app
updatePolicy:
updateMode: "Auto"
resourcePolicy:
containerPolicies:
- minAllowed:
cpu: "100m"
maxAllowed:
cpu: "4"- Analyzes usage over time (memory.current, cpu.stat)
- Recommends or sets new requests/limits
- Requires pod restart to apply (changes cgroup values)
HPA vs VPA: When to Use Which
| Scenario | HPA | VPA |
|---|---|---|
| Stateless web servers | Ideal — add more replicas | Less useful |
| Database / stateful workloads | Hard to scale horizontally | Ideal — right-size the single instance |
| Batch jobs | Not applicable | Good for right-sizing |
| Unknown resource needs | Needs correct requests first | Can discover correct requests |
Warning
Do not use HPA and VPA on the same resource (e.g., both on CPU). They will fight: HPA tries to scale replicas based on utilization, while VPA changes the requests that define utilization. Use HPA for CPU and VPA for memory, or use one at a time.
The Full Picture: From User Config to Kernel Enforcement
Here is the complete chain from a Kubernetes manifest to kernel-level enforcement:
- User writes a pod spec with
resources.requestsandresources.limits - API server admission: LimitRange injects defaults if missing. ResourceQuota checks namespace totals. Pod is rejected or accepted.
- Scheduler places the pod on a node with enough allocatable resources (based on requests, not limits).
- kubelet on the target node asks the container runtime (containerd/CRI-O) to create the container.
- Container runtime creates cgroups (via systemd or direct cgroupfs) and writes the computed values to
cpu.max,cpu.weight,memory.max,memory.low,pids.max, etc. - Linux kernel enforces the limits at the CFS scheduler (CPU), memory allocator (memory), and block I/O layer (I/O). This is all kernel code — no userspace overhead.
- kubelet reads cgroup accounting files (
cpu.stat,memory.current) and exposes them via/stats/summary. - metrics-server and HPA/VPA consume these metrics to make scaling decisions, which loop back to step 1 (new pod specs with adjusted resources).
Tip
The elegant simplicity: Containers are just processes in cgroups and namespaces. Kubernetes is just an orchestrator that writes values to cgroup files via a chain of API calls. The kernel does all the actual enforcement. There is no container runtime in the data path — once the cgroup is configured, the kernel handles everything directly.