CertGrid CertGrid
Hands-on Lab·Kubernetes and Cloud Native Associate

Service Types: ClusterIP, NodePort and LoadBalancer

A NodePort answers on all four nodes, including the ones running nothing. A LoadBalancer stays <pending> indefinitely and logs no event, because nobody is listening for the request. And a Pod that is Running is not necessarily in the endpoint list.

Networking and Discovery Guide 19 of 46 Beginner

Written against the versions above. ClusterIP and NodePort are core Kubernetes and behave the same everywhere. LoadBalancer depends entirely on what is watching for it: a managed cluster has that built in, and a self-managed one needs something like MetalLB installed before the type does anything at all.

Four nodes, and no cloud provider - which is what makes the pending LoadBalancer here a real state rather than a contrived one.
Server NameIP AddressOSRolesCPURAMHDD
CKA1001192.168.0.175Ubuntu 26.04 LTSControl Plane Node2 Core4 GB50 GB
CKA1001-NODE01192.168.0.176Ubuntu 26.04 LTSWorker Node2 Core4 GB50 GB
CKA1001-NODE02192.168.0.177Ubuntu 26.04 LTSWorker Node2 Core4 GB50 GB
CKA1001-NODE03192.168.0.178Ubuntu 26.04 LTSWorker Node2 Core4 GB50 GB

