Kubernetes for beginners. Part 37: How does Windows node networking work, and which CNI should you choose?
All parts of the series “Kubernetes for beginners”In the previous part we looked at what a Windows node consists of: kubelet and containerd are Windows services, containers are created by HCS, the network by HNS, kube-proxy and the network plugin run in HostProcess containers, and the kubernetes.io/os label, a taint and a RuntimeClass send pods to their own nodes. Today it is the network of such a node as a whole. On Linux nodes it works as described in part 14 and part 15: cali… veths, /26 routes from Felix, VXLAN in CrossSubnet mode, nftables rules from Felix and kube-proxy. Windows has none of this: no veth, no routing table in the usual sense, no netfilter. We will see what replaces them — HNS and VFP — which network modes Windows has, which network plugins work on it and with what limitations, how kube-proxy works in winkernel mode and why a cluster with Windows nodes needs Calico in VXLAN mode in all directions and without BGP. The series' lab cluster works differently — VXLANCrossSubnet and BGP — so at the end we will choose where to run Windows nodes. The recommended path is a separate test cluster built for Windows workers from the start: we will look at its network here and build it in part 38. The alternative is to migrate the existing cluster; we will go through that path step by step too, with the risks and the rollback. We do not have a cluster with a Windows node yet, so this part, like the previous one, is about the design; we will see everything described here live in part 38.
What changes compared to Linux
The requirements of the Kubernetes network model are the same on Windows: every pod has its own address, pods reach each other without NAT, a node reaches its pods. Everything below those requirements changes:
| Task | Linux node | Windows node |
|---|---|---|
| isolated pod network | network namespace (netns) | network compartment |
| pod interface | cali… veth pair |
a virtual adapter (vNIC) on a port of the Hyper-V virtual switch |
| who creates the interface | the CNI plugin via kernel system calls | the CNI plugin via the HNS service API |
| route to a neighbour's block | a kernel route from BIRD or Felix | a RemoteSubnetRoute policy in the HNS network, enforced by VFP |
| VXLAN | the vxlan.calico interface, FDB and ARP entries |
an HNS network of type Overlay with VSID 4096, encapsulation by VFP |
| Service | the ip kube-proxy table in nftables |
LoadBalancer objects in HNS, enforced by VFP |
| NetworkPolicy | the ip calico table in nftables |
ACL policies on HNS endpoints, enforced by VFP |
natOutgoing |
masquerading in nftables | an OutBoundNAT policy on the pod's endpoint |
| tools | ip, nft, tcpdump, conntrack |
Get-HnsNetwork, Get-HnsEndpoint, hnsdiag, vfpctrl, pktmon |
Almost every row on the right comes down to two components — HNS and VFP. That is where we start.
HNS: who builds the network on a Windows node
On Linux the network plugin creates veths, assigns addresses and writes routes itself — these are kernel system calls available to any process with the right privileges. On Windows that is not how it is done: all the containers' network objects are created by a system service, HNS (Host Networking Service, the hns service). The CNI plugin, kube-proxy and Felix are its clients: they describe the desired state in JSON and send it through the HCN (Host Compute Network) API, and HNS turns the description into the configuration of a virtual switch. The Kubernetes documentation says plainly that Windows keeps DNS addresses, routes and other network settings not in files like /etc/resolv.conf but in the registry and in HNS objects, so CNI plugins on Windows have to work through HNS rather than drop files into the container.
HNS objects:
- Network — the segment pods connect to: its type (
Overlay,L2Bridgeand others, see below), a subnet with a gateway address, network-level policies (the VXLAN segment number, routes to other nodes' subnets). When the first network is created, HNS creates a Hyper-V virtual switch (vSwitch) and binds it to the physical adapter — in our case theEthernetadapter. - Endpoint — the connection of one pod: a switch port, a virtual adapter with a MAC address, an IP address, a gateway, DNS and endpoint-level policies (ACL,
OutBoundNAT). It is the analogue of a veth pair together with the /32 route on the node. - HNS namespace — what Windows calls a network compartment: its own set of interfaces and its own routing table, separate from the host. In meaning it is a netns. A compartment is created per pod, the pause container lives in it, and all the pod's containers share it, as on Linux they share a network namespace.
- Load balancer — a rule "virtual address and port → list of endpoint addresses". kube-proxy creates them, one object per port of each service.
An important detail about the virtual switch. When HNS binds it to the physical adapter, the Ethernet adapter loses its own IP: its address moves to a new virtual host adapter, vEthernet (Ethernet), connected to the same switch. From then on the host itself is one of the "guests" of its switch. For a few seconds while the switch is created the node drops off the network and remote management sessions break; the Calico documentation separately warns that in clouds the route to the metadata service may disappear at that moment. This is normal behaviour on the first start of a network plugin on a node.
k8s-lab-win-1
┌──────────────────────────────────────────────────────────────────┐
│ compartment of pod web compartment of another pod │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ vNIC │ │ vNIC │ │
│ │ 172.21.41.6/26 │ │ 172.21.41.5/26 │ │
│ └─────────┬──────────┘ └─────────┬──────────┘ │
│ │ port │ port │
│ ┌─────────┴───────────────────────────┴──────────────────────┐ │
│ │ Hyper-V virtual switch │ │
│ │ VFP extension: ACL, load balancing, NAT, VXLAN │ │
│ └─────────┬───────────────────────────┬──────────────────────┘ │
│ │ port │ external port │
│ ┌─────────┴──────────┐ ┌─────────┴──────────┐ │
│ │ vEthernet │ │ Ethernet │ │
│ │ (Ethernet) │ │ physical adapter │ │
│ │ 192.168.10.25/24 │ │ no IP address │ │
│ └────────────────────┘ └─────────┬──────────┘ │
└────────────────────────────────────────┼─────────────────────────┘
▼
network 192.168.10.0/24
When a pod starts, containerd, through HCS (Host Compute Service — the service that creates and runs containers), creates a compartment and calls the CNI plugin; the plugin asks HNS to create an endpoint in its network and attach it to the compartment, and the kubelet, as on Linux, gets the pod's address and writes it into status.podIP. From PowerShell, HNS is handled by the HostNetworkingService module built into Windows Server 2025 (Get-HnsNetwork, Get-HnsEndpoint, Get-HnsNamespace, Get-HnsPolicyList) and by the hnsdiag console utility.
VFP: rules inside the virtual switch
HNS decides what there should be: which addresses, which ACLs, which load balancers. This is enforced by VFP (Virtual Filtering Platform), an extension of the Hyper-V virtual switch through which every packet between its ports passes. It is the same technology the software-defined network of Azure hosts is built on: VFP was developed to apply the virtual network policies of thousands of customers on every physical cloud server without touching the network hardware.
How VFP works:
- Layers. Every switch port has an ordered set of layers. A layer is a table of "condition → action" rules programmed by one owner: an ACL layer for policies, a load balancing and NAT layer for services, a routing and encapsulation layer for the overlay. A packet passes the layers in turn, like netfilter chains, and each layer can drop it or change its headers.
- Header transposition. The action of a VFP rule is not "jump to such-and-such chain" but a description of how to rewrite headers: replace the destination address, add an outer VXLAN header, swap the MAC.
- Unified flow. The first packet of a new connection passes all the layers, and VFP combines their actions into one final transformation, for example "replace the destination
10.97.141.163:80with172.21.211.8:8080and wrap it in VXLAN to192.168.10.24". The transformation is stored in the flow cache, and the connection's following packets no longer pass the layers — the ready transformation is applied to them straight away, and the reverse one to the replies. In meaning this is conntrack together with NAT rules, only done inside the switch.
a packet from the pod's vNIC
│
▼
VFP flow cache ── known flow ──► apply the ready transformation ──► onward
│
│ new flow
▼
ACL layer Felix: do the policies allow it?
▼
load balancing/NAT layer kube-proxy: ClusterIP → pod address, OutBoundNAT
▼
routing layer Calico: neighbour's block → wrap in VXLAN to its node
▼
merge the actions into a unified flow, cache it, apply it
One known limitation is tied to this model — outbound ICMP through NAT. ICMP is the IP service protocol for error messages and reachability checks; ping runs on it (part 8 of the networking series). The Kubernetes documentation lists it among the limitations of Windows networking: an echo request from a pod to an address inside the same network (to a pod, including one on another node) goes through, while a request to the outside, which has to be translated through OutBoundNAT, may not come back to the pod. On recent Windows Server 2025 builds with Calico in VXLAN mode, outbound ping worked for us, so the limitation depends on the OS build and the network plugin. The rule that follows is one: "ping to the gateway fails" on a Windows pod is not yet a sign of breakage, and outbound connectivity is checked with curl.exe or Test-NetConnection -Port, which work over TCP.
The layers and rules of a port are shown by the vfpctrl utility (/list-vmswitch-port, then /port <name> /list-layer), but for everyday troubleshooting the HNS level is enough. Packets on Windows are captured by the built-in pktmon, the analogue of tcpdump, which also sees packets on the virtual switch ports.
Network modes: l2bridge and overlay
The type of the HNS network determines how pod packets leave the node. Windows supports five types, and the Kubernetes documentation lists them all, but for Kubernetes on your own hardware two matter.
L2bridge. Pods are connected to the external switch, and their addresses are visible on the physical network as they are, without wrapping. So that the physical switch does not learn the MAC addresses of hundreds of short-lived pods, VFP replaces the source MAC of pod packets with the node's MAC, and back on the way in (MAC rewrite). A packet from a pod to a pod on a neighbouring node goes onto the network with the pods' IP addresses and the nodes' MAC addresses — exactly like Calico's direct routing on Linux from part 14. Hence the requirement: the nodes must be L2 neighbours, or the router must know where each pod block is (static routes or BGP). Flannel host-gw (via the win-bridge plugin), Calico with BGP without encapsulation and Azure CNI work in this mode. The Kubernetes documentation calls it the most performant.
Overlay. Pod packets between nodes are wrapped in VXLAN — a protocol that packs an entire Ethernet frame into a UDP datagram (on Windows only port 4789) with a header carrying a 24-bit segment number, the VNI (part 18 of the networking series). In Windows terms the segment number is called a VSID. The physical network sees only UDP between node addresses and does not need to know about the pod network. Flannel VXLAN (via the win-overlay plugin) and Calico VXLAN work in this mode. The mode table in the Kubernetes documentation still marks overlay as beta, although the WinOverlay feature gate that enabled it in kube-proxy has been stable since 1.34.
l2bridge: a pod packet on the wire
[Ethernet: MAC k8s-lab-win-1 → MAC k8s-lab-node-1][IP 172.21.41.6 → 172.21.211.8][TCP …]
overlay: the same packet
[Ethernet: node → node][IP 192.168.10.25 → 192.168.10.24][UDP → 4789][VXLAN, VNI 4096]
[Ethernet: VTEP → VTEP][IP 172.21.41.6 → 172.21.211.8][TCP …]
The other three types:
- L2tunnel — a variant of l2bridge for Azure only: all pod traffic is sent to the physical virtualization host, where the cloud network's policies are applied to it. Not used outside Azure.
- Transparent — pods are connected to the external switch without MAC rewriting; each is visible on the physical network with its own MAC; on a virtual machine the hypervisor must allow MAC spoofing. This mode gives neither routing between nodes nor load balancing, and the Kubernetes documentation mentions it only for ovn-kubernetes, which builds its own network on Open vSwitch on top of the switch.
- NAT — pods sit behind WinNAT address translation and are reachable from outside only through the node's port forwarding. This is Docker's mode on a single machine, and it contradicts the Kubernetes network model, where pods see each other by their addresses. Not used in Kubernetes.
Which plugins work on Windows
The network plugin in a mixed cluster must work on both kinds of nodes and understand the pod network in the same way. The choice on Windows is much narrower than on Linux:
| Plugin | HNS network type on Windows | Between nodes | kube-proxy on Windows | NetworkPolicy on Windows | Limitations and status |
|---|---|---|---|---|---|
| Flannel host-gw | l2bridge (win-bridge) |
directly, nodes in one L2 network | needed, winkernel | no | stable per the Kubernetes documentation |
| Flannel VXLAN | overlay (win-overlay) |
VXLAN, UDP 4789, VNI 4096 | needed, winkernel | no | beta; a node reaches only its own pods; no DirectRouting on Windows |
| Calico VXLAN | overlay | VXLAN without CrossSubnet, BGP off in the cluster | needed, winkernel | yes | no IPIP, IPv6, eBPF, WireGuard, host policies; one block per node, 4 block addresses taken |
| Calico BGP | l2bridge | no encapsulation, BGP through the Windows router | needed, winkernel | yes | the RRAS role is needed on nodes; route reflectors only on Linux; nodes need L2 connectivity or a router with BGP |
| Antrea | an HNS network with Open vSwitch | Geneve, VXLAN or STT | not needed: AntreaProxy | yes | node preparation script after every reboot; the compatibility table in the docs goes up to Kubernetes 1.30 |
| ovn-kubernetes | Transparent with OVS or hybrid overlay | GENEVE, STT or VXLAN | not needed in OVS mode | in OVS mode | rarely seen on Windows outside OpenShift |
| Azure CNI | l2bridge, l2tunnel | pod addresses from the Azure virtual network | needed | via Azure | AKS and Azure only |
| Amazon VPC CNI | addresses from the VPC | secondary addresses or prefixes on the network interface | needed | via add-ons | EKS only |
| Cilium | — | — | — | — | does not support Windows |
Now each in more detail.
The reference plugins win-bridge and win-overlay. These are CNI plugins from the containernetworking/plugins project that create an L2Bridge or Overlay network in HNS and endpoints in it. By themselves they do not hand out subnets to nodes and know nothing about neighbours — whoever calls them does that. The Kubernetes documentation notes that they do not implement the CHECK operation and so do not conform to CNI spec 0.4.0 and later.
Flannel is a simple network plugin: the flanneld daemon on each node takes the node's subnet from spec.podCIDR of the Node object and writes routes to the neighbours' subnets. Flannel does not implement network policies. On Windows it delegates network creation to the reference plugins. The host-gw backend is routes to the neighbours' subnets via their addresses, without wrapping, on top of win-bridge; the Kubernetes documentation calls it stable. The VXLAN backend runs on top of win-overlay and is beta; on Windows the VNI must be 4096 or higher, the port only 4789, and DirectRouting mode (the analogue of CrossSubnet: no wrapping for neighbours in the same subnet) and VXLAN GBP are not supported on Windows. One more limitation from the Kubernetes documentation: a node with Flannel VXLAN reaches only the pods on itself. Flannel does not suit us for two reasons. The main lab cluster already runs Calico, and changing the network plugin of a live cluster means recreating the network of all pods. And we build the test cluster for Windows nodes to resemble the main one, so that experience carries over from one to the other — with the same Calico and with the network policies from part 20, which Flannel cannot do.
Calico works on Windows in two modes, both narrower than on Linux. We cover it in detail in the next section.
Antrea is a network plugin built on Open vSwitch (OVS): a software switch controlled by flow rules over the OpenFlow protocol — "a packet with such-and-such fields → such-and-such actions". On Windows OVS runs as an extension of the Hyper-V virtual switch, that is, it takes the place where the other plugins have VFP, and Antrea programs the same rules on Linux and on Windows. Between nodes Antrea supports three kinds of tunnels: Geneve (UDP encapsulation on port 6081, similar to VXLAN but with extensible option fields in the header), VXLAN and STT (Stateless Transport Tunneling — encapsulation that looks like TCP to the network card, so the card can segment large packets itself). The main difference from the rest: AntreaProxy replaces kube-proxy on Windows nodes by default, and the Antrea documentation says not to install kube-proxy there. Limitations from the same documentation: the preparation script must be run after every reboot of the node (Antrea's HNS network does not survive it), the first start of the agent briefly interrupts the node's network, and the OVS driver in the shipped package is test-signed.
ovn-kubernetes is a network plugin built on OVN (Open Virtual Network — a layer above OVS that describes logical switches, routers and load balancers and lays them out into OVS rules on the nodes itself). The Kubernetes documentation describes it on Windows in Transparent mode with OVS, without kube-proxy. In practice it meets Windows nodes in OpenShift, and there the scheme is different — hybrid overlay: Linux nodes live in OVN, while Windows nodes get an ordinary HNS network of type Overlay with VXLAN.
Azure CNI and Amazon VPC CNI give pods addresses straight from the cloud's virtual network. Azure CNI on Windows works in l2bridge and l2tunnel modes. In EKS, addresses for Windows pods are handed out by the VPC Resource Controller — secondary addresses or /28 prefixes on the node's network interface (ENI), and a Windows node uses only one interface. Both plugins make sense only in their own cloud.
Cilium is a network plugin built on eBPF, a Linux kernel mechanism for loading verified packet-processing programs into the kernel. Windows has no such kernel, and Cilium has no official support for Windows nodes: even in AKS the documentation describes Azure CNI Powered by Cilium as available for Linux only. The eBPF for Windows project exists, but there is no full Cilium dataplane for Windows nodes built on it.
Calico on Windows
On a Windows node Calico is installed by the same Tigera operator as on Linux: the operator runs the calico-node-windows DaemonSet of HostProcess pods. A HostProcess container is a process that runs directly on the host, in its network and with access to its file system, but is started and updated like an ordinary pod; through it the network plugin gets the privileges it needs to work with HNS without being installed on the node by hand. In the calico-node-windows pod the init containers uninstall-calico (removes Calico installed on the node the old way — with scripts and Windows services) and install-cni (puts the CNI binaries and configuration in place) run first, and the main containers are felix and node (in BGP mode confd joins them). Felix on Windows does the same as on Linux, but through HNS: it creates ACL policies on the pods' endpoints and routing policies in the HNS network. The Calico documentation supports on Windows only installation by the operator, only the Kubernetes API datastore and containerd 1.6 and later (on k8s-lab-win-1 it is containerd 2.2.2). Of the Windows versions, Calico's requirements at the time of writing name Windows Server 1809 and 2022; Windows Server 2025, which our Windows node runs, is not on that list, although Kubernetes itself supports it. In part 38 the combination of Calico 3.33 and Windows Server 2025 will work for us, but formally Calico does not confirm it — worth keeping in mind and checking before Calico upgrades.
Two modes and what is missing
VXLAN. An HNS network Calico of type Overlay, VSID 4096, UDP 4789 — the only VXLAN port Windows supports. For every pod block on another node Felix adds a RemoteSubnetRoute policy to the HNS network: "destination subnet such-and-such, node address (provider address) such-and-such, MAC of its VTEP such-and-such, segment number 4096". A VTEP (VXLAN Tunnel Endpoint) is the point where frames are wrapped into VXLAN and unwrapped back; on Linux it is the vxlan.calico interface with its own MAC address. This is exactly the same information that Felix on Linux lays out into a route via vxlan.calico, an ARP entry and an FDB entry (the table by which a VXLAN interface chooses which node address to send a frame with a given destination MAC to).
The limitation of this mode that keeps the main lab cluster, as it is now, from accepting a Windows node: CrossSubnet is not supported on Windows, and the Calico documentation requires BGP to be off in the cluster when Calico VXLAN is used. They cannot be mixed: bgp in the Installation resource is a cluster-wide setting, and the encapsulation mode is set on the pool, shared by all nodes. So the pool must be VXLAN Always — wrapping between any two nodes, including Linux nodes in the same subnet — and BGP must be off: on Linux the routes to blocks are written by Felix anyway, and BIRD is not needed for them.
BGP without encapsulation. An HNS network of type L2Bridge, pod addresses visible on the physical network, and the Windows node gets routes to blocks over BGP. Calico for Windows has no BGP daemon of its own: confd configures the built-in Windows router — the RRAS service (Routing and Remote Access Service), which includes BGP. So on every Windows node you need to install the RemoteAccess, RSAT-RemoteAccess-PowerShell and Routing features, reboot and enable RRAS in routing-only mode. If the cluster uses route reflectors (BGP nodes that forward routes to all clients so that a full mesh of sessions is not needed), they must be Linux nodes: a Windows node cannot be a reflector. One more condition: without an external router with BGP, the Linux and Windows nodes must be in one L2 network. The pool in this mode has encapsulation: None.
What Windows lacks in either mode, per Calico's limitations table:
- IPIP — IP-in-IP encapsulation (IP protocol 4), which Calico supports on Linux: Windows does not have it, and a pool with IPIP cannot be used in a cluster with Windows nodes;
- IPv6 — with Calico (and in overlay/VXLAN generally) IPv6 on Windows nodes is not supported, IPv4 only;
- the eBPF dataplane — a Linux kernel feature;
- WireGuard — a VPN protocol in the Linux kernel that Calico can use to encrypt traffic between nodes;
- host endpoint policies and advertising service addresses over BGP;
- Typha — the caching proxy between the API and Felix from part 14 — does not run on Windows nodes; its replicas live only on Linux;
- CrossSubnet and MTU configuration for VXLAN.
IPAM: strictAffinity, one block and four addresses
The subnet of an HNS network is created once and does not change afterwards: Windows cannot update the subnet parameters of an existing network, and the Calico documentation says directly that some settings are not applied after the first pod is connected, and changing, for example, the VNI requires draining the node, deleting Calico's HNS network and reinstalling it. Hence several IPAM rules for Windows nodes:
- One block per node. kube-proxy on Windows supports only one subnet per HNS network, so a Windows node gets exactly one block of the pool. With the standard
blockSize: 26that is 64 addresses. - Four block addresses are taken. A Windows node reserves the first three addresses of its block and the last one: the network address, the HNS subnet's gateway, a service address and the broadcast address; for the
172.21.41.0/26block ofk8s-lab-win-1these are.0,.1,.2and.63. 60 remain for pods. When they run out, new pods on the node will not get an address rather than take one in someone else's block: Windows nodes do not borrow addresses from other blocks, even if the pool is full. So on a Windows node it is sensible to limit the kubelet'smaxPodsto 60 at most — otherwise the scheduler will send pods there that will get no address. If more pods are needed, the block is made larger before the pool starts handing out addresses: theblockSizeof an existing pool cannot be changed. strictAffinity: true. Calico's IPAMConfig resource has a strict affinity parameter: with it, IPAM gives a node addresses only from blocks affine to the node itself, and borrowing from part 14 is switched off for all nodes. Why Windows needs this: if a Linux node takes an address from a Windows node's block, that address ends up inside the Windows node's HNS subnet, and VFP will consider it its own, local — packets to the pod on the Linux node will not reach it. The Calico documentation requires strictAffinity to be enabled before Windows nodes join. The price for Linux is the same as for Windows: when the pool has no free blocks left, a new pod gets no address. A/16pool with /26 blocks has 1024 blocks, so in both the test cluster (172.21.0.0/16) and the main one (172.20.0.0/16) this is a theoretical danger.natOutgoingis applied when a pod is created. On Windows masquerading is anOutBoundNATpolicy on the pod's endpoint with an exception list (the pool addresses and the service network, which need no translation). The policy is attached once, when the endpoint is created, so changingnatOutgoingon the pool affects only new pods.
Network policies on Windows
Felix translates NetworkPolicy and Calico policies into HNS ACL rules on the pods' endpoints, and VFP enforces them in the ACL layer. It works the same as on Linux, with one warning from the Calico documentation: even simple policies can require a lot of resources from the Windows dataplane, especially rules where both source and destination selectors are set: such rules expand into many ACL entries. The documentation advises splitting such rules into separate rules for ingress and egress traffic. And also: restarting Felix or changing policies can reset established connections between pods with TCP RST packets.
What we choose
There are two options: VXLAN Always without BGP, or BGP without encapsulation. All our nodes are in one network, 192.168.10.0/24, so both would work. We choose VXLAN Always with BGP off:
- the Windows node needs neither RRAS nor routing roles — fewer components on Server Core, less to update and troubleshoot;
- the pod network does not depend on the physical network topology: a Windows node can appear in another subnet and nothing will have to change;
- it is the main path in the Calico for Windows documentation, and it is closest to the series' main cluster: the pool stays with VXLAN encapsulation, only the wrapping is on between any nodes.
The price is 50 bytes of VXLAN headers and the wrapping work on every packet between nodes, including between Linux nodes in the same subnet, which in CrossSubnet mode would talk directly. The MTU of pod interfaces is 1450 in both modes: Calico itself subtracts the VXLAN header from the node adapter's MTU, and in the main cluster the CrossSubnet mode reserved the same headroom (this can be seen in the ip addr output in part 14). The rejected option — keep Linux on CrossSubnet and give Windows nodes a separate pool with VXLAN — does not work: BGP is switched on and off for the whole cluster, and Calico for Windows with VXLAN requires it to be off.
kube-proxy in winkernel mode
On Windows kube-proxy has one mode — kernelspace; in the documentation and the code it is called winkernel. The name is historical: the "kernel" here is HNS and VFP. kube-proxy watches Service and EndpointSlice just the same, but instead of an nftables table it creates HNS objects:
- a load balancer per service port: virtual address and port (ClusterIP, NodePort, external address) → a list of backend endpoints. VFP picks a backend for a new connection and records the choice in the unified flow — like
numgen randomand conntrack in nftables mode from part 15; - remote endpoints for pods on other nodes. An HNS load balancer can refer only to endpoints, so for the backend
172.21.211.8onk8s-lab-node-1kube-proxy creates an "empty" endpoint with that address in the HNS network, marked as remote. VFP sends packets to it by the network's routing policies — in VXLAN to the right node.
Source VIP. In overlay mode kube-proxy needs an address from the node's subnet on behalf of which VFP sends service traffic when the source address has to be replaced (for example, when a request came to a NodePort from outside and goes to a pod on another node). This is the sourceVip parameter. With Calico, the kube-proxy start script from sig-windows-tools waits for the service endpoint Calico_ep to appear in the Calico network and takes its address as the source VIP — it is one of the reserved addresses of the block.
DSR (Direct Server Return). Without DSR, the node that picked a backend on another node replaces both the destination and the source address, so that the reply comes back through it and goes through the reverse transformation. With DSR, load balancing happens on the client's node, and the reverse transformation of the reply is done by VFP on the pod's node: the reply goes straight to the client, and the pod sees the real client address. DSR on Windows has been stable since Kubernetes 1.34 and is locked on: the WinDSR feature gate cannot be turned off, and an explicitly set value is ignored. It is needed not only for performance: externalTrafficPolicy: Local on Windows works only with DSR (the --enable-dsr=true flag). The service annotation preserve-destination: "true" goes further — it skips DNAT, and the pod receives the packet with the service's virtual address as the destination.
Session affinity (sessionAffinity: ClientIP) works since Windows Server 2022, so it is available on our node's Windows Server 2025.
Configuration. KubeProxyConfiguration for Windows has a winkernel section: networkName (the HNS network where kube-proxy creates objects), sourceVip, enableDSR, rootHnsEndpointName and forwardHealthCheckVip. The kube-proxy-windows DaemonSet from sig-windows-tools passes everything as flags: start.ps1 runs kube-proxy.exe with --proxy-mode=kernelspace, --source-vip, --enable-dsr=true and the feature gates WinDSR=true,WinOverlay=true (both stable, specifying them only confirms the default), and passes the network name Calico in the KUBE_NETWORK variable. From the shared kube-proxy ConfigMap it takes only the kubeconfig, so mode: nftables for the Linux nodes does not bother the Windows pod. Before starting, the script deletes old HNS policies: kube-proxy cannot reconcile with ones left from a previous run.
Limitations of Windows node networking
The list from the Kubernetes documentation, with an explanation of where each limitation comes from:
- No
hostNetworkfor ordinary pods. A Windows pod always has its own compartment. The alpha featureWindowsHostNetworkthat changed this has been withdrawn, and its feature gate has been deprecated since 1.33. Only HostProcess pods run in the node's network — that is howcalico-node-windowsandkube-proxy-windowswork. - NodePort is not reachable from the node itself. Accessing
localhost:30080or the node's own address on a NodePort port from a Windows node does not work; from other nodes and from outside it does. - No more than 64 backends per service. A VFP load balancer holds no more than 64 unique destination addresses. A service with a hundred pods on Windows nodes will not fit into one rule.
- Outbound ICMP through
win-overlay,win-bridgeand Azure CNI — the VFP transformation limitation covered above; on recent Windows Server 2025 builds it may not show up, but it is still better to check outbound connectivity over TCP. externalTrafficPolicy: Local— only with DSR.- No IPv6. With Calico (and in overlay/VXLAN generally) IPv6 on Windows nodes is not supported.
- Short names with a dot do not resolve. Among the supported access patterns on Windows the documentation lists "pod → service by partial name, only if it has no dot". The name
hellofrom the same namespace and the fullhello.demo.svc.cluster.localwork, whilehello.demodoes not: the Windows resolver does not append search suffixes to such a name the wayndots:5does on Linux (part 16). In the configuration of Windows applications, service names from other namespaces are best written in full. - Reference plugins without CHECK and the Flannel VXLAN limitations mentioned above.
Where to run Windows nodes: a new cluster or migrating the existing one
The series' main lab cluster currently works differently from what a Windows node needs. The default-ipv4-ippool pool is in VXLANCrossSubnet mode, BIRD on every node holds BGP sessions with all the others, although the routes to blocks are written into the kernel not by it but by Felix (proto 80), and strictAffinity is off. A Windows node needs VXLAN Always, BGP off and strictAffinity; VNI 4096 and port 4789 are the same as now. There are two ways to get there: build a separate test cluster for Windows workers, in the right mode from the start, or migrate the existing cluster with pods running. We recommend the first way; the second is an alternative, and we will go through it in full too.
The recommended path: a separate test cluster
Why a separate cluster rather than migrating the main one:
- Changing the network of a running cluster affects all pods. The encapsulation changes on all nodes at once, routes between nodes are rebuilt, connections in flight at that moment may break. Rolling back is the same cluster-wide operation.
- Calico on Windows puts conditions on the whole cluster. VXLAN without CrossSubnet, BGP off, strictAffinity, one block per Windows node, IPv4 only, no eBPF and WireGuard on Windows. It is easier to build these conditions in from day one than to rework a cluster that grew up without them.
- Windows nodes are a separate area of responsibility. Monthly updates, matching the image build with the host from part 36, domain accounts and the domain itself (part 40) — all of this is better learned on a cluster where a mistake will not hit the main services.
- The main cluster stays as it was —
VXLANCrossSubnetand BIRD — and everything built on it in the previous parts keeps working. Part 41 works with it too.
The test cluster is a minimal hybrid: a Linux control plane, two Linux workers and one Windows worker. The Linux workers are mandatory: they run CoreDNS, the Tigera operator and Typha, webhooks and CSI controllers — everything that exists only as Linux images — and Calico for Windows requires at least one Linux worker with Calico. The second worker is there so that either of them can be taken out for maintenance, as in the main cluster.
| Main cluster | Test cluster | |
|---|---|---|
| control plane | k8s-master-1, 192.168.10.17 |
k8s-lab-master-1, 192.168.10.23 |
| Linux workers | k8s-node-1 and k8s-node-2, 192.168.10.20 and .21 |
k8s-lab-node-1 and k8s-lab-node-2, 192.168.10.24 and .26 |
| Windows workers | none | k8s-lab-win-1, 192.168.10.25, Windows Server 2025 Server Core |
| API name | k8s-api.example.com |
k8s-lab-api.example.com |
| pod network | 172.20.0.0/16 |
172.21.0.0/16 |
| service network | 10.96.0.0/12 |
10.96.0.0/12 |
| Calico | VXLANCrossSubnet, BGP via BIRD |
VXLAN Always, BGP off |
| strictAffinity | off | on |
The test cluster's pod network is chosen to differ from the main one, so that addresses in command output are not confused and you can tell from a pod's address which cluster it is from. The service network can be the same: ClusterIPs do not leave their cluster.
Calico is installed into the test cluster by the Tigera operator, as in the main one, but in the right mode from the start — no migration at all. The Installation resource:
apiVersion: operator.tigera.io/v1
kind: Installation
metadata:
name: default
spec:
calicoNetwork:
bgp: Disabled
linuxDataplane: Nftables
windowsDataplane: HNS
ipPools:
- name: default-ipv4-ippool
cidr: 172.21.0.0/16
encapsulation: VXLAN
natOutgoing: Enabled
blockSize: 26
serviceCIDRs:
- 10.96.0.0/12
bgp: Disabled— BIRD and confd incalico-nodedo not start from the very beginning; the routes to the neighbours' blocks are written by Felix;encapsulation: VXLAN— the operator creates the pool withvxlanMode: Always; VNI 4096 and port 4789 are the defaults, we do not set them;linuxDataplane: Nftables— as in the main cluster;windowsDataplane: HNS— enables thecalico-node-windowsDaemonSet; without this field the operator does not serve Windows nodes;serviceCIDRs— the service network: Calico for Windows cannot learn it from the API, and on a Windows node it is needed, for example, for theOutBoundNATexception list discussed above.
Right after installation, before a Windows node appears, strictAffinity is enabled — with the same command as in step 1 of the alternative path below:
$ kubectl patch ipamconfigurations default --type merge -p '{"spec":{"strictAffinity":true}}'
ipamconfiguration.projectcalico.org/default patched
And the last thing that distinguishes installing Calico for Windows: the operator needs a direct address of the API server. Normally Calico components reach the API through the ClusterIP of the kubernetes service, but on a Windows node calico-node-windows starts before kube-proxy sets up load balancing, so the address is passed in a ConfigMap in the operator's namespace:
apiVersion: v1
kind: ConfigMap
metadata:
name: kubernetes-services-endpoint
namespace: tigera-operator
data:
KUBERNETES_SERVICE_HOST: k8s-lab-api.example.com
KUBERNETES_SERVICE_PORT: "6443"
The build itself is in part 38: three Linux machines the same way as in part 4, only with differences, Calico with these resources, a second context in the kubeconfig to work with both clusters from the workstation, and then the Windows worker.
The alternative: migrating the existing cluster
Migration makes sense when Windows applications need to live in the same cluster as the other services (shared Ingress, storage, monitoring) and there is nowhere to keep a second cluster. The price and the risks:
- the migration affects all pods on all nodes: at the moment the routes are replaced, connections between nodes may break, so in a production cluster it is done in a maintenance window;
- after the migration all pod traffic between any two nodes goes through VXLAN: UDP 4789 must be open between all nodes, and processes in the node's network start reaching pods from the tunnel address — NetworkPolicy rules with
ipBlockon node addresses will need to be extended (more in step 4); - turning BGP off also means giving up everything built on it: peering of nodes with a router and advertising service addresses over BGP;
- rolling back is possible only while there are no Windows nodes in the cluster; after the first one joins there is nothing to go back to.
The goal is VXLAN Always, BGP off, strictAffinity on, with VNI 4096 and port 4789 unchanged. This is done before a Windows node joins, on a running cluster, without recreating pods. After the migration you still need to add to the Installation what the test cluster gets at installation — windowsDataplane: HNS and serviceCIDRs — and create the kubernetes-services-endpoint ConfigMap; these changes rebuild nothing on the Linux nodes, but the ConfigMap makes the operator restart calico-node with the direct API address.
All changes go through the Installation resource, not by editing the IPPool: the pool was created by the operator, and the Calico documentation warns that the operator will revert direct edits of the pool to what is written in the Installation. After part 35 Calico is version 3.33, together with the Calico API server (it is visible in kubectl get tigerastatus), so Calico resources are available both through kubectl and through calicoctl of the same version.
now after the migration
┌────────────────────────────────────┐ ┌────────────────────────────────────┐
│ pool: vxlanMode CrossSubnet │ │ pool: vxlanMode Always │
│ Installation: bgp Enabled │ │ Installation: bgp Disabled │
│ BIRD: full BGP mesh, TCP 179 │ ──► │ BIRD and confd do not start │
│ routes to blocks: proto 80, │ │ routes to blocks: mtu 1450, │
│ via node address dev ens18 │ │ via VTEP dev vxlan.calico │
│ strictAffinity: false │ │ strictAffinity: true │
└────────────────────────────────────┘ └────────────────────────────────────┘
Step 0. What we have now
$ kubectl get installation default -o jsonpath='{.spec.calicoNetwork}' | jq '{bgp, ipPools: [.ipPools[] | {name, cidr, encapsulation, natOutgoing, blockSize}]}'
{
"bgp": "Enabled",
"ipPools": [
{
"name": "default-ipv4-ippool",
"cidr": "172.20.0.0/16",
"encapsulation": "VXLANCrossSubnet",
"natOutgoing": "Enabled",
"blockSize": 26
}
]
}
user@k8s-node-1:~$ sudo calicoctl node status | head -n 9
Calico process is running.
IPv4 BGP status
+---------------+-------------------+-------+------------+-------------+
| PEER ADDRESS | PEER TYPE | STATE | SINCE | INFO |
+---------------+-------------------+-------+------------+-------------+
| 192.168.10.17 | node-to-node mesh | up | 2026-10-09 | Established |
| 192.168.10.21 | node-to-node mesh | up | 2026-10-09 | Established |
+---------------+-------------------+-------+------------+-------------+
The routing table of k8s-node-1 is the same as in part 14: the neighbours' blocks via their addresses on ens18, proto 80. Before making changes, make sure UDP 4789 is open between all nodes, including the address of the future Windows node: if there is a filter on or between the nodes, all pod traffic will go through it after the migration. TCP 179, on the contrary, is no longer needed after the migration.
Step 1. strictAffinity
$ kubectl patch ipamconfigurations default --type merge -p '{"spec":{"strictAffinity":true}}'
ipamconfiguration.projectcalico.org/default patched
$ calicoctl ipam show --show-configuration
+--------------------+-------+
| PROPERTY | VALUE |
+--------------------+-------+
| StrictAffinity | true |
| AutoAllocateBlocks | true |
| MaxBlocksPerHost | 0 |
+--------------------+-------+
This change does nothing to existing addresses: it forbids future borrowing. To check that there are no borrowed addresses in the cluster right now, use calicoctl ipam check: it reconciles blocks, affinities and pod addresses and reports discrepancies. calicoctl ipam configure --strictaffinity=true does the same if the Calico API server is not at hand. The MaxBlocksPerHost field (how many blocks one node can get) we leave alone: it applies to all nodes, and a Windows node takes one block anyway.
Step 2. VXLAN encapsulation in all directions
$ kubectl patch installation default --type=json \
-p '[{"op":"replace","path":"/spec/calicoNetwork/ipPools/0/encapsulation","value":"VXLAN"}]'
installation.operator.tigera.io/default patched
$ kubectl get ippools -o custom-columns=NAME:.metadata.name,CIDR:.spec.cidr,VXLAN:.spec.vxlanMode,IPIP:.spec.ipipMode,NAT:.spec.natOutgoing
NAME CIDR VXLAN IPIP NAT
default-ipv4-ippool 172.20.0.0/16 Always Never true
The operator rewrote the pool's vxlanMode to Always. Then Felix acts on every node: it sees that the neighbours' blocks are now in a pool with permanent encapsulation and replaces its routes to them — instead of the neighbour's address on ens18, the gateway becomes its tunnel address on vxlan.calico. BIRD takes no part in this: it never wrote routes of a VXLAN pool into the kernel, the filter in its configuration leaves them to Felix. The BGP sessions are still alive at this point:
user@k8s-node-1:~$ ip route | grep vxlan
172.20.70.192/26 via 172.20.70.192 dev vxlan.calico onlink mtu 1450
172.20.172.0/26 via 172.20.172.0 dev vxlan.calico onlink mtu 1450
The gateway and the interface changed, and with them the look of the route. The proto 80 mark that Felix put on the direct routes to blocks before the migration is not shown on routes via vxlan.calico — Felix keeps it only on the blackhole of its own block. Instead, each such route has its own MTU of 1450: 50 bytes less than ens18, room for the VXLAN headers. The gateway is the neighbour's tunnel address (the first address of its block, as in part 14), and onlink tells the kernel to consider it reachable directly through the interface. A packet to 172.20.172.6 now goes into vxlan.calico; the kernel finds the neighbour's VTEP MAC by the ARP entry of the tunnel address, and by the FDB entry the node address that MAC belongs to, and sends a UDP datagram to 192.168.10.21:4789. Felix sets both entries statically: Calico's VXLAN interface is created with nolearning and learns nothing by itself.
What happens to pods at this moment: nothing. Addresses, blocks, cali… interfaces, policy rules and natOutgoing do not change, there are no restarts. Only the delivery between nodes changes, and at the moment a route is replaced, packets between nodes may be lost briefly; the Calico documentation warns that changing the encapsulation mode may break connections in flight. On a lab cluster it is a fraction of a second, but in a production cluster this is done in a maintenance window.
Step 3. Turning BGP off
$ kubectl patch installation default --type=merge -p '{"spec":{"calicoNetwork":{"bgp":"Disabled"}}}'
installation.operator.tigera.io/default patched
$ kubectl -n calico-system rollout status ds/calico-node
Waiting for daemon set "calico-node" rollout to finish: 1 out of 3 new pods have been updated...
Waiting for daemon set "calico-node" rollout to finish: 2 out of 3 new pods have been updated...
daemon set "calico-node" successfully rolled out
$ kubectl get tigerastatus
NAME AVAILABLE PROGRESSING DEGRADED SINCE
apiserver True False False 12d
calico True False False 34s
ippools True False False 12d
tiers True False False 12d
The operator changes the calico-node DaemonSet template: the network backend is vxlan instead of bird, and the readiness check covers only Felix, without BIRD:
$ kubectl -n calico-system get ds calico-node -o json | jq -r '.spec.template.spec.containers[0] | (.env[] | select(.name=="CALICO_NETWORKING_BACKEND") | .value), (.readinessProbe.exec.command | join(" "))'
vxlan
/usr/bin/calico component node health --felix-ready
and restarts calico-node one node at a time. Restarting the agent does not touch the dataplane: the routes and nftables tables stay in the kernel, and the new Felix reads them, compares them with the desired state and fixes only the differences. Pods keep running; a new pod started on a node during the seconds its calico-node restarts simply waits for the CNI. After the restart BIRD and confd do not start in calico-node; this does not change the routing table — Felix wrote the routes to the neighbours' blocks and the blackhole for its own block before too.
The rollback, while there are no Windows nodes in the cluster, is the same two patches in reverse order: bgp: Enabled, then encapsulation: VXLANCrossSubnet. After a Windows node joins there is nothing to roll back to: it will not work with BGP and CrossSubnet.
Step 4. Checking
The routing table of k8s-node-1 after the migration:
user@k8s-node-1:~$ ip route | grep 172.20
172.20.70.192/26 via 172.20.70.192 dev vxlan.calico onlink mtu 1450
172.20.172.0/26 via 172.20.172.0 dev vxlan.calico onlink mtu 1450
blackhole 172.20.211.0/26 proto 80
172.20.211.1 dev cali01e594701c5 scope link metric 1024
172.20.211.43 dev calia68edcf6ff3 scope link metric 1024
172.20.211.53 dev cali4d7612d46be scope link metric 1024
Compare with part 14: the routes to the node's own pods and the blackhole have not changed, while the neighbours' blocks now go not via their addresses on ens18 but via vxlan.calico. There is no BGP any more:
user@k8s-node-1:~$ sudo calicoctl node status
Calico process is running.
None of the BGP backend processes (BIRD or GoBGP) are running.
Packets. The net pod with busybox from part 14 on k8s-node-1 pings a hello pod on k8s-node-2, and we capture VXLAN on ens18:
$ kubectl -n demo exec net -- ping -c1 172.20.172.6
64 bytes from 172.20.172.6: seq=0 ttl=62 time=0.517 ms
user@k8s-node-1:~$ sudo tcpdump -ni ens18 udp port 4789
10:52:13.204511 IP 192.168.10.20.48213 > 192.168.10.21.4789: VXLAN, flags [I] (0x08), vni 4096
IP 172.20.211.53 > 172.20.172.6: ICMP echo request, id 23, seq 0, length 64
10:52:13.204896 IP 192.168.10.21.41377 > 192.168.10.20.4789: VXLAN, flags [I] (0x08), vni 4096
IP 172.20.172.6 > 172.20.211.53: ICMP echo reply, id 23, seq 0, length 64
Now even between neighbours in the same subnet there is an outer UDP packet with the node addresses and the pod packet inside, VNI 4096. ttl=62 is the same: there are still two routers on the path, the kernel of each node. The pod's MTU has not changed:
$ kubectl -n demo exec net -- ip link show eth0 | head -1
2: eth0@if173: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1450 qdisc noqueue qlen 1000
Services work as before: kubectl -n demo exec net -- wget -qO- http://hello/ answers from different pods; the migration did not touch the kube-proxy rules from part 15. One change is worth remembering: processes in the node's network (hostNetwork) reaching pods on other nodes now go out through vxlan.calico, and the pod sees the node's tunnel address (172.20.211.0) as the source rather than 192.168.10.20. If the NetworkPolicy from part 20 has ipBlock rules on node addresses, they need to be extended with the tunnel addresses. The ARP and FDB tables after the migration look the same as in the test cluster we will build in part 38, only with the main cluster's addresses.
Sources
- Kubernetes: Networking on Windows — network modes, supported access patterns, DSR, service settings, limitations; Windows in Kubernetes — supported Windows Server versions; kube-proxy configuration (v1alpha1) — the
winkernelsection and thekernelspacemode; feature gates —WinDSR,WinOverlay,WindowsHostNetwork; KEP-3503 — the withdrawn host network support for Windows pods; IPv4/IPv6 dual-stack, "Windows support" section — IPv6 on Windows nodes. - Microsoft: Windows container networking — HNS, HCS, VFP; Windows container network drivers — NAT, Transparent, Overlay, L2bridge, L2tunnel; the HostNetworkingService module; Azure CNI Powered by Cilium — the "Linux only" limitation.
- Calico: Requirements and Limitations for Windows; installation with the operator — strictAffinity,
bgp: Disabled, encapsulation via the Installation,windowsDataplane,serviceCIDRs, thekubernetes-services-endpointConfigMap,calico-node-windows; Overlay networking — VXLAN modes and the warning about changing the encapsulation; IP reservation — block addresses reserved by Windows nodes. - Flannel: Backends — VXLAN parameters on Windows.
- Antrea: Deploying Antrea on Windows.
- sig-windows-tools: hostprocess — the
kube-proxy-windowsDaemonSet for Calico and itsstart.ps1. - OpenShift: Configuring hybrid networking — ovn-kubernetes with Windows nodes.
Next — a hybrid cluster with kubeadm. First we build the test cluster: two Linux machines the same way as in part 4, only with differences (the k8s-lab-* names, the 172.21.0.0/16 pod network, the k8s-lab-api.example.com API name, Kubernetes 1.37.2 right away), Calico 3.33 by the operator in VXLAN without BGP and with strictAffinity from the start, a second context in the kubeconfig, metrics-server and cert-manager. Then we join a Windows worker to it: preparing Windows Server 2025 Server Core (the Containers feature, updates, name, time), containerd, kubelet and kubeadm as Windows services, kubeadm join, Calico for Windows through the Tigera operator and what appears on the node after that, the kube-proxy DaemonSet for Windows in HostProcess, a taint and a RuntimeClass; the check — a .NET web application in a Nano Server-based image, a Service and DNS between Linux and Windows pods, nodeSelector on Linux DaemonSets, and what to look at if the node stays NotReady.
Only registered users can leave a comment.
Sign in or register to leave a comment.