Netfilter,IPtables and Kubernetes: how do they work together?


The simplest explanation I could find was this Linux Howto document from 2002 by Rusty Russell, the author of ipchains, netfilters and iptables , where the stages of network packet-processing is described. There is an ASCII art image of the netfilters system in Linux in there that is still surprisingly relavant in 2026.


   --->PRE------>[ROUTE]--->FWD---------->POST------>
       Conntrack    |       Mangle   ^    Mangle
       Mangle       |       Filter   |    NAT (Src)
       NAT (Dst)    |                |    Conntrack
       (QDisc)      |             [ROUTE]
                    v                |
                    IN Filter       OUT Conntrack
                    |  Conntrack     ^  Mangle
                    |  Mangle        |  NAT (Dst)
                    v                |  Filter

What is Netfilter

The lifecyle of an incoming (and outgoing) network packet goes through certain stages:

  1. Packet comes in
  2. Packet is tracked wrt to an existing or a new connection
  3. The networking stack in the kernel decides if the packet must be delivered to a local process or if it has to be sent outwards (via another network interface) to a different machine
  4. Same for packets generated by our local processes (curl, ftp clients etc). A decision must be made if the packet must be sent out to the internet or delivered to another local processes
  5. Before the packet is sent out, processs it (swap source IP address etc)
  6. Before packets are delivered to processes, sent from processess to the outside world or forwarded from one interface to another, accept or drop them.

Admins and programmers would want to control at each stage to enable or disable certain kind of behaviours, for example:

  1. Do not allow connections initiated by external servers to our machines, ingress is only allowed for packets related to a connection already created by a local process
  2. Allow this VM running in our local machine to be able to talk to the internet
  3. If 2 VMs are running , allow them to be networked to each other and let them also be able to make calls to the internet

Netfilter is the framework that allows this extensibility of the Linux networking stack without users having to write drivers or extensions to the kernel and recompiling and running custom kernels. The mechanism is provided through NETFILTER_HOOKS, a set of pre-defined stages in the flow of a packet in/out of the networking stack where hook can be injected by a user to control the packet processing behaviour.

IPtables is a system built on top of Netfilter. Kernel modules can register tables and hook them up into the various hook points offered by Netfilter for packet processing.

There seem to be a few different mechanisms to inject these hooks, but the most common ones seem to be iptables (the older) and nftables (the newer) mechanism, where user writes these hooks as rules

What is not covered

I don’t want to go too deep into how these are setup as Kernel modules or how how the table structures exist (also honestly, I haven’t done any experiments on that front either). I set out to write this as a guide for some practical understanding of iptables and usecases and how they are used by tools we use day to day, such as containers and Kubernetes Pods/services etc. A lot of the work previously done by kernel modules seems to be done using eBPF these days; for example, there is Cilium a Kubernetes CNI plugin used widely to setup HTTP aware load-balances,proxies for their workloads on Kubernetes.

Likewise, NFtables are also not covered because we have no need for any custom tables beyond the existing tables from iptables. The scenarios described in this post add chains and rules to existing tables to setup networking for containers and VMS.

Back to Netfilters.

This is the image representation of the ASCII diagram from before Netfilter Diagram

There are only 5 tables as far as I know: nat, filter, raw, mangle, security and each table provides chains that hooks into one or more of the hook points. The hook pointers are fixed per table: that is chains in the filter table are applied only in the INPUT, FORWARD and OUTPUT hooks, whereas the nat table has chains running in the PREROUTING, OUTPUT and POSTROUTING hooks; Chains in the raw table can apply at PREROUTING and OUTPUT hooks whereas chains in the mangle table apply at all hooks.

If there are multiple chains from different tables for the same hook, then the chains are run as per a fixed order (in NFtables, the order can be arbitrarily changed). For example, the order of priority of chains at some of the hooks are

PREROUTING
  raw
  mangle
  nat (DNAT / destination translation)

INPUT
  mangle
  filter
  security

FORWARD
  mangle
  filter
  security

Most of the commands you will see here uses the iptables userspace CLI. This cli tool provides a user-friendly way to add or remove rules to chains and chains to tables. Technically, users can add or remove new tables via iptables too, but to do so you need to write and load kernel modules or use nftables. Kubernetes seems to support both iptables and nftables, but most installations seem to use iptables by default. For our deep-dive here we will mostly deal with the standard filter and nat tables exploring how kubernetes installed chains rewrite packets to support kubernetes services.

