Skip to content
On GitHub On Twitter RSS feed

Part 3 Kubernetes Networking Under the Hood

Who Builds the Pod Network? From Linux Commands to CNI

9 min read

In the first two parts, we kept Kubernetes out of the picture.

We created network namespaces, veth pairs, bridges, IP addresses, and routes ourselves. Then we moved beyond a single local network and followed packets through gateways, Linux routing, forwarding, and NAT.

So far, every piece of the network has been configured by hand.

For each new Pod-like namespace, we had to:

create namespace
      |
create veth pair
      |
move one end into namespace
      |
attach the other end to bridge
      |
assign an IP address
      |
configure routes

Kubernetes needs the same kind of networking setup whenever a Pod is created on a node, but that work is automated.

This is where CNI fits.

We briefly introduced CNI in the first article. Here, we will use it directly.

We will create an empty network namespace, connect it to a network through CNI, and inspect the Linux objects that appear.

What is CNI?

CNI stands for Container Network Interface.

It is a specification for how a runtime asks networking plugins to configure a network namespace.

The runtime provides information such as:

  • which network namespace to configure
  • what the interface inside it should be called
  • which operation should be performed
  • the network configuration itself

A plugin then performs the networking work.

A simple mental model is:

runtime
   |
   | CNI
   v
plugin
   |
   v
Linux networking

CNI is the contract. The plugin is the implementation.

For this lab, we will use two plugins from the official CNI plugins project.

The first is the bridge plugin.

It creates a veth pair, places one end inside the target network namespace, and connects the host end to a Linux bridge.

That is very close to the network we built manually in Part 1.

The second is host-local, an IP Address Management plugin, usually shortened to IPAM.

It chooses an available IP address from a configured range and keeps track of existing allocations.

Our setup will look roughly like this:

             CNI configuration
                    |
          +---------+---------+
          |                   |
          v                   v
   bridge plugin       host-local IPAM
          |                   |
          | create veth       | allocate IP
          | attach bridge     |
          +---------+---------+
                    |
                    v
             network namespace

Using the bridge plugin gives us a useful comparison with Part 1 because we already know what the resulting Linux network should look like.

Calling CNI with cnitool

When a runtime asks a CNI plugin to add a container to a network, the plugin receives information similar to:

CNI_COMMAND=ADD
CNI_CONTAINERID=...
CNI_NETNS=/run/netns/pod-a
CNI_IFNAME=eth0

It also receives the network configuration as JSON.

For this lab, the two operations we care about are:

ADD
 |
 | attach networking
 v

DEL
 |
 | remove networking
 v

CNI defines other operations as well, but ADD and DEL are enough for the lifecycle we want to look at here.

We could invoke the plugin binaries directly and provide those inputs ourselves, but cnitool gives us a simpler way to do it. It applies a CNI configuration to an existing network namespace.

A container runtime would normally sit above CNI:

container runtime
       |
       | CNI ADD
       v
    plugins
       |
       v
Linux networking

For this lab, cnitool takes the runtime's place:

    cnitool
       |
       | CNI ADD
       v
    plugins
       |
       v
Linux networking

That lets us inspect the CNI layer without needing a Kubernetes cluster or container runtime.

Before starting

I recommend using a disposable Linux VM.

I ran the lab inside a privileged Debian Bookworm container on Docker Desktop.

That environment also exposes a few kernel tunnel interfaces inside otherwise empty network namespaces. I leave those out of the outputs because they are unrelated to this lab. On a typical disposable VM, you may only see lo.

If you already have cnitool and the official CNI plugins installed, you can skip most of this section.

On Ubuntu or Debian:

sudo apt update
sudo apt install -y iproute2 iputils-ping git golang-go

Install cnitool:

go install github.com/containernetworking/cni/cnitool@latest
sudo install "$(go env GOPATH)/bin/cnitool" /usr/local/bin/cnitool

Check that it is available:

cnitool --help

Build the official CNI plugins:

git clone https://github.com/containernetworking/plugins.git
cd plugins
./build_linux.sh

For this lab, we only need:

bridge
host-local

Copy them into a conventional CNI plugin directory:

sudo mkdir -p /opt/cni/bin
sudo cp bin/bridge bin/host-local /opt/cni/bin/

Create the configuration directory:

sudo mkdir -p /etc/cni/net.d

Step 1: Create an empty network namespace

Create the first namespace:

sudo ip netns add pod-a

Inspect it:

sudo ip -n pod-a -br link

On my lab:

lo               DOWN           00:00:00:00:00:00 <LOOPBACK>

There is no eth0, Pod IP, host-side veth, or bridge yet.

Linux host

    pod-a
      |
      X

In Part 1, we started building those pieces manually from here.

For this lab, the namespace is all we create ourselves.

Step 2: Describe the network

Create /etc/cni/net.d/10-tiny-net.conf:

sudo tee /etc/cni/net.d/10-tiny-net.conf > /dev/null <<'EOF'
{
  "cniVersion": "1.0.0",
  "name": "tiny-net",
  "type": "bridge",
  "bridge": "cni0",
  "isGateway": true,
  "ipam": {
    "type": "host-local",
    "ranges": [
      [
        {
          "subnet": "10.10.0.0/24"
        }
      ]
    ]
  }
}
EOF

