A Service of type NodePort does one blunt thing: it reserves a port in the 30000-32767 range and makes every node in the cluster listen on it. kube-proxy programs the same forwarding rule into the iptables or IPVS tables of every machine, so curl <any-node-ip>:<nodePort> reaches the Service no matter which node answers. That is the whole appeal — external reachability with no cloud load balancer, no DNS record and no ingress controller.

The cost is hidden inside the same design. Because every node answers, a packet frequently lands on a machine that is not running the pod, and the cluster then has to decide whose address the backend sees. externalTrafficPolicy is that decision: Cluster spreads load but rewrites the client address, Local preserves it but drops traffic on any node without a local endpoint. Choosing one is unavoidable, and each choice breaks something different.

A port every node owns

kubectl create service nodeport without --node-port emits no nodePort field at all; the API server allocates one from the range. The allocation is cluster-wide rather than per node, so a given number belongs to exactly one Service across the whole fleet. That is why two Services pinning 31000 cannot coexist, and also why a single firewall rule for one number is portable to every machine.

Every node receives the rule whether or not it runs a backend. That is what makes the type useful — there is no node to avoid and no membership list for the caller to maintain — and it is also what makes it expensive, because the port is open on machines that may have no business accepting that traffic. When the endpoint set is empty the rule still exists and the connection is refused rather than silently dropped, which is at least honest.

The extra hop and the client address

A request arriving on node B is forwarded to node A when that is where the pod lives, so the datapath is one leg longer than hitting node A directly. With the default externalTrafficPolicy: Cluster, kube-proxy also source-NATs the packet to node B’s address, so the pod sees a cluster-internal address rather than the client. Anything that depends on the client address — rate limiting, geo rules, audit logs, an allow list — is then reading the wrong number.

externalTrafficPolicy: Local keeps the original source address by forwarding only to endpoints on the node that received the packet. The price is availability: a node with no local replica has no working node port at all, so a three-node cluster running one replica answers on one node and drops on two. That is the trade — a correct client address and one less hop, in exchange for a node port that only works on some of the nodes.

A side door around the ingress

A node port terminates nothing and inspects nothing. A request sent straight to node:31000 skips the load balancer, TLS termination, the WAF and every host or path routing rule that would have applied, then lands on the pod with whatever the caller chose to send. The pod is left making security decisions it was never written to make.

Treat it as a stopgap with an owner and an expiry date. Reserve the range, pin a number only when a firewall or DNS record forces you to, and restrict who can reach the range at the network layer instead of assuming the ingress is the only way in.

When it breaks

The classic failure is the collision. A nodePort is a single cluster-wide resource, so the second Service that pins a number already in use is rejected with provided port is already allocated, and a Service asking for an automatic port can never get one once the 2768-port range is full. Both present as a Service that exists but has no usable external port, which reads like a networking fault and is really a capacity one.

The second failure is quieter. A node port is a real socket bind on every host, so anything else already listening on that number makes it dead on that machine while kubectl get svc still reports a healthy Service — health checks, dashboards and the Service object all lie together. Because the same port bypasses the ingress layer, the rule that makes debugging easy also makes the cluster reachable in a way nobody is filtering. The blast radius is every node in the fleet, not the one you were looking at.

Check it quickly

Confirm the port, the endpoints, and who else owns the port:

kubectl get svc web -o jsonpath='{.spec.type} {.spec.ports[0].nodePort}{"\n"}'
kubectl get endpoints web
kubectl get svc -A -o json | jq -r '.items[] | select(.spec.type=="NodePort") | "\(.spec.ports[0].nodePort) \(.metadata.namespace)/\(.metadata.name)"' | sort -n | uniq -D -w5
ss -lnt | grep -E ':3[0-9]{4}\b'
curl -sS -o /dev/null -w '%{http_code}\n' "http://$(hostname -I | awk '{print $1}'):$(kubectl get svc web -o jsonpath='{.spec.ports[0].nodePort}')/"

The third line prints any nodePort claimed twice, and the fourth shows whether a process other than kube-proxy already owns that port on this host. The curl should print 200 from every node; a refusal on exactly one node means something local took the port, and a refusal everywhere means the Service has no ready endpoints.

Sources

  • Kubernetes Service, NodePort type, the 30000-32767 default range and cluster-wide port allocation, checked 2026-09-16.
  • Virtual IPs and Service Proxies, kube-proxy iptables/IPVS rule programming and externalTrafficPolicy, checked 2026-09-16.
  • Using Source IP, SNAT on NodePort and the client-IP consequence, checked 2026-09-16.