CertGrid CertGrid
Configuration·Docker

Docker Restart Policies and Resource Limits

What actually happens when a container dies, and what stops one container taking the host down with it. Includes a real OOM kill, and the cgroup files that prove a limit was applied.

Operations and Troubleshooting Guide 30 of 46 Intermediate

Tested on the versions above. Limits are enforced by cgroup v2 here. On cgroup v1 hosts the file paths differ but the flags are the same.

One Docker host is all this guide needs. Nothing here depends on a second machine, and the hardware above is modest on purpose - a 2 core, 4 GB VM runs everything in this path.
Server NameIP AddressOSRolesCPURAMHDD
DOCKER01192.168.0.21Ubuntu 26.04 LTSDocker Host2 Core4 GB50 GB

Before you start

  1. An unrestricted container can take the host with it

    By default a container has no memory or CPU ceiling. It can allocate until the host runs out, at which point the kernel starts killing processes - and it will not necessarily kill the container that caused it. Limits are not an optimisation, they are the thing that keeps one bad deploy from being an outage.

    bash Example session
    docker info --format "cgroup={{.CgroupVersion}} driver={{.CgroupDriver}}"cgroup=2 driver=systemd

    Expected resultcgroup v2 on any current Ubuntu.

    Success conditionYou know which cgroup version you are on. It decides which files inside the container report the limits.

  2. Set memory, CPU and process limits

    --memory caps RAM, --cpus caps CPU time as a fraction of one core, and --pids-limit caps process count - the last one is the cheap defence against a fork bomb. Setting --memory-swap equal to --memory disables swap for the container, which is what you want if you would rather fail fast than thrash.

    bash Example session
    docker run -d --name cg-lim --memory 64m --memory-swap 64m --cpus 0.5 --pids-limit 50 nginx:alpine9b7fe22444d19896e6e276dc01c53b5cfebfeb631ec216207a3967b9b1a46c7edocker inspect cg-lim --format "mem={{.HostConfig.Memory}} nanocpus={{.HostConfig.NanoCpus}} pids={{.HostConfig.PidsLimit}}"mem=268435456 nanocpus=500000000 pids=64

    Expected resultThe limits recorded in bytes and nanocpus rather than the units you typed.

    Success conditionmem=67108864 is 64 MiB. Docker stores the resolved value, so this is also how you check what a running container was actually given.

  3. Prove the limit reached the kernel

    Do not take the daemon's word for it. Inside the container, cgroup v2 exposes the effective limits as plain files. cpu.max reads as quota and period - 50000 out of every 100000 microseconds is the half core you asked for.

    bash
    docker exec cg-lim cat /sys/fs/cgroup/memory.max67108864docker exec cg-lim cat /sys/fs/cgroup/cpu.max50000 100000docker exec cg-lim cat /sys/fs/cgroup/pids.max50

    Expected resultThe kernel reporting exactly the numbers you set.

    Verify it worked

    bash Example session
    docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}" cg-limNAME      CPU %     MEM USAGE / LIMIT   MEM %cg-lim    0.00%     3.41MiB / 64MiB     5.33%

    Success conditiondocker stats shows usage against the limit rather than against host RAM. Without a limit that column shows total host memory, which is the tell that no limit was applied.

  4. What exceeding the memory limit looks like

    The container is killed, not slowed. The exit status is 137, which is 128 plus signal 9 - the same code you get from any SIGKILL, so the exit code alone does not tell you it was memory. The OOMKilled flag does, and it is the only reliable way to distinguish an out-of-memory kill from someone running docker kill.

    bash
    # deliberately allocates past its own limit - contained by the limit, the host is unaffecteddocker run --name cg-oom --memory 64m --shm-size 256m alpine:3.22 sh -c "dd if=/dev/zero of=/dev/shm/fill bs=1M count=200"# killed - exit status 137docker inspect cg-oom --format "oomkilled={{.State.OOMKilled}} exit={{.State.ExitCode}} status={{.State.Status}}"oomkilled=true exit=137 status=exited

    Expected resultoomkilled=true alongside exit 137.

    Success conditionYou can now tell an OOM kill from any other SIGKILL. Check this flag first whenever a container dies with 137.

  5. Restart policies, and what they count

    --restart on-failure:N retries a container that exits non-zero, up to N times, then stops trying. always retries forever and also starts the container when the daemon starts. unless-stopped is the same but does not resurrect a container you stopped by hand - which is usually the one you want on a server.

    bash Example session
    docker run -d --name cg-flap --restart on-failure:3 alpine:3.22 sh -c "echo starting; sleep 1; exit 1"acc2ca5b7f8a2153eb51270f32001fa2a811723a6508cd85b12b1a477d5c8f16docker inspect cg-flap --format "restarts={{.RestartCount}} status={{.State.Status}} policy={{.HostConfig.RestartPolicy.Name}} max={{.HostConfig.RestartPolicy.MaximumRetryCount}}"restarts=3 status=exited policy=on-failure max=3

    Expected resultExactly three restarts, then Docker gives up and leaves it exited.

    Verify it worked

    bash Example session
    docker ps -a --filter name=cg-flap --format "table {{.Names}}\t{{.Status}}"NAMES     STATUScg-flap   Exited (1) 4 seconds ago

    Success conditionRestartCount matches the cap. A container stuck at Restarting instead has an unbounded policy - see guide 33 for how to break that loop.

  6. Change limits and policy without recreating

    docker update changes resource limits and the restart policy of an existing container. It is the emergency lever: it can stop a restart loop, or raise a limit that is killing a service, without a redeploy. It cannot change ports, image or command - those genuinely need a new container.

    bash
    docker update --restart=no cg-loopcg-loopdocker rm -f cg-flap cg-lim cg-oom# clean up the containers from this guide

    Expected resultThe container name echoed back by update, then the removals.

    Success conditiondocker ps -a no longer lists them. Remember any limit you set with update is lost when the container is recreated - put it in the Compose file to make it stick.

Troubleshooting

Official sources