The important parts are:

"type": "bridge"
        |
        +--> use the bridge plugin

"bridge": "cni0"
        |
        +--> connect the namespace to this bridge

"isGateway": true
        |
        +--> let the bridge act as the subnet gateway

"ipam": {
  "type": "host-local"
}
        |
        +--> allocate IP addresses locally

"subnet": "10.10.0.0/24"
        |
        +--> allocate them from this network

At this point, we have only created a configuration file.

The bridge does not exist yet:

ip link show cni0
Device "cni0" does not exist.

And pod-a still has no eth0.

sudo ip -n pod-a -br addr

The configuration describes the network. The plugins have not applied it yet.

Step 3: Run CNI ADD

Run:

sudo env CNI_PATH=/opt/cni/bin \
  cnitool add tiny-net /var/run/netns/pod-a

In my lab, cnitool returned:

{
  "cniVersion": "1.0.0",
  "interfaces": [
    {
      "name": "cni0"
    },
    {
      "name": "veth6b96cdeb"
    },
    {
      "name": "eth0",
      "sandbox": "/var/run/netns/pod-a"
    }
  ],
  "ips": [
    {
      "address": "10.10.0.2/24",
      "gateway": "10.10.0.1",
      "interface": 2
    }
  ]
}

I removed the MAC addresses because they are not relevant here.

The result already tells us what was configured:

cni0
host-side veth
pod-a / eth0

and the allocated address:

10.10.0.2/24
gateway 10.10.0.1

We did not create any of those interfaces manually.

Let's inspect them.

Step 4: Inspect the namespace and host

Inside pod-a:

sudo ip -n pod-a -br addr
lo               DOWN
eth0@if12        UP             10.10.0.2/24 ...

lo is still down because the bridge plugin only configured the interface we asked it to create, eth0. It does not configure loopback. The official CNI plugins project has a separate loopback plugin for that job. We do not need it for this lab.

The ... hides the IPv6 link-local address that Linux also assigned.

The route table contains the directly connected subnet:

sudo ip -n pod-a route
10.10.0.0/24 dev eth0 proto kernel scope link src 10.10.0.2

On the host:

ip -br addr show cni0
cni0             UP             10.10.0.1/24 ...

Before CNI ADD, cni0 did not exist.

The bridge also has a veth attached:

bridge link

On my machine:

12: veth6b96cdeb@cni0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 master cni0 state forwarding priority 32 cost 2

The interface name and index will differ on another machine.

The important part is:

master cni0

Our topology is now:

                  Linux host

                    cni0
                10.10.0.1
                     |
               veth6b96cdeb
                     |
                    eth0
                10.10.0.2
                   pod-a

What did CNI automate?

This is almost the same network we built manually in Part 1.

Part 1This time
Created the veth pairbridge plugin created it
Moved one end into the namespacebridge plugin moved it
Attached the host end to the bridgebridge plugin attached it
Assigned an IP manuallyhost-local allocated one
Configured the namespace interfaceplugin configured eth0

The abstraction changed. The Linux objects did not.

The resulting network still consists of familiar pieces:

namespace
   |
  eth0
   |
  veth
   |
  cni0

CNI automated their creation and configuration.

Step 5: Where did 10.10.0.2 come from?

We gave host-local this range:

10.10.0.0/24

In a clean lab, the first namespace received:

10.10.0.2

The allocator needs to remember that address before another namespace is added.

By default, host-local stores allocation state under:

/var/lib/cni/networks/<network-name>

For tiny-net:

sudo ls /var/lib/cni/networks/tiny-net
10.10.0.2
last_reserved_ip.0
lock

The file named after the IP represents the active allocation.

sudo cat /var/lib/cni/networks/tiny-net/10.10.0.2

On my lab:

cnitool-af0507dddb173175b8b2
eth0

The allocator also keeps bookkeeping about where allocation last stopped:

sudo cat /var/lib/cni/networks/tiny-net/last_reserved_ip.0
10.10.0.2

So IPAM is not simply calculating an address. It keeps local allocation state.

Step 6: Add pod-b

Create another namespace:

sudo ip netns add pod-b

Before CNI touches it:

sudo ip -n pod-b -br addr

There is no eth0.

Apply the same network configuration:

sudo env CNI_PATH=/opt/cni/bin \
  cnitool add tiny-net /var/run/netns/pod-b

The second allocation in my clean lab was:

10.10.0.3/24
gateway 10.10.0.1

Inside pod-b:

sudo ip -n pod-b -br addr
eth0@if13        UP             10.10.0.3/24 ...

IPAM now has two active allocations:

sudo ls /var/lib/cni/networks/tiny-net
10.10.0.2
10.10.0.3
last_reserved_ip.0
lock

The topology is now:

                    Linux host

                       cni0
                   10.10.0.1
                    /       \
                   /         \
              vethXXXX     vethYYYY
                 |             |
                eth0          eth0
             10.10.0.2     10.10.0.3
               pod-a         pod-b

