>_ josetilabs.com
← blog

16 Oct 2026 · docker, containers, producao · 6 min

How to limit memory, logs and restarts of a container

A container with no memory cap takes the whole service down with it, and a log without rotation fills the disk until the server stops answering. The fix is four keys in the compose file: mem_limit, pids_limit, logging and restart: on-failure:3. Running all four limits on purpose in an isolated environment, the process blew past 120 MB under a 128 MB cap and exited with code 137, the restart stopped on the third attempt, and 4.2 MB of log ended up as 1 MB.

What each limit holds back

mem_limit is the memory ceiling for the container. Go past it and the cgroup OOM killer sends SIGKILL, which Docker records as exit 137. The difference between 137 and 143 is the signal itself: both mean killed.

pids_limit caps processes and threads, not memory. It catches the service stuck in a loop spawning children forever, the one that brings a box down with hundreds of processes sharing one name.

logging with max-size and max-file truncates the log once it passes the size. Without it the json-file driver only grows, and it is the most common way a disk fills up unnoticed.

restart: on-failure:3 lets the container come back after a failure, but only three times in a row. Without the number, always keeps respawning all night, burning CPU on a service that was never going to work.

Terminal with four limit tests: a process dies at 120 MB under a 128 MB cap and exits with code 137, a container that fails 3 times stops restarting, 40000 log lines of 4.2 MB leave 1050004 bytes on disk, and the 64 pids cap takes the container down with can't fork
The four limits tested on purpose, each with its command and the number that came back.

The number that matters most is 120 MB. The process asked for 120 MB and died under a 128 MB ceiling without ever reaching it. Anyone writing mem_limit: 128m needs headroom, because the container itself burns memory on the runtime before the application starts.

Docker’s resource constraints documentation explains the part that surprises people: the daemon protects itself from being killed, which leaves the container as the victim. The OOM killer usually takes the process that exceeded its own limit, not the engine.

The artifact

The compose file below is the whole thing, commented line by line. It starts a test container and applies all four limits.

yaml
services:
  app:
    image: python:3.12-slim
    command: ["sleep", "infinity"]

    # memory ceiling. the kernel kills whatever goes past it
    mem_limit: 128m

    # cap on processes and threads. stops a service spawning children forever
    pids_limit: 64

    # half a core at most, so the container does not take the machine
    cpus: 0.5

    # log that stops growing: 1 MB per file, 3 files
    logging:
      driver: json-file
      options:
        max-size: "1m"
        max-file: "3"

    # comes back after a failure, but stops on the third try in a row
    restart: on-failure:3

    # scratch space that dies with the container
    tmpfs:
      - /tmp:rw,size=16m,mode=1777

If you have hardened a service before, the rest of the pattern is familiar. The post on rootless containers covers what sits next to it: user, cap_drop and read_only, on the same line of defense.

How to verify

Before starting anything, the script below checks whether your compose file has all four limits. It only reads the file, does not need Docker, and runs anywhere bash does. Save it as verifica-limites.sh and run bash verifica-limites.sh compose.yaml.

bash
#!/usr/bin/env bash
# verifica-limites.sh: confere se o compose tem os quatro limites.
# uso: bash verifica-limites.sh compose.yaml
# Nao instala nada e nao mexe em container nenhum, so le o arquivo.

arquivo="${1:-compose.yaml}"

if [ ! -f "$arquivo" ]; then
  echo "arquivo nao encontrado: $arquivo" >&2
  exit 1
fi

conteudo=$(tr '\n' ' ' < "$arquivo")
faltando=0

conferir() {
  if printf '%s' "$conteudo" | grep -q "$1"; then
    echo "ok    $2"
  else
    echo "FALTA $2"
    faltando=$((faltando + 1))
  fi
}

conferir 'mem_limit:' 'teto de memoria (mem_limit)'
conferir 'pids_limit:' 'teto de processos (pids_limit)'
conferir 'max-size:' 'rotacao de log (max-size)'
conferir 'max-file:' 'quantidade de arquivos de log (max-file)'

