Tutorial: configure cgroup v2 and systemd ResourceControl to limit CPU, memory and I/O for Docker containers on Debian

Linux & Sistemas · By · Published on · 9 min read

Tutorial: configure cgroup v2 and systemd ResourceControl to limit CPU, memory and I/O for Docker containers on Debian
Photo: Google DeepMind / Pexels

In this walkthrough you'll enable cgroup v2 and use systemd ResourceControl to enforce CPU, memory and I/O limits for Docker containers on Debian. We'll create dedicated slices, assign containers to those slices, and validate the limits using cgroup v2 files. The outcome: predictable, maintainable limits without per-container hacks.

Prerequisites and what we'll configure

The goal is to apply CPU, memory and I/O limits to Docker containers using cgroup v2 managed by systemd. We’ll standardize limits per slice (a group of services) and, when needed, adjust an individual container.

What you need before starting:

- A recent Debian installation (11 or 12) with systemd and sudo access.

- Docker installed and working. If you don’t have it yet, see how to install Docker and Docker Compose on Linux: How to install Docker and Docker Compose on Linux.

- Availability for a reboot if you need to enable cgroup v2.

- Knowledge of the disk device used by Docker (required for I/O limits).

Important warnings:

- Modifying bootloader parameters and rebooting can affect production services. Schedule a maintenance window.

- Very aggressive memory limits may kill processes (OOMKill). Apply them gradually and monitor.

Confirm (and enable) cgroup v2 on Debian

We need cgroup v2 in unified mode. Check for the presence of cgroup.controllers and the available controllers. If it’s not active, enable it via a kernel parameter and reboot.

Debian 12 usually ships with v2 by default. On Debian 11 you might need to adjust GRUB. A reboot is mandatory after changing GRUB.

# Verificar se cgroup v2 está ativo (deve listar controladores, ex.: cpu io memory)
cat /sys/fs/cgroup/cgroup.controllers

# Se o arquivo não existir ou estiver vazio, habilite cgroup v2 e reinicie
# (cuidado: ajuste de GRUB exige reboot)
sudo sed -i 's/^GRUB_CMDLINE_LINUX="\([^"]*\)"/GRUB_CMDLINE_LINUX="\1 systemd.unified_cgroup_hierarchy=1"/' /etc/default/grub
sudo update-grub
sudo reboot

Configure Docker to use the systemd cgroup driver

For Docker to delegate cgroups correctly to systemd, set the cgroup driver to "systemd". Also ensure the Docker service has Delegate=yes. After changing the config, reload systemd and restart Docker.

# Definir driver de cgroup = systemd
sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json >/dev/null <<'JSON'
{
  "exec-opts": ["native.cgroupdriver=systemd"],
  "log-driver": "json-file",
  "log-opts": {"max-size": "10m", "max-file": "3"}
}
JSON

# (Opcional, a maioria das instalações já possui Delegate=yes)
sudo mkdir -p /etc/systemd/system/docker.service.d
sudo tee /etc/systemd/system/docker.service.d/override.conf >/dev/null <<'INI'
[Service]
Delegate=yes
INI

sudo systemctl daemon-reload
sudo systemctl restart docker

# Conferir se o Docker está usando systemd como cgroup driver
docker info | grep -i 'Cgroup Driver'

Create systemd slices with ResourceControl (default limits)

We'll create slices to group containers by role (for example: production and analytics). systemd's ResourceControl properties map to cgroup v2 files.

Quick tip for Docker disk: the default path is /var/lib/docker. The block device backing that path (for IO*Max) can be discovered with tools like findmnt/lsblk.

Apply limits gradually and monitor. Consider starting with MemoryHigh and only then setting MemoryMax.

systemd property Effect cgroup v2 file Notes
CPUQuota=50% CPU quota (half of 1 CPU) cpu.max Converts to quota/period (e.g.: 50000 100000)
CPUWeight=200 Relative CPU weight cpu.weight 1–10000; 100 = default; higher = more priority
AllowedCPUs=0-1 CPU pinning cpuset.cpus.effective Restricts to specific CPUs
MemoryHigh=1G Soft memory limit memory.high Triggers reclaim before the hard limit
MemoryMax=2G Hard memory limit memory.max Exceeding can OOM-kill processes
MemorySwapMax=0 Swap allowed memory.swap.max 0 disables swap; "max" for unlimited
IOReadBandwidthMax=/dev/sda 10M Read bandwidth limit (bytes/s) io.max Use the correct device (e.g.: /dev/nvme0n1)
IOWriteBandwidthMax=/dev/sda 5M Write bandwidth limit (bytes/s) io.max You can use IOPS with IO*IOPSMax
# Descobrir device do /var/lib/docker (ajuste se necessário)
# DEV=$(lsblk -no PKNAME $(findmnt -n -o SOURCE /var/lib/docker) | head -n1); echo "/dev/${DEV}"

# Criar duas slices com limites distintos
sudo tee /etc/systemd/system/containers-prod.slice >/dev/null <<'UNIT'
[Unit]
Description=Slice para containers de producao

[Slice]
CPUQuota=200%
CPUWeight=500
MemoryHigh=4G
MemoryMax=6G
MemorySwapMax=1G
# Ajuste o device conforme seu disco do Docker
IOReadBandwidthMax=/dev/sda 30M
IOWriteBandwidthMax=/dev/sda 15M
UNIT

sudo tee /etc/systemd/system/containers-analytics.slice >/dev/null <<'UNIT'
[Unit]
Description=Slice para containers de analytics

[Slice]
CPUQuota=100%
CPUWeight=200
MemoryHigh=2G
MemoryMax=3G
# I/O mais restritivo
IOReadBandwidthMax=/dev/sda 10M
IOWriteBandwidthMax=/dev/sda 5M
UNIT

