Docker & ContainersXXXIV Β· Capacity PlanningCapacity
Network capacity β bandwidth, connections, and the tables that fill up
What you'll learn
- Budget bandwidth and packet rate for a Docker host
- Size the conntrack table from the connection model
- Recognise ephemeral port and TIME_WAIT exhaustion before it causes an outage
Prerequisites
Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-11
Network capacity on a Docker host is three separate budgets that fail in three separate ways, and the one everybody plans for β bandwidth β is almost never the one that runs out first.
The two that actually run out are connection tracking entries and ephemeral ports, both of which are consequences of the NAT that Dockerβs default bridge networking performs on every packet.
Budget one: bandwidth and packet rate
bandwidth = rps x (request_bytes + response_bytes) x 8 bits/second
packets = rps x packets_per_transaction
Bandwidth is easy and rarely binding: a modest 10 GbE link carries a lot of JSON. Packet rate is the term that binds on small payloads, because the per-packet cost is fixed regardless of size. A host doing 200 000 packets per second of 200-byte responses is using 320 Mb/s of a 10 Gb link and a great deal of CPU in softirq.
IFACE=$(ip -o route get 192.0.2.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev") print $(i+1)}')
IFACE=${IFACE:-eth0}
read -r RX1 TX1 PR1 PT1 < <(awk -v i="$IFACE" '$1 ~ i":" {gsub(/.*:/,"",$1); print $2, $10, $3, $11}' /proc/net/dev)
sleep 10
read -r RX2 TX2 PR2 PT2 < <(awk -v i="$IFACE" '$1 ~ i":" {gsub(/.*:/,"",$1); print $2, $10, $3, $11}' /proc/net/dev)
echo "rx: $(( (RX2-RX1)*8/10/1000000 )) Mb/s tx: $(( (TX2-TX1)*8/10/1000000 )) Mb/s"
echo "pps in: $(( (PR2-PR1)/10 )) pps out: $(( (PT2-PT1)/10 ))"rx: 118 Mb/s tx: 342 Mb/s
pps in: 41200 pps out: 39880Illustrative output
ip -s link show docker0
nstat -az | grep -E 'TcpExtListenOverflows|TcpExtListenDrops|TcpExtTCPBacklogDrop'TcpExtListenOverflows 0 0.0
TcpExtListenDrops 0 0.0
TcpExtTCPBacklogDrop 0 0.0ListenOverflows above zero means a listening socketβs accept queue
was full and connections were dropped. That is a capacity signal with
no ambiguity in it: the application is not accepting fast enough, and
raising net.core.somaxconn only moves the queue, it does not add
capacity.
Budget two: the conntrack table
Every connection through a Docker bridge network is NATed, and every
NATed connection occupies an entry in the kernelβs connection-tracking
table. The table has a fixed size. When it is full, new connections
are dropped, not queued, and the kernel logs
nf_conntrack: table full, dropping packet.
sysctl net.netfilter.nf_conntrack_max net.netfilter.nf_conntrack_countnet.netfilter.nf_conntrack_max = 262144
net.netfilter.nf_conntrack_count = 827Sizing it
An entry exists for the life of the connection plus a timeout after it closes. That timeout is the term people forget:
entries = (new_connections_per_second x mean_connection_lifetime)
+ (closes_per_second x conntrack_timeout)
The default nf_conntrack_tcp_timeout_time_wait is 120 seconds. A
service closing 2 000 connections a second therefore carries
240 000 entries of pure residue β which on the default table of
262 144 leaves room for about 20 000 live connections before the
table fills.
Worked example for an edge proxy on a bridge network:
live connections = 3000 concurrent
new/sec = 2000
close timeout = 120 s (tcp_timeout_time_wait)
entries = 3000 + (2000 x 120) = 243000
sized at 2x for burst = 486000 -> nf_conntrack_max = 524288
# /etc/sysctl.d/60-conntrack.conf
net.netfilter.nf_conntrack_max = 524288
net.netfilter.nf_conntrack_tcp_timeout_time_wait = 30
Shortening time_wait from 120 to 30 seconds is usually the cheaper
half of the fix, and for a host whose peer is a load balancer it is
safe. Do not shorten it below 30 on a network with meaningful
reordering.
Avoiding conntrack entirely
Containers on the same user-defined bridge talk to each other without NAT, so east-west traffic between containers costs conntrack entries only because of the bridgeβs masquerade rule for egress. Two designs avoid the table:
--network hostfor the one high-connection-rate component, which removes NAT and port publishing from the path. It also removes network isolation, so it is a deliberate trade rather than a default.macvlanoripvlan, which give the container an address on the physical network with no NAT at all.
Both are covered elsewhere in the course; here the point is that they are capacity decisions as well as networking ones.
Budget three: ephemeral ports
A container making outbound connections allocates a source port from the ephemeral range for each one. The range is finite.
sysctl net.ipv4.ip_local_port_rangenet.ipv4.ip_local_port_range = 32768 60999The arithmetic that catches people:
available ports = 60999 - 32768 + 1 = 28232
ports consumed = new_conns/sec x time_wait
A worker opening 300 connections a second to a single database
endpoint, with a 120-second TIME_WAIT, holds 36 000 ports against a
28 232-port range. It exhausts the range and outbound connections
start failing with EADDRNOTAVAIL β βcannot assign requested addressβ
β which is a message that sends people to look at DNS and firewalls
for an hour.
ss -s | head -3
ss -tan state time-wait | wc -lTotal: 319
TCP: 79 (estab 20, closed 41, orphaned 0, timewait 0)
1The fixes, in order of preference:
- Connection pooling in the application. 300 connections a second to one database is almost always a missing pool. This removes the problem rather than raising its ceiling.
- Keep-alive on HTTP clients, for the same reason.
- Widen the range:
net.ipv4.ip_local_port_range = 10240 65535gives 55 296 ports. Do not go below 10240; you will collide with services that bind fixed ports. - Shorten
TIME_WAITvianf_conntrack_tcp_timeout_time_waiton the NAT path.
Which budget binds first
| Symptom | Budget | Evidence |
|---|---|---|
nf_conntrack: table full in dmesg | conntrack | nf_conntrack_count at nf_conntrack_max |
EADDRNOTAVAIL on outbound connect | ephemeral ports | large ss -tan state time-wait count |
| Connections accepted then reset | accept backlog | TcpExtListenOverflows rising |
| High latency, low throughput | packet rate / softirq | ksoftirqd CPU, ip -s link drops |
| Throughput plateau at link speed | bandwidth | interface counters at line rate |
Work down that table in order. The bottom row is the one everyone checks first and the one that is almost never the answer.
Sanity check
Knowledge check Β· 4 questions
Q1. A container makes 300 outbound connections per second to one database endpoint and connections spend 120 s in TIME_WAIT. What fails first?
Q2. Why is raising `nf_conntrack_max` without raising `nf_conntrack_buckets` a poor fix?
Q3. Which observations point specifically at a full conntrack table rather than at bandwidth? Select all that apply.
Q4. Setting `"userland-proxy": false` reduces the per-connection accounting cost of published ports.
Passing score: 75%. Answers are checked in this browser.