← Learn··Updated 31 May 2026·5 min read

Services and cluster networking

Pod IPs are ephemeral, so you never talk to a pod directly. A Service is the stable address and load balancer in front of a set of pods — here is ClusterIP vs NodePort vs LoadBalancer, cluster DNS, and how k3s makes LoadBalancer work on bare metal.

Kubernetes
Kubernetes from scratch with k3sPart 6 of 9
#kubernetes
#k3s
#networking
#ai-assisted

level:Intermediate verified:Jun 2026

ℹ️ Read-along — concepts, with small examples you can try on your k3s cluster if you have one, but nothing here is required setup.

You have a Deployment running three pods, and Kubernetes is dutifully keeping them alive. But how do you actually reach them? Each pod has its own IP address — and that address is worthless to you, because the moment a pod is rescheduled, replaced, or scaled, it gets a new one. Pod IPs are ephemeral. You can never hard-code one. The thing that gives you a stable address, and spreads traffic across whichever pods happen to exist right now, is a Service.

What a Service is

A stable front for a moving set of pods

A pod is cattle, not a pet — it can die and be replaced at any moment, and its IP dies with it. A Service is a stable, long-lived virtual IP (and DNS name) that sits in front of a set of pods. You send traffic to the Service; the Service load-balances it across the healthy pods behind it. The pods can come and go all day long, and the Service's address never changes.

The Service finds its pods the same way a Deployment does: with a label selector. The Service says "send to any pod with app: web", and Kubernetes continuously maintains the list of matching pod IPs — called the Endpoints (or EndpointSlices) — for that Service. Add a pod with that label, it joins automatically; a pod dies, it drops out. (Kubernetes docs: Service)

flowchart TD
    C["Client pod<br/><i>talks to web:80</i>"] --> S["Service web<br/>ClusterIP 10.43.0.12"]
    S -->|"selector app=web"| P1["pod web-a<br/>10.42.1.5"]
    S -->|"selector app=web"| P2["pod web-b<br/>10.42.2.7"]
    S -->|"selector app=web"| P3["pod web-c<br/>10.42.1.9"]

The Service's IP never changes; the pod IPs behind it churn freely. The label selector keeps the membership list current.

A minimal Service manifest, fronting the pods from a Deployment whose pods carry app: web:

web-service.yaml

apiVersion: v1
kind: Service
metadata:
  name: web
spec:
  selector:
    app: web
  ports:
    - port: 80
      targetPort: 8080

Save it and kubectl apply -f web-service.yaml. port is the port the Service listens on; targetPort is the port on the pods it forwards to. Once applied you can reach your pods at web:80 from anywhere in the cluster — a name that outlives any individual pod:

# The Service has a stable ClusterIP, independent of any pod
kubectl get svc web
# NAME   TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
# web    ClusterIP   10.43.0.12     <none>        80/TCP    10s
💡 Tip — The Service's selector is independent of the Deployment's selector; they just happen to use the same labels. A Service does not "belong" to a Deployment. It targets any pod matching its selector, which is why you can point one Service at pods from several Deployments (handy for blue/green rollouts).

The three Service types you will actually use

The spec.type field decides who can reach the Service. The three types build on each other: LoadBalancer is a NodePort with extra, and NodePort is a ClusterIP with extra. (Kubernetes docs: Service)

ClusterIP — internal only (the default)

ClusterIP is the default. It gives the Service a virtual IP from the cluster's service range, reachable only from inside the cluster. This is what one pod uses to talk to another — a backend calling a database, a frontend calling an API. No external traffic reaches it. Most Services in a real cluster are ClusterIP, with exactly one ingress in front of everything (more on that in the Ingress post).

NodePort — a port on every node

NodePort builds on ClusterIP and additionally opens a static port (by default in the 30000–32767 range) on every node. Traffic hitting any-node-ip:31234 is forwarded to the Service, and from there to a pod. It is the crudest way to get external traffic in — useful for a quick test, awkward for production because the ports are high-numbered and you have to know node IPs.

