Exposing Services Outside the Cluster — NodePort, LoadBalancer, ExternalName

Lesson 20 of 39 14 min read Updated

In the previous article you built a ClusterIP Service and reached it from another Pod. That covers traffic inside the cluster. It does nothing for the last of the four networking problems: letting a real user, sitting outside the cluster, reach your application.

This article covers the Service types that solve that, plus two special cases that often confuse people. There is only one kind: Service in Kubernetes; the type field is what changes its behaviour.

We will cover the following topics:

  1. NodePort, and the three different ports in its specification
  2. Why kubectl port-forward is not a substitute for it
  3. LoadBalancer, and when it is worth using
  4. Headless Services, which are ClusterIP with the load balancing removed
  5. ExternalName, which points at something outside the cluster entirely

Technical Requirements

  1. A working Kubernetes cluster and kubectl.
  2. Docker and the .NET 10 SDK if you want to build the sample image.
  3. For the LoadBalancer section, a cloud cluster or a local cluster with a load balancer controller. The article explains what to expect if you have neither.

Core Concepts

Choosing a Service Type

The types are not alternatives to each other. Each answers a different question, so the choice is usually obvious once the question is clear.

TypeReachable fromUse it when
ClusterIPInside the cluster onlyOne Pod needs to call another. The default.
NodePortAny node's IP, on a high portYou need external access without a cloud load balancer.
LoadBalancerA dedicated external IPYou are on a cloud that provisions load balancers for you.
ExternalNameNot applicableYou want a cluster name for something outside the cluster.

One Service can only be one type. But nothing stops you from creating two Services with different names and different types over the same Pods, which is a common pattern for an application that must be reachable both internally and externally.

The first three types nest. A NodePort Service also gets a ClusterIP. A LoadBalancer Service also gets a NodePort and a ClusterIP. Each one is a superset of the one above it.

NodePort and Its Three Ports

A NodePort Service opens the same port on every node in the cluster. Sending a request to any node's IP on that port reaches your Pods, no matter which node they actually run on. The address is <any-node-ip>:<nodePort>.

The part that trips people up is that its ports block names three ports:

FieldLives onMeaning
nodePortThe worker nodeThe externally reachable port. Must be in the 30000-32767 range by default. Omit it and Kubernetes picks one for you.
portThe ServiceThe port the Service itself listens on, used by cluster-internal callers.
targetPortThe PodThe port the container is actually listening on.

Read it as a chain: a request arrives at nodePort on a node, is handled by the Service on its port, and is forwarded to targetPort on a selected Pod.

Is NodePort Production Ready?

It is a real feature, not a debugging tool, and it does not need kubectl to keep running. But it has two limitations you must plan around.

The port range. You are restricted to 30000-32767. Browsers expect web traffic on 80 and 443. Nobody is going to type a port number after your domain name.

It is only one layer. A NodePort points at node IPs. If a node fails, that address is gone, so something in front must know which nodes are healthy.

The usual answer is a tiered architecture: put a reverse proxy or a cloud load balancer in front of the cluster, listening on 80 and 443, and have it forward to the NodePort on the healthy nodes. Kubernetes also offers Ingress and an Ingress controller, which let you configure a reverse proxy such as nginx or HAProxy through Kubernetes objects. That is a topic of its own.

NodePort Is Not kubectl port-forward

These two feel similar because both let you open a browser and see your application, but they are for completely different purposes.


kubectl port-forwardNodePort
LifetimeOnly while the command runs. Ctrl+C ends it.Permanent, until the Service is deleted.
Who can reach itOnly you, on your machineAnyone who can reach a node
Needs kubectlYes, plus cluster credentialsNo
Intended forDebugging and testingReal traffic

Use port-forward to test. Use NodePort to expose. Do not mix the two up: a port-forward is not a deployment strategy, and creating NodePort Services for every quick check leaves clutter behind.

LoadBalancer

A LoadBalancer Service asks the infrastructure to provision a real load balancer with its own external IP address and point it at your Pods. Provisioning is asynchronous; the result appears in the Service's .status.loadBalancer field and in the EXTERNAL-IP column, which shows <pending> until it is ready.

This requires infrastructure that knows how to do it. AWS, Google Cloud, Azure, and OpenStack all support it. On a bare-metal cluster with nothing installed, the EXTERNAL-IP stays <pending> forever, though the NodePort that comes with it still works. Projects such as MetalLB fill that gap.

