Docker Resource Limits and cgroup Tuning on a VPS: Keep One Container from Starving the Host

Containers on a VPS share the host kernel — and the host’s CPU, memory, and I/O. The classic failure mode: a single container with a memory leak or a runaway loop consumes so much RAM that the kernel’s OOM killer starts reaping processes from other containers, taking your database or web server down with it. Docker’s resource limits, implemented through Linux cgroups, prevent this by giving every container a hard budget. This guide covers setting memory, CPU, PID, and I/O limits with docker run and Compose, and verifying them under cgroup v2 — the default on modern distros.

Limits matter most on smaller VPS plans, where 2–4 GB of RAM is shared between several services. If you are choosing hardware for a container workload, our comparison table highlights plans with guaranteed CPU allocation — important because shared CPU can make --cpus accounting feel different than it does on bare metal.

Hard Limits with docker run

The most important flags are --memory and --cpus. Memory is enforced hard: the container cannot exceed it, and the kernel kills the container’s top process if it tries. CPU is enforced as a proportion of one core, so --cpus=1.5 means at most 150% of one core across all container processes:

docker run -d --name app \
  --memory 512m \
  --memory-swap 768m \
  --cpus 1.5 \
  --pids-limit 256 \
  --restart unless-stopped \
  myapp:latest
  • --memory 512m — hard RAM ceiling; the container is OOM-killed if it exceeds this.
  • --memory-swap 768m — total memory + swap budget (512 MB RAM + 256 MB swap). Set equal to --memory to disable swap for the container entirely.
  • --cpus 1.5 — CPU quota relative to one core, implemented via cgroup cpu.max.
  • --pids-limit 256 — caps processes/threads, stopping fork bombs from exhausting the host PID table.
  • --blkio-weight 500 — relative I/O priority (100–1000); give databases a higher weight than batch workers.

Declaring Limits in Docker Compose

Compose keeps limits declarative and versioned with the rest of your stack. A typical web + worker pair on a 2 GB VPS:

services:
  web:
    image: myapp:latest
    ports: ["8080:8080"]
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
        reservations:
          memory: 128M
    mem_swappiness: 0

  worker:
    image: myapp-worker:latest
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: 256M
          pids: 128

Note that Compose accepts the long-form deploy.resources syntax; the shorthand mem_limit/cpus still works but is deprecated in recent versions. Whatever you declare, the enforcement layer is the same cgroup machinery, so docker stats reflects the budget you set.

Verifying Limits Under cgroup v2

Never trust that a limit was applied — verify it. Get the container’s cgroup path and read the actual cgroup v2 files:

docker stats --no-stream          # live view of memory/CPU per container

# direct cgroup v2 inspection
CGROUP=$(docker inspect -f '{{.HostConfig.CgroupParent}}' app)
cat /sys/fs/cgroup$CGROUP/memory.max      # hard memory limit in bytes
cat /sys/fs/cgroup$CGROUP/cpu.max         # quota period, e.g. "150000 100000"
cat /sys/fs/cgroup$CGROUP/pids.max        # PID limit

On cgroup v2 hosts, memory.max is the hard ceiling and memory.current is live usage — the difference is your headroom. cpu.max reads as “quota period”: 150000 100000 means 1.5 cores out of every 100 ms slice. Monitoring these two files is the fastest way to catch a container drifting toward its limit before it hits it.

OOM Behavior and Systemd Integration

When a container hits memory.max, the kernel kills a process inside it (usually the one with the largest footprint) and Docker restarts it if --restart is set. That is the desired behavior — the blast radius stays inside the container. For stateful services like databases, prefer a higher limit plus --oom-kill-disable only with swap disabled, so the process blocks instead of dying. One more gotcha: if you run Docker under systemd, ensure Delegate=yes is set in docker.service (it is by default on current distros); without delegation, systemd can override the cgroup settings Docker writes, silently removing your limits.

A Practical Sizing Rule

On a 2 GB VPS, a sane split is: 512 MB web, 256 MB worker, 512 MB database, and 512 MB reserved for the kernel, page cache, and Docker itself — leaving ~200 MB of deliberate headroom. Sum your containers’ limits to at most 75% of total RAM, and let the OS use the rest for caching. This prevents the OOM killer from ever touching a container that is behaving correctly, which is the entire point of cgroup limits on a shared-kernel host.

Set limits on every container you deploy, then check docker stats weekly to spot services that grew into their budget. For the VPS hardware that makes these numbers predictable, compare plans side by side across providers.

Container workloads need a host where you control the kernel and the Docker daemon, which means unmanaged VPS access. InterServer’s VPS plans offer full root access and flat-rate pricing, so you can tune cgroups to your workload without platform fees per container.

Leave a Reply