Filtering

The filter table, as the name suggests, contains chains and rules to filter packets. These chains are attached to the hook points: NF_IP_FORWARD (when it is decided that a packet must be routed from one interface to another), NF_IP_LOCAL_IN (before a packet is delivered to a process) and NF_IP_LOCAL_OUT (when a packet leaves a process)

Scenario 1

Container runtimes on your local machine can setup a software bridge devices to allow 2 containers to talk to one another. The containers will have a default route to forward packets to this bridge if it can’t find a route to the resolved IP address. Take the usecase of a curl request to npmjs.com within the container. The IP address of npmjs.com resolves to an address that lies outside of the current local network, so the default route is to forward those packets to the bridge which then forwards to the right physical host or knows where to further forward the packets. In my case, the bridge simply forwards the the packets to my physical network card and the packets are sent to the actual host of npmjs.com through the public internet.The packets sent from my bridge to my physical ethernet/wireless interface were by default dropped, so I added a rule to the FORWARD chain in the filter table to accept all packets coming from br0 (the software bridge) to wlp0s0 (my physical network device)

iptables -t filter -A FORWARD -i br0 -o wlp0s0 -j ACCEPT

The filter table is the default table if -t is unspecified, so you will often see commands modify the filter table to just skip the -t option

iptables -A FORWARD -i br0 -o wlp0s0 -j ACCEPT
Scenario 2

Another rule would be to drop all packets unless they are for an SSH connection to port 22: Here we set the rule to allow incoming packets to a local process if the destination port is 22 and the protocol is TCP. The default policy of the input chain is to drop the packets. The rules are evaluated in order, kind of like a case/match expression in programming languages:

iptables -A INPUT -p tcp --dport 22 -j ACCEPT
iptables -P INPUT DROP

NAT

NAT stands for Network Address Translation. When a computer is connect simultaneously to a local networks and an external one, there will be a need for applications to deal with different IP addresses, one for the local networks and the other for the public internet.

Scenario 1

Take the case of 2 virtual machines on a localhost. I setup a bridge between the VMs and gave it each addresses in the 172.16.0.1/28 address range. The default route is set to that of the bridge.

When these VMs need to talk to the rest of the internet, the packets sent from these VMs will contain 172.16.0.x as the source IP. This 172.16.0.x IP range is classified as a private IP range for use within private networks and thus when a website wants to respond to this address, it quite simply doesn’t know how to to. This is also the same case with internet routes in our homes, where all of our devices have a private IP within the Wifi/LAN network and the router has an external public IP Address.

To solve this issue, we setup a post-routing rule that swaps the source IP of the VM with that of the public internet interface so that external servers can send their responses to us. This is set by adding a MASQUERADE rule to the POSTROUTING chain (masquerade means to disguise or hide)

iptables -t nat -A POSTROUTING -o wlp0s0 -j MASQUERADE

What this rule basically says is that when a packet leaves (-o output) through the wlp0s0 interface, swap the source IP in the packet with the IP address of the wlp0s0 interface so that our destination knows how to send the reply packets for our request. The reason we have to use masquerade is because our interface wlp0s0 can get a different public IP address over time (DHCP) because the number of IPV4 addresses are limited and broadband/home interner providers have a fixed pool of IP addresses that get assigned and reassigned to different devices as demand changes and devices get restarted

Do we need a rule to explicitly enable the reverse: That is to allow response packets coming in from external systems and swap the destination IP to that of the VM when the remote server responds to our packets ? There is another Linux networking feature called connntrack that keeps track of (by default) of all connections in and out of our interfaces. When we allow connection initiated from our VMs to forward packets, conntrack remembers this connection and on incoming reply packets allows them to continue traveling towards the VM. If that is not the case by default, we can enable it on the FORWARD chain to allow incoming packets for established connections by updating the forward chain

iptables -A FORWARD -m state --state ESTABLISHED,RELATED -j ACCEPT

Since -t is omitted, it updates the filter table.

IPtables: Targets and Policies

All these chains contain rules that match certain properties of packets (source IP, destination IP, etc.) and each rule has a target: the action to take if the packet matches (e.g. ACCEPT, DROP, REJECT, or jump to another chain). When no rule matches a packet, the chain falls back to its policy: a default action of either ACCEPT or DROP, applicable only to built-in chains (INPUT, OUTPUT, FORWARD). You can look up the current policies with:

