Back to posts
Post

Configuring Linux tc for VoIP DSCP EF Marking and Priority Queuing

Use Linux traffic control to mark VoIP packets with DSCP EF and prioritize them to reduce jitter and packet loss.

NetworktcVoIPDSCPtraffic shaping

In my environment, I rely on Linux tc to give VoIP traffic the handling it needs before it hits the WAN. Without proper marking and queuing, voice packets compete with bulk data, leading to jitter and dropped calls. Here’s how I configure DSCP EF marking and priority queuing on an egress interface to keep voice smooth.

Why DSCP EF Matters for VoIP

VoIP uses UDP, which has no retransmission. Even a small amount of delay or loss degrades quality. The DSCP EF (Expedited Forwarding) codepoint (0b101110) tells network devices to treat these packets as low-latency, high-priority traffic. I mark them at the source so downstream routers and switches can honor the tag without needing deep packet inspection.

Marking VoIP Traffic with tc filter

First, I identify VoIP traffic by port. In my setup, SIP signaling uses 5060/5061 and RTP media ranges from 10000-20000 UDP. I create a tc filter to match these and set the DSCP field:

# Clear existing qdisc on eth0 (adjust for your interface)
tc qdisc del dev eth0 root 2>/dev/null || true

# Create root HTB with a default class
tc qdisc add dev eth0 root handle 1: htb default 30

# Add a class for VoIP (10% guarantee, ceil to 100%)
tc class add dev eth0 parent 1: classid 1:10 htb rate 1mbit ceil 100mbit

# Add a bulk class for everything else
tc class add dev eth0 parent 1: classid 1:30 htb rate 4mbit ceil 100mbit

# Mark VoIP packets with DSCP EF (0x2e) and send to VoIP class
tc filter add dev eth0 protocol ip parent 1:0 prio 1 u32 \
    match ip dport 5060 0xffff flowid 1:10 \
    action pedit munge ip dscp set 0x2e

tc filter add dev eth0 protocol ip parent 1:0 prio 1 u32 \
    match ip dport 5061 0xffff flowid 1:10 \
    action pedit munge ip dscp set 0x2e

tc filter add dev eth0 protocol ip parent 1:0 prio 1 u32 \
    match ip sport 5060 0xffff flowid 1:10 \
    action pedit munge ip dscp set 0x2e

tc filter add dev eth0 protocol ip parent 1:0 prio 1 u32 \
    match ip sport 5061 0xffff flowid 1:10 \
    action pedit munge ip dscp set 0x2e

# RTP media range (example: 10000-20000)
tc filter add dev eth0 protocol ip parent 1:0 prio 1 u32 \
    match ip dport 10000 0xfc00 flowid 1:10 \
    action pedit munge ip dscp set 0x2e

tc filter add dev eth0 protocol ip parent 1:0 prio 1 u32 \
    match ip sport 10000 0xfc00 flowid 1:10 \
    action pedit munge ip dscp set 0x2e

This marks matching packets with DSCP EF and steers them into the VoIP class (1:10). The HTB scheduler gives this class a guaranteed 1 Mbps but allows bursting to line rate if bandwidth is free.

Verifying the Marking

I use tcpdump to confirm the DSCP field is set correctly. Looking for 0x2e in the IP header’s ToS byte:

tcpdump -i eth0 -nn -vvv 'udp portrange 10000-20000 or port 5060 or port 5061' -c 5

Sample output shows:

IP (tos 0x2e, ect 0), ttl 64, id 12345, offset 0, flags [none], proto UDP (17), length 200
    10.0.0.5.12345 > 10.0.0.10.5060: UDP, length 172

The tos 0x2e confirms EF marking. If you don’t see it, check filter order and syntax—tc filters are evaluated sequentially, and a misplaced default class can steal traffic.

Prioritizing with HTB and pfifo_fast

While marking helps downstream, local queuing prevents bursts from filling the tx ring and causing drops. I use HTB with a pfifo_fast leaf (the default) but tune the txqueuelen to absorb bursts without excessive latency:

# Set tx queue length to absorb bursts (default is often 1000)
ip link set dev eth0 txqueuelen 2000

# Show current qdisc stats
tc -s qdisc show dev eth0

Output includes:

qdisc htb 1: root refcnt 2 r2q 10 default 30 direct_packets 1234
Sent 567890 bytes 1234 pkt (dropped 0, overlimits 0)

qdisc pfifo_fast 10: parent 1:10 limit 1000p packets 1234 bytes 567890 lost 0

qdisc pfifo_fast 30: parent 1:30 limit 1000p packets 9876 bytes 432100 lost 2

Notice the VoIP class (1:10) has zero drops while the bulk class (1:30) shows some loss—this is the shaping doing its job.

Monitoring Jitter and Loss

I pair this with passive monitoring. Using tc -s class show gives real-time stats:

watch -n 1 'tc -s class show dev eth0'

Watch for rising delayed or overlimits counters in the VoIP class—if they increase, your rate or ceil is too low. Adjust based on actual call volume; a typical G.711 call needs ~87 kbps, so 1 Mbps supports ~10 concurrent calls with headroom.

Common Pitfalls

  • Forgetting ingress: This config only marks egress traffic. If your VoIP server receives calls, you need matching rules on ingress interfaces to prioritize incoming media.
  • Over-shaping: Setting rate too low starves VoIP even if the link is idle. Use ceil to allow bursting.
  • Ignoring VLANs: If eth0.100 carries VoIP, apply the qdisc to the VLAN subinterface, not the physical eth0.

Final Notes

I’ve used this pattern across Proxmox hosts running Asterisk and Kamailio setups. It’s lightweight, works without kernel patches, and integrates with existing DSCP-aware networks. For more on simulating WAN conditions to test this, see my post on [adding jitter with netem](https://furkanikkan.com/urun/linux-tc-netem-kullanarak-gercekci-wan-simulasyonu-icin-jitter-ekleme-126).

Once configured, monitor MOS scores or call logs—you should see fewer one-way audio complaints and more stable conversations during peak data transfers.


Cover image: Unknown · CC0 (Openverse / kamu malı) · https://www.rawpixel.com/image/6038427/photo-image-public-domain-technology-line