Netfilter Internals
The kernel framework behind iptables, conntrack, and NAT
Netfilter Hooks: The 5 Hook Points
Netfilter is a kernel framework that provides hook points in the network stack where kernel modules can register callback functions. Every packet traversing the Linux network stack passes through a defined sequence of these hooks. iptables, nftables, conntrack, and NAT are all clients of netfilter.
The 5 Netfilter Hooks
| Hook | Constant | When It Fires |
|---|---|---|
| PREROUTING | NF_IP_PRE_ROUTING |
Packet just arrived on an interface, before the routing decision |
| INPUT | NF_IP_LOCAL_IN |
Packet is destined for this host (after routing decided it's local) |
| FORWARD | NF_IP_FORWARD |
Packet is being routed through this host to another destination |
| OUTPUT | NF_IP_LOCAL_OUT |
Packet was generated locally by a process on this host |
| POSTROUTING | NF_IP_POST_ROUTING |
Packet is about to leave the host (after routing, before NIC) |
Packet Flow Through Netfilter Hooks
Incoming packet destined for this host
NIC receives
frame arrives
PREROUTING
DNAT, conntrack
Routing Decision
local or forward?
INPUT
filter, accept/drop
Local Process
application
Packet being forwarded (routed through this host)
NIC receives
frame arrives
PREROUTING
DNAT, conntrack
Routing Decision
not local → forward
FORWARD
filter, accept/drop
POSTROUTING
SNAT, MASQUERADE
NIC sends
out the wire
Locally generated outbound packet
Local Process
application
OUTPUT
filter, DNAT
Routing Decision
which interface?
POSTROUTING
SNAT, MASQUERADE
NIC sends
out the wire
How iptables/nftables Register at Hooks
iptables organizes rules into tables, each containing chains. Each built-in chain is registered at a specific netfilter hook with a defined priority.
| Table | Purpose | Chains (Hook → Priority) |
|---|---|---|
| raw | Bypass conntrack | PREROUTING (-300), OUTPUT (-300) |
| mangle | Alter packet headers (TTL, TOS, mark) | All 5 hooks (-150) |
| nat | Network Address Translation | PREROUTING (-100), OUTPUT (-100), POSTROUTING (100) |
| filter | Accept/drop/reject packets | INPUT (0), FORWARD (0), OUTPUT (0) |
| security | SELinux/AppArmor labeling | INPUT (50), FORWARD (50), OUTPUT (50) |
Note
Priority determines order: Lower number = earlier execution. At the PREROUTING hook, raw (-300) runs before mangle (-150) which runs before nat (-100). This is why raw can skip conntrack before NAT ever sees the packet.
Table/Chain Processing Order for an Incoming Forwarded Packet
raw
PREROUTING
conntrack
(if not skipped)
mangle
PREROUTING
nat
PREROUTING (DNAT)
routing
decision
filter
FORWARD
nat
POSTROUTING (SNAT)
Connection Tracking (conntrack) Internals
conntrack is a netfilter subsystem that tracks the state of every network flow passing through the host. It maintains a hash table keyed by the 5-tuple (source IP, dest IP, source port, dest port, protocol) and is essential for stateful firewalling and NAT.
Conntrack States
| State | Meaning | Example |
|---|---|---|
| NEW | First packet of a flow seen (SYN for TCP, first UDP packet) | TCP SYN to 10.0.0.5:80 |
| ESTABLISHED | Reply packet seen — bidirectional traffic confirmed | SYN-ACK received back |
| RELATED | New connection associated with an existing ESTABLISHED flow | FTP data channel, ICMP "port unreachable" for a UDP flow |
| INVALID | Packet doesn't match any known flow or violates protocol | ACK without prior SYN, malformed packet |
Inspecting conntrack
# List all tracked connections
$ conntrack -L
tcp 6 431999 ESTABLISHED src=10.0.0.2 dst=93.184.216.34 sport=54321 dport=443
dst=10.0.0.2 src=93.184.216.34 sport=443 dport=54321 [ASSURED]
udp 17 29 src=10.0.0.2 dst=8.8.8.8 sport=45678 dport=53
dst=10.0.0.2 src=8.8.8.8 sport=53 dport=45678
# Show conntrack stats
$ conntrack -S
cpu=0 found=1284 invalid=12 insert=0 insert_failed=0 drop=0 early_drop=0
cpu=1 found=987 invalid=8 insert=0 insert_failed=0 drop=0 early_drop=0
# Current table size and max
$ sysctl net.netfilter.nf_conntrack_count
net.netfilter.nf_conntrack_count = 2847
$ sysctl net.netfilter.nf_conntrack_max
net.netfilter.nf_conntrack_max = 262144
Warning
conntrack table exhaustion: When nf_conntrack_count reaches nf_conntrack_max, the kernel drops new connections with the infamous message: nf_conntrack: table full, dropping packet. This is a common issue on high-traffic load balancers, K8s nodes, and NAT gateways. Fix: increase nf_conntrack_max, decrease timeouts (nf_conntrack_tcp_timeout_established), or bypass conntrack for high-volume flows using the raw table's NOTRACK target.
conntrack Hash Table Internals
- Hash table with
nf_conntrack_bucketsbuckets (default:nf_conntrack_max / 4) - Each entry is ~300 bytes — 262,144 entries = ~75 MB memory
- Keyed by 5-tuple:
(src_ip, dst_ip, src_port, dst_port, protocol) - Entries expire based on protocol-specific timeouts (TCP ESTABLISHED: 5 days default, UDP: 30s)
- Entries marked [ASSURED] survive table pressure (they have seen traffic in both directions)
NAT Implementation
NAT in Linux is implemented as a conntrack extension. The first packet of a flow triggers NAT translation, and conntrack records the mapping. All subsequent packets in the same flow are automatically translated using the stored mapping.
- Hooks at POSTROUTING
- Changes the source IP/port of outgoing packets
- SNAT: static mapping (
--to-source 1.2.3.4) - MASQUERADE: dynamically uses outgoing interface's IP (for DHCP/dynamic IPs)
- Reply packets are automatically reverse-translated by conntrack
# SNAT with static IP
iptables -t nat -A POSTROUTING \
-s 10.0.0.0/24 -o eth0 \
-j SNAT --to-source 203.0.113.1
# MASQUERADE (dynamic IP)
iptables -t nat -A POSTROUTING \
-s 10.0.0.0/24 -o eth0 \
-j MASQUERADE- Hooks at PREROUTING
- Changes the destination IP/port of incoming packets
- Used for port forwarding, load balancing, transparent proxying
- Reply packets are automatically reverse-translated by conntrack
- Also available at OUTPUT (for locally-generated packets)
# DNAT: port forward 8080 → container:80
iptables -t nat -A PREROUTING \
-p tcp --dport 8080 \
-j DNAT --to-destination 172.17.0.2:80
# Redirect to local port (transparent proxy)
iptables -t nat -A PREROUTING \
-p tcp --dport 80 \
-j REDIRECT --to-port 3128Client → Host:8080
original dst
PREROUTING DNAT
rewrite dst to 172.17.0.2:80
FORWARD
route to container
Container replies
conntrack reverses DNAT
Client sees Host:8080
transparent to client
How Docker Injects iptables Rules
When you run docker run -p 8080:80 nginx, Docker programmatically inserts iptables rules across multiple chains. Understanding these rules is essential for debugging container networking issues.
Docker's iptables Chains
| Chain | Table | Purpose |
|---|---|---|
DOCKER |
nat | DNAT rules for port mappings (-p host:container) |
DOCKER |
filter | Allow incoming traffic to mapped ports |
DOCKER-ISOLATION-STAGE-1 |
filter | Prevent traffic between Docker networks (first check) |
DOCKER-ISOLATION-STAGE-2 |
filter | Drop traffic that crossed network boundaries |
DOCKER-USER |
filter | User-defined rules (processed before Docker's FORWARD rules) |
Example: Docker NAT Rules
$ iptables -t nat -L -n --line-numbers
Chain PREROUTING (policy ACCEPT)
1 DOCKER all -- 0.0.0.0/0 0.0.0.0/0 ADDRTYPE match dst-type LOCAL
Chain OUTPUT (policy ACCEPT)
1 DOCKER all -- 0.0.0.0/0 !127.0.0.0/8 ADDRTYPE match dst-type LOCAL
Chain POSTROUTING (policy ACCEPT)
1 MASQUERADE all -- 172.17.0.0/16 0.0.0.0/0
2 MASQUERADE tcp -- 172.17.0.2 172.17.0.2 tcp dpt:80
Chain DOCKER (2 references)
1 RETURN all -- 0.0.0.0/0 0.0.0.0/0
2 DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:80
What happens when a request arrives at Host:8080
- Packet hits PREROUTING → jumps to
DOCKERchain (dst-type LOCAL) DOCKERchain: DNAT rule rewrites destination fromhost:8080to172.17.0.2:80- Routing decision: destination is on
docker0bridge → packet enters FORWARD chain DOCKERchain in filter table:ACCEPTfor traffic to172.17.0.2:80- Packet forwarded through
docker0bridge to container's veth interface - Container replies → conntrack reverse-translates (src becomes host:8080) → reply sent to client
Tip
DOCKER-USER chain: Docker inserts its rules automatically and re-adds them if you delete them. To add custom firewall rules that survive Docker restarts, use the DOCKER-USER chain: iptables -I DOCKER-USER -s 10.0.0.0/8 -j DROP. This chain is processed before Docker's own FORWARD rules.
How kube-proxy Injects iptables Rules
kube-proxy watches the Kubernetes API for Service and Endpoints changes, then programs iptables rules to implement ClusterIP, NodePort, and LoadBalancer service types. This is the iptables proxy mode (default in many clusters).
kube-proxy's Chain Structure
| Chain | Purpose |
|---|---|
KUBE-SERVICES |
Entry point: match ClusterIP:port → jump to per-service chain |
KUBE-SVC-<hash> |
Per-service: probability-based load balancing across endpoints |
KUBE-SEP-<hash> |
Per-endpoint (Service EndPoint): DNAT to actual pod IP:port |
KUBE-NODEPORTS |
Match NodePort traffic → jump to per-service chain |
KUBE-MARK-MASQ |
Mark packets that need SNAT (external → service traffic) |
KUBE-POSTROUTING |
Apply MASQUERADE to marked packets |
Example: A Service with 3 Endpoints
# Service: my-app (ClusterIP 10.96.100.50:80) → 3 pods
Chain KUBE-SERVICES (2 references)
KUBE-SVC-XYZABC tcp -- 0.0.0.0/0 10.96.100.50 tcp dpt:80
Chain KUBE-SVC-XYZABC (1 references)
KUBE-SEP-AAA all -- 0.0.0.0/0 0.0.0.0/0 statistic mode random probability 0.33333
KUBE-SEP-BBB all -- 0.0.0.0/0 0.0.0.0/0 statistic mode random probability 0.50000
KUBE-SEP-CCC all -- 0.0.0.0/0 0.0.0.0/0 /* last endpoint, no probability needed */
Chain KUBE-SEP-AAA (1 references)
KUBE-MARK-MASQ all -- 10.244.1.5 0.0.0.0/0
DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp to:10.244.1.5:8080
Chain KUBE-SEP-BBB (1 references)
KUBE-MARK-MASQ all -- 10.244.2.8 0.0.0.0/0
DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp to:10.244.2.8:8080
Chain KUBE-SEP-CCC (1 references)
KUBE-MARK-MASQ all -- 10.244.3.2 0.0.0.0/0
DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp to:10.244.3.2:8080
Note
Probability math: With 3 endpoints, the first rule matches with 1/3 probability. If it doesn't match, the second rule matches with 1/2 probability (which is 1/2 of the remaining 2/3 = 1/3). The third catches everything left (also 1/3). This gives uniform distribution.
Packet flow: pod-to-Service
Pod sends to
10.96.100.50:80
KUBE-SERVICES
match ClusterIP
KUBE-SVC-XYZ
random select endpoint
KUBE-SEP-BBB
DNAT to 10.244.2.8:8080
Pod receives
at real IP:port
Why iptables Doesn't Scale
iptables processes rules linearly — every packet walks the chain from top to bottom until a match is found. This has significant performance implications at scale.
- Every packet walks rules sequentially
- 5,000 services × 3 endpoints = ~15,000 rules in KUBE-SVC chains
- Each Service adds: 1 KUBE-SERVICES entry + N KUBE-SEP entries + probability rules
- Rule updates are not incremental — entire table is rewritten atomically
- At 10K+ services, rule programming takes seconds and causes latency spikes
- Latency per packet increases linearly with service count
- IPVS (kube-proxy ipvs mode): hash-table based — O(1) lookup regardless of service count
- eBPF (Cilium): programmable kernel datapath, hash maps for service lookup, bypasses netfilter entirely
- nftables: sets and maps for efficient matching, but still not as fast as IPVS/eBPF
- IPVS supports more load balancing algorithms (round-robin, least connections, weighted)
- eBPF can short-circuit: skip conntrack, skip full netfilter traversal
Warning
Real-world impact: In clusters with 5,000+ services, iptables-mode kube-proxy can add milliseconds of latency per connection just for rule matching. Service endpoint changes trigger full iptables-restore, which holds a lock and blocks all rule evaluation. This is why production clusters increasingly use IPVS mode or Cilium (eBPF).
Summary: Netfilter Architecture
The Full Picture
- Netfilter = kernel framework providing 5 hook points in the packet path
- iptables/nftables = user-facing tools that register rules at these hooks via tables and chains
- conntrack = stateful connection tracking (hash table of 5-tuples) enabling stateful rules and NAT
- NAT = conntrack extension that rewrites addresses; DNAT at PREROUTING, SNAT at POSTROUTING
- Docker = injects DNAT (port mapping) + MASQUERADE (outbound NAT) + isolation rules
- kube-proxy = injects DNAT chains with probability-based load balancing to implement Services
- Scaling limit = iptables is O(n); alternatives like IPVS and eBPF provide O(1) lookups