<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>yet another engineering blog</title><description>a journey blending technical expertise with life&apos;s everyday wonders.</description><link>https://levent.dev/</link><language>en-us</language><lastBuildDate>Sat, 15 Aug 2026 00:00:00 GMT</lastBuildDate><managingEditor>Levent Anil Ozen</managingEditor><item><title>Who Builds the Pod Network? From Linux Commands to CNI</title><link>https://levent.dev/blog/who-builds-the-pod-network-from-linux-commands-to-cni/</link><guid isPermaLink="true">https://levent.dev/blog/who-builds-the-pod-network-from-linux-commands-to-cni/</guid><description>Part 3 of Kubernetes Networking Under the Hood: use CNI with cnitool, the bridge plugin, and host-local IPAM to create the same Pod-like Linux network we built by hand.</description><pubDate>Sat, 15 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;article&gt;&lt;p&gt;In the first two parts, we kept Kubernetes out of the picture.&lt;/p&gt;&lt;p&gt;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.&lt;/p&gt;&lt;p&gt;So far, every piece of the network has been configured by hand.&lt;/p&gt;&lt;p&gt;For each new Pod-like namespace, we had to:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;create namespace
      |
create veth pair
      |
move one end into namespace
      |
attach the other end to bridge
      |
assign an IP address
      |
configure routes
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Kubernetes needs the same kind of networking setup whenever a Pod is created on a node, but that work is automated.&lt;/p&gt;&lt;p&gt;This is where CNI fits.&lt;/p&gt;&lt;p&gt;We briefly introduced CNI in the first article. Here, we will use it directly.&lt;/p&gt;&lt;p&gt;We will create an empty network namespace, connect it to a network through CNI, and inspect the Linux objects that appear.&lt;/p&gt;&lt;h2&gt;What is CNI?&lt;/h2&gt;&lt;p&gt;CNI stands for &lt;strong&gt;Container Network Interface&lt;/strong&gt;.&lt;/p&gt;&lt;p&gt;It is a specification for how a runtime asks networking plugins to configure a network namespace.&lt;/p&gt;&lt;p&gt;The runtime provides information such as:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;which network namespace to configure&lt;/li&gt;&lt;li&gt;what the interface inside it should be called&lt;/li&gt;&lt;li&gt;which operation should be performed&lt;/li&gt;&lt;li&gt;the network configuration itself&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;A plugin then performs the networking work.&lt;/p&gt;&lt;p&gt;A simple mental model is:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;runtime
   |
   | CNI
   v
plugin
   |
   v
Linux networking
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;CNI is the contract. The plugin is the implementation.&lt;/strong&gt;&lt;/p&gt;&lt;p&gt;For this lab, we will use two plugins from the official CNI plugins project.&lt;/p&gt;&lt;p&gt;The first is the &lt;code&gt;bridge&lt;/code&gt; plugin.&lt;/p&gt;&lt;p&gt;It creates a veth pair, places one end inside the target network namespace, and connects the host end to a Linux bridge.&lt;/p&gt;&lt;p&gt;That is very close to the network we built manually in Part 1.&lt;/p&gt;&lt;p&gt;The second is &lt;code&gt;host-local&lt;/code&gt;, an IP Address Management plugin, usually shortened to IPAM.&lt;/p&gt;&lt;p&gt;It chooses an available IP address from a configured range and keeps track of existing allocations.&lt;/p&gt;&lt;p&gt;Our setup will look roughly like this:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;             CNI configuration
                    |
          +---------+---------+
          |                   |
          v                   v
   bridge plugin       host-local IPAM
          |                   |
          | create veth       | allocate IP
          | attach bridge     |
          +---------+---------+
                    |
                    v
             network namespace
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Using the bridge plugin gives us a useful comparison with Part 1 because we already know what the resulting Linux network should look like.&lt;/p&gt;&lt;h2&gt;Calling CNI with cnitool&lt;/h2&gt;&lt;p&gt;When a runtime asks a CNI plugin to add a container to a network, the plugin receives information similar to:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;CNI_COMMAND=ADD
CNI_CONTAINERID=...
CNI_NETNS=/run/netns/pod-a
CNI_IFNAME=eth0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;It also receives the network configuration as JSON.&lt;/p&gt;&lt;p&gt;For this lab, the two operations we care about are:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ADD
 |
 | attach networking
 v

DEL
 |
 | remove networking
 v
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;CNI defines other operations as well, but &lt;code&gt;ADD&lt;/code&gt; and &lt;code&gt;DEL&lt;/code&gt; are enough for the lifecycle we want to look at here.&lt;/p&gt;&lt;p&gt;We could invoke the plugin binaries directly and provide those inputs ourselves, but &lt;code&gt;cnitool&lt;/code&gt; gives us a simpler way to do it. It applies a CNI configuration to an existing network namespace.&lt;/p&gt;&lt;p&gt;A container runtime would normally sit above CNI:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;container runtime
       |
       | CNI ADD
       v
    plugins
       |
       v
Linux networking
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;For this lab, &lt;code&gt;cnitool&lt;/code&gt; takes the runtime&apos;s place:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;    cnitool
       |
       | CNI ADD
       v
    plugins
       |
       v
Linux networking
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;That lets us inspect the CNI layer without needing a Kubernetes cluster or container runtime.&lt;/p&gt;&lt;h2&gt;Before starting&lt;/h2&gt;&lt;p&gt;I recommend using a disposable Linux VM.&lt;/p&gt;&lt;p&gt;I ran the lab inside a privileged Debian Bookworm container on Docker Desktop.&lt;/p&gt;&lt;p&gt;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 &lt;code&gt;lo&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;If you already have &lt;code&gt;cnitool&lt;/code&gt; and the official CNI plugins installed, you can skip most of this section.&lt;/p&gt;&lt;p&gt;On Ubuntu or Debian:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt update
sudo apt install -y iproute2 iputils-ping git golang-go
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Install &lt;code&gt;cnitool&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;go install github.com/containernetworking/cni/cnitool@latest
sudo install &amp;quot;$(go env GOPATH)/bin/cnitool&amp;quot; /usr/local/bin/cnitool
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Check that it is available:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cnitool --help
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Build the official CNI plugins:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone https://github.com/containernetworking/plugins.git
cd plugins
./build_linux.sh
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;For this lab, we only need:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;bridge
host-local
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Copy them into a conventional CNI plugin directory:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo mkdir -p /opt/cni/bin
sudo cp bin/bridge bin/host-local /opt/cni/bin/
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Create the configuration directory:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo mkdir -p /etc/cni/net.d
&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;Step 1: Create an empty network namespace&lt;/h2&gt;&lt;p&gt;Create the first namespace:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns add pod-a
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Inspect it:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip -n pod-a -br link
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;On my lab:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;lo               DOWN           00:00:00:00:00:00 &amp;lt;LOOPBACK&amp;gt;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;There is no &lt;code&gt;eth0&lt;/code&gt;, Pod IP, host-side veth, or bridge yet.&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Linux host

    pod-a
      |
      X
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;In Part 1, we started building those pieces manually from here.&lt;/p&gt;&lt;p&gt;For this lab, the namespace is all we create ourselves.&lt;/p&gt;&lt;h2&gt;Step 2: Describe the network&lt;/h2&gt;&lt;p&gt;Create &lt;code&gt;/etc/cni/net.d/10-tiny-net.conf&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo tee /etc/cni/net.d/10-tiny-net.conf &amp;gt; /dev/null &amp;lt;&amp;lt;&apos;EOF&apos;
{
  &amp;quot;cniVersion&amp;quot;: &amp;quot;1.0.0&amp;quot;,
  &amp;quot;name&amp;quot;: &amp;quot;tiny-net&amp;quot;,
  &amp;quot;type&amp;quot;: &amp;quot;bridge&amp;quot;,
  &amp;quot;bridge&amp;quot;: &amp;quot;cni0&amp;quot;,
  &amp;quot;isGateway&amp;quot;: true,
  &amp;quot;ipam&amp;quot;: {
    &amp;quot;type&amp;quot;: &amp;quot;host-local&amp;quot;,
    &amp;quot;ranges&amp;quot;: [
      [
        {
          &amp;quot;subnet&amp;quot;: &amp;quot;10.10.0.0/24&amp;quot;
        }
      ]
    ]
  }
}
EOF
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The important parts are:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;&amp;quot;type&amp;quot;: &amp;quot;bridge&amp;quot;
        |
        +--&amp;gt; use the bridge plugin

&amp;quot;bridge&amp;quot;: &amp;quot;cni0&amp;quot;
        |
        +--&amp;gt; connect the namespace to this bridge

&amp;quot;isGateway&amp;quot;: true
        |
        +--&amp;gt; let the bridge act as the subnet gateway

&amp;quot;ipam&amp;quot;: {
  &amp;quot;type&amp;quot;: &amp;quot;host-local&amp;quot;
}
        |
        +--&amp;gt; allocate IP addresses locally