Two optional fields are worth knowing:

  1. loadBalancerIP requests a specific address. Providers that do not support it ignore the field silently.
  2. loadBalancerClass selects a non-default implementation. It can only be set on a LoadBalancer Service, and it cannot be changed afterwards.

Many teams deliberately avoid this type. The Kubernetes object exposes only a generic subset of what a cloud load balancer can do, so anything sophisticated ends up configured outside Kubernetes anyway. And each LoadBalancer Service typically bills as a separate cloud resource, so twenty Services means twenty load balancers. One Ingress in front of many ClusterIP Services is usually cheaper and more flexible.

Headless Services

A headless Service is not a fourth type. It is a ClusterIP Service with clusterIP: None, and that one line changes everything about how it behaves.

A normal ClusterIP Service gives you one virtual address and proxies your traffic. A headless Service allocates no address at all. DNS returns the addresses of the Pods themselves, and the client decides which to use.

So the proxying and the load balancing are gone. There is still a crude form of balancing, because DNS returns the records in varying order, but that happens in the resolver, not in Kubernetes. Responsibility moves to the client.

This is what you want when Pods are not interchangeable. Clustered stateful systems are the classic case: an LDAP client, a database driver that needs to know which node is the primary, or a sharded store that maps shard numbers to specific replicas. All of those need to address individual Pods, which a load-balanced virtual IP actively prevents.

ExternalName

An ExternalName Service has no selector, no ports, and no Pods. It is purely a DNS alias: cluster DNS answers with a CNAME record pointing at a name outside the cluster.

Your application asks for partner-crm and ends up talking to crm.partner.example.com, without knowing anything about it. The redirection happens entirely at the DNS layer, and no traffic passes through Kubernetes.

This buys you three things:

  1. Simpler configuration. Applications only ever know a service name.
  2. Freedom to move. If you later migrate that database into the cluster, you replace the ExternalName Service with a normal one and nothing else changes.
  3. Fewer leaked details. External hostnames live in one object rather than scattered across application configuration.

Hands-On: Kubernetes Commands

List Services with their types and ports

The PORT(S) column shows port:nodePort for NodePort Services.

kubectl get svc -n storefront

Find the node IP addresses

You need one of these to reach a NodePort Service.

kubectl get nodes -o custom-columns='NAME:.metadata.name,IP:.status.addresses[0].address'

On minikube, get the URL directly

Saves you from assembling the address by hand.

minikube service --url kiosk-api-nodeport -n storefront

Watch a LoadBalancer being provisioned

EXTERNAL-IP shows <pending> until the provider responds.

kubectl get svc kiosk-api-lb -n storefront -w

See all DNS records behind a headless Service

dig +short lists every A record, which is the point of a headless Service.

kubectl exec netshoot -n storefront -- dig +short kiosk-api-headless.storefront.svc.cluster.local

Confirm an ExternalName is a CNAME

The answer is a name, not an address.

kubectl exec netshoot -n storefront -- nslookup -type=cname partner-crm.storefront.svc.cluster.local

Delete a Service

This removes network access but leaves every Pod running. Pods and Services have entirely separate life cycles.

kubectl delete svc kiosk-api-nodeport -n storefront

Step-by-Step Example

The Scenario

A kiosk API with three replicas in a storefront namespace. Each reply names the Pod and the node that produced it, which makes the routing behaviour of each Service type visible. We expose the same three Pods four different ways.

Step 1 — Namespace and Application

apiVersion: v1
kind: Namespace
metadata:
name: storefront
kubectl apply -f 1-storefront-namespace.yaml

Create the application with the .NET CLI:

dotnet new web -n KioskApi
cd KioskApi

Replace Program.cs:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

string podName = Environment.GetEnvironmentVariable("POD_NAME") ?? Environment.MachineName;
string podIp = Environment.GetEnvironmentVariable("POD_IP") ?? "unknown";
string nodeName = Environment.GetEnvironmentVariable("NODE_NAME") ?? "unknown";

app.MapGet("/healthz", () => Results.Ok("healthy"));

// Reports the Pod and the Node, so you can see which node answered a NodePort request.
app.MapGet("/", (HttpContext context) => Results.Ok(new
{
servedByPod = podName,
podIp,
node = nodeName,
seenClientAddress = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"
}));

