Traffic Control Under the Hood: A Linux tc Deep Dive for Network Engineers

tc often appears in one-liners — a quick tc qdisc add dev eth0 root netem delay 50ms to simulate latency and that’s it. But beneath that one-liner is a complete traffic control subsystem that can replicate almost any WAN condition a network engineer needs to test: bandwidth caps, jitter profiles, DSCP-aware queuing, even multi-class hierarchical shaping that mirrors what a real carrier QoS policy looks like.

This post pulls back the curtain on tc, explains the model properly, and builds up to a practical lab rig for testing SD-WAN Performance SLA thresholds and QoS behaviour before anything touches production.

The tc Model: Qdiscs, Classes, and Filters

Everything in tc is attached to a network interface. Each interface has an egress qdisc (queuing discipline) that decides how outbound packets are ordered and scheduled. There is also an ingress qdisc, but it is limited — controlling inbound rate requires an intermediate functional block (IFB) adapter or, for simple cases, tc filter with policing.

Three building blocks:

Qdiscs — queue implementations. A qdisc receives packets, holds them, and releases them according to its own logic. pfifo_fast is the old kernel default: a simple three-band priority FIFO driven by the packet’s TOS bits. fq_codel is the modern default: flow-fair with active queue management to combat bufferbloat. netem adds impairments (delay, loss, reordering, corruption). htb (Hierarchical Token Bucket) enforces bandwidth rates and allows borrowing between classes. cake is a modern all-in-one shaper that handles bufferbloat, fairness, and DSCP simultaneously.

Classes — subdivisions within a classful qdisc (HTB is the most common). A classful qdisc can have a tree of classes, each with its own rate limit and priority. Leaf classes attach their own inner qdiscs (often pfifo or netem).

Filters — rules that classify packets into classes. Filters match on IP addresses, ports, DSCP, packet marks (fwmark), and more, then redirect matching traffic into a specific class.

The relationship is: qdisc → classes → filters → leaf qdiscs. Classless qdiscs (netem, fq_codel) skip the middle two.

Checking the Current State

Before adding anything, see what is already there:

# Show all qdiscs on all interfaces
tc qdisc show

# Show qdiscs on a specific interface
tc qdisc show dev eth0

# Show classes
tc class show dev eth0

# Show filters
tc filter show dev eth0

The default output on a fresh interface is usually:

qdisc fq_codel 0: root refcnt 2 limit 10240p flows 1024 quantum 1514 \
  target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64

fq_codel is the modern default on most distributions. It handles bufferbloat well via active queue management and ensures no single flow starves others.

Simple Rate Limiting with HTB

HTB is the go-to for bandwidth shaping. The pattern is always: root HTB qdisc → parent class (sets the ceiling) → leaf class (the actual rate).

Cap an interface to 10 Mbps

# Root HTB qdisc, unclassified traffic goes to class 1:10
tc qdisc add dev eth0 root handle 1: htb default 10

# Root class — ceiling for everything underneath
tc class add dev eth0 parent 1: classid 1:1 htb rate 10mbit

# Leaf class for unclassified (default) traffic
tc class add dev eth0 parent 1:1 classid 1:10 htb rate 10mbit burst 25k

burst is how much traffic can exceed the rate momentarily (in bytes). A useful heuristic: rate × typical_RTT / 8. For 10 Mbps with 20ms RTT: 10,000,000 × 0.020 / 8 = 25,000 bytes. A slightly generous burst value smooths token-bucket artefacts on TCP.

Multi-class: voice and video vs bulk

Two classes borrowing from a shared 10 Mbps ceiling:

tc qdisc add dev eth0 root handle 1: htb default 20

# Ceiling
tc class add dev eth0 parent 1: classid 1:1 htb rate 10mbit

# Class 10: voice/video — guaranteed 4 Mbps, can borrow up to ceiling, highest priority
tc class add dev eth0 parent 1:1 classid 1:10 htb rate 4mbit ceil 10mbit burst 10k prio 1