This is essentially the network from Part 1, but the CNI plugins configured it for us.

Step 7: Test communication

From pod-a:

sudo ip netns exec pod-a ping -c 3 10.10.0.3

My output:

64 bytes from 10.10.0.3: icmp_seq=1 ttl=64 time=0.063 ms
64 bytes from 10.10.0.3: icmp_seq=2 ttl=64 time=0.149 ms
64 bytes from 10.10.0.3: icmp_seq=3 ttl=64 time=0.056 ms

The TTL is still 64.

The namespaces are in the same subnet and communicate through the bridge, so there is no router between them.

The neighbour table confirms that pod-a reaches pod-b directly:

sudo ip -n pod-a neigh
10.10.0.3 dev eth0 lladdr 76:03:7d:ef:02:52 REACHABLE

We can also inspect the bridge forwarding database:

bridge fdb show br cni0

The packet path is the same one we examined in Part 1. What changed is how that network was configured.

Step 8: Remove pod-b from the network

CNI also has to deal with network cleanup when a workload disappears.

Before removing pod-b:

sudo ip -n pod-b -br addr
eth0@if13        UP             10.10.0.3/24 ...

Run:

sudo env CNI_PATH=/opt/cni/bin \
  cnitool del tiny-net /var/run/netns/pod-b

Then inspect the namespace again:

sudo ip -n pod-b -br link

eth0 is gone.

The namespace itself is still present:

ip netns list

That is because we created the namespace ourselves. CNI only managed its network attachment.

namespace lifecycle
        !=
network attachment lifecycle

IPAM also released the address:

sudo ls /var/lib/cni/networks/tiny-net
10.10.0.2
last_reserved_ip.0
lock

The 10.10.0.3 allocation file is gone.

last_reserved_ip.0 still contains:

10.10.0.3

That does not mean the address is still allocated. The active allocation file has been removed; last_reserved_ip.0 is allocator bookkeeping.

The host-side veth belonging to pod-b is also gone, while pod-a remains connected to the shared bridge.

Where Kubernetes fits

So far, we have called CNI ourselves.

On a Kubernetes node, kubelet talks to the container runtime through the Container Runtime Interface, or CRI.

As part of setting up a Pod sandbox, the runtime is responsible for configuring its networking, commonly through CNI plugins.

A simplified view is:

kubelet
   |
   | CRI
   v
container runtime
   |
   | CNI
   v
network plugins
   |
   v
Linux networking

Our lab removed the upper layers:

Real Kubernetes node:

kubelet
   |
runtime
   |
  CNI
   |
plugins
   |
Linux


Our lab:

cnitool
   |
  CNI
   |
plugins
   |
Linux

That is the connection between this lab and the Linux networking from the first two articles.

The bridge, veth pairs, addresses, routes, neighbour entries, and packet forwarding are still Linux networking.

CNI gives the runtime a standard way to delegate network setup to plugins.

Cleaning up

Remove pod-a from the network:

sudo env CNI_PATH=/opt/cni/bin \
  cnitool del tiny-net /var/run/netns/pod-a

If pod-b is still attached for any reason:

sudo env CNI_PATH=/opt/cni/bin \
  cnitool del tiny-net /var/run/netns/pod-b

After both attachments were removed in my lab, cni0 was still present:

ip -br addr show cni0
cni0             DOWN           10.10.0.1/24 ...

IPAM also kept its bookkeeping files:

sudo ls /var/lib/cni/networks/tiny-net
last_reserved_ip.0
lock

The individual allocation files were gone.

Delete the namespaces:

sudo ip netns delete pod-a
sudo ip netns delete pod-b

Remove the CNI configuration:

sudo rm /etc/cni/net.d/10-tiny-net.conf

Remove the bridge:

sudo ip link delete cni0 2>/dev/null || true

Clear the remaining allocator state:

sudo rm -rf /var/lib/cni/networks/tiny-net

Verify:

ip link show cni0
Device "cni0" does not exist.

Final thoughts

In the first article, we built a Pod-like network ourselves.

In the second, we looked at what happens when traffic needs to move beyond that local network.

Here, we let CNI plugins create the local networking that we previously configured by hand.

The resulting Linux objects were the same:

veth
bridge
IP address
routes

The layers now fit together like this:

Kubernetes
    |
   CRI
    |
runtime
    |
   CNI
    |
plugins
    |
Linux

A Pod can now receive an interface, an IP address, and a network path without us manually configuring those pieces.

But a Pod IP is tied to that Pod. Pods can be replaced or rescheduled, and their replacements can receive different addresses.

If an application has several replicas:

10.10.0.12
10.10.0.18
10.10.0.27

another application should not have to know which individual Pod IP to use or track how those addresses change.

Kubernetes Services give us a stable way to reach a changing set of Pods.

That is where we will continue next. Stay tuned!

References

  1. CNI Specification
  2. CNI bridge plugin
  3. CNI host-local IPAM plugin
  4. cnitool
  5. Kubernetes Container Runtime Interface