app.Run();

Add a Dockerfile and build:

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish KioskApi.csproj -c Release -o /app

FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "KioskApi.dll"]
docker build -t kiosk-api:1.0 .

Deploy three replicas:

apiVersion: apps/v1
kind: Deployment
metadata:
name: kiosk-api
namespace: storefront
labels:
app: kiosk-api
spec:
replicas: 3
selector:
matchLabels:
app: kiosk-api
template:
metadata:
labels:
app: kiosk-api
spec:
containers:
- name: kiosk-api
image: kiosk-api:1.0
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
resources:
requests:
cpu: "50m"
memory: "96Mi"
limits:
cpu: "300m"
memory: "256Mi"
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
kubectl apply -f 2-kiosk-api-deployment.yaml
kubectl rollout status deployment kiosk-api -n storefront

Step 2 — The NodePort Service

Three ports, one Service. The comments name which machine each one lives on.

apiVersion: v1
kind: Service
metadata:
name: kiosk-api-nodeport
namespace: storefront
labels:
app: kiosk-api
spec:
type: NodePort
selector:
app: kiosk-api
ports:
- name: http
nodePort: 30380 # port opened on EVERY node, must be 30000-32767
port: 80 # port of the Service itself
targetPort: 8080 # port the container listens on
protocol: TCP
kubectl apply -f 3-kiosk-api-nodeport.yaml
kubectl get svc kiosk-api-nodeport -n storefront
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kiosk-api-nodeport NodePort 10.96.252.193 <none> 80:30380/TCP 11s

Read 80:30380/TCP as "Service port 80, node port 30380". Note that it also has a ClusterIP, so cluster-internal callers can still use the name and port 80 as before.

Step 3 — Prove the Port Is Open on Every Node

First list the nodes:

kubectl get nodes -o custom-columns='NAME:.metadata.name,IP:.status.addresses[0].address'
desktop-control-plane 172.18.0.3
desktop-worker 172.18.0.5
desktop-worker2 172.18.0.4

Now hit the same port on each of them in turn:

for ip in 172.18.0.5 172.18.0.4 172.18.0.3; do
echo -n "$ip:30380 -> "
curl -s -m 5 http://$ip:30380/
echo
done
172.18.0.5:30380 -> {"servedByPod":"kiosk-api-...-6mq8t","node":"desktop-worker2",...}
172.18.0.4:30380 -> {"servedByPod":"kiosk-api-...-5brwf","node":"desktop-worker2",...}
172.18.0.3:30380 -> {"servedByPod":"kiosk-api-...-6mq8t","node":"desktop-worker2",...}

Two things are worth noticing. Every node answers, including the control plane, which runs no application Pods at all. And a request to desktop-worker was served by a Pod on desktop-worker2. The node you contact and the node that runs the Pod are unrelated; kube-proxy forwards across the cluster network.

A practical note: on some local setups the node addresses live on an internal Docker network that your host cannot reach. If curl from your laptop times out, run the loop from a Pod inside the cluster, or use minikube service --url. The NodePort itself is working either way.

Step 4 — The LoadBalancer Service

The specification is shorter, because the infrastructure fills in the details.

apiVersion: v1
kind: Service
metadata:
name: kiosk-api-lb
namespace: storefront
labels:
app: kiosk-api
spec:
type: LoadBalancer
selector:
app: kiosk-api
ports:
- name: http
port: 80
targetPort: 8080
protocol: TCP
# A LoadBalancer Service also allocates a nodePort and a ClusterIP, so it is a
# superset of the other two types.
kubectl apply -f 4-kiosk-api-loadbalancer.yaml
kubectl get svc kiosk-api-lb -n storefront
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kiosk-api-lb LoadBalancer 10.96.112.195 172.18.0.7 80:31596/TCP 11s

Three things were allocated at once: a ClusterIP, a node port (31596, chosen automatically because we did not ask for one), and an external IP. That is the nesting described earlier, visible in a single line of output.

curl -s http://172.18.0.7/
{"servedByPod":"kiosk-api-86b89f677d-v4ths","node":"desktop-worker",...}

If your EXTERNAL-IP stays <pending>, your cluster has no load balancer provider. That is normal on plain bare metal. The Service still works through its node port in the meantime.

Step 5 — The Headless Service