# Class 20: bulk/best-effort — guaranteed 1 Mbps, can borrow remainder, lower priority
tc class add dev eth0 parent 1:1 classid 1:20 htb rate 1mbit ceil 10mbit burst 15k prio 2

Without filters, everything goes to class 1:20 (the default). Add filters to steer voice traffic into 1:10.

Classifying Traffic with Filters

Match on DSCP EF (VoIP)

DSCP EF (Expedited Forwarding, value 46) maps to TOS byte 0xb8 (46 × 4 = 184 = 0xb8). The mask 0xfc covers the six DSCP bits, ignoring the two ECN bits:

tc filter add dev eth0 parent 1: protocol ip prio 1 u32 \
  match ip tos 0xb8 0xfc \
  flowid 1:10

Match DSCP AF41 (video streaming, value 34, TOS 0x88)

tc filter add dev eth0 parent 1: protocol ip prio 2 u32 \
  match ip tos 0x88 0xfc \
  flowid 1:10

Match by fwmark (set with nftables)

Mark traffic in nftables first:

# In an nftables mangle rule: mark all HTTPS traffic
nft add rule ip mangle OUTPUT tcp dport 443 meta mark set 0x1

Then steer marked packets to a class:

tc filter add dev eth0 parent 1: handle 0x1 fw flowid 1:20

This pattern is particularly useful when the classification logic is complex — do it in nftables with its full match vocabulary, then hand off to tc via a simple mark.

Combining HTB and netem

netem works as a child qdisc attached to an HTB leaf class. This lets you combine rate shaping and link impairments on the same class — exactly what you need for realistic WAN simulation.

# HTB shaping
tc qdisc add dev eth0 root handle 1: htb default 10
tc class add dev eth0 parent 1: classid 1:1 htb rate 10mbit
tc class add dev eth0 parent 1:1 classid 1:10 htb rate 10mbit burst 25k

# netem as the leaf qdisc
tc qdisc add dev eth0 parent 1:10 handle 10: netem delay 20ms 2ms distribution normal

Traffic on eth0 now sees a 10 Mbps cap with 20ms ± 2ms (normal distribution) one-way delay. End-to-end RTT will be approximately 40ms ± 4ms.

Adding packet loss

Modify the existing netem rule with tc qdisc change:

# 0.5% random loss
tc qdisc change dev eth0 parent 1:10 handle 10: netem delay 20ms 2ms loss 0.5%

# 1% correlated loss — 25% chance the next packet is also dropped after a loss event
tc qdisc change dev eth0 parent 1:10 handle 10: netem delay 20ms 2ms loss 1% 25%

Correlated loss is more realistic than uniform random — real link errors tend to cluster.

SD-WAN Performance SLA Testing

Fortinet SD-WAN Performance SLAs probe WAN links using ICMP, HTTP, or DNS and compare measured latency, jitter, and loss against configured thresholds. Breach a threshold for enough consecutive probes and SD-WAN marks the member dead and moves traffic to a healthier link.

Here is a complete workflow to trigger and verify that behaviour.

Lab topology

[FortiGate WAN1] ──── eth0 (Linux) ──── [probe target / internet]

                   tc impairments here

The Linux host sits inline between the FortiGate WAN port and the upstream network. For a self-contained setup, a network namespace pair with a veth link works well — see the Linux network namespaces post for the wiring.

Step 1: Clean baseline — SLA passes

Configure a Performance SLA on FortiGate:

  • Protocol: ICMP
  • Probe targets: 8.8.8.8, 1.1.1.1
  • Latency threshold: 150ms
  • Jitter threshold: 30ms
  • Packet loss threshold: 5%
  • Check interval: 500ms, failure threshold: 5

No tc rules yet. Confirm the SLA member shows Alive:

# On FortiGate
diagnose sys sdwan health-check

Step 2: Trip the latency threshold

Apply 160ms delay — above the 150ms threshold:

tc qdisc add dev eth0 root netem delay 160ms

Wait five probe intervals (2.5 seconds). The member flips to Dead and the SD-WAN rule shifts traffic to WAN2. Verify the switch:

diagnose sys sdwan service
diagnose netlink interface list wan1

