Containers

Kubernetes Networking in Rancher: Services and Ingress

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.

Original content from computingforgeeks.com - post 165155

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.

ComponentRKE2K3s
CNI pluginCanal (Calico + Flannel)Flannel
Network backendVXLANVXLAN
Network Policy supportYes (Calico engine)No (requires separate install)
Ingress ControllerIngress NGINX (DaemonSet)Traefik (Deployment)
Pod CIDR10.42.0.0/1610.42.0.0/16
Service CIDR10.43.0.0/1610.43.0.0/16
MetalLB compatibleYesYes
Service Mesh (bundled)NoneNone
Target workloadProduction, security-focusedEdge, 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.

Related Articles

Containers Solve Error response from daemon: Get https://registry-1.docker.io/v2/: x509: certificate signed by unknown authority Containers How To Install Harbor Registry on Kubernetes / OpenShift Containers K3s Kubernetes Quickstart on Ubuntu 24.04 and Rocky Linux 10 Containers Manage Multiple Kubernetes Clusters with kubectl and kubectx

Leave a Comment

Press ESC to close