Skip to content
Menu

Virtualization8 min read

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:

cgroupfs Driver

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
console
# cgroupfs path
/sys/fs/cgroup/docker/
    <container-id>/
        cpu.max
        memory.max
systemd Driver

How 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
console
# systemd path
/sys/fs/cgroup/system.slice/
    docker-<container-id>.scope/
        cpu.max
        memory.max

Warning

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
bash
docker run \
  --cpus 2 \
  --memory 512m \
  my-app

Docker only has limits — there is no concept of "minimum guaranteed." A container gets what it can until it hits the ceiling.

Kubernetes
bash
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)

BestEffort
No requests or limits set at all
FIRST to be evicted
Lowest priority
bash
resources: {}  # nothing specified
Burstable
requests < limits for at least one resource (or limits set without requests)
Evicted before Guaranteed
Middle priority
bash
resources:
  requests: { cpu: "250m", memory: "128Mi" }
  limits:   { cpu: "1",    memory: "512Mi" }
Guaranteed
requests == limits for BOTH cpu and memory (on every container in the pod)
LAST to be evicted
Highest priority
bash
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.

/sys/fs/cgroup/kubepods.slice/
kubepods-guaranteed (implied)

Guaranteed QoS pods live at this level

kubepods-burstable.slice/
kubepods-burstable-pod<uid>.slice/

Pod-level cgroup (total pod limits)

<container-1-id>.scope

nginx

<container-2-id>.scope

app

kubepods-besteffort.slice/

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: 256Mi each has a pod-level cgroup with memory.max = 536870912 (512Mi total), and each container cgroup has memory.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.

ResourceQuota

Namespace-level totals. Limits the aggregate resources across all pods in a namespace.

yaml
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.

LimitRange

Per-pod/container defaults and constraints. Sets defaults and min/max per container.

yaml
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.

  1. kubectl apply

    User submits pod spec

  2. API Server

    LimitRange injects defaults, ResourceQuota checks totals

  3. Scheduler

    Places pod based on requests

  4. 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

  1. cgroup Files

    cpu.stat, memory.current, etc.

  2. kubelet

    Reads cgroup files, exposes /stats/summary

  3. metrics-server

    Scrapes kubelet, aggregates into metrics API

  4. HPA / VPA

    Reads metrics, adjusts replicas or resources

HPA (Horizontal Pod Autoscaler)

Scales the number of pod replicas based on observed CPU/memory utilization.

yaml
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
VPA (Vertical Pod Autoscaler)

Adjusts resource requests and limits based on observed usage history.

yaml
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:

  1. User writes a pod spec with resources.requests and resources.limits
  2. API server admission: LimitRange injects defaults if missing. ResourceQuota checks namespace totals. Pod is rejected or accepted.
  3. Scheduler places the pod on a node with enough allocatable resources (based on requests, not limits).
  4. kubelet on the target node asks the container runtime (containerd/CRI-O) to create the container.
  5. 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.
  6. 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.
  7. kubelet reads cgroup accounting files (cpu.stat, memory.current) and exposes them via /stats/summary.
  8. 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.

Solidnines — solidnines.com