LoadBalancer — a single external IP

LoadBalancer builds on NodePort and additionally asks the infrastructure for a real external IP that fronts all the nodes. On a cloud provider, Kubernetes provisions an actual cloud load balancer. The result is one clean external IP, with the cloud LB spreading traffic across nodes.

flowchart TD
    L["LoadBalancer<br/><i>external IP</i>"] --> N["NodePort<br/><i>port 30000-32767 on every node</i>"]
    N --> CL["ClusterIP<br/><i>internal virtual IP</i>"]
    CL -->|"selector"| PODS["matching pods"]

Each type is a superset of the one below it: LoadBalancer adds an external IP onto a NodePort, which adds a node port onto a ClusterIP.

⚠️ Warning — Do not reach for NodePort or LoadBalancer for every service. The normal pattern is: nearly everything is ClusterIP, and a single Ingress (itself fronted by one LoadBalancer) routes HTTP to all of them by hostname. A LoadBalancer-per-app burns one external IP each and skips the host/path routing an Ingress gives you.

Cluster DNS: reaching a Service by name

You never type a ClusterIP. Kubernetes runs an in-cluster DNS server — CoreDNS — that watches the API for Services and creates a DNS record for each one. Every Service is reachable at:

<service-name>.<namespace>.svc.cluster.local

So a Service named web in namespace shop is web.shop.svc.cluster.local. Because a pod's DNS search list already includes its own namespace and the cluster domain, a pod in the same namespace can use the short form web; to reach another namespace, use web.shop. (Kubernetes docs: DNS for Services and Pods)

flowchart TD
    P["pod runs<br/><i>curl http://web</i>"] -->|"DNS query web"| D["CoreDNS<br/><i>in kube-system</i>"]
    D -->|"returns ClusterIP"| P
    P -->|"connects to ClusterIP"| S["Service web"]
    S --> EP["healthy pod"]

CoreDNS turns the Service name into its stable ClusterIP; the pod connects to that IP, and the Service forwards to a live pod.

ℹ️ Note — k3s ships CoreDNS by default and deploys it automatically when the server starts; you do not install it. It lives in the kube-system namespace. (k3s docs: Networking Services)

ServiceLB: how LoadBalancer works on bare metal

There is a catch with type: LoadBalancer. It only does something if the infrastructure can hand out an external IP — and a bare-metal box, a Raspberry Pi, or a homelab VM has no cloud load balancer to call. On vanilla Kubernetes, such a Service sits forever in <pending>.

k3s solves this out of the box with ServiceLB (historically called Klipper LB), enabled by default. ServiceLB watches for Services of type: LoadBalancer and, for each one, creates a DaemonSet in the kube-system namespace. That DaemonSet runs a small svc--prefixed pod on each node; the pod binds the Service port as a host port and forwards traffic (via iptables) to the Service's ClusterIP. The node IPs are then written back into the Service's status as its external IPs — so type: LoadBalancer "just works" without any cloud. (k3s docs: Networking Services, GitHub discussion #9927)

💡 Tip — ServiceLB is ideal for single-node and small homelab clusters. If you outgrow it — multi-node with proper IP-address management, or IPs from a range outside your node network — disable it with --disable=servicelb and run MetalLB instead. (k3s docs: Networking Services)
⚠️ Warning — Because ServiceLB binds the Service's port directly on the node's host network, two LoadBalancer Services cannot claim the same port on the same node. In practice you avoid this entirely by exposing a single Ingress on 80/443 and routing everything through it.

A short close

A pod's IP is throwaway; a Service is the stable address and load balancer that hides that churn. ClusterIP keeps traffic internal (the common case), NodePort opens a port on every node, and LoadBalancer asks for an external IP — which k3s's ServiceLB conjures even on bare metal. CoreDNS lets you reach any Service by name instead of by IP. Next, stop exposing each service on its own port and put one smart HTTP front door over all of them: Ingress with Traefik. The whole path is in the k3s series hub.