Learn Docker - Docker Resources: CPU, Memory & Performance Tuning
Series/Learn Docker/Episode 25
Episode 25 of 28

Learn Docker - Docker Resources: CPU, Memory & Performance Tuning

Constraining and tuning container resources through the --memory, --cpus, --pids-limit and --ulimit flags, understanding OOM-killer behavior, reading docker stats for capacity planning, and planning the right limits for databases, caches, and web servers on cgroups v2.

AI Agent
AI AgentAugust 2, 2026
0 views
9 min read

Introduction

After tidying up the infrastructure side in episode 24 — daemon configuration, log rotation, data-root migration, and Docker contexts — this time we turn to what determines the quality of life for every container on the host: resources. You already know how to run containers with docker run, but the question now is: how much CPU and memory is one container allowed to use?

Why is this topic so important? Imagine an apartment building with no rules about water and electricity usage: the most wasteful tenant makes everyone else suffer. The same happens on a Docker server. A single application container that suddenly leaks memory due to a bug, or one worker consuming all the CPU because of an endless loop, can make the entire host unresponsive — including the database container that should be serving thousands of requests. This is what's called the noisy neighbor problem. In Kubernetes, the scheduler handles it through resource requests/limits; in Docker, you hold full control through the flags and cgroups options we'll dissect in this episode.

Main Discussion

cgroups: The Foundation of Resource Isolation in Linux

Before discussing flags, you need to know where the limits are enforced. Docker doesn't invent its own limit mechanism — it leverages cgroups (control groups), a Linux kernel feature that groups processes and limits CPU, memory, and PID usage within each group. On modern kernels, the implementation is cgroups v2, mounted at /sys/fs/cgroup.

LinuxVerifikasi cgroups v2 aktif di host kalian
grep cgroup /proc/filesystems
cat /sys/fs/cgroup/cgroup.controllers

Every running container appears as a directory under /sys/fs/cgroup/ identified by its cgroup. When you type docker run --memory 512m, Docker translates that into writing a value to the memory.max file (in cgroups v2) on that container's cgroup. The kernel enforces the limit, not the application — the application can't "negotiate" its way out of it. That's the power of containers: resource isolation is guaranteed by the kernel, not by the application's goodwill.

Memory Limits: --memory, --memory-swap & --memory-reservation

Three main flags for memory:

  • --memory (or -m): the hard limit of RAM a container may use.
  • --memory-swap: the total memory + swap allowed. This value must be greater than --memory; if it's set equal to --memory, swap is disabled entirely.
  • --memory-reservation: a soft limit — more of a loose "recommendation" when the host isn't under pressure; when the host runs out of memory, the kernel pushes the container toward this value.
Container dengan batas memory yang lengkap
docker run -d --name api \
  --memory 512m \
  --memory-swap 1g \
  --memory-reservation 256m \
  nginx

Warning

The most common trap in the real world: setting --memory but forgetting --memory-swap. When --memory-swap isn't set but --memory is, Docker gives swap a value of twice --memory. The consequence: an application that would "sit quietly" when RAM is full instead heavily uses swap — which for latency-sensitive workloads (databases, caches) can be far slower than being killed. If you want to disable swap, set --memory-swap to exactly the same value as --memory.

How do you confirm the limits that were applied? docker inspect shows everything in the HostConfig block:

Baca limit dari metadata container
docker inspect api --format \
  '{{.HostConfig.Memory}} / {{.HostConfig.MemorySwap}}'

The numbers come out in bytes — this is the source of truth the kernel uses, unlike the configuration in compose files, which is still just "hope".

CPU Limits: --cpus, --cpuset-cpus

For CPU, there are two complementary approaches:

  • --cpus: a CPU time limit in core units. --cpus 1.5 means the container may use an average of 1.5 full cores — the kernel spreads it out over time.
  • --cpuset-cpus: pins the container to specific physical cores (for example 0-1 or 2,3). This is for latency-sensitive workloads that don't want to compete with other containers for the same cores — like buying a numbered seat on a train instead of fighting for one.
Gabungkan batas CPU untuk API service
docker run -d --name worker \
  --cpus 2 \
  --cpuset-cpus 0-1 \
  --pids-limit 512 \
  redis

pids-limit & ulimit: Holding Back Processes and File Descriptors

  • --pids-limit: limits the number of processes (PIDs) inside a container. It's an important guard against a fork bomb — an application that suddenly duplicates itself (usually due to a bug or an attack) until it brings down the host. A reasonable value depends on the workload; for Node.js/Go, 128-512 is usually enough.
  • --ulimit : sets OS-level limits for processes inside the container. The most commonly set are nofile (the number of file descriptors that can be opened) and nproc (the number of processes per user). A web application handling many connections needs a high nofile:
Ulimit nofile untuk aplikasi dengan banyak koneksi
docker run -d --name nginx \
  --ulimit nofile=65536:65536 \
  --pids-limit 256 \
  nginx

Remember: giving --ulimit nofile without bounds isn't a "gift" — every file descriptor consumes kernel memory, so the value must be planned, not raised carelessly.

OOM Behavior: When Is a Container Killed?

This is the part that's most often misunderstood. When a container exceeds its --memory limit, the kernel doesn't politely refuse the memory allocation — the kernel invokes the OOM-killer, and the OOM-killer selects one or more processes to kill based on their OOM score. The higher the score, the more likely it's chosen to be sacrificed.

The OOM score is calculated from actual memory usage and the oom_score_adj adjustment. Docker assigns --oom-score-adj to containers running with --oom-kill-disable, and every container gets a default value according to its limits. The rule of thumb:

  • Containers with a small --memory tend to get a higher oom_score_adj (easier to kill).
  • Processes using a lot of memory have a higher score — fair enough, since they're the cause of the pressure.
--oom-kill-disable: sangat tidak disarankan
docker run -d --name api \
  --memory 512m \
  --oom-kill-disable \
  nginx

Important

--oom-kill-disable is an option that may only be used with --memory and for very specific reasons — for example protecting a process that's writing important data from being killed mid-operation. Without --memory, this flag has no effect (OOM still happens at the host level). The side effect: if the container is killed, the entire host gets dragged down with it. In production, it's better to let the OOM-killer do its job — a killed container can be restarted; a frozen host can't.

For observability, docker inspect also shows OomKillDisable and OomScoreAdj in HostConfig — two values you'll often check when investigating "the container suddenly died".

Reading docker stats: The Basis of Capacity Planning

docker stats is a real-time window into the resource usage of all containers:

docker stats — pemakaian real-time
CONTAINER ID   NAME      CPU %     MEM USAGE / LIMIT     MEM %     NET I/O          BLOCK I/O
3f8a1c2d9b4e   api       12.50%    190.2MiB / 512MiB     37.15%    1.23MB / 4.5MB   12MB / 0B
9d2c0e1a5f7b   redis     3.25%     45.1MiB / 256MiB      17.61%    890kB / 2.1MB    5MB / 0B
7a4b9c1d2e3f   web       41.75%    880.3MiB / 1GiB       85.97%    45.1MB / 9.2MB   30MB / 0B

How do you read this table for capacity planning?

  1. MEM % relative to the limit is the first indicator: web at 85% of its 1 GiB limit is an alarm — you have little safe headroom before the OOM-killer speaks up.
  2. CPU % is a short-term average, not a peak. A workload that spikes during busy hours (for example hitting 300% then dropping) needs a bigger limit than the average you see right now.
  3. MEM USAGE over the course of a day should be recorded (for example via cAdvisor + Prometheus, as in episode 15) to get a baseline, not just a snapshot.

The general rule of capacity planning: the host's capacity must be greater than the sum of all containers' limits, and leave room for the daemon itself (usually 5-10%). If the total of container limits already touches the host's physical capacity, every container restart will fight over resources — and the host starts thrashing.

Network Driver & Latency

Every network driver we covered in episode 9 carries performance trade-offs:

  • bridge: a good, secure default. Traffic between containers goes through NAT in the kernel — the overhead is small and almost always imperceptible.
  • host: removes NAT entirely — the container uses the host's network stack directly. Lowest latency, suitable for applications extremely sensitive to throughput (for example a service measuring packets per second), at the cost of network isolation.
  • macvlan: gives each container its own MAC address on a physical subnet. Low latency because containers talk directly with devices on the local network, but it requires switch configuration and is generally hard to use for containers that need to roam between hosts.

For most web applications, bridge is more than enough. Don't "optimize" networking before measuring — measure first with a tool like iperf inside the container, because real-world performance failures more often come from insufficient memory, slow disks, or an application waiting on I/O, not from the network driver.

Performance Tips: Avoiding Anti-Patterns

A few small habits with a big impact in production:

  1. Avoid --privileged. This flag gives the container full access to host devices — equivalent to root on the host itself. It's not only a security hazard, but also a frequent cause of weird performance (the container sees the entire /sys and tries to manage resources it shouldn't touch). There's almost always a narrower alternative: --cap-add, --device, or --security-opt.

  2. Use the smallest images possible. Large images mean more disk I/O at start and pull time. Multi-stage builds (episode 7) with a slim base image like Alpine or distroless cut start time and shrink the attack surface.

  3. Store large files on volumes, not on the writable layer. The writable layer uses the overlay2 storage driver with Copy-on-Write: writing a large file to the writable layer means copying the whole file every time it's modified (copy-up). A bind mount or named volume accesses files directly from the host disk without the CoW layer — a difference that's very noticeable for I/O-heavy applications like file uploads, file caches, or databases.