sudo systemctl daemon-reload

Run containers inside the slice (docker run and Compose)

Place a container inside a slice with --cgroup-parent. In Compose, use the cgroup_parent field on the service. All container processes will inherit the slice limits.

If you host private images, you may want a private Docker registry with a pull-through mirror: Private Docker registry with mirror on VPS. For visual management, see how to use Portainer: How to use Portainer for Docker.

# docker run com slice de producao
docker run -d --name web \
  --cgroup-parent=containers-prod.slice \
  -p 8080:80 nginx:alpine

# docker run com slice de analytics
docker run -d --name etl \
  --cgroup-parent=containers-analytics.slice \
  alpine:3 sh -c 'while true; do dd if=/dev/zero of=/tmp/file bs=1M count=50; sleep 5; done'

# docker compose (compose.yaml) — campo cgroup_parent por serviço
cat > compose.yaml <<'YML'
services:
  api:
    image: nginx:alpine
    ports: ["8080:80"]
    cgroup_parent: containers-prod.slice
  worker:
    image: alpine:3
    command: ["sh","-c","while true; do sha256sum /dev/zero | head -c 100000 > /dev/null; done"]
    cgroup_parent: containers-analytics.slice
YML

docker compose up -d

Adjust limits for a running container (set-property)

For a temporary adjustment without recreating the container, modify the systemd scope that Docker created. The scope name follows docker-<CONTAINERID>.scope. Changes made with --runtime are temporary (until reboot); to persist, prefer slices.

# ID curto do container
CID=$(docker ps -qf name=web)
SCOPE="docker-${CID}.scope"

# Ver o scope
systemctl status "$SCOPE" --no-pager

# Aplicar limites em runtime (exemplos)
sudo systemctl set-property --runtime "$SCOPE" CPUQuota=50%
sudo systemctl set-property --runtime "$SCOPE" MemoryMax=512M
# Ajuste o device conforme seu disco
sudo systemctl set-property --runtime "$SCOPE" IOReadBandwidthMax=/dev/sda 5M

How to confirm it worked (practical checks)

Validate using systemd tools and by reading cgroup v2 files. You should see the container inside the slice and the cpu.max, memory.max and io.max files reflecting your settings.

Monitor continuously. A metrics stack helps — see Prometheus & Grafana with Docker Compose: Prometheus & Grafana with Docker Compose.

# Ver a hierarquia de cgroups/slices
systemd-cgls | sed -n '1,120p'

# Conferir uso ao vivo (cgroups)
systemd-cgtop | head -n 20

# Ler arquivos do cgroup v2 do container no slice
CID=$(docker ps -qf name=web)
CG="/sys/fs/cgroup/containers-prod.slice/docker-${CID}.scope"

printf "cpu.max:    "; cat "$CG/cpu.max" || true
printf "memory.max: "; cat "$CG/memory.max" || true
printf "io.max:     "; cat "$CG/io.max" || true

Common troubleshooting

- cgroup v2 missing: check /sys/fs/cgroup/cgroup.controllers. If it’s empty, enable cgroup v2 in GRUB and reboot.

- "cgroup parent not found": the slice must exist under /etc/systemd/system and you must run systemctl daemon-reload.

- No effect on I/O limits: the device is likely wrong. Discover the device for /var/lib/docker and use it in IO*Max directives.

- Containers ignoring cgroup_parent: ensure Docker’s cgroup driver is systemd (docker info).

- OOM killing processes: start with MemoryHigh and raise MemoryMax gradually.

- Persistence: set-property changes on scopes are runtime-only. To persist across container/host restarts, use slices + --cgroup-parent.

- Rootless Docker: cgroup paths and permissions differ; prefer applying limits via slices for the service user. For CI environments, check BuildKit remote cache in Docker CI: BuildKit remote cache for Docker CI.

Frequently asked questions

Do I need Debian 12 to use cgroup v2 with Docker?

Not necessarily. Debian 12 comes with cgroup v2 by default. On Debian 11 you can enable it with systemd.unified_cgroup_hierarchy=1 in GRUB and reboot. Then configure Docker to use the systemd cgroup driver.

What's the difference between CPUQuota and CPUWeight?

CPUQuota defines a hard ceiling for CPU (percentage). CPUWeight adjusts relative priority when contention occurs between groups. Use CPUWeight for balancing services and CPUQuota to enforce a strict limit.

How do I discover the correct device for IOReadBandwidthMax?

Find the block device backing /var/lib/docker with findmnt and lsblk. For example, use findmnt -n -o SOURCE /var/lib/docker and map that to the main block device with lsblk. Use a path like /dev/sda or /dev/nvme0n1.

Can I limit a single running container without recreating it?

Yes. Use systemctl set-property on the docker-<ID>.scope with --runtime for temporary adjustments. For persistent limits across reboots, create a slice with the desired limits and start the container with --cgroup-parent pointing to that slice.

Does Docker Compose support cgroup_parent?

Yes. In Docker Compose v2 you can use the cgroup_parent key on a service. All processes for that service inherit the slice and its limits. Ensure Docker uses the systemd cgroup driver.

Do these limits work with different storage or network drivers?

CPU and memory limits are independent of storage or network drivers. For I/O limits, they apply to the underlying block device. For layered storages (LVM, RAID, remote volumes), determine the correct device to apply the limit to and confirm the kernel exposes the io.* controls for that device.

Need help applying limits without disrupting your environment?

If your Docker deployment needs predictable CPU, memory and I/O limits, I can help with diagnosis and a gradual, safe rollout using systemd and cgroup v2.

Discuss your case

See also