VPP in Containerlab - Part 2

Containerlab Logo

Introduction

From time to time the subject of containerized VPP instances comes up. At IPng, I run the routers in AS8298 on bare metal (Supermicro and Dell hardware), as it allows me to maximize performance. However, VPP is quite friendly in virtualization. Notably, it runs really well on virtual machines like Qemu/KVM or VMWare. I can pass through PCI devices directly to the host, and use CPU pinning to allow the guest virtual machine access to the underlying physical hardware. In such a mode, VPP performance almost the same as on bare metal. But did you know that VPP can also run in Docker?

The other day I joined the [ZANOG'25] in Durban, South Africa. One of the presenters was Nardus le Roux of Nokia, and he showed off a project called [Containerlab], which provides a CLI for orchestrating and managing container-based networking labs. It starts the containers, builds virtual wiring between them to create lab topologies of users’ choice and manages the lab lifecycle.

Quite regularly I am asked ‘when will you add VPP to Containerlab?’, but at ZANOG I made a promise to actually add it. In my previous [article], I took a good look at VPP as a dockerized container. In this article, I’ll explore how to make such a container run in Containerlab!

Completing the Docker container

Just having VPP running by itself in a container is not super useful (although it is cool!). I decide first to add a few bits and bobs that will come in handy in the Dockerfile:

FROM debian:bookworm
ARG DEBIAN_FRONTEND=noninteractive
ARG VPP_INSTALL_SKIP_SYSCTL=true
ARG REPO=release
EXPOSE 22/tcp
RUN apt-get update && apt-get -y install curl procps tcpdump iproute2 iptables \
  iputils-ping net-tools git python3 python3-pip vim-tiny openssh-server bird2 \
  mtr-tiny traceroute && apt-get clean

# Install VPP
RUN mkdir -p /var/log/vpp /root/.ssh/
RUN curl -s https://packagecloud.io/install/repositories/fdio/${REPO}/script.deb.sh |  bash
RUN apt-get update && apt-get -y install vpp vpp-plugin-core && apt-get clean

# Build vppcfg
RUN pip install --break-system-packages build netaddr yamale argparse pyyaml ipaddress
RUN git clone https://git.ipng.ch/ipng/vppcfg.git && cd vppcfg && python3 -m build && \
    pip install --break-system-packages dist/vppcfg-*-py3-none-any.whl

# Config files
COPY files/etc/vpp/* /etc/vpp/
COPY files/etc/bird/* /etc/bird/
COPY files/init-container.sh /sbin/
RUN chmod 755 /sbin/init-container.sh
CMD ["/sbin/init-container.sh"]

A few notable additions:

  • vppcfg is a handy utility I wrote and discussed in a previous [article]. Its purpose is to take YAML file that describes the configuration of the dataplane (like which interfaces, sub-interfaces, MTU, IP addresses and so on), and then apply this safely to a running dataplane. You can check it out in my [vppcfg] git repository.
  • openssh-server will come in handy to log in to the container, in addition to the already available docker exec.
  • bird2 which will be my controlplane of choice. At a future date, I might also add FRR, which may be a good alterantive for some. VPP works well with both. You can check out Bird on the nic.cz [website].

I’ll add a couple of default config files for Bird and VPP, and replace the CMD with a generic /sbin/init-container.sh in which I can do any late binding stuff before launching VPP.

Initializing the Container

VPP Containerlab: NetNS

VPP’s Linux Control Plane plugin wants to run in its own network namespace. So the first order of business of /sbin/init-container.sh is to create it:

NETNS=${NETNS:="dataplane"}

echo "Creating dataplane namespace"
/usr/bin/mkdir -p /etc/netns/$NETNS
/usr/bin/touch /etc/netns/$NETNS/resolv.conf
/usr/sbin/ip netns add $NETNS

VPP Containerlab: SSH

Then, I’ll set the root password (which is vpp by the way), and start aan SSH daemon which allows for password-less logins:

echo "Starting SSH, with credentials root:vpp"
sed -i -e 's,^#PermitRootLogin prohibit-password,PermitRootLogin yes,' /etc/ssh/sshd_config
sed -i -e 's,^root:.*,root:$y$j9T$kG8pyZEVmwLXEtXekQCRK.$9iJxq/bEx5buni1hrC8VmvkDHRy7ZMsw9wYvwrzexID:20211::::::,' /etc/shadow
/etc/init.d/ssh start

VPP Containerlab: Bird2

I can already predict that Bird2 won’t be the only option for a controlplane, even though I’m a huge fan of it. Therefore, I’ll make it configurable to leave the door open for other controlplane implementations in the future:

BIRD_ENABLED=${BIRD_ENABLED:="true"}

if [ "$BIRD_ENABLED" == "true" ]; then
  echo "Starting Bird in $NETNS"
  mkdir -p /run/bird /var/log/bird
  chown bird:bird /var/log/bird
  ROUTERID=$(ip -br a show eth0 | awk '{ print $3 }' | cut -f1 -d/)
  sed -i -e "s,.*router id .*,router id $ROUTERID; # Set by container-init.sh," /etc/bird/bird.conf
  /usr/bin/nsenter --net=/var/run/netns/$NETNS /usr/sbin/bird -u bird -g bird
fi

I am reminded that Bird won’t start if it cannot determine its router id. When I start it in the dataplane namespace, it will immediately exit, because there will be no IP addresses configured yet. But luckily, it logs its complaint and it’s easily addressed. I decide to take the management IPv4 address from eth0 and write that into the bird.conf file, which otherwise does some basic initialization that I described in a previous [article], so I’ll skip that here. However, I do include an empty file called /etc/bird/bird-local.conf for users to further configure Bird2.

VPP Containerlab: Binding veth pairs

When Containerlab starts the VPP container, it’ll offer it a set of veth ports that connect this container to other nodes in the lab. This is done by the links list in the topology file [ref]. It’s my goal to take all of the interfaces that are of type veth, and generate a little snippet to grab them and bind them into VPP while setting their MTU to 9216 to allow for jumbo frames:

CLAB_VPP_FILE=${CLAB_VPP_FILE:=/etc/vpp/clab.vpp}

echo "Generating $CLAB_VPP_FILE"
: > $CLAB_VPP_FILE
MTU=9216
for IFNAME in $(ip -br link show type veth | cut -f1 -d@ | grep -v '^eth0$' | sort); do
  MAC=$(ip -br link show dev $IFNAME | awk '{ print $3 }')
  echo " * $IFNAME hw-addr $MAC mtu $MTU"
  ip link set $IFNAME up mtu $MTU
  cat << EOF >> $CLAB_VPP_FILE
create host-interface name $IFNAME hw-addr $MAC
set interface name host-$IFNAME $IFNAME
set interface mtu $MTU $IFNAME
set interface state $IFNAME up

EOF
done
Warning

One thing I realized is that VPP will assign a random MAC address on its copy of the veth port, which is not great. I’ll explicitly configure it with the same MAC address as the veth interface itself, otherwise I’d have to put the interface into promiscuous mode.

VPP Containerlab: VPPcfg

I’m almost ready, but I have one more detail. The user will be able to offer a [vppcfg] YAML file to configure the interfaces and so on. If such a file exists, I’ll apply it to the dataplane upon startup:

VPPCFG_VPP_FILE=${VPPCFG_VPP_FILE:=/etc/vpp/vppcfg.vpp}

echo "Generating $VPPCFG_VPP_FILE"
: > $VPPCFG_VPP_FILE
if [ -r /etc/vpp/vppcfg.yaml ]; then
  vppcfg plan --novpp -c /etc/vpp/vppcfg.yaml -o $VPPCFG_VPP_FILE
fi

Once the VPP process starts, it’ll execute /etc/vpp/bootstrap.vpp, which in turn executes these newly generated /etc/vpp/clab.vpp to grab the veth interfaces, and then /etc/vpp/vppcfg.vpp to further configure the dataplane. Easy peasy!

Adding VPP to Containerlab

Roman points out a previous integration for the 6WIND VSR in [PR#2540]. This serves as a useful guide to get me started. I fork the repo, create a branch so that Roman can also add a few commits, and together we start hacking in [PR#2571].

First, I add the documentation skeleton in docs/manual/kinds/fdio_vpp.md, which links in from a few other places, and will be where the end-user facing documentation will live. That’s about half the contributed LOC, right there!

Next, I’ll create a Go module in nodes/fdio_vpp/fdio_vpp.go which doesn’t do much other than creating the struct, and its required Register and Init functions. The Init function ensures the right capabilities are set in Docker, and the right devices are bound for the container.

I notice that Containerlab rewrites the Dockerfile CMD string and prepends an if-wait.sh script to it. This is because when Containerlab starts the container, it’ll still be busy adding these link interfaces to it, and if a container starts too quickly, it may not see all the interfaces. So, containerlab informs the container using an environment variable called CLAB_INTFS, so this script simply sleeps for a while until that exact amount of interfaces are present. Ok, cool beans.

Roman helps me a bit with Go templating. You see, I think it’ll be slick to have the CLI prompt for the VPP containers to reflect their hostname, because normally, VPP will assign vpp# . I add the template in nodes/fdio_vpp/vpp_startup_config.go.tpl and it only has one variable expansion: unix { cli-prompt {{ .ShortName }}# }. But I totally think it’s worth it, because when running many VPP containers in the lab, it could otherwise get confusing.

Roman also shows me a trick in the function PostDeploy(), which will write the user’s SSH pubkeys to /root/.ssh/authorized_keys. This allows users to log in without having to use password authentication.

Collectively, we decide to punt on the SaveConfig function until we’re a bit further along. I have an idea how this would work, basically along the lines of calling vppcfg dump and bind-mounting that file into the lab directory somewhere. This way, upon restarting, the YAML file can be re-read and the dataplane initialized. But it’ll be for another day.

After the main module is finished, all I have to do is add it to clab/register.go and that’s just about it. In about 170 lines of code, 50 lines of Go template, and 170 lines of Markdown, this contribution is about ready to ship!

Containerlab: Demo

After I finish writing the documentation, I decide to include a demo with a quickstart to help folks along. A simple lab showing two VPP instances and two Alpine Linux clients can be found on [git.ipng.ch/ipng/vpp-containerlab]. Simply check out the repo and start the lab, like so:

$ git clone https://git.ipng.ch/ipng/vpp-containerlab.git
$ cd vpp-containerlab
$ containerlab deploy --topo vpp.clab.yml

Containerlab: configs

The file vpp.clab.yml contains an example topology existing of two VPP instances connected each to one Alpine linux container, in the following topology:

Containerlab Topo

Two relevant files for each VPP router are included in this [repository]:

  1. config/vpp*/vppcfg.yaml configures the dataplane interfaces, including a loopback address.
  2. config/vpp*/bird-local.conf configures the controlplane to enable BFD and OSPF.

To illustrate these files, let me take a closer look at node vpp1. It’s VPP dataplane configuration looks like this:

pim@summer:~/src/vpp-containerlab$ cat config/vpp1/vppcfg.yaml 
interfaces:
  eth1:
    description: 'To client1'
    mtu: 1500
    lcp: eth1
    addresses: [ 10.82.98.65/28, 2001:db8:8298:101::1/64 ]
  eth2:
    description: 'To vpp2'
    mtu: 9216
    lcp: eth2
    addresses: [ 10.82.98.16/31, 2001:db8:8298:1::1/64 ]
loopbacks:
  loop0:
    description: 'vpp1'
    lcp: loop0
    addresses: [ 10.82.98.0/32, 2001:db8:8298::/128 ]

Then, I enable BFD, OSPF and OSPFv3 on eth2 and loop0 on both of the VPP routers:

pim@summer:~/src/vpp-containerlab$ cat config/vpp1/bird-local.conf 
protocol bfd bfd1 {
  interface "eth2" { interval 100 ms; multiplier 30; };
}

protocol ospf v2 ospf4 {
  ipv4 { import all; export all; };
  area 0 {
    interface "loop0" { stub yes; };
    interface "eth2" { type pointopoint; cost 10; bfd on; };
  };
}

protocol ospf v3 ospf6 {
  ipv6 { import all; export all; };
  area 0 {
    interface "loop0" { stub yes; };
    interface "eth2" { type pointopoint; cost 10; bfd on; };
  };
}

Containerlab: playtime!

Once the lab comes up, I can SSH to the VPP containers (vpp1 and vpp2) which have my SSH pubkeys installed thanks to Roman’s work. Barring that, I could still log in as user root using password vpp. VPP runs its own network namespace called dataplane, which is very similar to SR Linux default network-instance. I can join that namespace to take a closer look:

pim@summer:~/src/vpp-containerlab$ ssh root@vpp1
root@vpp1:~# nsenter --net=/var/run/netns/dataplane
root@vpp1:~# ip -br a
lo               DOWN           
loop0            UP             10.82.98.0/32 2001:db8:8298::/128 fe80::dcad:ff:fe00:0/64 
eth1             UNKNOWN        10.82.98.65/28 2001:db8:8298:101::1/64 fe80::a8c1:abff:fe77:acb9/64 
eth2             UNKNOWN        10.82.98.16/31 2001:db8:8298:1::1/64 fe80::a8c1:abff:fef0:7125/64 

root@vpp1:~# ping 10.82.98.1
PING 10.82.98.1 (10.82.98.1) 56(84) bytes of data.
64 bytes from 10.82.98.1: icmp_seq=1 ttl=64 time=9.53 ms
64 bytes from 10.82.98.1: icmp_seq=2 ttl=64 time=15.9 ms
^C
--- 10.82.98.1 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1002ms
rtt min/avg/max/mdev = 9.530/12.735/15.941/3.205 ms

From vpp1, I can tell that Bird2’s OSPF adjacency has formed, because I can ping the loop0 address of vpp2 router on 10.82.98.1. Nice! The two client nodes are running a minimalistic Alpine Linux container, which doesn’t ship with SSH by default. But of course I can still enter the containers using docker exec, like so:

pim@summer:~/src/vpp-containerlab$ docker exec -it client1 sh
/ # ip addr show dev eth1
531235: eth1@if531234: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 9500 qdisc noqueue state UP 
    link/ether 00:c1:ab:00:00:01 brd ff:ff:ff:ff:ff:ff
    inet 10.82.98.66/28 scope global eth1
       valid_lft forever preferred_lft forever
    inet6 2001:db8:8298:101::2/64 scope global 
       valid_lft forever preferred_lft forever
    inet6 fe80::2c1:abff:fe00:1/64 scope link 
       valid_lft forever preferred_lft forever
/ # traceroute 10.82.98.82
traceroute to 10.82.98.82 (10.82.98.82), 30 hops max, 46 byte packets
 1  10.82.98.65 (10.82.98.65)  5.906 ms  7.086 ms  7.868 ms
 2  10.82.98.17 (10.82.98.17)  24.007 ms  23.349 ms  15.933 ms
 3  10.82.98.82 (10.82.98.82)  39.978 ms  31.127 ms  31.854 ms

/ # traceroute 2001:db8:8298:102::2
traceroute to 2001:db8:8298:102::2 (2001:db8:8298:102::2), 30 hops max, 72 byte packets
 1  2001:db8:8298:101::1 (2001:db8:8298:101::1)  0.701 ms  7.144 ms  7.900 ms
 2  2001:db8:8298:1::2 (2001:db8:8298:1::2)  23.909 ms  22.943 ms  23.893 ms
 3  2001:db8:8298:102::2 (2001:db8:8298:102::2)  31.964 ms  30.814 ms  32.000 ms

From the vantage point of client1, the first hop represents the vpp1 node, which forwards to vpp2, which finally forwards to client2, which shows that both VPP routers are passing traffic. Dope!

Results

After all of this deep-diving, all that’s left is for me to demonstrate the Containerlab by means of this little screencast [asciinema]. I hope you enjoy it as much as I enjoyed creating it:

Acknowledgements

I wanted to give a shout-out Roman Dodin for his help getting the Containerlab parts squared away when I got a little bit stuck. He took the time to explain the internals and idiom of Containerlab project, which really saved me a tonne of time. He also pair-programmed the [PR#2471] with me over the span of two evenings.

Collaborative open source rocks!