&amp;quot;subnet&amp;quot;: &amp;quot;10.10.0.0/24&amp;quot;
        |
        +--&amp;gt; allocate them from this network
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;At this point, we have only created a configuration file.&lt;/p&gt;&lt;p&gt;The bridge does not exist yet:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ip link show cni0
&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Device &amp;quot;cni0&amp;quot; does not exist.
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And &lt;code&gt;pod-a&lt;/code&gt; still has no &lt;code&gt;eth0&lt;/code&gt;.&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip -n pod-a -br addr
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The configuration describes the network. The plugins have not applied it yet.&lt;/p&gt;&lt;h2&gt;Step 3: Run CNI ADD&lt;/h2&gt;&lt;p&gt;Run:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo env CNI_PATH=/opt/cni/bin \
  cnitool add tiny-net /var/run/netns/pod-a
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;In my lab, &lt;code&gt;cnitool&lt;/code&gt; returned:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;cniVersion&amp;quot;: &amp;quot;1.0.0&amp;quot;,
  &amp;quot;interfaces&amp;quot;: [
    {
      &amp;quot;name&amp;quot;: &amp;quot;cni0&amp;quot;
    },
    {
      &amp;quot;name&amp;quot;: &amp;quot;veth6b96cdeb&amp;quot;
    },
    {
      &amp;quot;name&amp;quot;: &amp;quot;eth0&amp;quot;,
      &amp;quot;sandbox&amp;quot;: &amp;quot;/var/run/netns/pod-a&amp;quot;
    }
  ],
  &amp;quot;ips&amp;quot;: [
    {
      &amp;quot;address&amp;quot;: &amp;quot;10.10.0.2/24&amp;quot;,
      &amp;quot;gateway&amp;quot;: &amp;quot;10.10.0.1&amp;quot;,
      &amp;quot;interface&amp;quot;: 2
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;I removed the MAC addresses because they are not relevant here.&lt;/p&gt;&lt;p&gt;The result already tells us what was configured:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;cni0
host-side veth
pod-a / eth0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;and the allocated address:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10.10.0.2/24
gateway 10.10.0.1
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;We did not create any of those interfaces manually.&lt;/p&gt;&lt;p&gt;Let&apos;s inspect them.&lt;/p&gt;&lt;h2&gt;Step 4: Inspect the namespace and host&lt;/h2&gt;&lt;p&gt;Inside &lt;code&gt;pod-a&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip -n pod-a -br addr
&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;lo               DOWN
eth0@if12        UP             10.10.0.2/24 ...
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;lo&lt;/code&gt; is still down because the &lt;code&gt;bridge&lt;/code&gt; plugin only configured the interface we asked it to create, &lt;code&gt;eth0&lt;/code&gt;. It does not configure loopback. The official CNI plugins project has a separate &lt;code&gt;loopback&lt;/code&gt; plugin for that job. We do not need it for this lab.&lt;/p&gt;&lt;p&gt;The &lt;code&gt;...&lt;/code&gt; hides the IPv6 link-local address that Linux also assigned.&lt;/p&gt;&lt;p&gt;The route table contains the directly connected subnet:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip -n pod-a route
&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10.10.0.0/24 dev eth0 proto kernel scope link src 10.10.0.2
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;On the host:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ip -br addr show cni0
&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;cni0             UP             10.10.0.1/24 ...
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Before &lt;code&gt;CNI ADD&lt;/code&gt;, &lt;code&gt;cni0&lt;/code&gt; did not exist.&lt;/p&gt;&lt;p&gt;The bridge also has a veth attached:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;bridge link
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;On my machine:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;12: veth6b96cdeb@cni0: &amp;lt;BROADCAST,MULTICAST,UP,LOWER_UP&amp;gt; mtu 1500 master cni0 state forwarding priority 32 cost 2
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The interface name and index will differ on another machine.&lt;/p&gt;&lt;p&gt;The important part is:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;master cni0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Our topology is now:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                  Linux host

                    cni0
                10.10.0.1
                     |
               veth6b96cdeb
                     |
                    eth0
                10.10.0.2
                   pod-a
&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;What did CNI automate?&lt;/h2&gt;&lt;p&gt;This is almost the same network we built manually in Part 1.&lt;/p&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Part 1&lt;/th&gt;&lt;th&gt;This time&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Created the veth pair&lt;/td&gt;&lt;td&gt;&lt;code&gt;bridge&lt;/code&gt; plugin created it&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Moved one end into the namespace&lt;/td&gt;&lt;td&gt;&lt;code&gt;bridge&lt;/code&gt; plugin moved it&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Attached the host end to the bridge&lt;/td&gt;&lt;td&gt;&lt;code&gt;bridge&lt;/code&gt; plugin attached it&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Assigned an IP manually&lt;/td&gt;&lt;td&gt;&lt;code&gt;host-local&lt;/code&gt; allocated one&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Configured the namespace interface&lt;/td&gt;&lt;td&gt;plugin configured &lt;code&gt;eth0&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;p&gt;&lt;strong&gt;The abstraction changed. The Linux objects did not.&lt;/strong&gt;&lt;/p&gt;&lt;p&gt;The resulting network still consists of familiar pieces:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;namespace
   |
  eth0
   |
  veth
   |
  cni0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;CNI automated their creation and configuration.&lt;/p&gt;&lt;h2&gt;Step 5: Where did 10.10.0.2 come from?&lt;/h2&gt;&lt;p&gt;We gave &lt;code&gt;host-local&lt;/code&gt; this range:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10.10.0.0/24
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;In a clean lab, the first namespace received:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10.10.0.2
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The allocator needs to remember that address before another namespace is added.&lt;/p&gt;&lt;p&gt;By default, &lt;code&gt;host-local&lt;/code&gt; stores allocation state under:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;/var/lib/cni/networks/&amp;lt;network-name&amp;gt;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;For &lt;code&gt;tiny-net&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ls /var/lib/cni/networks/tiny-net
&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10.10.0.2
last_reserved_ip.0
lock
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The file named after the IP represents the active allocation.&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo cat /var/lib/cni/networks/tiny-net/10.10.0.2
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;On my lab:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;cnitool-af0507dddb173175b8b2
eth0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The allocator also keeps bookkeeping about where allocation last stopped:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo cat /var/lib/cni/networks/tiny-net/last_reserved_ip.0
&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10.10.0.2
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;So IPAM is not simply calculating an address. It keeps local allocation state.&lt;/p&gt;&lt;h2&gt;Step 6: Add pod-b&lt;/h2&gt;&lt;p&gt;Create another namespace:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns add pod-b
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Before CNI touches it:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip -n pod-b -br addr
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;There is no &lt;code&gt;eth0&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;Apply the same network configuration:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo env CNI_PATH=/opt/cni/bin \
  cnitool add tiny-net /var/run/netns/pod-b
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The second allocation in my clean lab was:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10.10.0.3/24
gateway 10.10.0.1
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Inside &lt;code&gt;pod-b&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip -n pod-b -br addr
&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;eth0@if13        UP             10.10.0.3/24 ...
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;IPAM now has two active allocations:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ls /var/lib/cni/networks/tiny-net
&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10.10.0.2
10.10.0.3
last_reserved_ip.0
lock
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The topology is now:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                    Linux host

                       cni0
                   10.10.0.1
                    /       \
                   /         \
              vethXXXX     vethYYYY
                 |             |
                eth0          eth0
             10.10.0.2     10.10.0.3
               pod-a         pod-b
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This is essentially the network from Part 1, but the CNI plugins configured it for us.&lt;/p&gt;&lt;h2&gt;Step 7: Test communication&lt;/h2&gt;&lt;p&gt;From &lt;code&gt;pod-a&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns exec pod-a ping -c 3 10.10.0.3
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;My output:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;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
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The TTL is still &lt;code&gt;64&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;The namespaces are in the same subnet and communicate through the bridge, so there is no router between them.&lt;/p&gt;&lt;p&gt;The neighbour table confirms that &lt;code&gt;pod-a&lt;/code&gt; reaches &lt;code&gt;pod-b&lt;/code&gt; directly:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip -n pod-a neigh
&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10.10.0.3 dev eth0 lladdr 76:03:7d:ef:02:52 REACHABLE
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;We can also inspect the bridge forwarding database:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;bridge fdb show br cni0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The packet path is the same one we examined in Part 1. What changed is how that network was configured.&lt;/p&gt;&lt;h2&gt;Step 8: Remove pod-b from the network&lt;/h2&gt;&lt;p&gt;CNI also has to deal with network cleanup when a workload disappears.&lt;/p&gt;&lt;p&gt;Before removing &lt;code&gt;pod-b&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip -n pod-b -br addr
&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;eth0@if13        UP             10.10.0.3/24 ...
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Run:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo env CNI_PATH=/opt/cni/bin \
  cnitool del tiny-net /var/run/netns/pod-b
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Then inspect the namespace again:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip -n pod-b -br link
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;eth0&lt;/code&gt; is gone.&lt;/p&gt;&lt;p&gt;The namespace itself is still present:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ip netns list
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;That is because we created the namespace ourselves. CNI only managed its network attachment.&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;namespace lifecycle
        !=
network attachment lifecycle
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;IPAM also released the address:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ls /var/lib/cni/networks/tiny-net
&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10.10.0.2
last_reserved_ip.0
lock
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The &lt;code&gt;10.10.0.3&lt;/code&gt; allocation file is gone.&lt;/p&gt;&lt;p&gt;&lt;code&gt;last_reserved_ip.0&lt;/code&gt; still contains:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10.10.0.3
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;That does not mean the address is still allocated. The active allocation file has been removed; &lt;code&gt;last_reserved_ip.0&lt;/code&gt; is allocator bookkeeping.&lt;/p&gt;&lt;p&gt;The host-side veth belonging to &lt;code&gt;pod-b&lt;/code&gt; is also gone, while &lt;code&gt;pod-a&lt;/code&gt; remains connected to the shared bridge.&lt;/p&gt;&lt;h2&gt;Where Kubernetes fits&lt;/h2&gt;&lt;p&gt;So far, we have called CNI ourselves.&lt;/p&gt;&lt;p&gt;On a Kubernetes node, kubelet talks to the container runtime through the &lt;strong&gt;Container Runtime Interface&lt;/strong&gt;, or CRI.&lt;/p&gt;&lt;p&gt;As part of setting up a Pod sandbox, the runtime is responsible for configuring its networking, commonly through CNI plugins.&lt;/p&gt;&lt;p&gt;A simplified view is:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;kubelet
   |
   | CRI
   v
container runtime
   |
   | CNI
   v
network plugins
   |
   v
Linux networking
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Our lab removed the upper layers:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Real Kubernetes node:

kubelet
   |
runtime
   |
  CNI
   |
plugins
   |
Linux


Our lab:

cnitool
   |
  CNI
   |
plugins
   |
Linux
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;That is the connection between this lab and the Linux networking from the first two articles.&lt;/p&gt;&lt;p&gt;The bridge, veth pairs, addresses, routes, neighbour entries, and packet forwarding are still Linux networking.&lt;/p&gt;&lt;p&gt;CNI gives the runtime a standard way to delegate network setup to plugins.&lt;/p&gt;&lt;h2&gt;Cleaning up&lt;/h2&gt;&lt;p&gt;Remove &lt;code&gt;pod-a&lt;/code&gt; from the network:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo env CNI_PATH=/opt/cni/bin \
  cnitool del tiny-net /var/run/netns/pod-a
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;If &lt;code&gt;pod-b&lt;/code&gt; is still attached for any reason:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo env CNI_PATH=/opt/cni/bin \
  cnitool del tiny-net /var/run/netns/pod-b
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;After both attachments were removed in my lab, &lt;code&gt;cni0&lt;/code&gt; was still present:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ip -br addr show cni0
&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;cni0             DOWN           10.10.0.1/24 ...
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;IPAM also kept its bookkeeping files:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ls /var/lib/cni/networks/tiny-net
&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;last_reserved_ip.0
lock
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The individual allocation files were gone.&lt;/p&gt;&lt;p&gt;Delete the namespaces:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns delete pod-a
sudo ip netns delete pod-b
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Remove the CNI configuration:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo rm /etc/cni/net.d/10-tiny-net.conf
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Remove the bridge:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip link delete cni0 2&amp;gt;/dev/null || true
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Clear the remaining allocator state:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo rm -rf /var/lib/cni/networks/tiny-net
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Verify:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ip link show cni0
&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Device &amp;quot;cni0&amp;quot; does not exist.
&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;Final thoughts&lt;/h2&gt;&lt;p&gt;In the first article, we built a Pod-like network ourselves.&lt;/p&gt;&lt;p&gt;In the second, we looked at what happens when traffic needs to move beyond that local network.&lt;/p&gt;&lt;p&gt;Here, we let CNI plugins create the local networking that we previously configured by hand.&lt;/p&gt;&lt;p&gt;The resulting Linux objects were the same:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;veth
bridge
IP address
routes
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The layers now fit together like this:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Kubernetes
    |
   CRI
    |
runtime
    |
   CNI
    |
plugins
    |
Linux
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;A Pod can now receive an interface, an IP address, and a network path without us manually configuring those pieces.&lt;/p&gt;&lt;p&gt;But a Pod IP is tied to that Pod. Pods can be replaced or rescheduled, and their replacements can receive different addresses.&lt;/p&gt;&lt;p&gt;If an application has several replicas:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10.10.0.12
10.10.0.18
10.10.0.27
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;another application should not have to know which individual Pod IP to use or track how those addresses change.&lt;/p&gt;&lt;p&gt;Kubernetes Services give us a stable way to reach a changing set of Pods.&lt;/p&gt;&lt;p&gt;That is where we will continue next. Stay tuned!&lt;/p&gt;&lt;/article&gt;</content:encoded><author>Levent Anil Ozen</author></item><item><title>Beyond the Bridge: Routing Our Tiny Pod Network</title><link>https://levent.dev/blog/beyond-the-bridge-routing-our-tiny-pod-network/</link><guid isPermaLink="true">https://levent.dev/blog/beyond-the-bridge-routing-our-tiny-pod-network/</guid><description>Part 2 of Kubernetes Networking Under the Hood: route traffic between separate Pod-like subnets, enable Linux IP forwarding, and add NAT for internet access.</description><pubDate>Sun, 09 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;article&gt;&lt;p&gt;In the first part, we built a tiny Pod-like network using Linux network namespaces, veth pairs, and a bridge.&lt;/p&gt;&lt;p&gt;If you missed it, you can find it here: &lt;a href=&quot;https://levent.dev/blog/build-a-tiny-pod-network-with-linux/&quot;&gt;Build a Tiny Pod Network with Linux&lt;/a&gt;.&lt;/p&gt;&lt;p&gt;We ended up with two isolated network namespaces connected to the same Linux bridge:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;pod-a
  |
veth pair
  |
Linux bridge
  |
veth pair
  |
pod-b
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Both network namespaces belonged to the same subnet.&lt;/p&gt;&lt;p&gt;That made the setup relatively simple. When &lt;code&gt;pod-a&lt;/code&gt; wanted to reach &lt;code&gt;pod-b&lt;/code&gt;, it could use ARP to find the destination MAC address, and the bridge could forward the Ethernet frame directly.&lt;/p&gt;&lt;p&gt;But real networks rarely remain one large Ethernet segment forever.&lt;/p&gt;&lt;p&gt;Imagine a company where employee devices belong to one network and internal servers belong to another. Or an application network that is kept separate from the database network.&lt;/p&gt;&lt;p&gt;Machines inside each network can communicate through their own switches. Things get more interesting when a machine in one network needs to reach a machine in the other.&lt;/p&gt;&lt;p&gt;We will place &lt;code&gt;pod-a&lt;/code&gt; and &lt;code&gt;pod-b&lt;/code&gt; in two separate subnets. Once we do that, they will no longer share the same local Ethernet network, and a bridge will not be enough to carry traffic directly between them.&lt;/p&gt;&lt;p&gt;The packet will need a router: something connected to both networks that can inspect the destination IP address and decide where to send it next.&lt;/p&gt;&lt;p&gt;We will build that path ourselves, introducing gateways, routing tables, and IP forwarding as each one becomes necessary. Then we will extend the same network toward the internet, discover why an outbound route is not always enough, and introduce NAT and masquerading.&lt;/p&gt;&lt;p&gt;Finally, we will bring the model back to Kubernetes and use it to reason about communication between Pods on different nodes.&lt;/p&gt;&lt;p&gt;The setup will still be small enough to build by hand. We are just moving one layer beyond the bridge.&lt;/p&gt;&lt;h2&gt;What are we building?&lt;/h2&gt;&lt;p&gt;Our initial topology will look like this:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                     Linux host

              br-a              br-b
          10.10.0.1          10.20.0.1
               |                  |
        veth-a-host        veth-b-host
               |                  |
              eth0               eth0
          10.10.0.2          10.20.0.2
             pod-a              pod-b
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;pod-a&lt;/code&gt; will belong to &lt;code&gt;10.10.0.0/24&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;&lt;code&gt;pod-b&lt;/code&gt; will belong to &lt;code&gt;10.20.0.0/24&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;The Linux host will be connected to both networks through &lt;code&gt;br-a&lt;/code&gt; and &lt;code&gt;br-b&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;At first, the two network namespaces will not be able to reach each other. We will add each missing piece one at a time until traffic begins to flow.&lt;/p&gt;&lt;p&gt;After that, we will extend the path toward the outside world:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;pod-a
  |
br-a
  |
Linux routing
  |
NAT / masquerading
  |
host external interface
  |
internet
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;We will use an IP address rather than a domain name for the internet test. DNS is a separate problem and deserves its own explanation.&lt;/p&gt;&lt;h2&gt;Before starting&lt;/h2&gt;&lt;p&gt;You need a Linux environment with root access.&lt;/p&gt;&lt;p&gt;The commands in this article change network interfaces, routes, kernel settings, and firewall rules. A disposable virtual machine is the safest place to follow along.&lt;/p&gt;&lt;p&gt;Before starting, check that &lt;code&gt;10.10.0.0/24&lt;/code&gt; and &lt;code&gt;10.20.0.0/24&lt;/code&gt; do not already overlap with a local, VPN, or container network on your machine. If they do, use two different private subnets throughout the article.&lt;/p&gt;&lt;p&gt;On Ubuntu or Debian, install the tools we will use:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt update
sudo apt install iproute2 iputils-ping tcpdump iptables
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Before changing anything, check whether IPv4 forwarding is currently enabled:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cat /proc/sys/net/ipv4/ip_forward
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;You will see either:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;or:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Linux exposes this kernel setting as a file under &lt;code&gt;/proc/sys&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;A value of &lt;code&gt;0&lt;/code&gt; means the host will not forward IPv4 packets between its interfaces. A value of &lt;code&gt;1&lt;/code&gt; means forwarding is enabled.&lt;/p&gt;&lt;p&gt;Make a note of the current value. We will restore it during cleanup.&lt;/p&gt;&lt;p&gt;This lab begins with forwarding disabled so that we can observe what fails before Linux starts acting as a router. If the current value is &lt;code&gt;1&lt;/code&gt;, temporarily disable it:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo sysctl -w net.ipv4.ip_forward=0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Expected output:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;net.ipv4.ip_forward = 0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;We will enable it again only after the packet reaches the host and gets stuck there.&lt;/p&gt;&lt;h2&gt;Step 1: Build two separate networks&lt;/h2&gt;&lt;p&gt;Start by creating two network namespaces:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns add pod-a
sudo ip netns add pod-b
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Now create one bridge for each network:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip link add br-a type bridge
sudo ip link add br-b type bridge
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Assign an IP address to each bridge:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip addr add 10.10.0.1/24 dev br-a
sudo ip addr add 10.20.0.1/24 dev br-b
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Bring them up:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip link set br-a up
sudo ip link set br-b up
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The host is now connected to two separate IP networks:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                     Linux host

              br-a              br-b
          10.10.0.1          10.20.0.1
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The bridges still have no network namespaces connected to them.&lt;/p&gt;&lt;h3&gt;Connect pod-a&lt;/h3&gt;&lt;p&gt;Create a veth pair:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip link add veth-a-host type veth peer name veth-a
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Move one end into &lt;code&gt;pod-a&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip link set veth-a netns pod-a
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Attach the host-side interface to &lt;code&gt;br-a&lt;/code&gt; and bring it up:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip link set veth-a-host master br-a
sudo ip link set veth-a-host up
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Configure the other side inside the namespace:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip -n pod-a link set veth-a name eth0
sudo ip -n pod-a addr add 10.10.0.2/24 dev eth0
sudo ip -n pod-a link set eth0 up
sudo ip -n pod-a link set lo up
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Verify the result:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip -n pod-a -br addr
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;You should see something similar to:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;lo      UNKNOWN    127.0.0.1/8
eth0    UP         10.10.0.2/24
&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;Connect pod-b&lt;/h3&gt;&lt;p&gt;Repeat the process for &lt;code&gt;pod-b&lt;/code&gt;, this time using &lt;code&gt;br-b&lt;/code&gt; and the second subnet:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip link add veth-b-host type veth peer name veth-b

sudo ip link set veth-b netns pod-b

sudo ip link set veth-b-host master br-b
sudo ip link set veth-b-host up

sudo ip -n pod-b link set veth-b name eth0
sudo ip -n pod-b addr add 10.20.0.2/24 dev eth0
sudo ip -n pod-b link set eth0 up
sudo ip -n pod-b link set lo up
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Verify it:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip -n pod-b -br addr
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Expected output:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;lo      UNKNOWN    127.0.0.1/8
eth0    UP         10.20.0.2/24
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Our topology is now complete:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                     Linux host

              br-a              br-b
          10.10.0.1          10.20.0.1
               |                  |
        veth-a-host        veth-b-host
               |                  |
              eth0               eth0
          10.10.0.2          10.20.0.2
             pod-a              pod-b
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;We can also verify the host-side addresses:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ip -br addr show br-a
ip -br addr show br-b
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Expected output:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;br-a    UP    10.10.0.1/24
br-b    UP    10.20.0.1/24
&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;Step 2: Try to reach the other network&lt;/h2&gt;&lt;p&gt;Before adding anything else, let us try to ping &lt;code&gt;pod-b&lt;/code&gt; from &lt;code&gt;pod-a&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns exec pod-a ping -c 2 10.20.0.2
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The result should look similar to this:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ping: connect: Network is unreachable
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This failure is useful. &lt;code&gt;pod-a&lt;/code&gt; knows how to reach its own subnet, but nothing beyond it.&lt;/p&gt;&lt;p&gt;Inspect its routing table:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip -n pod-a route
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Expected output:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10.10.0.0/24 dev eth0 proto kernel scope link src 10.10.0.2
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This route says:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Destination inside 10.10.0.0/24?
Send it directly through eth0.
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;But &lt;code&gt;10.20.0.2&lt;/code&gt; belongs to a different subnet.&lt;/p&gt;&lt;p&gt;&lt;code&gt;pod-a&lt;/code&gt; checks its routing table, finds no matching route, and stops before sending the packet.&lt;/p&gt;&lt;p&gt;The bridge never even sees it.&lt;/p&gt;&lt;h2&gt;Step 3: Give unknown destinations somewhere to go&lt;/h2&gt;&lt;p&gt;&lt;code&gt;pod-a&lt;/code&gt; needs a next step for destinations outside &lt;code&gt;10.10.0.0/24&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;The Linux host owns the address &lt;code&gt;10.10.0.1&lt;/code&gt; on &lt;code&gt;br-a&lt;/code&gt;, and that address is directly reachable from &lt;code&gt;pod-a&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;We can tell &lt;code&gt;pod-a&lt;/code&gt;:&lt;/p&gt;&lt;blockquote&gt;&lt;p&gt;When none of your more specific routes match, send the packet to &lt;code&gt;10.10.0.1&lt;/code&gt;.&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;Add a default route:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip -n pod-a route add default via 10.10.0.1
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;pod-b&lt;/code&gt; needs a return path too:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip -n pod-b route add default via 10.20.0.1
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Inspect the routing table inside &lt;code&gt;pod-a&lt;/code&gt; again:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip -n pod-a route
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Expected output:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;default via 10.10.0.1 dev eth0
10.10.0.0/24 dev eth0 proto kernel scope link src 10.10.0.2
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And inside &lt;code&gt;pod-b&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip -n pod-b route
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Expected output:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;default via 10.20.0.1 dev eth0
10.20.0.0/24 dev eth0 proto kernel scope link src 10.20.0.2
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;A default route is the fallback:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Does a more specific route match?
    |
    +-- Yes: use that route
    |
    +-- No: send the packet to the default gateway
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;For &lt;code&gt;pod-a&lt;/code&gt;, that gateway is &lt;code&gt;10.10.0.1&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;For &lt;code&gt;pod-b&lt;/code&gt;, it is &lt;code&gt;10.20.0.1&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;Both addresses belong to our Linux host.&lt;/p&gt;&lt;p&gt;Try the ping again:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns exec pod-a ping -c 2 10.20.0.2
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This time, you should no longer get &lt;code&gt;Network is unreachable&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;In a typical setup, however, you still will not receive a reply:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;2 packets transmitted, 0 received, 100% packet loss
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;We no longer fail inside &lt;code&gt;pod-a&lt;/code&gt;. The packet reaches the Linux host, but it does not make it to the other network.&lt;/p&gt;&lt;h2&gt;Step 4: Find where the packet stops&lt;/h2&gt;&lt;p&gt;We can watch this with &lt;code&gt;tcpdump&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;In one terminal, listen on &lt;code&gt;br-a&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo tcpdump -n -i br-a icmp
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;In another terminal, run the ping again:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns exec pod-a ping 10.20.0.2
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;You should see echo requests on &lt;code&gt;br-a&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10.10.0.2 &amp;gt; 10.20.0.2: ICMP echo request
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Now listen on &lt;code&gt;br-b&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo tcpdump -n -i br-b icmp
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Run the ping again.&lt;/p&gt;&lt;p&gt;You will probably see nothing on &lt;code&gt;br-b&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;The packet arrived at the host through &lt;code&gt;br-a&lt;/code&gt;, but the host did not pass it to &lt;code&gt;br-b&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;Our packet currently gets this far:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;pod-a
  |
eth0
  |
veth-a-host
  |
br-a
  |
Linux host
  X
br-b
  |
pod-b
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The host is connected to both networks. It also has routes for both of them:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ip route show 10.10.0.0/24
ip route show 10.20.0.0/24
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Expected output:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10.10.0.0/24 dev br-a proto kernel scope link src 10.10.0.1
10.20.0.0/24 dev br-b proto kernel scope link src 10.20.0.1
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The host knows where both networks are, but having routes is not enough by itself.&lt;/p&gt;&lt;h2&gt;Step 5: Turn the Linux host into a router&lt;/h2&gt;&lt;p&gt;&lt;code&gt;pod-a&lt;/code&gt; and &lt;code&gt;pod-b&lt;/code&gt; belong to different networks.&lt;/p&gt;&lt;p&gt;The Linux host is the only machine connected to both, so it has to route packets between them.&lt;/p&gt;&lt;p&gt;In our lab, the Linux host is already connected to both networks:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10.10.0.0/24
      |
    br-a
      |
 Linux host
      |
    br-b
      |
10.20.0.0/24
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;It has the required interfaces.&lt;/p&gt;&lt;p&gt;It has routes for both subnets.&lt;/p&gt;&lt;p&gt;But the host is not forwarding packets between its interfaces yet.&lt;/p&gt;&lt;p&gt;Check the kernel setting again:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cat /proc/sys/net/ipv4/ip_forward
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Expected output:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Enable forwarding:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo sysctl -w net.ipv4.ip_forward=1
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Expected output:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;net.ipv4.ip_forward = 1
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This tells the kernel that it may forward IPv4 packets arriving through one interface and leaving through another.&lt;/p&gt;&lt;p&gt;We did not need this in the first article.&lt;/p&gt;&lt;p&gt;Back then, both network namespaces belonged to the same Layer 2 network. The bridge moved Ethernet frames between its ports.&lt;/p&gt;&lt;p&gt;Now the destination belongs to another IP network. The host must make a Layer 3 routing decision.&lt;/p&gt;&lt;p&gt;Try the ping again:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns exec pod-a ping -c 3 10.20.0.2
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Expected output:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;64 bytes from 10.20.0.2: icmp_seq=1 ttl=63 time=0.090 ms
64 bytes from 10.20.0.2: icmp_seq=2 ttl=63 time=0.071 ms
64 bytes from 10.20.0.2: icmp_seq=3 ttl=63 time=0.068 ms
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;On a typical Linux setup, the reply now arrives with a TTL of &lt;code&gt;63&lt;/code&gt; rather than &lt;code&gt;64&lt;/code&gt;. That gives us another clue that the packet crossed a router, because a router decrements the TTL before forwarding it.&lt;/p&gt;&lt;p&gt;The complete path now works:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;pod-a
  |
eth0
  |
veth-a-host
  |
br-a
  |
Linux routing
  |
br-b
  |
veth-b-host
  |
eth0
  |
pod-b
&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;If the ping still does not work&lt;/h3&gt;&lt;p&gt;Some Linux systems have firewall rules that block forwarded traffic.&lt;/p&gt;&lt;p&gt;Inspect the &lt;code&gt;FORWARD&lt;/code&gt; chain:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo iptables -L FORWARD -n -v
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;You may see a default policy of &lt;code&gt;DROP&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Chain FORWARD (policy DROP)
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;For this disposable learning environment, temporarily allow traffic between our two bridges:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo iptables -I FORWARD 1 -i br-a -o br-b -j ACCEPT
sudo iptables -I FORWARD 1 -i br-b -o br-a -j ACCEPT
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Then try the ping again.&lt;/p&gt;&lt;p&gt;These rules are deliberately limited to the two bridges in our lab. Do not broadly disable the firewall on a real system just to make the example work.&lt;/p&gt;&lt;h2&gt;Step 6: Follow the routed packet&lt;/h2&gt;&lt;p&gt;With the ping working, let us follow the packet.&lt;/p&gt;&lt;p&gt;The packet begins inside &lt;code&gt;pod-a&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Source IP:      10.10.0.2
Destination IP: 10.20.0.2
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;pod-a&lt;/code&gt; checks its routing table.&lt;/p&gt;&lt;p&gt;The destination does not match the local &lt;code&gt;10.10.0.0/24&lt;/code&gt; route, so the default route wins:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;default via 10.10.0.1 dev eth0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;pod-a&lt;/code&gt; does not use ARP to find the MAC address of &lt;code&gt;10.20.0.2&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;That address is not part of its local Ethernet network.&lt;/p&gt;&lt;p&gt;Instead, &lt;code&gt;pod-a&lt;/code&gt; uses ARP to find the MAC address of its gateway, &lt;code&gt;10.10.0.1&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;Inspect the neighbour table:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip -n pod-a neigh
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;You should see something similar to:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10.10.0.1 dev eth0 lladdr 2a:71:9c:40:18:44 REACHABLE
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The MAC address will be different on your machine.&lt;/p&gt;&lt;p&gt;Before the host routes it, the packet looks roughly like this:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Ethernet source:      pod-a eth0 MAC
Ethernet destination: br-a MAC

IP source:            10.10.0.2
IP destination:       10.20.0.2
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The Ethernet frame is addressed to the gateway.&lt;/p&gt;&lt;p&gt;The IP packet inside it is still addressed to &lt;code&gt;pod-b&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;When the host receives the frame, it removes the Ethernet header and checks the destination IP address.&lt;/p&gt;&lt;p&gt;Its routing table says:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10.20.0.0/24 dev br-b
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The host now needs to deliver the packet through &lt;code&gt;br-b&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;If it does not already know the destination MAC address, it sends an ARP request on the second network.&lt;/p&gt;&lt;p&gt;Inspect the host&apos;s neighbour table:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ip neigh show dev br-b
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Expected output:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10.20.0.2 lladdr 8e:b6:26:91:2f:70 REACHABLE
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The host creates a new Ethernet frame:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Ethernet source:      br-b MAC
Ethernet destination: pod-b eth0 MAC

IP source:            10.10.0.2
IP destination:       10.20.0.2
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The Layer 2 addresses changed because the packet entered a different Ethernet network.&lt;/p&gt;&lt;p&gt;The Layer 3 addresses stayed the same.&lt;/p&gt;&lt;p&gt;That is the key idea behind routing in this Ethernet lab:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Each routed hop carries the IP packet inside a new Layer 2 frame.

The source and destination IP addresses stay the same unless something such as NAT rewrites them.
&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;Step 7: Give pod-a internet access&lt;/h2&gt;&lt;p&gt;&lt;code&gt;pod-a&lt;/code&gt; can now reach another private network.&lt;/p&gt;&lt;p&gt;Can it reach the internet too?&lt;/p&gt;&lt;p&gt;It already has a default route:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;default via 10.10.0.1 dev eth0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The Linux host should also have its own default route, normally through its external interface.&lt;/p&gt;&lt;p&gt;Find it:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ip route show default
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Example output:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;default via 192.168.1.1 dev eth0 proto dhcp src 192.168.1.50
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;In this example:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;External interface: eth0
Host IP:             192.168.1.50
Upstream gateway:    192.168.1.1
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Your interface might be called &lt;code&gt;ens3&lt;/code&gt;, &lt;code&gt;enp0s3&lt;/code&gt;, &lt;code&gt;eth0&lt;/code&gt;, or something else.&lt;/p&gt;&lt;p&gt;Store its name so we can reuse it in the next commands:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;EXT_IF=$(ip route show default | awk &apos;/default/ {print $5; exit}&apos;)
echo &amp;quot;$EXT_IF&amp;quot;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Example output:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;eth0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This assumes the host has one usable default route.&lt;/p&gt;&lt;p&gt;If the command returns nothing or selects the wrong interface, inspect the output of &lt;code&gt;ip route show default&lt;/code&gt; and set it manually:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;EXT_IF=eth0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Use the actual external interface name from your system.&lt;/p&gt;&lt;p&gt;Now try to reach a public IP address from &lt;code&gt;pod-a&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns exec pod-a ping -c 3 1.1.1.1
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;In a typical environment, this will still fail:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;3 packets transmitted, 0 received, 100% packet loss
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The route setup looks complete: the namespace has a default route, IP forwarding is enabled, and the host knows how to send traffic toward the internet. The missing part is the return path.&lt;/p&gt;&lt;h2&gt;Step 8: Routing also needs a return path&lt;/h2&gt;&lt;p&gt;Follow the outbound packet.&lt;/p&gt;&lt;p&gt;Inside &lt;code&gt;pod-a&lt;/code&gt;, it starts like this:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Source IP:      10.10.0.2
Destination IP: 1.1.1.1
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The packet follows the namespace&apos;s default route to &lt;code&gt;10.10.0.1&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;The Linux host receives it, checks its own default route, and sends it through the external interface:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;pod-a
  |
br-a
  |
Linux host
  |
external interface
  |
internet
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Assuming the host firewall allows forwarding, the packet can reach the external interface.&lt;/p&gt;&lt;p&gt;But it still has its original source address:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10.10.0.2
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;That address belongs to the private network we created inside the Linux host.&lt;/p&gt;&lt;p&gt;The host has a route out, but the upstream network does not know that &lt;code&gt;10.10.0.0/24&lt;/code&gt; exists behind it. It may drop the packet because of the private source address, and even if the request reaches its destination, the reply still has no practical route back to &lt;code&gt;10.10.0.2&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;Networking is not only about getting the packet to the destination. The return path must work too.&lt;/p&gt;&lt;p&gt;We can observe the packet reaching the external interface with its original source address.&lt;/p&gt;&lt;p&gt;Start a capture on the external interface:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo tcpdump -n -i &amp;quot;$EXT_IF&amp;quot; &apos;icmp and host 1.1.1.1&apos;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Then run:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns exec pod-a ping 1.1.1.1
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Depending on your host firewall and upstream network, you may see:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10.10.0.2 &amp;gt; 1.1.1.1: ICMP echo request
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The request reached the host&apos;s external interface.&lt;/p&gt;&lt;p&gt;That does not prove that it reached &lt;code&gt;1.1.1.1&lt;/code&gt;; it only shows how far we can see from this host.&lt;/p&gt;&lt;h2&gt;Step 9: Add NAT and masquerading&lt;/h2&gt;&lt;p&gt;One way to solve the return-path problem is to change the packet&apos;s source address before it leaves the host.&lt;/p&gt;&lt;p&gt;Instead of sending this:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10.10.0.2 → 1.1.1.1
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;the host can send this:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;192.168.1.50 → 1.1.1.1
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;192.168.1.50&lt;/code&gt; is only an example. The real value will be the IP address assigned to your external interface.&lt;/p&gt;&lt;p&gt;The upstream network already knows how to return traffic to that address.&lt;/p&gt;&lt;p&gt;This is source network address translation, usually shortened to source NAT or SNAT.&lt;/p&gt;&lt;p&gt;Add a masquerading rule for traffic leaving from &lt;code&gt;10.10.0.0/24&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo iptables -t nat -A POSTROUTING \
  -s 10.10.0.0/24 \
  -o &amp;quot;$EXT_IF&amp;quot; \
  -j MASQUERADE
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;MASQUERADE&lt;/code&gt; is useful when the external address may change, which is common with DHCP, virtual machines, laptops, and cloud instances.&lt;/p&gt;&lt;p&gt;If the external address is fixed, an explicit &lt;code&gt;SNAT --to-source&lt;/code&gt; rule is usually more appropriate.&lt;/p&gt;&lt;p&gt;Inspect the rule:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo iptables -t nat -L POSTROUTING -n -v --line-numbers
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;You should see something similar to:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;num  pkts bytes target      source         destination
1       0     0 MASQUERADE  10.10.0.0/24  0.0.0.0/0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;If the host&apos;s &lt;code&gt;FORWARD&lt;/code&gt; policy is &lt;code&gt;DROP&lt;/code&gt;, also allow outbound traffic from &lt;code&gt;br-a&lt;/code&gt; and returning traffic from the external interface:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo iptables -I FORWARD 1 \
  -i br-a \
  -o &amp;quot;$EXT_IF&amp;quot; \
  -j ACCEPT

sudo iptables -I FORWARD 1 \
  -i &amp;quot;$EXT_IF&amp;quot; \
  -o br-a \
  -m conntrack \
  --ctstate ESTABLISHED,RELATED \
  -j ACCEPT
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Try the ping again:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns exec pod-a ping -c 3 1.1.1.1
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Expected output:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;64 bytes from 1.1.1.1: icmp_seq=1 ttl=56 time=12.4 ms
64 bytes from 1.1.1.1: icmp_seq=2 ttl=56 time=11.9 ms
64 bytes from 1.1.1.1: icmp_seq=3 ttl=56 time=12.1 ms
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Our tiny network can now reach the internet.&lt;/p&gt;&lt;p&gt;Some networks block ICMP traffic. If the routing and NAT setup looks correct but ping still fails, that does not always mean the network path is broken.&lt;/p&gt;&lt;h2&gt;Step 10: Watch NAT change the packet&lt;/h2&gt;&lt;p&gt;NAT becomes much easier to understand when we observe the packet on both sides of the host.&lt;/p&gt;&lt;p&gt;In one terminal, listen on &lt;code&gt;br-a&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo tcpdump -n -i br-a &apos;icmp and host 1.1.1.1&apos;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;In another terminal, listen on the external interface:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo tcpdump -n -i &amp;quot;$EXT_IF&amp;quot; &apos;icmp and host 1.1.1.1&apos;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Run the ping again:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns exec pod-a ping 1.1.1.1
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;On &lt;code&gt;br-a&lt;/code&gt;, you should see the original source:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10.10.0.2 &amp;gt; 1.1.1.1: ICMP echo request
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;On the external interface, you should see the host&apos;s external address instead:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;192.168.1.50 &amp;gt; 1.1.1.1: ICMP echo request
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The exact address will be different on your machine.&lt;/p&gt;&lt;p&gt;The packet changes as it crosses the host:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Inside the private network:

10.10.0.2 → 1.1.1.1
      |
      | NAT on the Linux host
      v

Outside the private network:

host external IP → 1.1.1.1
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;When the reply returns, Linux reverses the translation:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1.1.1.1 → host external IP
      |
      | reverse translation
      v
1.1.1.1 → 10.10.0.2
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Linux keeps track of this state using conntrack. The namespace does not know its source address was changed, and the remote destination only sees the host&apos;s external address.&lt;/p&gt;&lt;h2&gt;Three different packet paths&lt;/h2&gt;&lt;p&gt;Put side by side, our three packet paths look like this.&lt;/p&gt;&lt;h3&gt;Same bridge&lt;/h3&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;pod-a
  |
veth
  |
bridge
  |
veth
  |
pod-b
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Both endpoints belong to the same subnet.&lt;/p&gt;&lt;p&gt;The bridge forwards Ethernet frames.&lt;/p&gt;&lt;p&gt;No gateway, IP forwarding, or NAT is required for communication between these two endpoints.&lt;/p&gt;&lt;h3&gt;Different private networks&lt;/h3&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;pod-a
  |
br-a
  |
Linux routing
  |
br-b
  |
pod-b
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The endpoints belong to different subnets.&lt;/p&gt;&lt;p&gt;Each side needs a route to the other subnet. In this lab, that route goes through a gateway.&lt;/p&gt;&lt;p&gt;The Linux host forwards IP packets between the networks.&lt;/p&gt;&lt;p&gt;No NAT is required because both private networks have a working return path through the same router.&lt;/p&gt;&lt;h3&gt;Internet access&lt;/h3&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;pod-a
  |
br-a
  |
Linux routing
  |
NAT / masquerading
  |
external interface
  |
internet
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The private subnet is not known to the outside network.&lt;/p&gt;&lt;p&gt;The Linux host translates the source address so the reply can return through an address the upstream network already knows.&lt;/p&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Scenario&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Explicit route or gateway needed&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Host IP forwarding needed&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;NAT on this host needed&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Same bridge and subnet&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;No, for this path&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;No&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;No&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Different private subnets&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;Yes&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;Yes&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;No&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Internet access from a private subnet&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;Yes&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;Yes&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;Yes, in this lab&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;p&gt;Routing and SNAT solve different problems here. Routing decides where the packet should go. SNAT changes the source address so the reply can return through an address the upstream network can reach.&lt;/p&gt;&lt;h2&gt;How does this relate to Kubernetes nodes?&lt;/h2&gt;&lt;p&gt;The same model carries over to Kubernetes.&lt;/p&gt;&lt;p&gt;Imagine a cluster with two nodes:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Node A                                  Node B

Pod network:                            Pod network:
10.10.0.0/24                            10.20.0.0/24

pod-a: 10.10.0.2                        pod-b: 10.20.0.2
       |                                       |
 local Pod network                       local Pod network
       |                                       |
Node IP: 192.168.50.10  &amp;lt;----------&amp;gt;  Node IP: 192.168.50.11
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;pod-a&lt;/code&gt; and &lt;code&gt;pod-b&lt;/code&gt; are not connected to the same local bridge.&lt;/p&gt;&lt;p&gt;From Node A&apos;s perspective, &lt;code&gt;10.20.0.2&lt;/code&gt; belongs to the Pod network behind Node B.&lt;/p&gt;&lt;p&gt;Node A needs to know where that network lives.&lt;/p&gt;&lt;p&gt;In a small lab, we could express that using a static route:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip route add 10.20.0.0/24 via 192.168.50.11
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Node B would need the reverse route:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip route add 10.10.0.0/24 via 192.168.50.10
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;These simplified routes assume that both node IPs belong to the same directly connected underlay network, so each node can use the other node&apos;s IP as its next hop.&lt;/p&gt;&lt;p&gt;Both nodes would also need to forward traffic between their Pod-facing and node-facing interfaces.&lt;/p&gt;&lt;p&gt;The cross-node path would look roughly like this:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;pod-a
  |
Node A local Pod network
  |
Node A routing table
  |
node network
  |
Node B routing table
  |
Node B local Pod network
  |
pod-b
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The packet preserves its original Pod addresses:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Source IP:      10.10.0.2
Destination IP: 10.20.0.2
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This matches the Kubernetes network model, where Pods are expected to communicate directly across nodes without NAT.&lt;/p&gt;&lt;p&gt;This is the same basic model as the two-subnet lab, only at a different scale and with a different implementation. The central question has not changed:&lt;/p&gt;&lt;blockquote&gt;&lt;p&gt;How does this machine know where the destination network lives?&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;Static routes may be enough for a tiny lab.&lt;/p&gt;&lt;p&gt;They are only one possible implementation. A real Pod network may use routes, encapsulation, eBPF, or a combination of them to maintain that reachability as nodes and Pods are added, removed, or moved.&lt;/p&gt;&lt;p&gt;Somebody still has to allocate Pod addresses, create interfaces, and keep the necessary network state up to date. Who performs that work when Kubernetes creates or removes a Pod is the subject of the next article.&lt;/p&gt;&lt;h2&gt;Cleaning everything up&lt;/h2&gt;&lt;p&gt;Only remove firewall rules that you actually added during the article.&lt;/p&gt;&lt;p&gt;If you opened a new shell since Step 7, set &lt;code&gt;EXT_IF&lt;/code&gt; again:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;EXT_IF=$(ip route show default | awk &apos;/default/ {print $5; exit}&apos;)
echo &amp;quot;$EXT_IF&amp;quot;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Delete the NAT rule:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo iptables -t nat -D POSTROUTING \
  -s 10.10.0.0/24 \
  -o &amp;quot;$EXT_IF&amp;quot; \
  -j MASQUERADE
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;If you added the internet forwarding rules, remove them:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo iptables -D FORWARD \
  -i br-a \
  -o &amp;quot;$EXT_IF&amp;quot; \
  -j ACCEPT

sudo iptables -D FORWARD \
  -i &amp;quot;$EXT_IF&amp;quot; \
  -o br-a \
  -m conntrack \
  --ctstate ESTABLISHED,RELATED \
  -j ACCEPT
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;If you added the temporary bridge-to-bridge rules, remove those too:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo iptables -D FORWARD -i br-a -o br-b -j ACCEPT
sudo iptables -D FORWARD -i br-b -o br-a -j ACCEPT
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Restore IPv4 forwarding to the value you saw at the beginning.&lt;/p&gt;&lt;p&gt;If it was originally &lt;code&gt;0&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo sysctl -w net.ipv4.ip_forward=0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;If it was originally &lt;code&gt;1&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo sysctl -w net.ipv4.ip_forward=1
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Delete the network namespaces:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns delete pod-a
sudo ip netns delete pod-b
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Deleting the namespaces also removes their ends of the veth pairs. The host-side peers disappear with them.&lt;/p&gt;&lt;p&gt;Finally, delete the bridges:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip link delete br-a
sudo ip link delete br-b
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Verify the cleanup:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ip netns list
ip link show type bridge
&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;Final thoughts&lt;/h2&gt;&lt;p&gt;In the first article, our packet never had to leave its local Ethernet network.&lt;/p&gt;&lt;p&gt;This time, we gave it somewhere else to go.&lt;/p&gt;&lt;p&gt;We created two separate subnets, gave each namespace a gateway, enabled Linux to forward packets between them, and watched the Ethernet frame change while the IP packet kept its original source and destination.&lt;/p&gt;&lt;p&gt;Then we sent the same packet toward the internet and ran into a different problem: the request had a route out, but the private source network had no usable route back. SNAT solved that by translating the source address into one the outside network already knew how to reach.&lt;/p&gt;&lt;p&gt;The important distinction is simple:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Routing decides the packet&apos;s next hop and outgoing interface.

In our internet path, SNAT changes the source address so the reply can return through an address the upstream network can reach.
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;None of this is specific to Kubernetes. But the same questions appear as soon as Pods need to communicate across nodes or leave the cluster.&lt;/p&gt;&lt;p&gt;The next article will look at who actually builds and maintains that Pod network.&lt;/p&gt;&lt;/article&gt;</content:encoded><author>Levent Anil Ozen</author></item><item><title>Build a Tiny Pod Network with Linux</title><link>https://levent.dev/blog/build-a-tiny-pod-network-with-linux/</link><guid isPermaLink="true">https://levent.dev/blog/build-a-tiny-pod-network-with-linux/</guid><description>Part 1 of Kubernetes Networking Under the Hood: build a small Pod-like network with Linux network namespaces, veth pairs, and a bridge—no cluster, CNI, or container runtime required.</description><pubDate>Fri, 07 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;article&gt;&lt;p&gt;Not long ago, I decided to go back to the fundamentals of Kubernetes and started preparing for the CKA certification.&lt;/p&gt;&lt;p&gt;I have been using Kubernetes for quite a while, but networking was always one of those areas I mostly understood through the abstractions Kubernetes provides. I knew enough to use it and debug common problems, but I never built a particularly strong mental model of what was happening underneath.&lt;/p&gt;&lt;p&gt;Preparing for the CKA gave me a good reason to slow down and look at that layer more closely. Part of the reason I had not done that before is simply how work tends to go. Between deadlines, incidents, meetings, and the next thing that needs to be shipped, fundamentals are often the first thing we stop making time for.&lt;/p&gt;&lt;p&gt;Abstractions exist for a reason, and nobody can understand every layer of every system they use. The problem begins when the abstraction becomes our only mental model. When something breaks outside the happy path, our understanding may end exactly where that abstraction does.&lt;/p&gt;&lt;p&gt;Okay, that is enough philosophy for an article about virtual Ethernet devices. Let us get into it!&lt;/p&gt;&lt;p&gt;In this article, we are going to build a small Pod-like network by hand using Linux network namespaces, virtual Ethernet devices, and a bridge.&lt;/p&gt;&lt;p&gt;There will be no Kubernetes cluster, container runtime, or CNI plugin involved.&lt;/p&gt;&lt;p&gt;For now, the goal is to build the smallest useful version of the idea, follow a packet through it, and make what happens under the hood a little less mysterious. From there, we can gradually work our way toward cross-node communication and the larger Kubernetes networking model built on top of these Linux primitives.&lt;/p&gt;&lt;h2&gt;What are we building?&lt;/h2&gt;&lt;p&gt;By the end of the article, we will have two isolated network environments communicating through a Linux bridge.&lt;/p&gt;&lt;p&gt;Our final setup will look like this:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                         Linux host

                    br0: 10.10.0.1
                     /           \
                    /             \
         veth-a-host               veth-b-host
              |                         |
              |                         |
             eth0                      eth0
        10.10.0.2                 10.10.0.3
   pod-a network namespace   pod-b network namespace
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The two network namespaces will act as our fake Pods.&lt;/p&gt;&lt;p&gt;Each one will have:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;its own network interfaces&lt;/li&gt;&lt;li&gt;its own IP addresses&lt;/li&gt;&lt;li&gt;its own routing table&lt;/li&gt;&lt;li&gt;its own neighbour table&lt;/li&gt;&lt;li&gt;its own isolated view of the network stack&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;They will be connected to a shared Linux bridge through virtual Ethernet pairs.&lt;/p&gt;&lt;p&gt;These are not real Kubernetes Pods, of course. Still, the Linux primitives we are going to use are closely related to the ones used by container networking.&lt;/p&gt;&lt;h2&gt;Before starting&lt;/h2&gt;&lt;p&gt;You need a Linux environment with root access.&lt;/p&gt;&lt;p&gt;I am using commands from the &lt;code&gt;iproute2&lt;/code&gt; package. On Ubuntu or Debian, you can install the tools we need with:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt update
sudo apt install iproute2 iputils-ping
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The &lt;code&gt;bridge&lt;/code&gt; command we will use later is also included in the &lt;code&gt;iproute2&lt;/code&gt; package.&lt;/p&gt;&lt;p&gt;We will use &lt;code&gt;tcpdump&lt;/code&gt; to watch the traffic:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt install tcpdump
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This setup will not work directly on macOS because network namespaces are a Linux feature. If you are using a Mac, the easiest option is to run the commands inside a Linux virtual machine.&lt;/p&gt;&lt;h2&gt;Step 1: Create two isolated network worlds&lt;/h2&gt;&lt;p&gt;Let us start by creating two network namespaces:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns add pod-a
sudo ip netns add pod-b
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;A network namespace gives processes an isolated view of the network stack.&lt;/p&gt;&lt;p&gt;We can list the network namespaces we just created:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ip netns list
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The output should look similar to this:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;pod-b
pod-a
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Now let us inspect the interfaces inside the &lt;code&gt;pod-a&lt;/code&gt; network namespace:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns exec pod-a ip link
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;You may also see the shorter form:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip -n pod-a link
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;It does the same thing here: runs the &lt;code&gt;ip link&lt;/code&gt; command inside the &lt;code&gt;pod-a&lt;/code&gt; network namespace. I will keep using &lt;code&gt;ip netns exec&lt;/code&gt; for now because it makes the namespace boundary a little more explicit.&lt;/p&gt;&lt;p&gt;You should see only the loopback interface:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1: lo: &amp;lt;LOOPBACK&amp;gt; mtu 65536 state DOWN mode DEFAULT
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The network namespace exists, but it is completely isolated.&lt;/p&gt;&lt;p&gt;It does not have a useful network interface, an IP address, or a route to anywhere.&lt;/p&gt;&lt;p&gt;Our current topology is basically this:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;pod-a network namespace        pod-b network namespace

           lo                             lo
         DOWN                           DOWN
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;We have created two separate network worlds. Now we need a way to connect them.&lt;/p&gt;&lt;h2&gt;Step 2: Create a virtual cable&lt;/h2&gt;&lt;p&gt;We will begin with &lt;code&gt;pod-a&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;To connect a network namespace to something outside itself, we can use a virtual Ethernet pair, usually called a veth pair.&lt;/p&gt;&lt;p&gt;A veth pair consists of two connected interfaces. A packet entering one side appears on the other.&lt;/p&gt;&lt;p&gt;Create the pair:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip link add veth-a-host type veth peer name veth-a
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;We now have two connected interfaces:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;veth-a-host &amp;lt;------------&amp;gt; veth-a
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Both currently live in the host network namespace.&lt;/p&gt;&lt;p&gt;We want one side to remain on the host and the other side to belong to the &lt;code&gt;pod-a&lt;/code&gt; network namespace.&lt;/p&gt;&lt;p&gt;Move &lt;code&gt;veth-a&lt;/code&gt; into it:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip link set veth-a netns pod-a
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Now the two ends of the pair live in different network namespaces:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Host network namespace             pod-a network namespace

veth-a-host  &amp;lt;-----------------&amp;gt;           veth-a
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;We can verify both sides.&lt;/p&gt;&lt;p&gt;On the host:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ip link show veth-a-host
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Inside &lt;code&gt;pod-a&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns exec pod-a ip link
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The output inside the network namespace should now contain both &lt;code&gt;lo&lt;/code&gt; and &lt;code&gt;veth-a&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1: lo: &amp;lt;LOOPBACK&amp;gt; mtu 65536 state DOWN
2: veth-a@if3: &amp;lt;BROADCAST,MULTICAST&amp;gt; mtu 1500 state DOWN
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The interface numbers and the value after &lt;code&gt;@if&lt;/code&gt; will probably be different on your machine.&lt;/p&gt;&lt;p&gt;At this point, &lt;code&gt;pod-a&lt;/code&gt; has a connection to the host network namespace.&lt;/p&gt;&lt;p&gt;It still cannot communicate with anything, though. Both interfaces are down, and the host side is not connected to a network.&lt;/p&gt;&lt;h2&gt;Step 3: Why do we need a bridge?&lt;/h2&gt;&lt;p&gt;We could connect two network namespaces directly with a veth pair.&lt;/p&gt;&lt;p&gt;That might work for exactly two network namespaces, but it does not scale particularly well.&lt;/p&gt;&lt;p&gt;Imagine having five or ten of them. Connecting every network namespace directly to every other network namespace would quickly become a mess.&lt;/p&gt;&lt;p&gt;Instead, we can connect the host side of each veth pair to a shared bridge.&lt;/p&gt;&lt;p&gt;A Linux bridge behaves like a small virtual Layer 2 switch. It learns which MAC addresses are reachable through which ports and forwards Ethernet frames accordingly.&lt;/p&gt;&lt;p&gt;Create the bridge:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip link add br0 type bridge
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;One detail that can feel slightly confusing at first is that the bridge itself also appears as a network interface on the host.&lt;/p&gt;&lt;p&gt;That is why we can bring &lt;code&gt;br0&lt;/code&gt; up and assign an IP address to it just like we would with another interface. Its bridge ports, such as &lt;code&gt;veth-a-host&lt;/code&gt;, are then attached underneath it.&lt;/p&gt;&lt;p&gt;Bring it up:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip link set br0 up
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;We will also assign an IP address to it:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip addr add 10.10.0.1/24 dev br0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The bridge does not need an IP address just to forward Ethernet frames between the network namespaces.&lt;/p&gt;&lt;p&gt;We are giving it one so the host can also communicate with them. It could also become their gateway later if we decided to add connectivity outside this small network.&lt;/p&gt;&lt;p&gt;Now attach the host side of our veth pair to the bridge:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip link set veth-a-host master br0
sudo ip link set veth-a-host up
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Our topology now looks like this:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Host network namespace             pod-a network namespace

           br0
            |
      veth-a-host  &amp;lt;-------------&amp;gt;         veth-a
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;We can verify that the interface belongs to the bridge:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;bridge link
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The output should contain &lt;code&gt;veth-a-host&lt;/code&gt; with &lt;code&gt;master br0&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;A simplified version looks like this:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;3: veth-a-host@if2: &amp;lt;BROADCAST,MULTICAST,UP,LOWER_UP&amp;gt;
    master br0 state forwarding
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The important part is:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;master br0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;That tells us &lt;code&gt;veth-a-host&lt;/code&gt; is now one of the bridge ports.&lt;/p&gt;&lt;h3&gt;So we found another abstraction&lt;/h3&gt;&lt;p&gt;At this point, after all that talk about looking underneath abstractions, it is probably worth admitting that we have already landed on another set of abstractions.&lt;/p&gt;&lt;p&gt;A veth pair gives us the behaviour of an Ethernet cable without an actual cable. A Linux bridge gives us the basic behaviour of a network switch without a physical switch sitting on the desk.&lt;/p&gt;&lt;p&gt;In a physical setup, we might connect two machines to a switch using real cables.&lt;/p&gt;&lt;p&gt;In our setup, the machines are network namespaces, the cables are veth pairs, and the switch is a Linux bridge.&lt;/p&gt;&lt;p&gt;So yes, we removed the Kubernetes abstraction only to find more abstractions underneath it.&lt;/p&gt;&lt;p&gt;Apparently, there is no final layer where everything suddenly stops being an abstraction and becomes obvious. There are just smaller pieces that are easier to inspect.&lt;/p&gt;&lt;p&gt;The good news is that we do not need to crawl under a desk and untangle any cables for this one.&lt;/p&gt;&lt;h2&gt;Step 4: Finish configuring pod-a&lt;/h2&gt;&lt;p&gt;The network namespace has an interface, but the interface is still called &lt;code&gt;veth-a&lt;/code&gt;, has no IP address, and is down.&lt;/p&gt;&lt;p&gt;Inside a container, the primary network interface is normally called &lt;code&gt;eth0&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;Let us rename it:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns exec pod-a ip link set veth-a name eth0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Now assign an IP address:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns exec pod-a ip addr add 10.10.0.2/24 dev eth0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Bring the interface and loopback device up:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns exec pod-a ip link set eth0 up
sudo ip netns exec pod-a ip link set lo up
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Instead of checking the result after every command, we can now inspect the final state:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns exec pod-a ip addr
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The relevant part of the output should look similar to this:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1: lo: &amp;lt;LOOPBACK,UP,LOWER_UP&amp;gt;
    inet 127.0.0.1/8

2: eth0@if3: &amp;lt;BROADCAST,MULTICAST,UP,LOWER_UP&amp;gt;
    inet 10.10.0.2/24
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Linux also created a route for the connected subnet automatically:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns exec pod-a ip route
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Expected output:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10.10.0.0/24 dev eth0 proto kernel scope link src 10.10.0.2
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This route means that addresses in the &lt;code&gt;10.10.0.0/24&lt;/code&gt; network are directly reachable through &lt;code&gt;eth0&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;We can now test communication between &lt;code&gt;pod-a&lt;/code&gt; and the bridge:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns exec pod-a ping -c 3 10.10.0.1
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;You should receive replies:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;64 bytes from 10.10.0.1: icmp_seq=1 ttl=64 time=0.050 ms
64 bytes from 10.10.0.1: icmp_seq=2 ttl=64 time=0.048 ms
64 bytes from 10.10.0.1: icmp_seq=3 ttl=64 time=0.047 ms
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;We now have one isolated network namespace connected to the host through a veth pair and a Linux bridge.&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                         Host network namespace

                              br0
                          10.10.0.1
                              |
                        veth-a-host
                              |
                              |
                             eth0
                          10.10.0.2
                   pod-a network namespace
&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;Step 5: Add pod-b&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;pod-b&lt;/code&gt; network namespace needs the same pieces:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;a veth pair&lt;/li&gt;&lt;li&gt;one end moved into the network namespace&lt;/li&gt;&lt;li&gt;the host end attached to the bridge&lt;/li&gt;&lt;li&gt;an IP address from the same subnet&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;We already walked through each operation for &lt;code&gt;pod-a&lt;/code&gt;, so we can configure &lt;code&gt;pod-b&lt;/code&gt; in one go:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip link add veth-b-host type veth peer name veth-b

sudo ip link set veth-b netns pod-b

sudo ip link set veth-b-host master br0
sudo ip link set veth-b-host up

sudo ip netns exec pod-b ip link set veth-b name eth0
sudo ip netns exec pod-b ip addr add 10.10.0.3/24 dev eth0
sudo ip netns exec pod-b ip link set eth0 up
sudo ip netns exec pod-b ip link set lo up
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Inspect the final state:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns exec pod-b ip addr
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;You should see:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1: lo: &amp;lt;LOOPBACK,UP,LOWER_UP&amp;gt;
    inet 127.0.0.1/8

2: eth0@if5: &amp;lt;BROADCAST,MULTICAST,UP,LOWER_UP&amp;gt;
    inet 10.10.0.3/24
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Our tiny network is now complete:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                         Linux host

                    br0: 10.10.0.1
                     /           \
                    /             \
         veth-a-host               veth-b-host
              |                         |
              |                         |
             eth0                      eth0
        10.10.0.2                 10.10.0.3
   pod-a network namespace   pod-b network namespace
&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;Step 6: Test communication&lt;/h2&gt;&lt;p&gt;Let us send traffic from &lt;code&gt;pod-a&lt;/code&gt; to &lt;code&gt;pod-b&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns exec pod-a ping -c 3 10.10.0.3
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Expected output:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;64 bytes from 10.10.0.3: icmp_seq=1 ttl=64 time=0.080 ms
64 bytes from 10.10.0.3: icmp_seq=2 ttl=64 time=0.063 ms
64 bytes from 10.10.0.3: icmp_seq=3 ttl=64 time=0.067 ms
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Try the opposite direction too:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns exec pod-b ping -c 3 10.10.0.2
&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;If the ping does not work&lt;/h3&gt;&lt;p&gt;If you do not receive a reply, check the final state before changing the setup:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip -n pod-a addr
sudo ip -n pod-b addr

sudo ip -n pod-a link
sudo ip -n pod-b link

sudo ip -n pod-a route
sudo ip -n pod-b route

bridge link
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Make sure that:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;both addresses include the &lt;code&gt;/24&lt;/code&gt; prefix&lt;/li&gt;&lt;li&gt;both &lt;code&gt;eth0&lt;/code&gt; interfaces are up&lt;/li&gt;&lt;li&gt;both host-side veth interfaces are up and attached to &lt;code&gt;br0&lt;/code&gt;&lt;/li&gt;&lt;li&gt;both network namespaces have a connected route for &lt;code&gt;10.10.0.0/24&lt;/code&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;Host firewall or nftables rules can also interfere with bridged traffic on some systems. If the interfaces, addresses, and routes all look correct, inspect the host firewall rules next.&lt;/p&gt;&lt;p&gt;I would not recommend disabling the firewall blindly, especially outside a disposable learning environment.&lt;/p&gt;&lt;p&gt;The two isolated network namespaces can now communicate with each other.&lt;/p&gt;&lt;p&gt;There is no Kubernetes cluster here.&lt;/p&gt;&lt;p&gt;There is no container runtime.&lt;/p&gt;&lt;p&gt;There is no CNI plugin.&lt;/p&gt;&lt;p&gt;Linux is moving the packets.&lt;/p&gt;&lt;p&gt;The ping works, but the more interesting question is this:&lt;/p&gt;&lt;blockquote&gt;&lt;p&gt;What path did the packet actually take?&lt;/p&gt;&lt;/blockquote&gt;&lt;h2&gt;Step 7: Follow one packet&lt;/h2&gt;&lt;p&gt;When &lt;code&gt;pod-a&lt;/code&gt; sends a packet to &lt;code&gt;10.10.0.3&lt;/code&gt;, the packet follows this path:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;pod-a eth0
    |
    v
veth-a-host
    |
    v
   br0
    |
    v
veth-b-host
    |
    v
pod-b eth0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Let us walk through it.&lt;/p&gt;&lt;h3&gt;pod-a checks its routing table&lt;/h3&gt;&lt;p&gt;Before sending anything, &lt;code&gt;pod-a&lt;/code&gt; needs to decide where the packet should go.&lt;/p&gt;&lt;p&gt;Its routing table contains:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10.10.0.0/24 dev eth0 proto kernel scope link src 10.10.0.2
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The destination &lt;code&gt;10.10.0.3&lt;/code&gt; belongs to the same subnet, so &lt;code&gt;pod-a&lt;/code&gt; knows it can reach the destination directly through &lt;code&gt;eth0&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;No gateway is needed.&lt;/p&gt;&lt;h3&gt;pod-a needs a MAC address&lt;/h3&gt;&lt;p&gt;The IP address tells &lt;code&gt;pod-a&lt;/code&gt; which destination it wants to reach, but Ethernet delivery requires a destination MAC address.&lt;/p&gt;&lt;p&gt;If &lt;code&gt;pod-a&lt;/code&gt; does not already know the MAC address for &lt;code&gt;10.10.0.3&lt;/code&gt;, it sends an ARP request:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Who has 10.10.0.3?
Tell 10.10.0.2.
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;That ARP frame leaves through &lt;code&gt;eth0&lt;/code&gt;.&lt;/p&gt;&lt;h3&gt;The veth pair moves the frame to the host&lt;/h3&gt;&lt;p&gt;&lt;code&gt;eth0&lt;/code&gt; inside &lt;code&gt;pod-a&lt;/code&gt; is one side of a veth pair.&lt;/p&gt;&lt;p&gt;When the frame enters &lt;code&gt;eth0&lt;/code&gt;, it appears on &lt;code&gt;veth-a-host&lt;/code&gt; in the host network namespace.&lt;/p&gt;&lt;p&gt;The veth pair itself does not make routing decisions. It simply carries traffic from one end to the other.&lt;/p&gt;&lt;h3&gt;The bridge forwards the frame&lt;/h3&gt;&lt;p&gt;&lt;code&gt;veth-a-host&lt;/code&gt; is connected to &lt;code&gt;br0&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;The bridge receives the Ethernet frame and forwards it through its ports.&lt;/p&gt;&lt;p&gt;Because an ARP request is a broadcast, the bridge sends it through the other relevant ports, including &lt;code&gt;veth-b-host&lt;/code&gt;.&lt;/p&gt;&lt;h3&gt;The second veth pair delivers it to pod-b&lt;/h3&gt;&lt;p&gt;The frame enters &lt;code&gt;veth-b-host&lt;/code&gt; and appears on &lt;code&gt;eth0&lt;/code&gt; inside the &lt;code&gt;pod-b&lt;/code&gt; network namespace.&lt;/p&gt;&lt;p&gt;&lt;code&gt;pod-b&lt;/code&gt; sees that the ARP request is asking about its IP address and replies with its MAC address.&lt;/p&gt;&lt;p&gt;After that exchange, &lt;code&gt;pod-a&lt;/code&gt; can send the ICMP packet directly to &lt;code&gt;pod-b&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;We can inspect the neighbour table inside &lt;code&gt;pod-a&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns exec pod-a ip neigh
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;You should see an entry similar to:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10.10.0.3 dev eth0 lladdr 8a:41:36:d5:92:c4 REACHABLE
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The MAC address will be different on your machine.&lt;/p&gt;&lt;p&gt;The neighbour table is where Linux keeps the relationship between IP addresses and link-layer addresses learned through protocols such as ARP.&lt;/p&gt;&lt;h2&gt;Step 8: What has the bridge learned?&lt;/h2&gt;&lt;p&gt;Like a physical Ethernet switch, a Linux bridge learns which MAC addresses are reachable through which ports.&lt;/p&gt;&lt;p&gt;Because we have already generated traffic between the network namespaces, the bridge should now have learned their MAC addresses.&lt;/p&gt;&lt;p&gt;We can inspect its forwarding database:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;bridge fdb show br br0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The output will contain entries associated with &lt;code&gt;veth-a-host&lt;/code&gt; and &lt;code&gt;veth-b-host&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;A simplified example might look like this:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;8a:41:36:d5:92:c4 dev veth-b-host master br0
d2:72:f4:e8:51:10 dev veth-a-host master br0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This tells the bridge which port it should use when forwarding frames to each MAC address.&lt;/p&gt;&lt;p&gt;The bridge did not need us to configure those MAC addresses manually. It learned them by observing the source addresses of frames passing through it.&lt;/p&gt;&lt;p&gt;If you run this command before generating any traffic, you may not see the same learned entries yet.&lt;/p&gt;&lt;h2&gt;Step 9: Watch the traffic yourself&lt;/h2&gt;&lt;p&gt;Diagrams and explanations are useful, but we do not have to trust them.&lt;/p&gt;&lt;p&gt;We can watch the packets moving through the network.&lt;/p&gt;&lt;p&gt;Start &lt;code&gt;tcpdump&lt;/code&gt; on the bridge:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo tcpdump -n -i br0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;In another terminal, run:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns exec pod-a ping 10.10.0.3
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;You should see ICMP traffic:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10.10.0.2 &amp;gt; 10.10.0.3: ICMP echo request
10.10.0.3 &amp;gt; 10.10.0.2: ICMP echo reply
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;If you clear the neighbour entry first:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns exec pod-a ip neigh flush all
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;and start the ping again, you should also see the ARP exchange:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ARP, Request who-has 10.10.0.3 tell 10.10.0.2
ARP, Reply 10.10.0.3 is-at 8a:41:36:d5:92:c4
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;You can observe the same traffic at different points in the path.&lt;/p&gt;&lt;p&gt;Inside &lt;code&gt;pod-a&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns exec pod-a tcpdump -n -i eth0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;On the host side of its veth pair:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo tcpdump -n -i veth-a-host
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;On the bridge:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo tcpdump -n -i br0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This becomes useful when debugging real container networking.&lt;/p&gt;&lt;p&gt;If you can see a packet inside the Pod network namespace but not on the host-side veth interface, the problem is somewhere near that boundary.&lt;/p&gt;&lt;p&gt;If you can see it on the bridge but not on the destination interface, the problem is further along the path.&lt;/p&gt;&lt;p&gt;Networking debugging often comes down to following the packet and finding the point where it disappears.&lt;/p&gt;&lt;h2&gt;So where does Kubernetes fit?&lt;/h2&gt;&lt;p&gt;When Kubernetes creates a Pod, it does not normally run these exact shell commands itself.&lt;/p&gt;&lt;p&gt;A simplified Pod creation flow looks like this:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Scheduler selects a node
          |
          v
Kubelet asks the container runtime
to create a Pod sandbox
          |
          v
A network namespace is prepared
          |
          v
The configured CNI plugin is called
          |
          v
Interfaces, addresses, and routes
are configured using Linux
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The scheduler decides which node should run the Pod.&lt;/p&gt;&lt;p&gt;The kubelet on that node asks the container runtime to create the Pod sandbox.&lt;/p&gt;&lt;p&gt;The runtime and CNI integration then handle the network setup.&lt;/p&gt;&lt;p&gt;It helps to think of CNI as a contract rather than a particular networking technology. CNI defines how a runtime invokes networking plugins and how those plugins report the result. It does not require every plugin to build the network in the same way.&lt;/p&gt;&lt;p&gt;What we have done manually is roughly the kind of work a CNI plugin may automate:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;create a veth pair&lt;/li&gt;&lt;li&gt;move one end into the Pod network namespace&lt;/li&gt;&lt;li&gt;rename it to &lt;code&gt;eth0&lt;/code&gt;&lt;/li&gt;&lt;li&gt;assign the Pod IP address&lt;/li&gt;&lt;li&gt;configure routes&lt;/li&gt;&lt;li&gt;connect the host side to a bridge or another networking layer&lt;/li&gt;&lt;li&gt;bring the interfaces up&lt;/li&gt;&lt;li&gt;configure routing, encapsulation, policy, or eBPF programs when needed&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;The runtime invokes the configured plugin when a Pod is added to or removed from the network. The actual network design is left to the plugin.&lt;/p&gt;&lt;p&gt;A simple bridge-based plugin might create something close to what we built.&lt;/p&gt;&lt;p&gt;Other plugins work differently.&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;strong&gt;Calico&lt;/strong&gt; may rely heavily on routing and BGP.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Flannel&lt;/strong&gt; may use VXLAN to create an overlay network.&lt;/li&gt;&lt;li&gt;Cloud CNI plugins may assign addresses from the cloud virtual network directly to Pods.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Cilium&lt;/strong&gt; can use eBPF for networking, load balancing, and policy enforcement.&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;So it would not be correct to say:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Kubernetes networking is a Linux bridge.
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;A better way to put it is:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Kubernetes networking is built using Linux networking primitives,
configured according to the model implemented by the CNI plugin.
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Kubernetes defines the desired model.&lt;/p&gt;&lt;p&gt;The CNI plugin configures it.&lt;/p&gt;&lt;p&gt;Linux carries the packets.&lt;/p&gt;&lt;h2&gt;What this tiny network does not cover&lt;/h2&gt;&lt;p&gt;Our setup only demonstrates communication between two Pod-like environments on the same Linux machine.&lt;/p&gt;&lt;p&gt;A real Kubernetes network needs to solve several additional problems.&lt;/p&gt;&lt;p&gt;For example, what happens if &lt;code&gt;pod-a&lt;/code&gt; runs on Node 1 and &lt;code&gt;pod-b&lt;/code&gt; runs on Node 2?&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Node 1                              Node 2

pod-a: 10.10.1.2                    pod-b: 10.10.2.3
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Node 1 needs to know how to reach the Pod network on Node 2.&lt;/p&gt;&lt;p&gt;That might involve:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;normal Linux routes&lt;/li&gt;&lt;li&gt;BGP route distribution&lt;/li&gt;&lt;li&gt;VXLAN encapsulation&lt;/li&gt;&lt;li&gt;IP-in-IP&lt;/li&gt;&lt;li&gt;cloud network routes&lt;/li&gt;&lt;li&gt;eBPF-based forwarding&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;We have also not covered:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;Kubernetes Services&lt;/li&gt;&lt;li&gt;ClusterIP addresses&lt;/li&gt;&lt;li&gt;kube-proxy&lt;/li&gt;&lt;li&gt;NAT&lt;/li&gt;&lt;li&gt;DNS&lt;/li&gt;&lt;li&gt;NetworkPolicy&lt;/li&gt;&lt;li&gt;ingress traffic&lt;/li&gt;&lt;li&gt;internet access&lt;/li&gt;&lt;li&gt;conntrack&lt;/li&gt;&lt;li&gt;cross-node MTU problems&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;Our bridge solves the same-node part of the problem: two Pod-like network environments can reach each other directly on one Linux host.&lt;/p&gt;&lt;p&gt;The next layer is cluster-wide reachability. A Pod on one node must be able to reach a Pod on another node while preserving the Pod addressing model.&lt;/p&gt;&lt;p&gt;That is where Linux routing, overlays, VXLAN, BGP, cloud routes, and different CNI implementations begin to matter.&lt;/p&gt;&lt;p&gt;Before following a packet across nodes, though, it helps to understand how it leaves the Pod in the first place. That is the part we have built here.&lt;/p&gt;&lt;h2&gt;Cleaning everything up&lt;/h2&gt;&lt;p&gt;When you are finished, delete the network namespaces:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip netns delete pod-a
sudo ip netns delete pod-b
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Deleting the network namespaces also deletes the interfaces inside them.&lt;/p&gt;&lt;p&gt;When one side of a veth pair is deleted, the other side disappears as well.&lt;/p&gt;&lt;p&gt;Finally, remove the bridge:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ip link delete br0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;You can verify that everything is gone:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ip netns list
ip link show
&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;Final thoughts&lt;/h2&gt;&lt;p&gt;In this article, we manually created:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;two isolated network namespaces&lt;/li&gt;&lt;li&gt;two virtual Ethernet pairs&lt;/li&gt;&lt;li&gt;one Linux bridge&lt;/li&gt;&lt;li&gt;two Pod-like IP addresses&lt;/li&gt;&lt;li&gt;a working network path between them&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;We also followed a packet from one network namespace to the other:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;pod-a eth0
    |
veth-a-host
    |
   br0
    |
veth-b-host
    |
pod-b eth0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This is not a complete Kubernetes network, but it gives us a useful mental model.&lt;/p&gt;&lt;p&gt;A Pod is not magically connected to the network.&lt;/p&gt;&lt;p&gt;It has a network namespace.&lt;/p&gt;&lt;p&gt;It has a virtual interface.&lt;/p&gt;&lt;p&gt;That interface connects to something on the node.&lt;/p&gt;&lt;p&gt;The node then needs to decide where the packet goes next.&lt;/p&gt;&lt;p&gt;Once those pieces become visible, Kubernetes networking starts to feel less like a collection of magic abstractions and more like automated Linux networking.&lt;/p&gt;&lt;p&gt;The next question is what happens when the destination Pod is not on the same bridge, or even on the same node.&lt;/p&gt;&lt;/article&gt;</content:encoded><author>Levent Anil Ozen</author></item></channel></rss>