Quality of Service with DSCP Marking and Linux tc
I implemented QoS by marking latency-sensitive traffic with DSCP and shaping bandwidth using Linux tc with hierarchical token bucket (HTB) classes. Under simulated congestion, priority traffic held low latency while bulk transfers were capped to their allocation.
Objective & Context
Without QoS, bulk transfers starve interactive flows during congestion. This lab classifies traffic with DSCP and enforces per-class bandwidth and priority with HTB, the mechanism behind voice/video prioritization on shared links.
Learning Objectives
- Build an HTB class hierarchy with tc for per-class bandwidth control.
- Classify traffic with DSCP marks and tc filters.
- Assign priority and ceiling rates to interactive vs bulk classes.
- Generate load with iperf3 and observe class statistics under contention.
- Explain where QoS marking must be trusted across network boundaries.
Environment & Prerequisites
Prerequisites
- Linux host with tc (iproute2) and iperf3.
- A constrained test link to create congestion.
- Defined traffic classes (priority vs bulk).
- Linux host with tc from iproute2 and iperf3 installed (sudo apt install -y iperf3).
- Root or sudo access: tc qdisc changes require privileges.
- A constrained test link (or a virtual link) so congestion can actually occur.
- Defined traffic classes: which flows are priority and which are bulk.
- Understanding of DSCP code points (EF for voice, AF classes for assured forwarding).
60mbit, prio 0] Root --> B[Bulk class
40mbit, prio 1] P --> Pf[DSCP EF flows] B --> Bf[Bulk flows]
Step-by-Step Execution
1. Build the HTB hierarchy [PRIVILEGED]
tc qdisc add dev eth0 root handle 1: htb default 30 && tc class add dev eth0 parent 1: classid 1:1 htb rate 100mbitExpected output:
(no output on success)
What this teaches: the qdisc is the root scheduler; HTB lets child classes borrow unused bandwidth while respecting ceilings.
2. Add priority and bulk classes
tc class add dev eth0 parent 1:1 classid 1:10 htb rate 60mbit prio 0 && tc class add dev eth0 parent 1:1 classid 1:30 htb rate 40mbit prio 1Expected output:
class htb 1:1 root prio 0 rate 100Mbit ceil 100Mbit burst 1375b cburst 1375b
class htb 1:10 parent 1:1 leaf 10: prio 0 rate 60Mbit ceil 60Mbit burst 1375b cburst 1375b
class htb 1:30 parent 1:1 leaf 30: prio 1 rate 40Mbit ceil 40Mbit burst 1375b cburst 1375b
What this teaches: classes turn a flat link into isolated lanes with guaranteed rates and priorities.
The second class is 1:30 (matching the classid in the command), and its ceiling defaults to its rate, 40Mbit. Now add the classifier that actually steers traffic into these classes:
tc filter add dev eth0 parent 1:0 protocol ip prio 1 u32 match ip dsfield 0xb8 0xfc flowid 1:10Expected output:
(no output on success)
Without this filter, marked packets fall into the default class 1:30 and the priority class never sees traffic: classification is what makes QoS real. match ip dsfield 0xb8 0xfc compares the TOS byte against 0xb8 (DSCP 46, EF) with mask 0xfc, which ignores the two ECN bits. Confirm with: tc filter show dev eth0.
3. Mark and verify under load with iperf3
iperf3 -c 192.168.10.30 -S 0xb8 && tc -s class show dev eth0Expected output:
class htb 1:10 parent 1:1 leaf 10: prio 0 rate 60Mbit ceil 60Mbit burst 1375b cburst 1375b
Sent 75497472 bytes 51456 pkt (dropped 0, overlimits 0 requeues 0)
backlog 0b 0p requeues 0
lended: 51456 borrowed: 0 giants: 0
tokens: 16762 ctokens: 16762
class htb 1:30 parent 1:1 leaf 30: prio 1 rate 40Mbit ceil 40Mbit burst 1375b cburst 1375b
Sent 50331648 bytes 34320 pkt (dropped 214, overlimits 391 requeues 0)
backlog 0b 0p requeues 0
lended: 34320 borrowed: 0 giants: 0
tokens: 12500 ctokens: 12500
What this teaches: DSCP marks survive across the network, so classification at the edge drives treatment everywhere downstream.
iperf3 -S 0xb8 sets the TOS byte to 0xb8 (184 decimal): the top six bits are DSCP 46 (EF), the low two are ECN. Under contention the priority class drops nothing while the bulk class sheds packets (overlimits), which is the visible proof the filter steered the EF-marked flow into 1:10. tc shapes egress only; inbound shaping needs an ingress qdisc or IFB.
How It Works
Quality of Service is about what happens during congestion. When a link is not saturated, QoS does nothing: packets leave immediately. When demand exceeds capacity, the scheduler decides the order and the drops, and without QoS that decision is effectively random, which is why bulk transfers starve interactive flows.
HTB (Hierarchical Token Bucket) arranges classes in a tree. Each class gets a guaranteed rate and a ceiling; unused bandwidth can be borrowed by sibling classes according to priority. DSCP marks in the IP header classify packets into classes: EF (expedited forwarding) for latency-sensitive traffic, AF classes for tiered assurance, and best-effort for everything else.
Marking must happen at a trust boundary. End hosts can set any DSCP value they like, so networks typically re-mark or police at the edge and only trust marks from known devices. Inside the trusted domain, every hop reads the same mark and applies the same per-hop behavior.
Validation & Testing
Generate simultaneous priority and bulk flows and measure latency/throughput per class. Pass criteria: priority class retains low latency and its rate, while bulk is shaped to its ceiling under contention.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| tc commands report RTNETLINK answers: Invalid argument | qdisc or class parameters invalid for the device (or HTB module missing) | Check dmesg for the module and simplify the command to isolate the bad parameter |
| Priority traffic still starves under load | Filter not matching, so priority packets land in the default class | Verify with tc -s filter show dev eth0 and confirm the DSCP mark on the wire with tcpdump |
| Classes show zero traffic | The qdisc is attached to the wrong interface or traffic uses another path | Confirm the interface name and that test traffic actually egresses through it |
| iperf3 cannot saturate the link | CPU or TCP window limits, not the qdisc | Run parallel streams (-P 4) and check that the link, not the host, is the bottleneck |
| Settings vanish after reboot | tc configuration is runtime-only | Persist with a systemd unit or a networkd/dispatcher script that reapplies on link up |
Advanced: Troubleshooting
- No shaping effect: tc shapes egress; apply on the correct outbound interface or use IFB for ingress.
- Wrong class match: verify the filter maps DSCP/marks to the intended classid.
- Bursty behaviour: tune
burstandcburstfor HTB.
Key Results
- Held priority-class latency low under simulated link saturation.
- Capped bulk flows to a 40mbit ceiling during contention.
- Classified traffic by DSCP into 2 enforced HTB classes.
- Verified per-class throughput with tc statistics under iperf3 load.
Key Takeaways
- QoS only matters under congestion; test with real load, not idle links.
- Classify at a trust boundary and police untrusted marks.
- Verify with tc -s counters, not with how the config looks.
- Guaranteed rates plus ceilings give both isolation and efficient borrowing.
Practice Challenges
Challenge 1: Add a third class
Goal: Add a scavenger class capped at 5 Mbit/s for backup traffic and prove bulk traffic is unaffected.
Hint: Create class 1:40 with ceil 5mbit and a filter matching a third DSCP value.
Challenge 2: Police inbound
Goal: Use an ingress qdisc with a policer to drop inbound traffic exceeding 50 Mbit/s.
Hint: Attach qdisc ingress and add a filter with action police rate 50mbit burst 10k drop.