# restart precisa ter numero: 'always' e 'on-failure' sem :N nao param
if printf '%s' "$conteudo" | grep -qE 'restart:[[:space:]]*(on-failure|unless-stopped):[0-9]'; then
  echo "ok    reinicio com numero de tentativas"
elif printf '%s' "$conteudo" | grep -q 'restart:'; then
  echo "FALTA restart sem numero: um servico quebrado entra em laco"
  faltando=$((faltando + 1))
else
  echo "FALTA politica de reinicio (restart:)"
  faltando=$((faltando + 1))
fi

if [ "$faltando" -gt 0 ]; then
  echo "$faltando limite(s) faltando em $arquivo"
  exit 1
fi

echo "os quatro limites estao em $arquivo"

It prints ok per line and exits 1 if anything is missing. Against the full file from this post, the last line is os quatro limites estao em compose.yaml.

To see what Docker actually applied instead of trusting the file:

bash
docker compose up -d

docker inspect $(docker compose ps -q) \
  --format 'mem={{.HostConfig.Memory}} pids={{.HostConfig.PidsLimit}}'

Expected output is mem=134217728 pids=64. The big number is the same ceiling in bytes: 128 MB becomes 134217728.

The test worth running is blowing the limit on purpose. Inside an isolated container, this script allocates 8 MB per round and prints the running total:

python
blocos = []
for i in range(1000):
    blocos.append(bytearray(8 * 1024 * 1024))
    print(f"alocado 8 MB x{i + 1} = {(i + 1) * 8} MB", flush=True)
print("SOBREVIVEU")

Run it as docker run --memory 128m python:3.12-slim python3 -u come.py and the last line is alocado 8 MB x15 = 120 MB, the process dies right there, and docker inspect reports OOMKilled=true ExitCode=137. That 120 MB is the headroom I mentioned: if you have watched a process die before it reached the cap, you stop setting the limit to the application’s exact number.

If it goes wrong

The service restarts endlessly and burns CPU overnight. Cause: restart: always with no number, or on-failure without the :3. Fix: switch to on-failure:3 and check RestartCount after the failure.

The container dies with 137 and nobody knows why. Cause: the memory cap is too tight once the runtime is counted. Fix: raise mem_limit, or measure real usage before picking the number.

The disk fills up even with logging configured. Cause: max-file only counts alongside max-size, and any driver other than json-file ignores both. Fix: check docker inspect --format '{{.HostConfig.LogConfig.Type}}' and stick with the default driver.

Table

Limit What it holds back What happens at the ceiling
mem_limit: 128m a process that grows without a ceiling OOM killer strikes, exit 137
pids_limit: 64 a loop spawning processes forever can't fork, exit 2
max-size + max-file a log that fills the disk file rotates, disk stops growing
restart: on-failure:3 a service dying in a loop stops on the third try

A memory cap does not protect the disk, and a log cap does not protect memory. Different defenses for different failures. The memory audit post shows the outside view of a process that grows with no limit.

FAQ

Why did the process die at 120 MB when the cap is 128 MB? The container uses memory beyond the application, through the runtime and the page cache. The cap counts all of it, so the process dies before reaching the limit number.

mem_limit or deploy.resources.limits in compose? Both work in current Compose. mem_limit is the per-service shortcut: shorter and easier to read in a small file.

Can a container use swap? Yes, but memory-swap equal to mem_limit turns swap off in practice. If you want page cache as headroom, set memory-swap above the memory limit.

Does the pids limit take down a normal service? No. A normal container uses a few dozen processes. If 64 is too few, the service is opening far too many, and the limit just found a bug.

Sources

Docker resource constraints, checked on 16 October 2026, and the Compose services reference, same date. For the cgroup underneath mem_limit, cgroups(7) from the Linux manual. Outside the container, the same discipline of capping growth on disk shows up in the rsync backup post.

Read next

// By e-mail

Posts published here also go out by e-mail. Delivery has not started yet: whoever leaves an address now joins the first batch.

I store only your e-mail, the date and the record of this consent. No cookies, no trackers, nothing shared with third parties. Details in the privacy policy.