One line separates this from an ordinary ClusterIP Service.

apiVersion: v1
kind: Service
metadata:
name: kiosk-api-headless
namespace: storefront
labels:
app: kiosk-api
spec:
clusterIP: None # this single line is what makes the Service headless
selector:
app: kiosk-api
ports:
- name: http
port: 80
targetPort: 8080
protocol: TCP
kubectl apply -f 5-kiosk-api-headless.yaml

Now compare what DNS returns for each of the two Services:

kubectl exec netshoot -n storefront -- dig +short kiosk-api-headless.storefront.svc.cluster.local
kubectl exec netshoot -n storefront -- dig +short kiosk-api-nodeport.storefront.svc.cluster.local
10.244.2.82
10.244.1.76
10.244.1.75

10.96.252.193

The headless Service returned three Pod addresses. The ordinary Service returned one virtual address. Compare the first three against the actual Pods:

kubectl get pods -n storefront -l app=kiosk-api -o custom-columns='NAME:.metadata.name,IP:.status.podIP'
kiosk-api-86b89f677d-5brwf 10.244.1.75
kiosk-api-86b89f677d-6mq8t 10.244.1.76
kiosk-api-86b89f677d-v4ths 10.244.2.82

They match exactly. The headless Service handed the client the real Pod addresses and got out of the way. There is no proxy in the path any more, which is precisely what a clustered database client needs and precisely what a plain web client does not.

Step 6 — The ExternalName Service

No selector, no ports, no Pods. Just a name.

apiVersion: v1
kind: Service
metadata:
name: partner-crm
namespace: storefront
spec:
type: ExternalName
externalName: crm.partner.example.com
# No selector and no ports: this Service is only a CNAME record in cluster DNS.
kubectl apply -f 6-partner-crm-externalname.yaml
kubectl get svc partner-crm -n storefront
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
partner-crm ExternalName <none> crm.partner.example.com <none> 11s

CLUSTER-IP and PORT(S) are both empty, which is unlike every other Service you have seen. Confirm what it really is:

kubectl exec netshoot -n storefront -- nslookup -type=cname partner-crm.storefront.svc.cluster.local
partner-crm.storefront.svc.cluster.local canonical name = crm.partner.example.com.

A CNAME, nothing more. An application configured to call partner-crm now reaches the partner system, and the day that system moves into your cluster you replace this object with a normal Service and change no application configuration at all.

Step 7 — Four Services, One Set of Pods

kubectl get svc -n storefront
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
kiosk-api-headless ClusterIP None <none> 80/TCP
kiosk-api-lb LoadBalancer 10.96.112.195 172.18.0.7 80:31596/TCP
kiosk-api-nodeport NodePort 10.96.252.193 <none> 80:30380/TCP
partner-crm ExternalName <none> crm.partner.example.com <none>

The first three all select the same three Pods and expose them in three different ways at the same time. This is a normal thing to do, not a trick.

Step 8 — Deleting a Service Does Not Delete Pods

kubectl delete svc kiosk-api-nodeport -n storefront
kubectl get pods -n storefront -l app=kiosk-api

All three Pods are still running. Only that route to them is gone. Treat Service deletion with care: it is invisible from the cluster's point of view and immediately visible to your users.

Step 9 — Clean Up

kubectl delete namespace storefront

Summary

  1. There is one kind: Service. The type field decides its behaviour, and a Service can only be one type at a time.
  2. The types nest: LoadBalancer includes a NodePort, which includes a ClusterIP.
  3. NodePort opens one port on every node, in the 30000-32767 range. The node you contact need not run the Pod that answers.
  4. Its three ports are nodePort on the node, port on the Service, and targetPort on the Pod.
  5. kubectl port-forward is a debugging tool that dies with the command. NodePort is a permanent route.
  6. LoadBalancer needs infrastructure support and bills per Service. One Ingress in front of many ClusterIP Services is usually cheaper.
  7. Headless is clusterIP: None. DNS returns the Pod addresses and the client chooses. Use it when Pods are not interchangeable.
  8. ExternalName is a CNAME to something outside the cluster, with no selector, no ports, and no traffic passing through Kubernetes.
  9. Deleting a Service never deletes Pods. Their life cycles are independent.

In the final article of this series we look at readiness, liveness, and startup probes, which decide when a Pod is allowed to appear behind any of these Services.