Testing tricky network services with Linux Namespaces
Revamping PXE boot stacks seems to be a recurring theme in my career, and my current workplace is no exception. I am part of a tiger team tasked with a significant overhaul of our pxeboot stack on our datacenters, but I am facing a common problem: no access to a dedicated hardware lab for safe testing. We are working on it but in the meantime I could not afford to get stuck. I needed a scalable and scriptable solution that would work on my MacBook and in our CI/CD pipelines.
For the DHCP component of this project, I chose to reuse dhcplb, a tool I’m intimately familiar with, having co-created it with my colleagues at Facebook. I knew it was powerful, but it was missing a key feature for my use case: direct support for broadcast (DHCPv4) and multicast (DHCPv6) traffic.
At Facebook, dhcplb
only ever handled unicast, as the network relies on DHCP
relays in the top-of-rack switches. The need for broadcast support was something
I had anticipated since 2016, when I first
opened an issue for it.
Almost a decade later, it was time to implement it. But to do so, I needed a
reliable way to test.
This challenge led me to create the testing environment detailed in this post, all based on a powerful, yet often underutilized, feature of the Linux kernel: network namespaces. In this article, we’ll explore how the use of a clever combination of namespaces, virtual Ethernet (veth) pairs, and bridges can be used to create a fully isolated, scriptable test lab that can run anywhere: like your laptop or your CI/CD pipeline virtual runners.
The Building Blocks: Namespaces, Veth Pairs, and Bridges
Before diving into the lab I built to test my recent dhcplb
changes, let’s quickly
recap the tools at our disposal:
- Network Namespaces (
ip netns
): A network namespace is a logical copy of the network stack, complete with its own interfaces, routing tables, and firewall rules. Processes running in one namespace are isolated from others. It’s like having a separate, virtual machine for networking, but with minimal overhead. - Virtual Ethernet Pairs (
veth
) (man page): Aveth
pair is a simple concept: it’s a link with two ends. A particularly interesting use case is to place one end of a veth pair in one network namespace and the other end in another network namespace, thus allowing communication between network namespaces. - Network Bridges (
brctl
orip link
): A bridge is a virtual switch. They are necessary when you are working 3 or more network namespaces. A bridge can connect multiple network interfaces (like one end of aveth
pair) together, allowing them to communicate as if they were on a switch on the same physical network segment.
By combining these three tools, we can build any network topology we can imagine, all on a single Linux host.
Visualizing the Topologies
To better understand the test scenarios, here are diagrams of the virtual networks created by the lab scripts.
Relay Lab Topology
This is the standard 4-actor topology that simulates dhcplb
operating as a
load balancer, actors are:
- a dhcp
client
in thens-client
namespace - a dhcp
relay
in thens-relay
namespace - a dhcplb instance acting as load balancer in the
ns-dhcplb
namespace - and finally a dhcp
server
in thens-server
namespace
Packets Flows
The flow of DHCPv4 packets is as follows, the server directly responds to the relay skipping the LB (aka D.S.R.: Direct Server Return).
In contrast to that, the flow of DHCPv6 packets is as follows, the relay traverses the LB to the server.
Server/Broadcast Lab Topology
This 3-actor topology places dhcplb
acting as a standard DHCP server,
as the first-hop relay for the client,
testing its ability to handle broadcast/multicast packets directly.
What About macOS? Enter Lima
But what if your development machine isn’t running Linux? Network namespaces
are a Linux-specific kernel feature. The dhcplb
project solves this
elegantly using Lima (Linux virtual machines on macOS).
The tests
directory contains a dhcplb-vm.yaml
file that defines a
lightweight Ubuntu VM. The Makefile
seamlessly manages this VM, starting it
when needed and executing all the test commands inside it. This approach
provides the best of both worlds:
- Developers on macOS can run the full, Linux-based integration test
suite with a single
make
command. - CI/CD pipelines (like GitHub Actions) can run the same test scripts directly on a Linux runner, without the overhead of a VM.
Under the Hood: A Look at the dhcplb
Test Lab Code
Testing the relay topology
The dhcplb
test lab, found in the /tests
directory, provides a fantastic,
real-world example of these concepts in action. Let’s walk through how the
relay
mode topology is constructed using shell commands from the
setup_relay_mode.sh
script.
The Goal: Create the following network:
[ ns-client ] <-> [ br-int ] <-> [ ns-relay ] <-> [ br-ext ] <-> [ ns-dhcplb ] <-> [ ns-server ]
Step 1: Create the Namespaces
First, each actor in our simulation gets its own isolated network stack.
# From includes/setup_relay_mode.sh
ip netns add "ns-client"
ip netns add "ns-relay"
ip netns add "ns-dhcplb"
ip netns add "ns-server"
Step 2: Create the Virtual Switches (Bridges)
Next, two bridges are created to act as our internal and external networks.
# From includes/setup_relay_mode.sh
ip link add name "br-int" type bridge
ip link set "br-int" up
ip link add name "br-ext" type bridge
ip link set "br-ext" up
Step 3: Connect the Client to the Internal Bridge
To connect the ns-client
namespace to the br-int
bridge, a veth
pair is
created. One end (v-cli
) is moved into the namespace, and the other end
(v-br-cli
) is attached to the bridge.
# Create the virtual patch cable
ip link add "v-cli" type veth peer name "v-br-cli"
# Plug one end into the namespace
ip link set "v-cli" netns "ns-client"
# Plug the other end into the bridge
ip link set "v-br-cli" master "br-int"
# Bring the interfaces up
ip link set "v-br-cli" up
ip netns exec "ns-client" ip link set dev "v-cli" up
This process is repeated for every connection in the topology diagram, methodically building the virtual network, piece by piece.
Step 4: Assigning IP Addresses
Once the interfaces are in place, they are assigned IP addresses within their
respective namespaces. For example, the ns-relay
namespace gets IPs on both
the internal and external networks.
# From includes/setup_relay_mode.sh
# Assign 192.168.100.1 to the relay's internal interface
ip netns exec "ns-relay" ip addr add "192.168.100.1/24" dev "v-rly-int"
# Assign 192.168.200.1 to the relay's external interface
ip netns exec "ns-relay" ip addr add "192.168.200.1/24" dev "v-rly-ext"
Step 5: Running the Services and the Test
With the topology built, the Makefile
can now orchestrate the test. It uses
ip netns exec
to run each service within its designated namespace,
redirecting their output to log files.
Testing the Server/Broadcast Topology
The server
mode topology is simpler. It tests dhcplb
’s ability to act as a standalone DHCP server, directly responding to broadcast (DHCPv4) and multicast (DHCPv6) discovery packets from clients on the same network segment.
The Goal: Create the following network:
[ ns-client ] <-> [ br-int ] <-> [ ns-dhcplb ]
Step 1: Create Namespaces and the Bridge
Only two namespaces are needed: one for the client and one for dhcplb
. A single bridge connects them.
# From includes/setup_server_mode.sh
ip netns add "ns-client"
ip netns add "ns-dhcplb"
ip link add name "br-int" type bridge
ip link set "br-int" up
Step 2: Connect the Actors to the Bridge
Both the client and dhcplb
are connected to the br-int
bridge using veth
pairs.
# From includes/setup_server_mode.sh
# Connect the client
ip link add "v-cli" type veth peer name "v-br-cli"
ip link set "v-cli" netns "ns-client"
ip link set "v-br-cli" master "br-int"
ip link set "v-br-cli" up
ip netns exec "ns-client" ip link set dev "v-cli" up
# Connect dhcplb
ip link add "v-lb" type veth peer name "v-br-lb"
ip link set "v-lb" netns "ns-dhcplb"
ip link set "v-br-lb" master "br-int"
ip link set "v-br-lb" up
ip netns exec "ns-dhcplb" ip link set dev "v-lb" up
Step 3: Assign an IP Address to dhcplb
dhcplb
needs an IP address on the network to serve DHCP requests. The client’s interface is left unconfigured, as it will request an address via DHCP.
# From includes/setup_server_mode.sh
ip netns exec "ns-dhcplb" ip addr add "192.168.100.1/24" dev "v-lb"
ip netns exec "ns-dhcplb" ip -6 addr add "fd00:100::1/64" dev "v-lb"
Step 4: Running the Services and the Test
With the topology built, the Makefile
can now orchestrate the test. It uses
ip netns exec
to run each service within its designated namespace,
redirecting their output to log files.
Configuration in Action
The setup scripts dynamically generate the configuration for each service. Let’s look at what they contain for each mode.
Relay Mode: Backend DHCP Server (dnsmasq
in ns-server
)
This configuration, generated by render_dnsmasq_server_config
, tells dnsmasq
to act as a standard DHCP server, handing out IPs from a defined range.
# /etc/dhcplb_lab/dnsmasq-server.conf
interface=v-srv
port=0
dhcp-range=192.168.100.10,192.168.100.50,12h
dhcp-range=fd00:100::10,fd00:100::50,12h
dhcp-option=option:router,192.168.100.1
log-dhcp
dhcp-leasefile=/var/lib/dhcp/dnsmasq.leases
Relay Mode: DHCP Relay (dnsmasq
in ns-relay
)
This dnsmasq
instance is configured as a relay. It listens on its internal
interface and forwards any DHCP requests it receives to the dhcplb
IP address
(192.168.200.2
).
# /etc/dhcplb_lab/dnsmasq-relay.conf
port=0
interface=v-rly-int
interface=v-rly-ext
dhcp-relay=192.168.100.1,192.168.200.2
dhcp-relay=fd00:100::1,fd00:200::2
enable-ra
log-dhcp
Relay Mode: DHCPLB Configuration
Finally, the dhcplb
configuration tells the service to listen for packets and
specifies a file from which to read the list of available backend DHCP servers.
// /etc/dhcplb_lab/dhcplb.config.json (relay mode)
{
"v4": {
"version": 4,
"listen_addr": "0.0.0.0",
"port": 67,
"host_sourcer": "file:/etc/dhcplb_lab/dhcp-servers-v4.cfg"
},
"v6": {
"version": 6,
"listen_addr": "fd00:200::2",
"port": 547,
"host_sourcer": "file:/etc/dhcplb_lab/dhcp-servers-v6.cfg"
}
}
Server Mode: DHCPLB Configuration
In server mode, dhcplb
is configured with a range
handler, allowing it to
manage a pool of IP addresses and act as a full-fledged DHCP server.
// /etc/dhcplb_lab/dhcplb.config.json (server mode)
{
"v4": {
"version": 4,
"listen_addr": "0.0.0.0",
"port": 67,
"handler": {
"type": "range",
"start_ip": "192.168.100.10",
"end_ip": "192.168.100.50",
"lease_time": "10m",
"lease_file": "/etc/dhcplb_lab/dhcplb-v4.leases",
"options": {
"subnet-mask": "255.255.255.0",
"router": "192.168.100.1"
}
}
},
"v6": {
"version": 6,
"listen_addr": "::",
"port": 547,
"handler": {
"type": "range",
"start_ip": "fd00:100::10",
"end_ip": "fd00:100::50",
"lease_time": "10m",
"lease_file": "/etc/dhcplb_lab/dhcplb-v6.leases",
"options": {
"dns-server": ["fd00:100::1"]
}
}
}
}
Kicking off the Tests
The actual tests are triggered by running a DHCP client in the ns-client
namespace.
Relay Mode Test
This command asks the client to get a lease on its virtual interface, v-cli
,
which is then relayed to dhcplb
.
# From tests/Makefile
test-relay-v4: delete-lease
@echo "▶️ Running DHCPv4 RELAY client test..."
@limactl shell $(LIMA_INSTANCE_NAME) sudo ip netns exec ns-client dhclient -v -1 v-cli
test-relay-v6: delete-lease
@echo "▶️ Running DHCPv4 RELAY client test..."
@limactl shell $(LIMA_INSTANCE_NAME) sudo ip netns exec ns-client dhclient -6 -v -1 v-cli
The test passes if dhclient
successfully obtains a lease, proving that the
entire chain—from client to relay to dhcplb
to the server and back—is working
correctly.
Here is the tcpdump proving this worked for DHCPv4
:
root@lima-dhcplb-vm:~# tcpdump -l -i any 'udp port 67 or udp port 68'
tcpdump: WARNING: any: That device doesn't support promiscuous mode
(Promiscuous mode not supported on the "any" device)
tcpdump: data link type LINUX_SLL2
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on any, link-type LINUX_SLL2 (Linux cooked v2), snapshot length 262144 bytes
22:28:41.087260 v-br-cli B IP 0.0.0.0.bootpc > 255.255.255.255.bootps: BOOTP/DHCP, Request from 02:66:b4:13:54:2c (oui Unknown), length 300
22:28:41.087281 v-br-srv-int Out IP 0.0.0.0.bootpc > 255.255.255.255.bootps: BOOTP/DHCP, Request from 02:66:b4:13:54:2c (oui Unknown), length 300
22:28:41.087282 v-br-rly-int Out IP 0.0.0.0.bootpc > 255.255.255.255.bootps: BOOTP/DHCP, Request from 02:66:b4:13:54:2c (oui Unknown), length 300
22:28:41.087260 br-int B IP 0.0.0.0.bootpc > 255.255.255.255.bootps: BOOTP/DHCP, Request from 02:66:b4:13:54:2c (oui Unknown), length 300
22:28:41.088466 v-br-rly-ext P IP relay-int.bootps > dhcplb.bootps: BOOTP/DHCP, Request from 02:66:b4:13:54:2c (oui Unknown), length 300
22:28:41.088467 v-br-lb Out IP relay-int.bootps > dhcplb.bootps: BOOTP/DHCP, Request from 02:66:b4:13:54:2c (oui Unknown), length 300
22:28:41.088953 v-br-lb P IP dhcplb.bootps > server.bootps: BOOTP/DHCP, Request from 02:66:b4:13:54:2c (oui Unknown), length 300
22:28:41.088955 v-br-srv Out IP dhcplb.bootps > server.bootps: BOOTP/DHCP, Request from 02:66:b4:13:54:2c (oui Unknown), length 300
22:28:44.123169 v-br-srv-int P IP 192.168.100.3.bootps > relay-int.bootps: BOOTP/DHCP, Reply, length 300
22:28:44.123178 v-br-rly-int Out IP 192.168.100.3.bootps > relay-int.bootps: BOOTP/DHCP, Reply, length 300
22:28:44.123610 v-br-rly-int P IP relay-int.bootps > 192.168.100.12.bootpc: BOOTP/DHCP, Reply, length 300
22:28:44.123678 v-br-cli Out IP relay-int.bootps > 192.168.100.12.bootpc: BOOTP/DHCP, Reply, length 300
22:28:44.124201 v-br-cli B IP 0.0.0.0.bootpc > 255.255.255.255.bootps: BOOTP/DHCP, Request from 02:66:b4:13:54:2c (oui Unknown), length 300
22:28:44.124215 v-br-srv-int Out IP 0.0.0.0.bootpc > 255.255.255.255.bootps: BOOTP/DHCP, Request from 02:66:b4:13:54:2c (oui Unknown), length 300
22:28:44.124218 v-br-rly-int Out IP 0.0.0.0.bootpc > 255.255.255.255.bootps: BOOTP/DHCP, Request from 02:66:b4:13:54:2c (oui Unknown), length 300
22:28:44.124201 br-int B IP 0.0.0.0.bootpc > 255.255.255.255.bootps: BOOTP/DHCP, Request from 02:66:b4:13:54:2c (oui Unknown), length 300
22:28:44.124715 v-br-rly-ext P IP relay-int.bootps > dhcplb.bootps: BOOTP/DHCP, Request from 02:66:b4:13:54:2c (oui Unknown), length 300
22:28:44.124724 v-br-lb Out IP relay-int.bootps > dhcplb.bootps: BOOTP/DHCP, Request from 02:66:b4:13:54:2c (oui Unknown), length 300
22:28:44.125091 v-br-lb P IP dhcplb.bootps > server.bootps: BOOTP/DHCP, Request from 02:66:b4:13:54:2c (oui Unknown), length 300
22:28:44.125098 v-br-srv Out IP dhcplb.bootps > server.bootps: BOOTP/DHCP, Request from 02:66:b4:13:54:2c (oui Unknown), length 300
22:28:44.131279 v-br-srv-int P IP 192.168.100.3.bootps > relay-int.bootps: BOOTP/DHCP, Reply, length 302
22:28:44.131287 v-br-rly-int Out IP 192.168.100.3.bootps > relay-int.bootps: BOOTP/DHCP, Reply, length 302
22:28:44.131384 v-br-rly-int P IP relay-int.bootps > 192.168.100.12.bootpc: BOOTP/DHCP, Reply, length 302
22:28:44.131390 v-br-cli Out IP relay-int.bootps > 192.168.100.12.bootpc: BOOTP/DHCP, Reply, length 30
Here is the tcpdump proving this worked for DHCPv6
:
root@lima-dhcplb-vm:~# tcpdump -l -i any 'udp port 546 or udp port 547'
tcpdump: WARNING: any: That device doesn't support promiscuous mode
(Promiscuous mode not supported on the "any" device)
tcpdump: data link type LINUX_SLL2
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on any, link-type LINUX_SLL2 (Linux cooked v2), snapshot length 262144 bytes
22:22:59 v-br-cli M IP6 fe80::66:b4ff:fe13:542c.dhcpv6-client > ff02::1:2.dhcpv6-server: dhcp6 solicit
22:22:59 v-br-srv-int Out IP6 fe80::66:b4ff:fe13:542c.dhcpv6-client > ff02::1:2.dhcpv6-server: dhcp6 solicit
22:22:59 v-br-rly-int Out IP6 fe80::66:b4ff:fe13:542c.dhcpv6-client > ff02::1:2.dhcpv6-server: dhcp6 solicit
22:22:59 br-int M IP6 fe80::66:b4ff:fe13:542c.dhcpv6-client > ff02::1:2.dhcpv6-server: dhcp6 solicit
22:22:59 v-br-rly-ext P IP6 relay-ext-v6.dhcpv6-server > dhcplb-v6.dhcpv6-server: dhcp6 relay-fwd
22:22:59 v-br-lb Out IP6 relay-ext-v6.dhcpv6-server > dhcplb-v6.dhcpv6-server: dhcp6 relay-fwd
22:22:59 v-br-lb P IP6 dhcplb-v6.dhcpv6-server > server-v6.dhcpv6-server: dhcp6 relay-fwd
22:22:59 v-br-srv Out IP6 dhcplb-v6.dhcpv6-server > server-v6.dhcpv6-server: dhcp6 relay-fwd
22:22:59 v-br-srv P IP6 server-v6.dhcpv6-server > dhcplb-v6.dhcpv6-server: dhcp6 relay-reply
22:22:59 v-br-lb Out IP6 server-v6.dhcpv6-server > dhcplb-v6.dhcpv6-server: dhcp6 relay-reply
22:22:59 v-br-lb P IP6 dhcplb-v6.dhcpv6-server > relay-ext-v6.dhcpv6-server: dhcp6 relay-reply
22:22:59 v-br-rly-ext Out IP6 dhcplb-v6.dhcpv6-server > relay-ext-v6.dhcpv6-server: dhcp6 relay-reply
22:22:59 v-br-rly-int P IP6 fe80::9c54:f8ff:fe97:d75.dhcpv6-server > fe80::66:b4ff:fe13:542c.dhcpv6-client: dhcp6 advertise
22:22:59 v-br-cli Out IP6 fe80::9c54:f8ff:fe97:d75.dhcpv6-server > fe80::66:b4ff:fe13:542c.dhcpv6-client: dhcp6 advertise
22:23:00 v-br-cli M IP6 fe80::66:b4ff:fe13:542c.dhcpv6-client > ff02::1:2.dhcpv6-server: dhcp6 request
22:23:00 v-br-srv-int Out IP6 fe80::66:b4ff:fe13:542c.dhcpv6-client > ff02::1:2.dhcpv6-server: dhcp6 request
22:23:00 v-br-rly-int Out IP6 fe80::66:b4ff:fe13:542c.dhcpv6-client > ff02::1:2.dhcpv6-server: dhcp6 request
22:23:00 br-int M IP6 fe80::66:b4ff:fe13:542c.dhcpv6-client > ff02::1:2.dhcpv6-server: dhcp6 request
22:23:00 v-br-rly-ext P IP6 relay-ext-v6.dhcpv6-server > dhcplb-v6.dhcpv6-server: dhcp6 relay-fwd
22:23:00 v-br-lb Out IP6 relay-ext-v6.dhcpv6-server > dhcplb-v6.dhcpv6-server: dhcp6 relay-fwd
22:23:00 v-br-lb P IP6 dhcplb-v6.dhcpv6-server > server-v6.dhcpv6-server: dhcp6 relay-fwd
22:23:00 v-br-srv Out IP6 dhcplb-v6.dhcpv6-server > server-v6.dhcpv6-server: dhcp6 relay-fwd
22:23:00 v-br-srv P IP6 server-v6.dhcpv6-server > dhcplb-v6.dhcpv6-server: dhcp6 relay-reply
22:23:00 v-br-lb Out IP6 server-v6.dhcpv6-server > dhcplb-v6.dhcpv6-server: dhcp6 relay-reply
22:23:00 v-br-lb P IP6 dhcplb-v6.dhcpv6-server > relay-ext-v6.dhcpv6-server: dhcp6 relay-reply
22:23:00 v-br-rly-ext Out IP6 dhcplb-v6.dhcpv6-server > relay-ext-v6.dhcpv6-server: dhcp6 relay-reply
22:23:00 v-br-rly-int P IP6 fe80::9c54:f8ff:fe97:d75.dhcpv6-server > fe80::66:b4ff:fe13:542c.dhcpv6-client: dhcp6 reply
22:23:00 v-br-cli Out IP6 fe80::9c54:f8ff:fe97:d75.dhcpv6-server > fe80::66:b4ff:fe13:542c.dhcpv6-client: dhcp6 reply
Server Mode Test
This command runs the client in the same way, but this time dhcplb
responds
to the broadcast/multicast directly.
# From tests/Makefile
test-server-v4: delete-lease
@echo "▶️ Running DHCPv4 SERVER client test..."
@limactl shell $(LIMA_INSTANCE_NAME) sudo ip netns exec ns-client dhclient -v -1 v-cli
test-relay-v6: delete-lease
@echo "▶️ Running DHCPv4 RELAY client test..."
@limactl shell $(LIMA_INSTANCE_NAME) sudo ip netns exec ns-client dhclient -6 -v -1 v-cli
The test passes if dhclient
successfully obtains a lease from dhcplb
acting
as a server.
Automation with a Simple Makefile
The beauty of this setup is its scriptability. The entire lifecycle of the
lab—creation, setup, test execution, and teardown—is managed by a simple
Makefile
.
make setup-relay
: Builds the relay topology.
❯ make setup-relay
✅ Lima VM instance 'dhcplb-vm' is already running.
📦 Copying project files to VM...
🛠 Running lab setup script (setup_lab.sh relay)...
🧹 Cleaning up previous lab setup...
📦 Installing dhcplb...
✅ dhcplb installed.
🧪 Setting up lab in RELAY mode...
🚀 Starting services...
✅ Relay test lab is running.
❯ make setup-server
✅ Lima VM instance 'dhcplb-vm' is already running.
📦 Copying project files to VM...
🛠 Running lab setup script (setup_lab.sh server)...
🧹 Cleaning up previous lab setup...
📦 Installing dhcplb...
✅ dhcplb installed.
🧪 Setting up lab in SERVER mode...
🚀 Starting services...
✅ Server test lab is running.
make test-relay-v4
: Runs a DHCPv4 client in the relay lab.make test-relay-v6
: Runs a DHCPv6 client in the relay lab.make test-server-v4
: Runs a DHCPv4 client in the server lab.make test-server-v6
: Runs a DHCPv6 client in the server lab.make clean
: Tears down all namespaces, bridges, and processes.
This makes running complex integration tests as simple as a single command.
Conclusion
Testing complex network services doesn’t have to be a choice between limited
unit tests and a costly hardware lab. By leveraging Linux network namespaces,
veth
pairs, and bridges, you can create realistic, isolated, and fully
automated test environments. With tools like Lima, these powerful testing
capabilities can be used on any platform. The dhcplb
project provides a
fantastic, real-world example of how to master the complexity of network
testing, enabling developers to ship more reliable software with confidence.
The solution described here proved so effective that the PR is now on GitHub, and the CI integration test passed flawlessly.
So next time you’re faced with a “tricky” service to test, remember the power of namespaces. Your network is your oyster.
Further Reading
- An excellent article on GitHub covering network namespaces.
- As Wikipedia correctly mentions, Linux supports other types of namespaces beyond networking (such as mount, process, and user namespaces).
- The official Linux man pages for namespaces.
- Facebook engineering blog post on dhcplb
- Facebook engineering blog post on extending dhcplb to work in server mode