Before you start

  1. NodePort opens the same port on every node

    A NodePort Service is a ClusterIP with one addition: the same high port is opened on every node in the cluster.

    NodePort port=80 nodePort=30080

    port is the Service's own port; nodePort is what is open on the machines. Now ask all four nodes for it:

    192.168.0.175 -> 200
    192.168.0.176 -> 200
    192.168.0.177 -> 200
    192.168.0.178 -> 200

    All four answer, and only one of them is running the Pod. That is the part worth holding on to. Every node forwards traffic for that port to wherever the endpoints actually are, so you can point anything - a load balancer you manage yourself, a DNS record, a laptop - at any node and it works.

    The cost is the port range. NodePorts come from 30000-32767 by default, they are cluster-wide, and two Services cannot share one. That is why NodePort is a fine way to expose a handful of things and a poor way to expose fifty.

    bash Example session
    kubectl get svc web-nodeport -n netlab -o jsonpath="{.spec.type}{\" port=\"}{.spec.ports[0].port}{\" nodePort=\"}{.spec.ports[0].nodePort}{\"\n\"}"NodePort port=80 nodePort=30080for n in 192.168.0.175 192.168.0.176 192.168.0.177 192.168.0.178; do printf '%s -> ' $n; curl -s -o /dev/null -w '%{http_code}\n' --max-time 10 http://$n:30080; done192.168.0.175 -> 200192.168.0.176 -> 200192.168.0.177 -> 200192.168.0.178 -> 200

    Expected resultOne nodePort, and an HTTP 200 from every node in the cluster.

    Success conditionYou got a 200 from a node that is not running the Pod.

  2. A LoadBalancer with nobody listening

    Now type: LoadBalancer on a cluster with no cloud provider:

    web-lb   LoadBalancer   10.99.38.35   <pending>   80:30987/TCP   20s

    It stays that way. Read the fields and notice what is and is not there:

    type=LoadBalancer clusterIP=10.99.38.35 nodePort=30987 externalIP=

    It already got a ClusterIP and a nodePort. A LoadBalancer Service is a NodePort Service plus a request for an external address - so the internal half works immediately and only the external half is outstanding.

    And the events say nothing:

    Events:                   <none>

    That silence is the whole lesson. type: LoadBalancer is not an instruction to Kubernetes; it is a request left out for something else to collect. On a managed cluster the cloud provider's controller collects it and creates a real load balancer. Here nothing is watching, so there is nobody to report a failure - the Service simply waits, correctly, forever.

    bash Example session
    sleep 15; kubectl get svc web-lb -n mlb --no-headers | awk '{print $1, $2, $4}'web-lb LoadBalancer <pending>kubectl get svc web-lb -n mlb -o jsonpath='type={.spec.type} clusterIP={.spec.clusterIP} nodePort={.spec.ports[0].nodePort} externalIP={.status.loadBalancer.ingress}{"\n"}'type=LoadBalancer clusterIP=10.99.38.35 nodePort=30987 externalIP=kubectl describe svc web-lb -n mlb | sed -n '/Events/,$p' | head -5Events:                   <none>

    Expected resultA LoadBalancer holding a ClusterIP and a nodePort, an empty externalIP, and no events.

    Success conditionYou can say why there is no error message.

  3. Give it something to collect the request

    Install MetalLB - a controller whose whole job is to watch for pending LoadBalancer Services on clusters that have no cloud provider - and the address appears:

    web-lb LoadBalancer 192.168.0.240 80:30987/TCP

    Two details from that install are worth knowing, because both surprise people.

    Installing the controller was not enough on its own. It has to be told which addresses it may hand out, with an IPAddressPool; until that existed the Service stayed exactly as before. A controller with no configuration behaves identically to no controller.

    And the pool is finite. This one holds five addresses. The sixth Service to ask gets:

    Warning  AllocationFailed  metallb-controller  Failed to allocate IP for
    "mlb/web-lb7": no available IPs

    Note the difference from step 2: now there is an event. Something is listening, so a failure can be reported. A LoadBalancer with events is a controller telling you why; a LoadBalancer with none usually means no controller at all.

    bash Example session
    sleep 20; kubectl get svc web-lb -n mlb --no-headers | awk '{print $1, $2, $4, $5}'web-lb LoadBalancer 192.168.0.240 80:30987/TCPsleep 20; kubectl get svc web-lb7 -n mlb --no-headers | awk '{print $1, $4}'web-lb7 <pending>kubectl describe svc web-lb7 -n mlb | sed -n '/Events/,$p' | tail -2  ----     ------            ----  ----                -------  Warning  AllocationFailed  20s   metallb-controller  Failed to allocate IP for "mlb/web-lb7": no available IPs

    Expected resultAn external IP from the pool, and an explicit AllocationFailed once the pool is exhausted.

    Success conditionYou can tell a pending LoadBalancer with a controller from one without.

  4. Running is not the same as receiving traffic

    One more thing every Service type shares. This Pod carries the app: fleet label, so it matches the Service, and it has a readiness probe pointed at a path that does not exist:

    notready 0/1 Running

    Phase Running, readiness 0/1. And in the endpoint list:

    ["10.244.100.37"] ready=true serving=true
    ["10.244.93.42"] ready=true serving=true
    ["10.244.86.255"] ready=true serving=true
    ["10.244.93.43"] ready=false serving=false

    It is in the list, marked ready=false, and gets no traffic. That is the mechanism behind every rolling update: a new Pod is a member of the Service from the moment it exists, and its readiness decides whether requests reach it.

    So when a Service is only sometimes working, the endpoint list is the first thing to read - not the Pod list. kubectl get pods showing everything Running tells you nothing about how many of them are actually serving.

    bash Example session
    sleep 25; kubectl get pod notready -n netlab --no-headers | awk '{print $1, $2, $3}'notready 0/1 Runningkubectl get endpointslice -n netlab -l kubernetes.io/service-name=fleet -o jsonpath="{range .items[*].endpoints[*]}{.addresses}{\" ready=\"}{.conditions.ready}{\" serving=\"}{.conditions.serving}{\"\n\"}{end}"["10.244.100.37"] ready=true serving=true["10.244.93.42"] ready=true serving=true["10.244.86.255"] ready=true serving=true["10.244.93.43"] ready=false serving=false

    Expected resultA Running Pod present in the endpoint list with ready=false.

    Success conditionYou looked at the endpoint list rather than the Pod list.

Troubleshooting

Official sources