iptables -L -v -n
# -L list rules
# -v verbose
# -n numeric, just display the IPs in the rules' conditions as is

Now that we know about netfilters (hooks in the networking stack where we can attach code or rules), iptables: an organization of chains that contain rules which attach at one of the hook points that are table specific and NAT, lets see how these tools are employed to build something most of us would use everyday (or atleast frequently): Kubernetes Services

Software Bridge

The software bridge in Linux is an extremely flexible virtual device for networking. It can operate at 2 levels:

  1. At Layer 2 (like a physical Ethernet switch), forwarding ethernet frames between the interfaces plugged into it based on MAC addresses. You can spin up a network namespace (eg for a container), setup a virtual ethernet pair with one end in the namspace and another on the host connected to the bridge, routing packets between the host and the namespace as if 2 physical machines connected with a ethernet cable

or

  1. Assign an IP address to the bridge and to the virtual ethernet devices in the network namespace thus making the bridge act like a packet switch

In our Kubernetes scenario, our VMs and Bridge have both a MAC address and an IP address thus allow us to route IP packets from the host -> VMs and vice-versa. We will not go too deep into bridges here, but for a more in-depth reading on bridges and virtual devices in Linux I would highly encourage you to read Iximiuz’s course and container networking tutorial

Kubernetes Networking

Have you used a Service in Kubernetes? Chances are you have. Have you ever wondered how it works, like how can pods run on different nodes and pods on the same node can communicate with one another or send traffic to a pod on the other node? If you don’t know the answer to this question, fear not, for this post will guide you through my setup and how I traced the flow of packets through the system until it landed on the right port within a container’s network namespace where our nginx process is listening, buried deep within the system.

Let’s start with the setup first. I will deploy an nginx server as a Kubernetes Pod via a Deployment. Then we create a Service for the nginx server exposing a NodePort, that is each worker node in the cluster exposing a port to external traffic. If you know the ip address of the node, you can reach the nginx server via:

http://ip-of-node:targetPort

There might be a confusing with a clusterIP which is an IP address within the cluster that other pods can use to reach our nginx service. This IP cannot be reached outside of the cluster. We will also take a look at the clusterIP later to see how it is fake and isn’t really an IP but is used by Kubernetes to route traffic to the right pod/container within the cluster.

The setup

VM setup

It is difficult to setup a physical multi-node cluster (I am running a few experiments for setting up a cluster with an Rpi, X86_64 and a Macbook, but that’s probably for another post). So instead I used minikube on Linux to setup a cluster with multiple virtual machines as worker nodes on my Desktop. I used the kvm driver to setup the VMs.

For our intents and purposes, Virtual Machines vs Physical worker nodes should not make a difference. I will explain how the networking is setup for virtual machines and the only difference between physical worker nodes and virtual nodes would be the routes in our host’s routing tables.

Once you follow the instructions on minikube’s page to install the kvm driver (libvirt and qemu), you can start a cluster with 1 control-plane node and 2 workers nodes as simply as

minikube start --driver=kvm2 -p mmd --nodes 3

This starts a cluster named mmd with 2 worker nodes and 1 control plane node. I encountered errors initially due to groups permission issue with the libvirt group. To use libvirt daemon for managing VMs, the user has to be made part of the libvirt user group. Unfortunately my shell didn’t pickup the updated group membership until I rebooted my system, so if you see Permission denied or minikube failure issue, try to see if your user is part of the libvirt group in the shell first.

Once the cluster has been started, you can ssh into the worker VMs using

minikube ssh -p mmd --node mmd-node02/03

or run kubectl against the cluster using

minikube kubectl -p mmd -- get pods -A 

The VMs are connected to one another (and the host) through the bridge

> virsh net-list --all
 Name      State    Autostart   Persistent
--------------------------------------------
 default   active   yes         yes
 mk-mmd    active   yes         yes

> virsh net-info mk-mmd
Name:           mk-mmd
UUID:           7119a156-5b6a-477f-8d0a-f3747e099151
Active:         yes
Persistent:     yes
Autostart:      yes
Bridge:         virbr1