Step 3: Trip the jitter threshold

Reset to a clean baseline, then apply jitter above 30ms:

tc qdisc del dev eth0 root
tc qdisc add dev eth0 root netem delay 20ms 35ms distribution normal

Step 4: Trip the loss threshold

tc qdisc del dev eth0 root
tc qdisc add dev eth0 root netem loss 6% 25%

Step 5: Test recovery

Remove all impairments and verify the link recovers within the SLA’s restore link after (times) interval:

tc qdisc del dev eth0 root

Watch the health-check output cycle the member back to Alive.

The real test: a congested 10 Mbps link where voice traffic (DSCP EF) must still pass with under 30ms jitter while bulk traffic fills the rest of the pipe.

# HTB hierarchy: 10 Mbps, two classes
tc qdisc add dev eth0 root handle 1: htb default 20
tc class add dev eth0 parent 1: classid 1:1 htb rate 10mbit
tc class add dev eth0 parent 1:1 classid 1:10 htb rate 4mbit ceil 10mbit burst 10k prio 1
tc class add dev eth0 parent 1:1 classid 1:20 htb rate 1mbit ceil 10mbit burst 15k prio 2

# netem on each leaf
tc qdisc add dev eth0 parent 1:10 handle 10: netem delay 15ms 2ms
tc qdisc add dev eth0 parent 1:20 handle 20: netem delay 15ms 5ms

# Steer DSCP EF into the high-priority class
tc filter add dev eth0 parent 1: protocol ip prio 1 u32 \
  match ip tos 0xb8 0xfc \
  flowid 1:10

Saturate the link with bulk traffic:

iperf3 -c <target> -t 60 -P 4

Simultaneously run a UDP stream with EF marking to simulate VoIP:

iperf3 -c <target> -u -b 500k --dscp ef -t 60

Measure jitter in the iperf3 output. With correct HTB priority and the EF filter in place, the voice stream jitter should stay well under 30ms even while bulk traffic saturates the link. Without the priority class — with everything in the default class — voice jitter spikes along with the congestion.

This is the exact scenario FortiGate QoS profiles are designed for. Running it on a Linux test rig gives you concrete proof of what your DSCP markings are (or are not) doing before any traffic touches a production WAN interface.

CAKE: The Modern Alternative

CAKE (Common Applications Kept Enhanced) is a single qdisc that replaces the HTB + fq_codel hierarchy. It handles bufferbloat, per-flow fairness, and DSCP classification in one rule:

tc qdisc replace dev eth0 root cake bandwidth 10mbit diffserv4 nat wash

Key options:

  • bandwidth 10mbit — rate cap
  • diffserv4 — four-tier DSCP classification: bulk, best-effort, video, voice
  • nat — per-host fairness that survives MASQUERADE NAT by tracking the pre-NAT IP
  • wash — scrubs DSCP on egress (useful at the WAN edge when you do not trust upstream marking)

Inspect per-tier statistics:

tc -s qdisc show dev eth0

CAKE’s stats output shows packets, bytes, and drops per diffserv tier — a quick confirmation that EF traffic is landing in the voice tier rather than best-effort.

Cleanup

Remove all rules from an interface:

tc qdisc del dev eth0 root

For ingress rules (if you added an IFB adapter):

tc qdisc del dev eth0 ingress
tc qdisc del dev ifb0 root
ip link set dev ifb0 down

A helper function worth adding to .bashrc on a lab host:

tc_reset() {
    local iface="${1:-eth0}"
    tc qdisc del dev "$iface" root 2>/dev/null \
      && echo "Root qdisc removed" \
      || echo "No root qdisc on $iface"
    tc qdisc del dev "$iface" ingress 2>/dev/null \
      && echo "Ingress qdisc removed" || true
}

Usage: tc_reset eth0 or just tc_reset to default to eth0.

  • Linux netem — the impairment model and full parameter reference for the delay, loss, and jitter rules used throughout this post
  • Linux network namespaces — build multi-node lab topologies on a single host, a natural pairing with tc for self-contained WAN simulation rigs