Every Kubernetes cluster needs a networking layer that actually works, and Rancher-managed clusters (RKE2 and K3s) ship with sane defaults that handle most of what you need out of the box. The catch is understanding which pieces do what, because “networking” in Kubernetes covers everything from pod-to-pod communication to exposing services externally with a real IP address.
This guide walks through the full networking stack on a Rancher-managed RKE2 cluster: CNI plugins, the three main Service types, MetalLB for bare-metal LoadBalancer support, Ingress resources for HTTP routing, and Network Policies for traffic control. Every command and YAML snippet here was tested on a live 3-node HA cluster. Where RKE2 and K3s differ, those differences are called out explicitly. If you’re running an RKE2 HA cluster or a K3s single-node setup, the concepts are the same but the default components vary.
Tested March 2026 | RKE2 v1.35.3 on Rocky Linux 10.1, Rancher v2.14.0, MetalLB v0.15.3, Ingress NGINX (bundled)
CNI Networking: How Pods Talk to Each Other
Before any Service or Ingress matters, pods need to reach each other. That’s the CNI plugin’s job. RKE2 ships with Canal, which combines Calico’s network policy engine with Flannel’s overlay networking. K3s takes the simpler route and ships with Flannel alone.
Both use VXLAN as the backend, meaning pod traffic gets encapsulated in UDP packets (port 4789) and routed between nodes. The default Pod CIDR is 10.42.0.0/16 and the Service CIDR is 10.43.0.0/16. Every pod gets its own IP from the pod CIDR, and every pod can reach every other pod directly without NAT. That’s the Kubernetes networking model in one sentence.
Verify the CNI on an RKE2 cluster:
kubectl get pods -n kube-system -l k8s-app=canal
You should see one Canal pod per node, all in Running state:
NAME READY STATUS RESTARTS AGE
canal-7k2xp 2/2 Running 0 4d
canal-bqt9n 2/2 Running 0 4d
canal-xfm8h 2/2 Running 0 4d
On K3s, check for Flannel instead:
kubectl get pods -n kube-system -l app=flannel
Canal gives you Network Policy support out of the box (because of the Calico component). Pure Flannel on K3s does not. If you need Network Policies on K3s, you’ll need to install Calico separately or switch to a different CNI.
Kubernetes Service Types
Pods are ephemeral. Their IPs change every time they restart. Services give you a stable endpoint that routes traffic to the right pods regardless of where they’re running. There are four Service types, each solving a different access pattern.
ClusterIP is the default. It assigns a virtual IP from the Service CIDR (10.43.x.x) that’s only reachable from inside the cluster. Use this for internal communication between microservices.
NodePort opens a static port (30000-32767) on every node’s IP. Traffic to any node on that port gets forwarded to the Service. Simple but not production-friendly because you’re exposing high-numbered ports.
LoadBalancer requests an external IP from a cloud provider or a bare-metal load balancer like MetalLB. This is the cleanest way to expose a service externally.
ExternalName is a DNS alias. It maps a Service name to an external DNS record (like an RDS endpoint). No proxying, just a CNAME. Useful when migrating services into the cluster gradually.
ClusterIP Services
Start with a simple nginx deployment and expose it internally. Create a file called nginx-clusterip.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
labels:
app: nginx
spec:
replicas: 2
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:latest
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: nginx-clusterip
spec:
type: ClusterIP
selector:
app: nginx
ports:
- port: 80
targetPort: 80
Apply it:
kubectl apply -f nginx-clusterip.yaml
Check the Service and note the ClusterIP assigned:
kubectl get svc nginx-clusterip
The output shows an internal IP from the 10.43.x.x range:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
nginx-clusterip ClusterIP 10.43.87.214 <none> 80/TCP 12s
This IP is only reachable from inside the cluster. Test it by running a temporary pod:
kubectl run test-curl --image=curlimages/curl --rm -it --restart=Never -- curl -s http://nginx-clusterip
You’ll see the default nginx welcome page HTML. Kubernetes DNS resolves the Service name nginx-clusterip to its ClusterIP automatically, so pods can reach it by name without knowing the IP. Refer to the kubectl cheat sheet for more ways to debug Services from inside the cluster.
NodePort Services
NodePort extends ClusterIP by opening a port on every node. Create nginx-nodeport.yaml:
apiVersion: v1
kind: Service
metadata:
name: nginx-nodeport
spec:
type: NodePort
selector:
app: nginx
ports:
- port: 80
targetPort: 80
Apply and check what port Kubernetes assigned:
kubectl apply -f nginx-nodeport.yaml
kubectl get svc nginx-nodeport
The output shows the allocated NodePort:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
nginx-nodeport NodePort 10.43.92.155 <none> 80:31927/TCP 5s
Port 31927 is now open on all three nodes. Access nginx via any node IP:
curl http://10.0.1.11:31927
curl http://10.0.1.12:31927
curl http://10.0.1.13:31927
All three return the nginx welcome page. Kubernetes routes the traffic to a healthy pod regardless of which node you hit. The downside is that NodePort uses ephemeral ports in the 30000-32767 range, which means you can’t serve traffic on standard ports like 80 or 443 without an external load balancer in front.
LoadBalancer Services with MetalLB
On cloud providers (AWS, GCP, Azure), creating a LoadBalancer Service automatically provisions an external load balancer. On bare metal, that Service stays in Pending state forever because there’s no cloud API to call. MetalLB fills that gap by assigning real IPs from a pool you define.
Install MetalLB
Deploy MetalLB v0.15.3 using the upstream manifest:
kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/v0.15.3/config/manifests/metallb-native.yaml
Wait for all MetalLB pods to be ready. This is not optional. The webhook pods must be running before you apply any configuration, otherwise you’ll hit a webhook validation error (more on that in the troubleshooting section).
kubectl wait --namespace metallb-system --for=condition=ready pod --selector=app=metallb --timeout=120s
Confirm the controller and speaker pods are up:
kubectl get pods -n metallb-system
You should see 1 controller pod and 1 speaker pod per node (DaemonSet):
NAME READY STATUS RESTARTS AGE
controller-7f77b7d55b-kx4wz 1/1 Running 0 45s
speaker-5jq7n 1/1 Running 0 45s
speaker-8rt2m 1/1 Running 0 45s
speaker-vbn6k 1/1 Running 0 45s
Configure IP Address Pool
MetalLB needs to know which IPs it can hand out. Create metallb-config.yaml with an IPAddressPool and an L2Advertisement resource:
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: default-pool
namespace: metallb-system
spec:
addresses:
- 10.0.1.220-10.0.1.230
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: default
namespace: metallb-system
Apply the configuration:
kubectl apply -f metallb-config.yaml
The L2Advertisement tells MetalLB to respond to ARP requests for the allocated IPs on the local network. This works well for flat networks where all nodes share a Layer 2 domain. For routed environments, you’d use BGP mode instead.
Create a LoadBalancer Service
Now create a Service of type LoadBalancer. Save this as nginx-lb.yaml:
apiVersion: v1
kind: Service
metadata:
name: nginx-lb
spec:
type: LoadBalancer
selector:
app: nginx
ports:
- port: 80
targetPort: 80
Apply and check:
kubectl apply -f nginx-lb.yaml
kubectl get svc nginx-lb
MetalLB assigns the first available IP from the pool:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
nginx-lb LoadBalancer 10.43.15.201 10.0.1.220 80:30412/TCP 3s
The EXTERNAL-IP column now shows a real IP instead of <pending>. Access it from any machine on the same network:
curl http://10.0.1.220
That’s a clean external IP on port 80, no NodePort hacks. In production, you’d point a DNS record at this IP and put it behind a firewall with proper access controls.
Ingress Controllers
Services expose individual applications. Ingress resources give you HTTP/HTTPS routing with host-based and path-based rules, all through a single entry point. The Ingress resource itself is just a routing declaration. The actual work is done by an Ingress Controller, which is a reverse proxy (usually Nginx or Traefik) that watches for Ingress objects and reconfigures itself accordingly.
RKE2 and K3s handle this differently. RKE2 bundles Ingress NGINX as a DaemonSet, meaning one instance runs on every node. K3s bundles Traefik instead. Both work, but Ingress NGINX is more common in production environments. Full details on RKE2’s networking stack are available in the official RKE2 networking documentation.
Verify the Ingress Controller is running on RKE2:
kubectl get pods -n kube-system -l app.kubernetes.io/name=rke2-ingress-nginx
One pod per node confirms the DaemonSet is healthy:
NAME READY STATUS RESTARTS AGE
rke2-ingress-nginx-controller-7hk2p 1/1 Running 0 4d
rke2-ingress-nginx-controller-bx9rm 1/1 Running 0 4d
rke2-ingress-nginx-controller-wqz4f 1/1 Running 0 4d
Create an Ingress Resource
With the Ingress Controller running, define an Ingress resource for host-based routing. This routes all traffic for nginx.example.com to the nginx ClusterIP Service. Save as nginx-ingress.yaml:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: nginx-ingress
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
ingressClassName: nginx
rules:
- host: nginx.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: nginx-clusterip
port:
number: 80
Apply it:
kubectl apply -f nginx-ingress.yaml
Verify the Ingress was created and has an address assigned:
kubectl get ingress nginx-ingress
The ADDRESS column shows the node IPs where the Ingress Controller is listening:
NAME CLASS HOSTS ADDRESS PORTS AGE
nginx-ingress nginx nginx.example.com 10.0.1.11,10.0.1.12,10.0.1.13 80 8s
Test it by passing the Host header to any node IP:
curl -H "Host: nginx.example.com" http://10.0.1.11
The nginx welcome page confirms the Ingress route is working. In a real deployment, you’d create a DNS record pointing nginx.example.com to one of the node IPs (or to the MetalLB IP if you expose the Ingress Controller via LoadBalancer).
For TLS termination, add a tls section to the Ingress spec referencing a Kubernetes Secret that contains your certificate and key. Cert-manager can automate this with Let’s Encrypt.
Network Policies
By default, every pod can talk to every other pod. That’s fine in a lab. In production, you want to restrict traffic. Network Policies let you define firewall rules at the pod level, and they only work if your CNI supports them. Canal on RKE2 does. Pure Flannel on K3s does not.
A common pattern is to start with a deny-all policy and then open specific paths. This deny-all policy blocks all ingress traffic to pods in the default namespace:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: deny-all-ingress
namespace: default
spec:
podSelector: {}
policyTypes:
- Ingress
After applying this, no pod in the default namespace accepts incoming traffic. Now selectively allow traffic to nginx from pods with the label role: frontend:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-frontend-to-nginx
namespace: default
spec:
podSelector:
matchLabels:
app: nginx
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
role: frontend
ports:
- protocol: TCP
port: 80
Only pods labeled role: frontend can reach nginx on port 80. Everything else gets dropped. This is the microsegmentation approach that zero-trust architectures rely on.
K3s vs RKE2 Networking Comparison
Both are CNCF-certified Kubernetes distributions from SUSE/Rancher, but their default networking stacks differ in meaningful ways.
| Component | RKE2 | K3s |
|---|---|---|
| CNI plugin | Canal (Calico + Flannel) | Flannel |
| Network backend | VXLAN | VXLAN |
| Network Policy support | Yes (Calico engine) | No (requires separate install) |
| Ingress Controller | Ingress NGINX (DaemonSet) | Traefik (Deployment) |
| Pod CIDR | 10.42.0.0/16 | 10.42.0.0/16 |
| Service CIDR | 10.43.0.0/16 | 10.43.0.0/16 |
| MetalLB compatible | Yes | Yes |
| Service Mesh (bundled) | None | None |
| Target workload | Production, security-focused | Edge, IoT, lightweight |
The biggest practical difference is Network Policy support. If you need pod-level traffic control and you’re on K3s, install Calico manually or switch to RKE2. The Ingress Controller choice rarely matters since both Ingress NGINX and Traefik support the same Ingress resource API. Traefik has its own IngressRoute CRD with extra features, but standard Ingress objects work on both.
Troubleshooting
Error: “Internal error occurred: failed calling webhook metallb-webhook”
This happens when you apply the MetalLB IPAddressPool or L2Advertisement before the webhook pods are ready. MetalLB uses admission webhooks to validate its custom resources, and Kubernetes rejects the request if the webhook service isn’t responding yet.
The fix: wait for all pods in the metallb-system namespace to be running before applying any configuration.
kubectl wait --namespace metallb-system --for=condition=ready pod --selector=app=metallb --timeout=120s
Then retry your kubectl apply. If it still fails, check that the webhook service exists:
kubectl get validatingwebhookconfigurations | grep metallb
LoadBalancer Service Stuck in “Pending” EXTERNAL-IP
If kubectl get svc shows <pending> in the EXTERNAL-IP column, MetalLB either isn’t installed, isn’t running, or has no available IPs in its pool.
Check MetalLB pods first:
kubectl get pods -n metallb-system
If the pods are running, check the IPAddressPool:
kubectl get ipaddresspools -n metallb-system
If no pool exists, that’s the problem. Apply your pool configuration. If the pool exists but all IPs are allocated, either expand the range or delete unused LoadBalancer Services to free up addresses. You can also check MetalLB controller logs for specific errors:
kubectl logs -n metallb-system -l app.kubernetes.io/component=controller
A common cause on fresh installs: the L2Advertisement resource was never created. MetalLB allocates the IP but doesn’t announce it on the network, so it’s unreachable. Always create both the IPAddressPool and L2Advertisement together.