11. Resource Management and Performance
By default, a container can use as much memory, CPU, disk bandwidth, and as many processes as the host will give it. One leaking or runaway container can therefore slow down or crash everything else on the machine. This chapter first covers the controls Docker offers to limit and prioritize resources (memory, CPU, CPU pinning, block I/O, PIDs) and what happens when a container runs out of memory. It then covers how to make containers start faster, pull faster, read and write storage faster, and move network traffic faster, both on Linux servers and on Docker Desktop.
<a id="111-resource-constraints"></a>
11.1 Resource Constraints
This section turns the cgroup controllers introduced in Control Groups (cgroups) into practical docker run and Compose settings, and explains how each limit behaves when a container reaches it.
Memory Limits
Why it matters. Memory is the one resource a container cannot be slowed down on: when it runs out, something gets killed. Without a limit, a container with a memory leak grows until the host runs out of memory. At that point the kernel's global OOM killer chooses a victim from every process on the machine, which can be a database, an unrelated container, or the SSH daemon you need to fix the problem. A memory limit keeps the damage inside the container that caused it.
Analogy. A memory limit works like a prepaid phone plan. Under the cap, everything works normally. At the cap, the network does not slow you down; it cuts you off. The soft limit (reservation) is the amount the operator promises to keep for you when the network is congested.
The flags
| Flag | Meaning | cgroup v2 file |
|---|---|---|
--memory, -m | Hard limit on RAM (minimum 6m) | memory.max |
--memory-swap | Total of RAM plus swap the container may use | memory.swap.max = swap − memory |
--memory-reservation | Soft limit: memory the kernel tries to protect from reclaim when the host is under pressure | memory.low |
--memory-swappiness | 0–100 tendency to swap anonymous pages (cgroup v1 only) | n/a |
--oom-kill-disable | Do not OOM-kill; block allocations instead (cgroup v1 only, see OOM Behavior) | n/a |
--kernel-memory | Deprecated; ignored on modern kernels | n/a |
Sizes accept the suffixes b, k, m, and g.
How --memory-swap combines with --memory. It is a total, not an amount of swap, which is the most common source of confusion:
--memory | --memory-swap | Result |
|---|---|---|
512m | not set | 512 MB RAM + up to 512 MB swap (if the host has swap) |
512m | 512m | 512 MB RAM, no swap: predictable, recommended for latency-sensitive services |
512m | 1g | 512 MB RAM + 512 MB swap |
512m | -1 | 512 MB RAM + unlimited swap |
What counts toward the limit. The kernel charges to the cgroup everything the container's processes cause to be held in memory:
- Anonymous memory: heap, stacks, and memory-mapped data of the processes.
- Page cache for files the container reads and writes. Most of this is reclaimable, so the kernel evicts it before killing anything.
tmpfsmounts, including/dev/shm. Files there are memory and cannot be evicted, only swapped.- Kernel memory used on behalf of the container (socket buffers, dentries, and so on).
Because page cache counts, a container that reads large files can show usage close to its limit without being in danger. docker stats subtracts inactive file cache from the number it shows for this reason.
memory.max (--memory=512m) ............................. 512 MB <- hard wall: reclaim, then OOM kill
+------------------------------------------------------+
| anonymous memory (heap, stacks) | page cache | tmpfs |
+------------------------------------------------------+
memory.low (--memory-reservation=256m) ................ 256 MB <- protected from reclaim
under host pressure
Example: setting and inspecting memory limits
# 256 MB of RAM, no swap, 128 MB protected from reclaim
docker run -d --name mem-demo \
--memory=256m \
--memory-swap=256m \
--memory-reservation=128m \
nginx:1.27-alpine
# The limits as the kernel sees them (cgroup v2 host)
docker exec mem-demo cat /sys/fs/cgroup/memory.max # 268435456
docker exec mem-demo cat /sys/fs/cgroup/memory.swap.max # 0
docker exec mem-demo cat /sys/fs/cgroup/memory.low # 134217728
# Gotcha: /proc/meminfo is not namespaced, so `free` shows the HOST's memory
docker exec mem-demo free -m
# total used free ...
# Mem: 15890 ... <- not 256!
# Current usage and the limit, as Docker reports them
docker stats --no-stream --format '{{.Name}}: {{.MemUsage}} ({{.MemPerc}})' mem-demo
# mem-demo: 3.1MiB / 256MiB (1.21%)
# Change limits on a running container (no restart needed).
# When swap was set, update --memory-swap together with --memory.
docker update --memory=512m --memory-swap=512m mem-demo
docker rm -f mem-demo
Example: the same limits in Compose
services:
api:
image: example/api:1.4.0
deploy:
resources:
limits:
memory: 512M # --memory
reservations:
memory: 256M # --memory-reservation
memswap_limit: 512M # --memory-swap; equal to the limit -> no swap
Make the runtime aware of the limit. The kernel enforces the limit, but the application decides how much memory to try to use. Garbage-collected runtimes that size their heap from "total system memory" read the host's value and plan to use far more than the container is allowed.
| Runtime | How to fit inside a container limit |
|---|---|
| Java (JDK 10+, 8u191+) | Container-aware by default; tune with -XX:MaxRAMPercentage=75 instead of a fixed -Xmx |
| Node.js | Set --max-old-space-size (in MB) below the limit, for example NODE_OPTIONS=--max-old-space-size=384 for a 512 MB limit |
| Go (1.19+) | Set GOMEMLIMIT (for example 450MiB) so the GC works harder before hitting the wall |
| Python | No heap cap; control worker counts (gunicorn --workers) and per-process memory |
Always leave headroom: the heap is not the only memory a process uses (thread stacks, native buffers, metaspace, page cache).
Key takeaways
--memoryis a hard limit enforced by the kernel; reaching it causes reclaim and then an OOM kill, never slowdown alone.--memory-swapis RAM plus swap; set it equal to--memoryto disable swap.--memory-reservationis a soft guarantee that protects memory from reclaim when the host is under pressure.- Page cache, tmpfs, and
/dev/shmcount toward the limit; tools such asfreeinside the container show host values. - Configure the runtime's heap (
MaxRAMPercentage,--max-old-space-size,GOMEMLIMIT) to stay below the container limit with headroom.
🧪 Practice
- (Beginner) Start
nginx:1.27-alpinewith--memory=128mand no--memory-swap. Readmemory.maxandmemory.swap.maxinside the container and explain the swap value you see.- (Intermediate) Run
docker run --rm --memory=256m eclipse-temurin:21-jre java -XX:+PrintFlagsFinal -version | grep -E 'MaxHeapSize|MaxRAMPercentage'. Repeat with--memory=1gand with-XX:MaxRAMPercentage=75. Explain how the JVM derives its maximum heap.- Interview question: "
docker statsshows a container at 95% of its memory limit, but it has been stable for days and never gets OOM-killed. Is something wrong?" Hint: consider what kinds of memory are charged to the cgroup, which of them the kernel can reclaim, and where to look (memory.stat) to tell them apart.
CPU Limits and Shares
Why it matters. CPU is a compressible resource: when a container wants more than it may have, it does not crash, it waits. That makes CPU problems quieter than memory problems: they appear as higher latency, slower batch jobs, and timeouts. Docker offers two different mechanisms, and confusing them is common:
- Limits (quota) cap how much CPU time a container may use, even when the host is idle.
- Shares (weight) decide how CPU time is split only when containers compete for it.
Analogy. A CPU quota is a fixed allowance: "you may spend 50 ms of CPU every 100 ms, whether or not anyone else is using the CPU." Shares are a ranking at a buffet: when there is plenty of food everyone eats freely, and only when the trays run low does a guest with weight 1024 get twice as much as a guest with weight 512.
How quotas work. The Linux Completely Fair Scheduler (CFS) splits time into periods (100 ms by default). A container with --cpus=1.5 gets a quota of 150 ms of CPU time per 100 ms period, spread across any number of cores. When the quota is used up, every thread in the container is paused (throttled) until the next period starts.
--cpus=1 (quota 100 ms per 100 ms period), app with 4 busy threads on 4 cores
period 1 (0-100 ms) period 2 (100-200 ms)
|#########...........................|#########...........................|
0 25 ms 100 ms 125 ms 200 ms
4 threads x 25 ms = 100 ms used throttled 75 ms: requests wait
The average looks fine (exactly 1 CPU), but every request that arrives during the throttled window waits up to 75 ms. This is why multi-threaded services under tight quotas show bad tail latency (p99) while their average CPU usage looks normal.
The flags
| Flag | Mechanism | Applies when | Example |
|---|---|---|---|
--cpus | Quota per period (convenience form) | Always | --cpus=1.5 -> cpu.max = 150000 100000 |
--cpu-quota, --cpu-period | Same, set explicitly in microseconds | Always | --cpu-period=50000 --cpu-quota=25000 |
--cpu-shares | Relative weight, default 1024 (cpu.weight on v2) | Only under contention | --cpu-shares=512 |
--cpuset-cpus | Which cores may be used (see CPU Pinning) | Always | --cpuset-cpus=0-1 |
Example: watching throttling happen
# A busy loop limited to half a CPU
docker run --rm --name cpu-demo --cpus=0.5 alpine sh -c '
cat /sys/fs/cgroup/cpu.max # 50000 100000
timeout 5 sh -c "while :; do :; done" # burn CPU for 5 seconds
cat /sys/fs/cgroup/cpu.stat'
# usage_usec 2502312 <- ~2.5 s of CPU in 5 s of wall time = 0.5 CPU
# nr_periods 50
# nr_throttled 49 <- throttled in almost every period
# throttled_usec 2471091 <- total time spent waiting for the next period
In another terminal, docker stats cpu-demo shows CPU usage stuck at about 50%.
Example: shares only matter under contention
# Pin both containers to the same core so they must compete for it
docker run -d --name heavy --cpuset-cpus=0 --cpu-shares=1024 alpine sh -c 'while :; do :; done'
docker run -d --name light --cpuset-cpus=0 --cpu-shares=512 alpine sh -c 'while :; do :; done'
docker stats --no-stream --format '{{.Name}}: {{.CPUPerc}}' heavy light
# heavy: 66.5%
# light: 33.4% <- 2:1 split, matching the weights
# Stop "heavy": "light" now gets the whole core, because shares are not a cap
docker stop heavy
docker stats --no-stream --format '{{.Name}}: {{.CPUPerc}}' light
# light: 99.8%
docker rm -f heavy light
Compose equivalent
services:
worker:
image: example/worker:2.0
cpu_shares: 512 # relative weight
deploy:
resources:
limits:
cpus: "1.5" # quota
reservations:
cpus: "0.5" # used by schedulers (Swarm) for placement
The CPU count gotcha. A quota does not change how many CPUs a process sees. nproc and most runtime APIs (Python's os.cpu_count(), Node's os.cpus()) still report every core of the host. A runtime that starts one worker thread per core on a 64-core host, but has a quota of 2 CPUs, creates 64 threads that throttle each other. Set worker and thread-pool sizes explicitly, or rely on runtimes that read the cgroup quota (the JVM does; Go does since 1.25 for GOMAXPROCS).
Choosing between limits and shares
| Goal | Use |
|---|---|
| Protect neighbors from a runaway or untrusted workload | --cpus (hard cap) |
| Let batch jobs use idle CPU but yield to interactive services | --cpu-shares (lower weight for batch) |
| Predictable latency for a critical service | No tight quota; enough cores, possibly pinned, and lower shares for neighbors |
| Billing or fairness per tenant | --cpus |
Key takeaways
- CPU is compressible: going over the limit causes throttling and latency, not termination.
--cpussets a CFS quota per 100 ms period that applies even when the host is idle.--cpu-sharesis a relative weight that only matters when containers compete for CPU.- Throttling shows up in
cpu.stat(nr_throttled,throttled_usec) and hurts tail latency of multi-threaded apps. - Processes inside a container still see every host core; size thread pools to the quota, not to
nproc.
🧪 Practice
- (Beginner) Run the busy-loop example with
--cpus=0.25,--cpus=1, and--cpu-period=10000 --cpu-quota=5000. Comparedocker statsand thenr_throttledvalues, and explain the differences.- (Intermediate) Run three CPU-bound containers pinned to the same core with shares 2048, 1024, and 1024. Predict the CPU split, verify it with
docker stats, then stop the 2048 one and predict again.- Interview question: "A service has a 2-CPU limit, its average CPU usage is 40% of the limit, yet p99 latency is poor and improves when the limit is raised. Why?" Hint: think about what happens inside a single 100 ms period when many threads are runnable at once, and which counters reveal it.
CPU Pinning
Why it matters. Quotas and shares control how much CPU a container gets, not where it runs. The scheduler is free to move its threads between cores, which is usually ideal. Some workloads benefit from fixed placement:
- Cache locality: a thread that stays on one core keeps its L1/L2 cache warm.
- NUMA locality: on multi-socket servers, memory attached to the local socket is faster than remote memory.
- Isolation of noisy neighbors: a latency-critical service on cores 0–3 and batch jobs on cores 4–15 cannot interfere through the scheduler.
- Licensing: some commercial software is licensed per core it can run on.
Analogy. Pinning assigns desks in an open-plan office. Workers who always sit at the same desk keep their papers within reach (cache), and seating the noisy team at the far end keeps the quiet team productive. The trade-off: an empty desk cannot be used by someone from another team, even if they need it.
The flags
| Flag | Meaning | Example |
|---|---|---|
--cpuset-cpus | Cores the container may run on (list or range) | --cpuset-cpus=0-3, --cpuset-cpus=0,2,4 |
--cpuset-mems | NUMA memory nodes the container may allocate from | --cpuset-mems=0 |
Example: inspecting the topology and pinning a container
# Host topology: which logical CPUs share a physical core and a NUMA node
lscpu -e=CPU,CORE,SOCKET,NODE
# CPU CORE SOCKET NODE
# 0 0 0 0
# 1 1 0 0
# 2 0 0 0 <- CPU 2 is the hyperthread sibling of CPU 0
# 3 1 0 0
# Pin a container to logical CPUs 0 and 1 (two different physical cores)
docker run -d --name pinned --cpuset-cpus=0,1 nginx:1.27-alpine
# Unlike a quota, pinning changes what the container sees
docker exec pinned nproc # 2
docker exec pinned cat /sys/fs/cgroup/cpuset.cpus.effective # 0-1
# Pinning and a quota can be combined: 1.5 CPUs worth of time, only on cores 0-1
docker update --cpus=1.5 pinned
# On a NUMA host: keep both CPUs and memory on node 0
docker run -d --name numa-local --cpuset-cpus=0-7 --cpuset-mems=0 example/db:1.0
docker rm -f pinned numa-local
Pinning is not exclusive. --cpuset-cpus=0-3 restricts the container to cores 0–3, but it does not keep other processes off those cores. For real isolation, also:
- Pin every other container to the remaining cores.
- Keep host services away from them (kernel parameter
isolcpus, or systemd'sCPUAffinity=).
cores: 0 1 2 3 | 4 5 6 7 8 9 10 11
[ latency API ][ batch workers, other containers ]
--cpuset-cpus=0-3 --cpuset-cpus=4-11
host services kept off 0-3 with isolcpus or CPUAffinity
When not to pin. Pinning removes the scheduler's flexibility. A pinned container cannot use idle cores elsewhere, and two containers pinned to the same cores compete harder than if they were spread out. Reserve it for measured needs: latency-critical services, NUMA-sensitive databases, or benchmarks that need repeatable results.
Key takeaways
--cpuset-cpuscontrols which cores a container may use;--cpuscontrols how much time it gets. They can be combined.- Pinning improves cache and NUMA locality and isolates noisy neighbors, at the cost of flexibility.
- Pinned containers see only their cores (
nproc), which helps runtimes size thread pools correctly. - Pinning is not exclusive; other containers and host processes must be kept off the pinned cores explicitly.
- Use
lscpu -eto avoid pinning to hyperthread siblings when you want separate physical cores.
🧪 Practice
- (Beginner) Run
docker run --rm --cpuset-cpus=0 alpine nprocanddocker run --rm --cpus=1 alpine nproc. Explain why the outputs differ.- (Intermediate) On a host with at least four cores, run a CPU-bound container pinned to core 0 and another not pinned. Use
topormpstat -P ALL 1on the host to confirm where each one runs. Then pin a second CPU-bound container to core 0 and observe the effect on both.- Interview question: "You pinned a latency-sensitive service to cores 0–3, but you still see latency spikes that correlate with a batch job. What could be wrong?" Hint: consider what pinning guarantees and what it does not, hyperthread siblings, and where the batch job and host interrupts are allowed to run.
Block I/O Limits
Why it matters. Disk bandwidth is shared by every container on a host, and storage is often the slowest component. A container that runs a backup, rebuilds an index, or writes logs heavily can saturate the disk and make a database in a neighboring container unresponsive. Block I/O limits cap the bandwidth (bytes per second) or operation rate (IOPS) a container may use on a given device.
Analogy. A disk is a single-lane road into a warehouse. Without rules, one truck convoy blocks everyone. Bandwidth limits are a speed limit per company; IOPS limits cap how many trips each company may make per second; weights decide who goes first at the gate when the road is busy.
The flags
| Flag | Meaning | Example |
|---|---|---|
--device-read-bps | Max read bytes per second for a device | --device-read-bps /dev/sda:50mb |
--device-write-bps | Max write bytes per second for a device | --device-write-bps /dev/sda:20mb |
--device-read-iops | Max read operations per second | --device-read-iops /dev/sda:1000 |
--device-write-iops | Max write operations per second | --device-write-iops /dev/sda:500 |
--blkio-weight | Relative weight 10–1000 under contention | --blkio-weight=300 |
--blkio-weight-device | Weight for one specific device | --blkio-weight-device /dev/sda:300 |
Throttling limits (bps, iops) map to cgroup v2's io.max. Weights depend on the host's I/O scheduler (on cgroup v2 they require BFQ) and are often unavailable; docker info prints a warning such as No blkio weight support when they are. Prefer throttling limits for predictable results.
Which device to target. Limits apply to a whole block device on the host (such as /dev/sda or /dev/nvme0n1), not a partition and not a path inside the container. Target the device that stores the data the container writes:
- The container's writable layer and named volumes live under the Docker root directory (usually
/var/lib/docker). - Bind mounts live wherever their host path is, possibly on another device.
tmpfsis in memory and is not affected by block I/O limits.
# Find the disk that backs Docker's data directory
df --output=source /var/lib/docker | tail -1 # /dev/sda2 (a partition)
lsblk -no pkname /dev/sda2 # sda (its parent disk)
Example: limiting write bandwidth
# Limit writes to 20 MB/s on the disk that holds Docker's volumes.
# oflag=direct bypasses the page cache, so the write goes straight to the device.
docker run --rm \
--device-write-bps /dev/sda:20mb \
-v iotest:/data \
ubuntu:24.04 bash -c '
cat /sys/fs/cgroup/io.max
dd if=/dev/zero of=/data/out bs=1M count=100 oflag=direct'
# 8:0 rbps=max wbps=20971520 riops=max wiops=max <- 8:0 is /dev/sda
# 104857600 bytes (105 MB, 100 MiB) copied, 5.0 s, 21.0 MB/s
# Same command without the limit for comparison
docker run --rm -v iotest:/data ubuntu:24.04 \
dd if=/dev/zero of=/data/out bs=1M count=100 oflag=direct
# 104857600 bytes (105 MB, 100 MiB) copied, 0.4 s, 262 MB/s
docker volume rm iotest
Buffered writes. Most applications write through the page cache and let the kernel flush data later. On cgroup v1, write limits applied only to direct I/O, so ordinary buffered writes escaped them. cgroup v2 attributes background writeback to the cgroup that dirtied the pages, so limits also work for buffered writes. This is another reason to prefer cgroup v2 hosts.
Compose equivalent
services:
backup:
image: example/backup:1.0
blkio_config:
weight: 300
device_write_bps:
- path: /dev/sda
rate: 20mb
device_read_iops:
- path: /dev/sda
rate: 1000
Key takeaways
- Block I/O limits cap bandwidth (
--device-*-bps) or operations per second (--device-*-iops) per host device. - Target the whole disk that backs the data (
/var/lib/dockerfor layers and volumes, the host path for bind mounts), not a partition. - Weights (
--blkio-weight) depend on the I/O scheduler and are often unsupported; throttling limits are more reliable. - cgroup v2 throttles buffered writes as well as direct I/O; cgroup v1 only throttled direct I/O.
tmpfsand network storage are not controlled by block I/O limits.
🧪 Practice
- (Beginner) Identify the disk that backs
/var/lib/dockeron your host, then run theddexample with--device-write-bpsset to 10 MB/s and confirm the throughput.- (Intermediate) Run the same test with
--device-write-iopsset to 100 andbs=4k count=2000, then withbs=1M count=100. Explain why the IOPS limit affects the two block sizes so differently.- Interview question: "You set
--device-write-bpson a container, but its writes to a bind-mounted directory are not throttled. What are the possible reasons?" Hint: think about which device the bind-mount path lives on, partitions versus disks, buffered writes, and the cgroup version.
PID Limits
Why it matters. Every process and every thread uses an entry in the kernel's task table, which is shared by the whole host. A fork bomb, a bug that spawns a child per request without reaping it, or a thread leak can exhaust the host's PIDs. When that happens, nothing on the host can start a new process: not other containers, not docker exec, not even ssh login shells. Docker sets no PID limit by default.
Analogy. PIDs are tickets at a deli counter shared by the whole building. One tenant who grabs every ticket stops everyone from being served. A PID limit lets each tenant hold only a fixed number of tickets.
How it works. --pids-limit sets pids.max in the container's cgroup. When the cgroup reaches the limit, fork() and clone() fail with EAGAIN ("Resource temporarily unavailable"). Nothing is killed: the processes already running continue, and only the creation of new ones fails. How the application reacts depends on its code, from a logged error to a crash.
Example: containing a runaway process spawner
# Try to start 100 background processes with a limit of 50
docker run --rm --name pid-demo --pids-limit=50 alpine sh -c '
for i in $(seq 1 100); do sleep 30 & done
echo "running: $(cat /sys/fs/cgroup/pids.current) / $(cat /sys/fs/cgroup/pids.max)"
cat /sys/fs/cgroup/pids.events
wait'
# sh: can't fork: Resource temporarily unavailable
# ...
# running: 50 / 50
# max 51 <- number of times the limit blocked a fork
# Threads count too: the PIDS column of docker stats counts tasks (processes + threads)
docker run -d --name threads --pids-limit=200 python:3.13-slim python -c '
import threading, time
for _ in range(30):
threading.Thread(target=time.sleep, args=(300,)).start() # 30 idle threads
time.sleep(300)'
docker stats --no-stream --format '{{.Name}}: {{.PIDs}} pids' threads
# threads: 31 pids <- one process, 31 tasks (main thread + 30)
docker rm -f threads
Typical error messages when the limit is reached:
| Runtime | Message |
|---|---|
| Shell, C programs | fork: Resource temporarily unavailable |
| Java | java.lang.OutOfMemoryError: unable to create native thread |
| Go | runtime: failed to create new OS thread |
| Python | RuntimeError: can't start new thread or BlockingIOError: [Errno 11] |
The Java message is misleading: it mentions memory, but the cause is often a PID limit.
PID limit compared with ulimit nproc
| Aspect | --pids-limit | --ulimit nproc=N |
|---|---|---|
| Scope | All tasks in the container's cgroup | All processes of a UID, counted across the whole host |
| Containers sharing a UID | Independent limits | Share one count, which causes surprising failures |
| Recommended for containers | Yes | No |
Choosing a value. Measure the normal task count under peak load (docker stats PIDS column or pids.peak on recent kernels), then set the limit with generous headroom, for example 2–4 times the peak. Even a high limit, such as 1024 or 4096, protects the host from a fork bomb.
services:
web:
image: example/web:3.1
deploy:
resources:
limits:
pids: 512 # or the service-level key: pids_limit: 512
Key takeaways
- Processes and threads share the host's PID space; without a limit, one container can prevent any new process from starting on the host.
--pids-limitcaps tasks in the container's cgroup; reaching it makesfork/clonefail withEAGAIN, but kills nothing.- Threads count toward the limit, so thread-heavy runtimes need larger values.
- Prefer
--pids-limitover--ulimit nproc, which counts per UID across the whole host. - Base the limit on measured peak usage with headroom; any limit is better than none.
🧪 Practice
- (Beginner) Run
docker run --rm --pids-limit=10 alpine sh -c 'for i in $(seq 1 20); do sleep 5 & done; wait'and explain the output. Then checkpids.eventsin a similar run.- (Intermediate) Start a Java, Node.js, or Go web server of your choice, generate load with a tool such as
heyorab, and record the peak PIDS value indocker stats. Choose a limit, apply it withdocker update --pids-limit, and verify the service still handles the load.- Interview question: "A Java service fails with
OutOfMemoryError: unable to create native thread, but its heap usage is low. How do you investigate?" Hint: consider what else besides heap a thread needs, and which cgroup counters and container settings to check.
OOM Behavior
Why it matters. When a container reaches its memory limit and the kernel cannot free enough memory, a process is killed immediately with SIGKILL. It gets no warning and no chance to clean up. Understanding which process gets killed, how to detect it, and how to prevent it is essential, because an OOM kill can look like a random crash, a silent restart, or a single failing worker inside an otherwise healthy container.
Analogy. The OOM killer is a lifeboat officer. When the boat (the cgroup) is overloaded and nothing else can be thrown overboard (cache, swap), someone must go, and the officer picks whoever is heaviest. Whether that is the captain (PID 1) or a passenger (a worker process) changes everything that follows.
What happens step by step
allocation request inside the container
|
v
usage < memory.max ? --yes--> allocation succeeds
| no
v
reclaim: evict page cache, swap out (if --memory-swap allows)
|
enough freed? --yes--> allocation succeeds (slower)
| no
v
cgroup OOM killer: pick the process with the highest oom_score
in THIS cgroup (mostly memory size, adjusted by oom_score_adj)
|
+-------+------------------------------+
v v
victim is PID 1 victim is a child process
container exits, code 137 container keeps running;
OOMKilled = true the app sees a worker die
restart policy may restart it (gunicorn re-forks it, PostgreSQL
restarts all backends, ...)
Container OOM compared with host OOM
| Aspect | Container (cgroup) OOM | Host (global) OOM |
|---|---|---|
| Trigger | Container reaches --memory | The host runs out of memory |
| Candidates | Only processes in that container | Every process on the host |
| Typical cause | Limit too low, leak, burst | No limits set, or limits sum to more than the host has |
| Kernel log | Memory cgroup out of memory: Killed process ... | Out of memory: Killed process ... |
Host OOM is the dangerous case, because the victim may be an unrelated service. Docker lowers the OOM score of dockerd and containerd so the daemon itself is unlikely to be chosen, but your containers are fair game. Setting a memory limit on every container turns most host OOM events into contained cgroup OOM events.
Influencing the choice
| Flag | Effect |
|---|---|
--oom-score-adj=<-1000..1000> | Raise (positive) or lower (negative) the chance of being chosen in a host OOM |
--oom-kill-disable | Processes hang instead of being killed when the limit is hit. cgroup v1 only; dangerous without --memory, because then the host OOM killer must find another victim |
Detecting OOM kills
# Provoke an OOM kill of the main process
docker run --name oom-demo --memory=64m --memory-swap=64m python:3.13-slim \
python -c "data = bytearray(200 * 1024 * 1024)"
echo $? # 137 = 128 + SIGKILL(9)
# 1. Container state
docker inspect -f 'exit={{.State.ExitCode}} oom={{.State.OOMKilled}}' oom-demo
# exit=137 oom=true
# 2. The cgroup's own counters (in a running container)
docker run -d --name oom-child --memory=64m --memory-swap=64m python:3.13-slim \
sh -c 'python -c "bytearray(200*1024*1024)"; sleep infinity' # a CHILD gets killed
docker exec oom-child cat /sys/fs/cgroup/memory.events
# low 0
# high 0
# max 21
# oom 1
# oom_kill 1 <- a process was killed, although the container is still "Up"
docker ps --filter name=oom-child --format '{{.Names}}: {{.Status}}'
# oom-child: Up 10 seconds
# 3. Docker events (also reports kills of child processes)
docker events --since 10m --filter event=oom
# 4. Kernel log on the host (on Docker Desktop, inside the VM)
sudo dmesg -T | grep -i "killed process"
# Memory cgroup out of memory: Killed process 4321 (python) total-vm:... anon-rss:...
docker rm -f oom-demo oom-child
Exit code 137 alone does not prove an OOM kill: docker kill and a docker stop timeout also end in SIGKILL. Check OOMKilled, memory.events, or the kernel log to be sure.
Preventing and handling OOM kills
| Practice | Why |
|---|---|
| Set limits based on measured peak usage plus headroom (20–30%) | Avoids kills during normal bursts |
| Configure runtime heaps below the limit (see Memory Limits) | The GC works harder before the kernel intervenes |
Disable or limit swap (--memory-swap equal to --memory) | Fail fast and visibly instead of degrading slowly |
Size /dev/shm and tmpfs mounts deliberately | They count toward the limit and cannot be evicted |
| Make the app degrade gracefully (bounded queues, request size limits) | Bursts are rejected instead of exhausting memory |
| Use a restart policy and alert on OOM events | Recovery is automatic, and repeated kills become visible |
| Ensure the sum of limits does not exceed host memory | Prevents host OOM, where any process can be the victim |
Key takeaways
- When reclaim fails at the memory limit, the kernel kills the process with the highest OOM score inside the container's cgroup using
SIGKILL. - If PID 1 is killed, the container exits with 137 and
OOMKilled=true; if a child is killed, the container keeps running and the failure is easy to miss. memory.events(oom_kill),docker events --filter event=oom, and the kernel log reveal OOM kills, including those of child processes.- Host OOM can kill any process on the machine; memory limits on every container keep OOM events contained.
--oom-kill-disableis cgroup v1 only and risky; prefer correct limits, runtime-aware heaps, and restart policies.
🧪 Practice
- (Beginner) Reproduce the
oom-demoexample and confirm the OOM kill in three different ways: exit code,docker inspect, and the kernel log.- (Intermediate) Run a gunicorn or Nginx container with several workers and a tight memory limit. Make one worker allocate too much memory, then show that the container stays
Upwhilememory.eventsrecords an OOM kill. Write a one-linedocker eventscommand that alerts on such events.- Interview question: "A container restarts every few hours with exit code 137, but
OOMKilledis false. What could cause this?" Hint: list every way a process can receiveSIGKILL, including stop timeouts, health-check-driven restarts by an orchestrator, and host-level OOM, and say which logs would show each one.
<a id="112-performance-optimization"></a>
11.2 Performance Optimization
This section covers where time goes when a container starts, pulls, stores data, and talks over the network, and the settings and design choices that make each step faster, including the specific bottlenecks of Docker Desktop.
Startup Time Optimization
Why it matters. Container startup time determines how quickly you can scale out under load, recover from a crash, roll out a deployment, and iterate in development. A container that takes 60 seconds to become ready means 60 seconds of degraded capacity every time it restarts, and minutes of waiting in CI pipelines that start many containers.
Analogy. Opening a restaurant involves receiving deliveries (pulling the image), unlocking and turning on the lights (creating the container), firing up the kitchen (starting the process), and prepping ingredients (application warm-up). Customers only care about when the first dish is served, so you speed up whichever step is slowest, and you do as much prep the night before (at build time) as possible.
Where the time goes
docker run
|-- 1. pull image (if missing) ........ seconds to minutes -> see Image Pull Performance
|-- 2. create container ............... ~100 ms (snapshot, network, mounts)
|-- 3. start process .................. ~100 ms (runc, namespaces, cgroups)
|-- 4. entrypoint script .............. 0 to minutes (chown, migrations, downloads)
'-- 5. application ready .............. ms to minutes (runtime boot, warm-up, caches)
Docker's own overhead (steps 2 and 3) is usually well under a second. Most slow starts come from steps 1, 4, and 5, which you control.
Measuring each step
# Docker's own overhead with a trivial image (already pulled)
time docker run --rm alpine true
# real 0m0.45s
# Timestamps recorded by Docker
docker run -d --name startup-demo nginx:1.27-alpine
docker inspect -f 'created={{.Created}} started={{.State.StartedAt}}' startup-demo
# Time until the application actually answers (the number users care about)
start=$(date +%s%N) # nanoseconds
docker run -d --name app -p 8080:80 nginx:1.27-alpine >/dev/null
until curl -sf localhost:8080 >/dev/null; do sleep 0.05; done
echo "ready after $(( ($(date +%s%N) - start) / 1000000 )) ms"
docker rm -f startup-demo app
Common causes and fixes
| Slow step | Typical cause | Fix |
|---|---|---|
| Entrypoint | chown -R over large directories at every start | Set ownership at build time (COPY --chown), or only on first run |
| Entrypoint | pip install, npm install, downloading models or assets at startup | Install at build time; mount large models from a volume |
| Entrypoint | Fixed sleep waiting for dependencies | Use health checks and depends_on: condition: service_healthy, plus retries in the app |
| Process start | npm start or shell wrappers around the real process | Run the binary directly in exec form: CMD ["node", "server.js"] |
| Runtime boot | JVM class loading and JIT warm-up | Class Data Sharing (AppCDS), smaller dependency trees, or native images |
| Runtime boot | Python compiling .pyc on every start, especially with a read-only filesystem | Precompile bytecode at build time |
| Container create | Publishing a huge port range (-p 10000-20000:...) | Publish only the ports you need; each one sets up proxy and firewall rules |
| Health reporting | Health check interval too long for fast detection | --health-start-period with a short --health-start-interval (Docker Engine 25+) |
Example: moving startup work into the build
# syntax=docker/dockerfile:1
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt # pip already precompiles dependencies
# Set ownership while copying instead of running chown -R in the entrypoint
COPY --chown=10001:10001 . .
# Precompile the application's bytecode so a read-only container
# does not recompile every module at each start
RUN python -m compileall -q /app
USER 10001:10001
# Exec form: Python is PID 1, with no shell in between
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "app:wsgi"]
# Check readiness frequently while starting, then rarely once healthy
HEALTHCHECK --start-period=30s --start-interval=1s --interval=30s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
Example: Java Class Data Sharing. The JVM spends much of its startup loading and verifying classes. A CDS archive created during a training run lets later starts map the classes directly from a file:
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY app.jar .
# Training run: start the app, let it exit after initialization, and dump loaded classes.
# How the app exits after startup is framework-specific
# (Spring Boot: -Dspring.context.exit=onRefresh).
RUN java -XX:ArchiveClassesAtExit=app.jsa -Dspring.context.exit=onRefresh -jar app.jar
# Use the archive at runtime; startup is often 30-50% faster
CMD ["java", "-XX:SharedArchiveFile=app.jsa", "-jar", "app.jar"]
Startup also connects to HEALTHCHECK (when the container is considered ready) and PID 1 and Init Processes (running the real process directly instead of through wrappers).
Key takeaways
- Docker's own create and start overhead is usually sub-second; slow starts come from pulls, entrypoint work, and application boot.
- Measure time-to-ready (the first successful request), not only time-to-running.
- Move work from startup to build time: dependency installs, ownership changes, bytecode compilation, class archives.
- Replace fixed sleeps with health checks and dependency conditions, and run the main process directly in exec form.
- Avoid publishing large port ranges; each published port adds setup work.
🧪 Practice
- (Beginner) Measure
time docker run --rm <image> trueforalpine,ubuntu:24.04, andpython:3.13-slimafter pulling them. Then measurepython -c 'import json'in the Python image. Which parts of the startup time come from Docker and which from the program?- (Intermediate) Take an image whose entrypoint runs
chown -Ror installs dependencies, measure its time-to-ready with thecurlloop above, move the work into the Dockerfile, and measure again.- Interview question: "Your autoscaler adds containers under load, but new instances take 90 seconds to serve traffic, by which time the spike is over. How do you approach this?" Hint: break the 90 seconds into pull, start, and warm-up, and think about pre-pulling, build-time work, readiness signals, and scaling earlier.
Image Pull Performance
Why it matters. On a fresh host, in CI, or after a new release, pulling the image is often the slowest part of starting a container. Pull time is dominated by how many bytes must be downloaded and decompressed, how far away the registry is, and how many layers can be reused from images already on the host.
Analogy. Pulling an image is like moving into a furnished apartment from boxes in storage. You first receive the inventory list (manifest), skip boxes you already own (cached layers), fetch the rest a few at a time (parallel downloads), and unpack each one (decompression and extraction). Fewer, smaller boxes, a closer warehouse, and more helpers all make moving day shorter.
What a pull does
docker pull example/api:1.4.0
1. resolve tag -> manifest (index -> platform manifest) one or two small requests
2. for each layer digest:
already on host? -> "Already exists", skipped shared base layers are free
otherwise -> download (3 in parallel by default) network-bound
3. verify digest, decompress (gzip: single-threaded) CPU-bound
4. extract into the storage driver / snapshotter disk-bound
Techniques
| Technique | Effect |
|---|---|
| Smaller images (multi-stage builds, slim or distroless bases) | Fewer bytes to download, decompress, and extract |
| Shared base images across services | Base layers are pulled once per host and reused |
| Stable layers first (Layer Ordering) | Releases change only the small top layers, so updates pull a few MB |
| Registry close to the hosts (same cloud region) | Lower latency and higher bandwidth, often no egress cost |
| Pull-through cache or registry mirror | Repeated pulls served from the local network; avoids Docker Hub rate limits |
| More parallel downloads | Helps images with many layers on fast networks |
| zstd compression | Faster decompression than gzip, often slightly smaller |
| Pre-pulling before deploying | Moves the pull out of the startup critical path |
| Lazy pulling (eStargz, SOCI, Nydus) | Starts the container before the whole image is downloaded, fetching files on demand |
Example: daemon settings for faster pulls
{
"registry-mirrors": ["https://mirror.internal.example.com"],
"max-concurrent-downloads": 6,
"max-download-attempts": 5
}
Saved in /etc/docker/daemon.json (then sudo systemctl restart docker). registry-mirrors applies only to Docker Hub images; images from other registries are pulled directly.
Example: measuring and comparing pulls
# Cold pull: remove the image first, then time the pull
docker image rm -f node:22 node:22-alpine 2>/dev/null
time docker pull node:22 # Debian-based: hundreds of MB
time docker pull node:22-alpine # much smaller
# Layer reuse: a second image with the same base pulls only its own layers
docker pull node:22-alpine
docker pull example/web:2.0 # built FROM node:22-alpine
# a1b2c3...: Already exists <- base layers reused
# d4e5f6...: Pull complete
# Which layers are large (uncompressed sizes)
docker image history --format '{{.Size}}\t{{.CreatedBy}}' node:22-alpine
Example: pushing a zstd-compressed image
# zstd decompresses faster than gzip, which speeds up the CPU-bound part of a pull.
# Requires a reasonably recent Docker or containerd on the pulling side.
docker buildx build \
--output type=registry,name=registry.example.com/api:1.4.0,compression=zstd,compression-level=3,force-compression=true \
.
Pre-pulling. Pulling just before starting is the worst time to pull. Common patterns:
docker compose pullfollowed bydocker compose up -d, so a failed or slow pull does not stop the running version.- Baking frequently used images into VM templates or CI runner images.
- On clusters, pre-pulling images to every node before a rollout.
Key takeaways
- Pull time is download plus decompression plus extraction; smaller images and reused layers speed up all three.
- Layers already on the host are skipped, so shared bases and stable layer ordering make updates cheap.
- Registry mirrors, pull-through caches, and same-region registries cut latency and avoid rate limits.
- zstd compression and more concurrent downloads help large images; lazy pulling lets containers start before the pull completes.
- Pre-pull images before deploying to take the pull out of the startup path.
🧪 Practice
- (Beginner) Time cold pulls of
python:3.13,python:3.13-slim, andpython:3.13-alpine. Record the size of each and compute an approximate throughput in MB/s.- (Intermediate) Run a local registry as a pull-through cache (
registry:2withREGISTRY_PROXY_REMOTEURL=https://registry-1.docker.io), configure it as aregistry-mirrorsentry, and compare a first and a second pull of the same image after deleting it locally.- Interview question: "Deployments to a fleet of 200 hosts take 15 minutes, mostly waiting for image pulls. What would you change?" Hint: consider image size and layer structure, where the registry lives, caching close to the hosts, pre-pulling, and whether every host must pull at the same moment.
Storage Driver Performance
Why it matters. Every file a container reads or writes outside of volumes goes through the storage driver (usually overlay2, see Storage Drivers and overlay2). Union filesystems are optimized for reading shared image layers, not for heavy writing. Databases, caches, build tools, and log-heavy applications that write into the container layer can run noticeably slower than on a plain filesystem, and they fill up the Docker root disk.
Analogy. The image layers are a printed reference book that many readers share. The container layer is a stack of sticky notes on top of it. Reading is fast. Changing a word on a page means first photocopying the whole page onto a sticky note (copy-up), and that cost grows with the size of the page.
Where overlay2 is slow
| Operation | Cost | Why |
|---|---|---|
| Reading files from image layers | Near-native | Served directly from the lower directories, shared page cache |
| First write to an existing file from the image | Proportional to file size | Copy-up: the whole file is copied into the writable layer first |
Many small writes, fsync-heavy workloads | Overhead per operation | Extra layer of metadata and synchronization |
| Renaming directories from lower layers | Can be expensive or emulated | Directories must be copied up or redirected |
| Deleting files from lower layers | Creates whiteouts, frees no space | The data still exists in the image layer |
Example: the cost of copy-up
# Build an image containing a 1 GB file
cat > Dockerfile.copyup <<'EOF'
FROM alpine
RUN dd if=/dev/zero of=/big.bin bs=1M count=1024
EOF
docker build -t copyup-demo -f Dockerfile.copyup .
# Append a single byte: the whole 1 GB file is copied up first
time docker run --rm copyup-demo sh -c 'echo x >> /big.bin'
# real 0m2.8s
# Reading the same file does not copy anything
time docker run --rm copyup-demo sh -c 'tail -c 1 /big.bin > /dev/null'
# real 0m0.5s
Example: comparing write paths
# Write 512 MB with fsync to the container layer, a named volume, and a tmpfs mount
docker run --rm -v perfvol:/vol --tmpfs /fast:size=600m alpine sh -c '
for dir in /tmp /vol /fast; do
echo "== $dir"
dd if=/dev/zero of=$dir/test bs=1M count=512 conv=fsync 2>&1 | tail -1
done'
# == /tmp (container layer, overlay2) ... slowest
# == /vol (named volume, host fs) ... near-native disk speed
# == /fast (tmpfs, memory) ... fastest, not persistent
docker volume rm perfvol
Choosing where data goes
| Location | Performance | Persistence | Use for |
|---|---|---|---|
| Container writable layer | Lowest for writes | Lost with the container | Small, incidental files only |
| Named volume | Native filesystem speed | Survives the container | Databases, uploads, caches you want to keep |
| Bind mount (Linux) | Native filesystem speed | On the host path | Development, host-managed data |
tmpfs | Memory speed | Lost when the container stops; counts toward memory limit | Scratch files, sockets, sensitive temporary data |
Host-level settings that matter
- Use
overlay2on a supported backing filesystem: ext4, or XFS formatted withftype=1. Check withdocker info: look forStorage Driver: overlay2,Backing Filesystem, andSupports d_type: true. - Avoid slow or legacy drivers:
vfscopies every layer in full (only for testing);fuse-overlayfsruns in user space (needed only for rootless mode on old kernels);devicemapperandaufsare removed in recent releases. - Put
/var/lib/dockeron fast storage (local SSD or NVMe), separate from the OS disk if containers write a lot. - Bound log files: the default
json-filelogging driver writes every log line to disk with no size limit; configure Log Rotation. - Watch disk usage: a full Docker root disk makes every write fail. Use
docker system dfanddocker ps -s(the size of each writable layer), and prune unused data regularly.
Key takeaways
- overlay2 reads image files at near-native speed but pays a copy-up cost proportional to file size on the first write.
- Write-heavy and
fsync-heavy data belongs in volumes (or tmpfs for scratch), never in the container layer. - Use overlay2 on ext4 or XFS with
ftype=1, on fast local storage; avoidvfsand legacy drivers. - Unbounded logs and growing writable layers both degrade performance and can fill the disk.
docker ps -sanddocker system dfreveal which containers write into their layers.
🧪 Practice
- (Beginner) Run the "comparing write paths" example on your machine and record the throughput of each location. Then run
docker infoand note the storage driver and backing filesystem.- (Intermediate) Run PostgreSQL twice, once storing its data in the container layer and once in a named volume, and compare
pgbench -i -s 50initialization times. Usedocker ps -sto show where the data went in each case.- Interview question: "A service that modifies a large SQLite file shipped in its image is very slow on its first request after every deployment, then fast. Why?" Hint: think about what overlay2 must do the first time a file from a lower layer is opened for writing, and where that file should live instead.
Networking Performance
Why it matters. Container networking adds a few steps that bare-metal processes do not have: a virtual Ethernet pair, a bridge, network address translation, and possibly an encapsulating overlay. For most applications the overhead is negligible. It becomes visible with very high packet rates, many short-lived connections, cross-host overlays, or misconfigurations such as MTU mismatches and slow DNS, which cause timeouts and hangs rather than modest slowdowns.
Analogy. A packet leaving a container on the default bridge is a letter that goes through the building's mailroom (veth and bridge) and gets re-addressed with the building's street address (NAT) before reaching the street. Host networking hands the letter directly to the mail carrier. An overlay network puts the letter in a second envelope addressed to another building (encapsulation), which takes space and can make an envelope too big for the slot (MTU).
The packet path on a bridge network
container host
+-----------+ veth pair +-----------+ +---------------+
| eth0 |<------------->| vethXXXX |--bridge--->| iptables/nft |---> eth0 ---> network
| 172.18.0.2| | (br-xxxx) | | NAT, conntrack|
+-----------+ +-----------+ +---------------+
published ports: DNAT on the way in;
outbound: MASQUERADE to the host IP
Network modes compared
| Mode | Overhead | Isolation | Notes |
|---|---|---|---|
host (Host Network) | None | No network isolation, port conflicts | Full support on Linux, limited on Docker Desktop; best for very high packet rates |
| User-defined bridge | veth + bridge; NAT only for traffic leaving the host or entering via published ports | Yes | Default choice; container-to-container traffic is not NATed |
Default bridge with published ports | Same as above, plus docker-proxy for some traffic | Yes | Prefer user-defined bridges |
macvlan / ipvlan | Low; no bridge, no NAT | Yes, containers get addresses on the LAN | Needs network-level support |
overlay (Overlay Network) | VXLAN encapsulation (50 bytes per packet) | Yes, across hosts | MTU must be lower than the underlay's |
Common problems and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Small requests work, large responses or TLS handshakes hang | MTU larger than the path allows (VPNs, cloud networks, overlays) | Set the MTU: "mtu": 1400 in daemon.json for the default bridge, -o com.docker.network.driver.mtu=1400 for user-defined networks |
Containers on the same host talk through host-ip:published-port | Hairpin NAT through docker-proxy | Put them on a shared user-defined network and use service names |
| Each request starts with a delay of seconds | Slow or unreachable upstream DNS behind the embedded DNS server (127.0.0.11) | Check /etc/resolv.conf in the container, set --dns, cache lookups in the app |
| Random connection drops under high connection rates | conntrack table full (nf_conntrack: table full in dmesg) | Increase net.netfilter.nf_conntrack_max on the host, reuse connections (keep-alive, pooling) |
| Connection refusals under bursts | Small listen backlog | --sysctl net.core.somaxconn=4096 (namespaced, set per container) |
| Slow startup with large published port ranges | One proxy process and rule set per port | Publish fewer ports, or use host networking |
The userland proxy (docker-proxy) handles connections to published ports from the host's loopback interface and some hairpin cases. Setting "userland-proxy": false in daemon.json replaces it with kernel NAT rules, which saves processes and memory when many ports are published.
Example: measuring throughput with iperf3
# Bridge network: server and client on a user-defined network
docker network create perfnet
docker run -d --name iperf-server --network perfnet networkstatic/iperf3 -s
docker run --rm --network perfnet networkstatic/iperf3 -c iperf-server -t 10
# [SUM] 0.00-10.00 sec ... Gbits/sec
# Host network: no veth, no bridge
docker rm -f iperf-server
docker run -d --name iperf-server --network host networkstatic/iperf3 -s
docker run --rm --network host networkstatic/iperf3 -c 127.0.0.1 -t 10
# Compare; then test with many parallel streams (-P 8) and small packets (-l 64)
# to see where per-packet overhead starts to matter
docker rm -f iperf-server
docker network rm perfnet
Example: network tuning in Compose
services:
api:
image: example/api:1.4.0
networks: [backend]
sysctls:
net.core.somaxconn: 4096 # larger listen backlog
net.ipv4.ip_local_port_range: "1024 65000" # more ports for outbound connections
dns:
- 10.0.0.2 # a fast, nearby resolver
networks:
backend:
driver: bridge
driver_opts:
com.docker.network.driver.mtu: "1400" # match a VPN or cloud network MTU
Key takeaways
- Bridge networking adds a veth pair, a bridge, and NAT; the throughput cost is small, while per-packet and per-connection costs matter at high rates.
- Host networking removes the overhead at the cost of isolation; overlay networks add encapsulation and require a lower MTU.
- MTU mismatches cause hangs on large packets, not slowdowns; set the MTU per network when running over VPNs or overlays.
- Containers on the same host should communicate over a user-defined network using service names, not through published ports.
- Watch DNS latency, conntrack capacity, and listen backlogs for high-connection-rate services.
🧪 Practice
- (Beginner) Run the iperf3 example on a user-defined bridge and on the host network, and compare throughput with
-P 1and-P 8.- (Intermediate) Create a network with
com.docker.network.driver.mtu=1200, runping -M do -s 1300 <target>from a container on it (use an image whosepingsupports-M, such asubuntuwithiputils-ping), and explain the result. Then find the largest payload that passes.- Interview question: "After moving a service into Docker on a VPN-connected host, health checks pass but some API calls hang forever. What do you suspect and how do you confirm it?" Hint: think about which requests are small and which are large, and what limits packet size along the path.
Docker Desktop Performance Tuning
Why it matters. On macOS and Windows, containers run inside a Linux virtual machine managed by Docker Desktop (see Docker Desktop for Windows and macOS and Docker on WSL2). Most of the "Docker is slow on my laptop" problems come from this boundary: files shared between the host operating system and the VM, resources assigned to the VM, and images built for a different CPU architecture.
Analogy. Working in Docker Desktop is like working in an office inside a building next door. Anything kept inside that office (named volumes, the VM's filesystem) is at hand. Every file you fetch from your own building (a bind mount from macOS or Windows) has to be carried across the courtyard, and doing that for thousands of tiny files (node_modules) is what makes the day slow.
The VM boundary
macOS / Windows host Linux VM (Docker Desktop)
+---------------------------+ +-----------------------------------+
| ~/project (source code) |== file ===>| bind mount /app <- slow path |
| | sharing | named volume <- fast path |
| browser, IDE | (VirtioFS,| container layer <- fast path |
| | 9P, ...) | dockerd, containers |
+---------------------------+ +-----------------------------------+
CPU, memory, disk limits apply to the whole VM
Common symptoms and fixes
| Symptom | Cause | Fix |
|---|---|---|
npm install, test runs, or hot reload are slow with a bind-mounted project | Many small files crossing the VM boundary | Keep dependency folders in named volumes; use VirtioFS (macOS) or synchronized file shares; use Compose Watch |
| Very slow file access on Windows | Project lives on the Windows filesystem (/mnt/c/...) with the WSL2 backend | Keep the project inside the WSL2 Linux filesystem (~/project in the distribution) |
| Builds or containers are slow on Apple Silicon | Running linux/amd64 images under emulation | Use arm64 or multi-architecture images; enable Rosetta for x86_64 emulation when amd64 is unavoidable |
| Containers OOM-killed or the host is swapping | VM memory too small, or too large for the host | Adjust memory in Settings > Resources (or .wslconfig on WSL2) |
| "No space left on device" | VM disk image full | docker system prune, raise the disk limit, remove unused volumes |
| First command after idle is slow | Resource Saver mode stopped the VM | Expected; disable it if the delay matters |
| Laptop fans and battery drain | Unused features (built-in Kubernetes, extensions) running | Disable Kubernetes and extensions you do not use |
Example: keeping node_modules out of the bind mount
services:
web:
build: .
command: npm run dev
ports:
- "3000:3000"
volumes:
- .:/app # source code: bind mount, edited from the IDE
- node_modules:/app/node_modules # dependencies: named volume, stays inside the VM
volumes:
node_modules:
The source files (which you edit) still cross the boundary, but tens of thousands of dependency files no longer do. Compose Watch goes further by syncing only changed files into the container instead of bind-mounting the project (see Bind Mounts for Development).
Example: sizing the WSL2 VM. With the WSL2 backend, the VM's resources are configured in %UserProfile%\.wslconfig on Windows, not in Docker Desktop's settings:
[wsl2]
# Cap the VM's RAM (default: 50% of host memory)
memory=8GB
# Number of virtual CPUs
processors=4
# Swap file size inside the VM
swap=4GB
[experimental]
# Return cached memory to Windows over time
autoMemoryReclaim=gradual
# Let the virtual disk shrink when space is freed
sparseVhd=true
Apply changes with wsl --shutdown and restart Docker Desktop.
Example: checking for architecture emulation
# Host architecture of the VM
docker info --format '{{.Architecture}}' # aarch64 on Apple Silicon
# Architecture of an image
docker image inspect --format '{{.Architecture}}' example/api:1.4.0 # amd64 -> emulated
# Pull or build the native variant explicitly
docker pull --platform linux/arm64 example/api:1.4.0
docker buildx build --platform linux/amd64,linux/arm64 -t example/api:1.4.0 --push .
Key takeaways
- On Docker Desktop, containers run in a Linux VM; bind mounts from the host OS are the most common bottleneck.
- Keep dependency directories in named volumes, use VirtioFS or synchronized file shares on macOS, and keep projects inside the WSL2 filesystem on Windows.
- Size the VM deliberately: Settings > Resources on macOS,
.wslconfigon the WSL2 backend. - Prefer native-architecture images; amd64 images on Apple Silicon run under emulation.
- Disable unused features (Kubernetes, extensions) and prune images and volumes to keep the VM lean.
🧪 Practice
- (Beginner) Check the CPU, memory, and disk limits of your Docker Desktop VM in the settings (or
.wslconfig), and compare them withdocker info --format '{{.NCPU}} CPUs, {{.MemTotal}} bytes'.- (Intermediate) In a Node.js project, time
npm ciinside a container with the whole project bind-mounted, then again withnode_modulesin a named volume. On Windows, repeat with the project on/mnt/cand inside the WSL2 filesystem.- Interview question: "A team says Docker makes their test suite three times slower on their Macs than on Linux CI. How would you diagnose it?" Hint: separate CPU-bound from I/O-bound time, check where the files the tests touch live, and check the image architecture and VM resources.
<a id="12-logging-monitoring-and-debugging"></a>