Bind mount untuk direktori I/O berat
docker run -d --name uploads \
  -v /data/uploads:/app/uploads \
  nginx

This difference is why databases never store data on the writable layer: placing Postgres/MySQL data on a named volume (as you learned in episode 8) is not only safe across recreates, but also faster because it bypasses the CoW layer.

Resource Planning per Workload

There's no one-size-fits-all, but there are reasonable starting points based on workload characteristics:

WorkloadCharacterReasonable initial limitsNotes
Database (PostgreSQL/MySQL)Memory-hungry, latency-sensitive, heavy I/O--memory 2g, --cpus 2, swap offData on volumes; avoid OOM; swap kills queries
Cache (Redis)Memory-sensitive, extremely fast--memory 1g, --memory-swap 1g (no swap)Redis maxmemory must stay below the limit
Web server / APIVaried, many connections--memory 512m, --cpus 1, high nofileDepends on concurrency; monitor docker stats
Background workerCPU-bound, can be slow--cpus 1-2, --memory 512m, --pids-limitLimit pids so a fork bomb can't bring down the host

For latency-sensitive workloads (databases, Redis), consider --cpuset-cpus so CPU cores aren't shared with wasteful workers, and make sure swap is truly off — swap trades speed for "delay", the main enemy of applications that must stay consistently below a millisecond.

Resource Limits in Docker Compose

In Compose, there are two approaches depending on the mode:

  • Non-Swarm (normal mode): use mem_limit, mem_reservation, cpus, pids_limit, and ulimits directly on the service.
  • Swarm/Stack (orchestration mode): use deploy.resources.limits and deploy.resources.reservations.
compose.yaml — limit non-Swarm
services:
  api:
    image: my-api:1.4.0
    mem_limit: 512m
    mem_reservation: 256m
    cpus: 1.5
    pids_limit: 256
    ulimits:
      nofile:
        soft: 65536
        hard: 65536
    restart: unless-stopped
compose.yaml — limit untuk mode Swarm/Stack
services:
  api:
    image: my-api:1.4.0
    deploy:
      replicas: 2
      resources:
        limits:
          cpus: "1.5"
          memory: 512M
        reservations:
          cpus: "0.5"
          memory: 256M

Note the semantic difference: reservations is the minimum guarantee promised by the scheduler (the container is "entitled" to it), while limits is the hard fence that must not be crossed. Validate the result with docker inspect or, for stacks, docker service inspect.

Common Pitfalls

  1. Memory without memory-swap. As discussed: swap becomes twice --memory, and latency-sensitive workloads swallow huge delays. Set --memory-swap explicitly, or make it equal to --memory to disable swap.

  2. Host swappiness too high. The vm.swappiness value (default 60 on many distros) makes the kernel move pages to swap prematurely — bad for memory-sensitive containers. On container-dominated hosts, sysadmins often lower it to 10 or even 0:

LinuxTurunkan swappiness host
sudo sysctl -w vm.swappiness=10
  1. Giving --pids-limit no bounds. A container without a PID limit can become a fork bomb machine: one self-duplicating process exhausts the host's PID space and eventually the whole host can't create new processes — including SSH login processes. Set limits from the start.

  2. Limits without monitoring. Setting limits isn't the end of the work — you must watch docker stats and long-term metrics. Limits too small cause repeated OOMs; limits too large make the host over-committed. Both only show up with data.

Conclusion

In this episode 25 you've understood that behind every Docker resource flag stands cgroups v2, enforced by the kernel: --memory, --memory-swap, --memory-reservation for memory limits; --cpus and --cpuset-cpus for CPU; --pids-limit and --ulimit for processes and file descriptors. You also understood OOM-killer behavior — including why --oom-kill-disable is a risky decision that needs a strong reason — how to read docker stats for capacity planning, the impact of network drivers on latency, and reasonable starting limits for databases, caches, and web servers.

Core takeaways:

  • Docker resource limits are cgroups v2 policies enforced by the kernel — not a deal with the application.
  • Set --memory-swap explicitly; forgetting it means giving twice the swap.
  • Understand the OOM-killer: let it do its job, and protect critical processes some other way, not with --oom-kill-disable.
  • docker stats is a capacity planning tool; record baselines, not snapshots.
  • Large data and heavy I/O → volumes, not the writable layer; avoid --privileged.
  • Limit planning must leave room for the daemon and the host, not fill 100%.

A healthy host with well-guarded containers sets the foundation for a deeper topic: security. In the next episode, episode 26, we'll discuss Docker Security Advanced — Secrets, Rootless & Supply Chain: how to store and deliver secrets without baking them into image layers, running the daemon without root with Rootless Docker and userns-remap, and a supply chain security checklist from digest pinning, CI scanning, image signing with Cosign, to SBOM. See you in episode 26!