> virsh net-dumpxml mk-mmd
<network connections='3'>
  <name>mk-mmd</name>
  <uuid>7119a156-5b6a-477f-8d0a-f3747e099151</uuid>
  <bridge name='virbr1' stp='on' delay='0'/>
  <mac address='52:54:00:42:7c:5d'/>
  <dns enable='no'/>
  <ip address='192.168.39.1' netmask='255.255.255.0'>
    <dhcp>
      <range start='192.168.39.2' end='192.168.39.253'/>
      <host mac='fe:e9:8d:3c:5d:8e' name='mmd' ip='192.168.39.217'/>
      <host mac='7a:e5:d3:9f:09:61' name='mmd-m02' ip='192.168.39.133'/>
      <host mac='ce:ba:58:f7:6b:cd' name='mmd-m03' ip='192.168.39.167'/>
    </dhcp>
  </ip>
</network>

Kubernetes Service Setup

  1. We create a deployment for nginx that runs exactly 1 pod in a specific worker node
  2. We create a NodePort service for nginx. The service provisions a port on each worker node such that the service can be reached through the node’s IP address: curl http://ip-addr-of-node:targetPort
  3. Scenario 1: We investigate the how the networking works when making requests directly to the IP of the node that’s running the pod and through the other node’s IP
  4. Scenario 2: We touch upon how pods can reach the service internally by resolving the nginx.default.svc.cluster.local DNS record

This is the manifest to create the Deployment and the NodePort service. I set a nodeSelector label in the deployment so that the replica lands on the m02 node. Before that add a label to our m02 node

minikube kubectl -p mmd -- label node mmd-m02 role=nginx-host

specify the role label in the Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      nodeSelector:
        role: nginx-host # Pod gets deployed on the node with this label
      containers:
      - name: nginx-host
        image: nginx:latest
        ports:
        - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: nginx-svc
spec:
  selector:
    app: nginx
  type: NodePort
  ports:
  - port: 80
    targetPort: 80

Apply the manifest and once the service is created and the pod is up and running we can make requests to the IP address of the worker (Note the fact it is the IP addr of the worker)

> minikube kubectl -p mmd -- apply -f manifest.yaml
> minikube kubectl -p mmd -- get pods
NAME                     READY   STATUS    RESTARTS   AGE
nginx-6f764748fc-4wk85   1/1     Running   0          9h

### Scenario 1: Making requests to the nginx pod via Node 03's NodePort

```sh
> minikube ip -p mmd -n mmd-m03
192.168.39.167
> minikube ip -p mmd -n mmd-m02
192.168.39.133

>curl http://$(minikube ip -p mmd --node mmd-m03):31831/
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
...
<p><em>Thank you for using nginx.</em></p>
</body>
</html>

How does our host know to route the packets for our curl’s request to the Worker node ? If I do ip route show this is the routing table on my host

...
192.168.39.0/24 dev virbr1 proto kernel scope link src 192.168.39.1 
192.168.122.0/24 dev virbr0 proto kernel scope link src 192.168.122.1 

The first rule says that for all addresses in the range 192.168.39.0/24 route it to the device with the ip 192.168.39.1 which happens to be the bridge device created for our VM <-> VM network.

So we have a route from host -> node m03. How does m03 route our incoming requests to the Pod on m02. This is where the magic of iptables comes into the picture.

When the packet reaches our worker node, the packet must either be routed to another interface or sent to a local process on the worker node itself. There is a PREROUTING hook that runs before the routing decision is made and our iptables for m03 does some tricks here. We can look at what happens by investigating the chains and the rules in the PREROUTING hook of the NAT table.

> minikube ssh -p mmd -n mmd-m03
# Inside the worker shell
> sudo iptables -t nat -L -v -n
Chain PREROUTING (policy ACCEPT 37 packets, 10218 bytes)
 pkts bytes target     prot opt in     out     source               destination         
   71 12592 KUBE-SERVICES  all  --  *      *       0.0.0.0/0            0.0.0.0/0            /* kubernetes service portals */
....
....

Chain KUBE-SERVICES (2 references)
 pkts bytes target     prot opt in     out     source               destination         
    .....
    0     0 KUBE-SVC-HL5LMXD5JFHQZ6LN  tcp  --  *      *       0.0.0.0/0            10.97.146.53         /* default/nginx-svc cluster IP */ tcp dpt:80
    2   120 KUBE-NODEPORTS  all  --  *      *       0.0.0.0/0            0.0.0.0/0            /* kubernetes service nodeports; NOTE: this must be the last rule in this chain */ ADDRTYPE match dst-type LOCAL

Chain KUBE-NODEPORTS (1 references)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 KUBE-EXT-HL5LMXD5JFHQZ6LN  tcp  --  *      *       0.0.0.0/0            127.0.0.0/8          /* default/nginx-svc */ tcp dpt:31831 nfacct-name  localhost_nps_accepted_pkts
    1    60 KUBE-EXT-HL5LMXD5JFHQZ6LN  tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            /* default/nginx-svc */ tcp dpt:31831
Chain KUBE-EXT-HL5LMXD5JFHQZ6LN (2 references)
 pkts bytes target     prot opt in     out     source               destination         
    1    60 KUBE-SVC-HL5LMXD5JFHQZ6LN  all  --  *      *       0.0.0.0/0            0.0.0.0/0           

Chain KUBE-SVC-HL5LMXD5JFHQZ6LN (2 references)
 pkts bytes target     prot opt in     out     source               destination         
    1    60 KUBE-SEP-SADCJIHRQW7RJ62U  all  --  *      *       0.0.0.0/0            0.0.0.0/0            /* default/nginx-svc -> 10.244.1.2:80 */
    
Chain KUBE-SEP-SADCJIHRQW7RJ62U (1 references)
 pkts bytes target     prot opt in     out     source               destination 
    .... 
    1    60 DNAT       tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            /* default/nginx-svc */ tcp to:10.244.1.2:80

Our PREROUTING chain sends our packet through the following chains: PREROUTING -> KUBE-SERVICES -> KUBE-NODEPORTS -> KUBE-EXT-HL5LMXD5JFHQZ6LN -> KUBE-SVC-HL5LMXD5JFHQZ6LN -> KUBE-SEP-SADCJIHRQW7RJ62U

(Ignore the comments within the /* */ section) Following the chain we endup with a DNAT target that routes the tcp connection to the ip,port combo 10.244.1.2 DNAT or destination NAT is a target in a rule that rewrites the destination ip,port in the packet to the values mentioned in the rule. So when our worker node gets a TCP packet with the destination port 31831 (the second rule in our KUBE-NODEPORTS chain) meant for the nginx service, the packet processing rule follows the chains until it lands on our DNAT target rule which then rewrites the destination ip and port in our packet.

The packet is now ready to be routed. The new ip of the packet is now not 127.0.0.1 or 0.0.0.0 which means that this packet is not meant for any process in the localhost. Where should the packet be sent to then ? Lets consult the routing table in our worker

# on m03
> ip route show
ip route show
default via 192.168.122.1 dev eth1 proto dhcp src 192.168.122.74 metric 1024 
10.244.0.0/24 via 192.168.39.217 dev eth0 
10.244.1.0/24 via 192.168.39.133 dev eth0 
192.168.39.0/24 dev eth0 proto kernel scope link src 192.168.39.167 metric 1024 
192.168.122.0/24 dev eth1 proto kernel scope link src 192.168.122.74 metric 1024 
192.168.122.1 dev eth1 proto dhcp scope link src 192.168.122.74 metric 1024 

and voila ! The route for 10.244.1.2 must be routed via 192.168.39.133 which maps to the ip address of our worker m02 (where the nginx pod is running)

      <host mac='7a:e5:d3:9f:09:61' name='mmd-m02' ip='192.168.39.133'/>

Lets peek inside our worker m02 routing table and nat chains to see how m02 hands off the packet to the nginx pod/container

>  ip route show
default via 192.168.122.1 dev eth1 proto dhcp src 192.168.122.177 metric 1024 
...
10.244.1.2 dev veth85b23f64 scope host 
...

This seems to be a veth device that is attached to a container. We can verify this by inspecting the running containers and the addr associated with the veth devices within them

> sudo crictl ps
CONTAINER           IMAGE               CREATED             STATE               NAME                ATTEMPT             POD ID              POD                      NAMESPACE
6c9692da0d57f       5f52b9aca7a79       13 hours ago        Running             nginx-host          0                   1c9b1927ce044       nginx-6f764748fc-4wk85   default
ee1108dce6687       4626fe10df5b9       14 hours ago        Running             kindnet-cni         1                   f0fbf1f141558       kindnet-8g44n            kube-system
f77ac4d52ac87       d6a28daf3e6b0       14 hours ago        Running             kube-proxy          1                   08679416f77a6       kube-proxy-n4pfh         kube-system
> sudo crictl inspect 6c9692da0d57f | grep "pid" 
    "pid": 1816,
> $ sudo nsenter -t 1816 -n ip addr show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
   .....
3: eth0@if5: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether 7a:a4:12:50:88:8f brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 10.244.1.2/24 brd 10.244.1.255 scope global eth0
       valid_lft forever preferred_lft forever

So confirmed ! Our simple curl request from the host goes through a really long-winding path of:

  1. Reaching the worker node m03 and having the destination ip + port rewritten in the PREROUTING chain
  2. Routed to the worker node m03 by the routing table on m03
  3. On m02 routed to the veth device inside the nginx container’s network namespace where the nginx process is listening on port 80

Complicated, am I right ?

What happens if we make a curl request directly to the m02 node’s NodePort service from the host ?

Lets look at the PREROUTING table on the node m02 again and surprise, the chain is almost exactly similar to what we had seen in m03 with incoming packets for the NodePort service having its dnat re-written to the ip:port combo of 10.244.1.2:80 thus getting routed to our container !

$ sudo iptables -t nat -L -v -n 
Chain PREROUTING (policy ACCEPT 5 packets, 498 bytes)
 pkts bytes target     prot opt in     out     source               destination         
  127 19160 KUBE-SERVICES  all  --  *      *       0.0.0.0/0            0.0.0.0/0            /* kubernetes service portals */

Chain KUBE-SERVICES (2 references)
 pkts bytes target     prot opt in     out     source               destination         
    ....
    0     0 KUBE-SVC-HL5LMXD5JFHQZ6LN  tcp  --  *      *       0.0.0.0/0            10.97.146.53         /* default/nginx-svc cluster IP */ tcp dpt:80
    1    60 KUBE-NODEPORTS  all  --  *      *       0.0.0.0/0            0.0.0.0/0            /* kubernetes service nodeports; NOTE: this must be the last rule in this chain */ ADDRTYPE match dst-type LOCAL

Chain KUBE-NODEPORTS (1 references)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 KUBE-EXT-HL5LMXD5JFHQZ6LN  tcp  --  *      *       0.0.0.0/0            127.0.0.0/8          /* default/nginx-svc */ tcp dpt:31831 nfacct-name  localhost_nps_accepted_pkts
    0     0 KUBE-EXT-HL5LMXD5JFHQZ6LN  tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            /* default/nginx-svc */ tcp dpt:31831

Chain KUBE-EXT-HL5LMXD5JFHQZ6LN (2 references)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 KUBE-MARK-MASQ  all  --  *      *       0.0.0.0/0            0.0.0.0/0            /* masquerade traffic for default/nginx-svc external destinations */
    0     0 KUBE-SVC-HL5LMXD5JFHQZ6LN  all  --  *      *       0.0.0.0/0            0.0.0.0/0           

Chain KUBE-SVC-HL5LMXD5JFHQZ6LN (2 references)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 KUBE-MARK-MASQ  tcp  --  *      *      !10.244.0.0/16        10.97.146.53         /* default/nginx-svc cluster IP */ tcp dpt:80
    0     0 KUBE-SEP-SADCJIHRQW7RJ62U  all  --  *      *       0.0.0.0/0            0.0.0.0/0            /* default/nginx-svc -> 10.244.1.2:80 */

Chain KUBE-SEP-SADCJIHRQW7RJ62U (1 references)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 KUBE-MARK-MASQ  all  --  *      *       10.244.1.2           0.0.0.0/0            /* default/nginx-svc */
    0     0 DNAT       tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            /* default/nginx-svc */ tcp to:10.244.1.2:80

The solution to handling the pre-routing chain is quite elegant ! All the pre-routing for all services whether it is traffic from within or external from cluster start processing at POSTROUTING -> KUBE-SERVICES. From KUBE-SERVICES, if the traffic is from within the cluster, then it gets processed directly via the KUBE-SVC-HL5... chain which handles the DNAT for our nginx service. If the traffic originated from the outside, then it is handled via the KUBE-NODEPORTS -> KUBE-EXT-HL5LMXD5JFHQZ6LN -> KUBE-SVC-HL5.. chain thus all ending up in the same DNAT rule. Elegant, isn’t it ?

How pods can reach the service internally by resolving the nginx.default.svc.cluster.local DNS record

We saw how the PREROUTING chain rewrites the destination ip:port combo to allow the packets originating outside the cluster to be routed to the right container. What happens when we try to reach the nginx service from within the cluster from another pod ?

We can see from the kubectl get output that each kubernetes services has an associated IP address

> k get services -o wide
NAME         TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)        AGE   SELECTOR
kubernetes   ClusterIP   10.96.0.1      <none>        443/TCP        28h   <none>
nginx-svc    NodePort    10.97.146.53   <none>        80:31831/TCP   14h   app=nginx

Running an nslookup from another pod/container resolves the same IP for the DNS

> minikube kubectl -p mmd -- exec -it dnsutils -- nslookup nginx-svc.default.svc.cluster.local
Server:		10.96.0.10
Address:	10.96.0.10#53

Name:	nginx-svc.default.svc.cluster.local
Address: 10.97.146.53

Remember the PREROUTING tables from the previous section, yes the same rules now apply to within cluster traffic as well ! Each container in the worker node is connected to the worker node via a veth pair

> ip addr show
...
6: veth6adf44b8@if3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default 
    link/ether c6:47:04:73:98:b7 brd ff:ff:ff:ff:ff:ff link-netns cni-55c28e65-ac40-f2d7-ede3-8acb435f8d11

Just like a packet coming into the eth0 interface connect to the host (and bridge) any packet originating from a pod/container is also processed via the PREROUTING chain (since it is coming INTO the worker node, of sorts) and our PREROUTING chain performs the same dance as above through the KUBE-SERVICES chain

Chain KUBE-SERVICES (2 references)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 KUBE-SVC-ERIFXISQEP7F7OF4  tcp  --  *      *       0.0.0.0/0            10.96.0.10           /* kube-system/kube-dns:dns-tcp cluster IP */ tcp dpt:53
    0     0 KUBE-SVC-JD5MR3NA4I4DYORP  tcp  --  *      *       0.0.0.0/0            10.96.0.10           /* kube-system/kube-dns:metrics cluster IP */ tcp dpt:9153
    0     0 KUBE-SVC-HL5LMXD5JFHQZ6LN  tcp  --  *      *       0.0.0.0/0            10.97.146.53         /* default/nginx-svc cluster IP */ tcp dpt:80
    0     0 KUBE-SVC-NPX46M4PTMTKRN6Y  tcp  --  *      *       0.0.0.0/0            10.96.0.1            /* default/kubernetes:https cluster IP */ tcp dpt:443
    9   829 KUBE-SVC-TCOU7JCQXEZGVUNU  udp  --  *      *       0.0.0.0/0            10.96.0.10           /* kube-system/kube-dns:dns cluster IP */ udp dpt:53
    5   300 KUBE-NODEPORTS  all  --  *      *       0.0.0.0/0            0.0.0.0/0            /* kubernetes service nodeports; NOTE: this must be the last rule in this chain */ ADDRTYPE match dst-type LOCAL

See how rule 3 matches the destination 10.97.146.53: which is our clusterIP for the nginx-svc ? And the target is our Chain KUBE-SVC-HL5LMXD5JFHQZ6LN which handled the NodePort traffic as well ?

** Magic ! Pure Magic ! **

Conclusion

I had been putting away exploring this topic for almost a year now. The idea to explore how Kubernetes services were setup was something that I thought of last September, but work and other commitments sadly didn’t leave me the time to explore it further. And now that I finally did, I am positively shocked at the elegance of these ideas ! The same PREROUTING chains in both the workers even when the Pod was running on only one node ? Mind boggling ! There is an entirely different topic of how routing works with multiple pods per service, but that’s probably left for another post. Even exploring and mapping this much turned out to be a time-consuming exercise and I haven’t even began to explore conntrack or the MARK & MASQUERADE techniques yet, but hey mor content for the future am I right ?

Nonetheless, exploring Kubernetes internals has been a deeply satisfying experience (thanks to LLMs that help so much with debugging and explaining very tersely written Linux documentation) and I cannot stop but marvel at the elegance of simple ideas composed together beautifully, just like chains in an iptable !