Problem: NodePort conflicts during rolling updates
During a rolling update of a Deployment that exposes a Service of type NodePort, new Pods are scheduled while the old Pods are still running. If the Service definition specifies a static nodePort (or relies on the default allocation) and the update creates a second Service object (e.g., during a canary or blue‑green rollout), the API server may reject the creation with errors such as:
Error from kube-apiserver: "service \"my-service\" already has a NodePort allocated: 30001"
Failed to create Service: NodePort 30001 is already allocated
The symptom manifests as a temporary outage (5‑minute loss of external traffic in a FinTech incident, 2023) or failed health checks during the rollout. The root cause is the collision of NodePort assignments between the old and the newly created Service objects.
Root Cause Analysis
Kubernetes allocates a nodePort per Service object. The allocation is cluster‑wide; the same port cannot be bound on any node for two distinct Service objects, even if they target different Pods. During a rolling update that creates a new Service (common in canary or blue‑green patterns), the controller attempts to allocate the same static nodePort again. Because the old Service still exists and its endpoints are bound, the API server returns a conflict error (official Service docs).
Key factors that exacerbate the issue:
- Explicit
nodePortvalues in the Service manifest (e.g.,nodePort: 30001). - Rolling update strategy that does not delete the old Service before creating the new one.
- Use of
publishNotReadyAddresses: falsecausing the old Service to keep its endpoints bound until the new Pods are ready. - Cluster configurations that disable automatic NodePort reclamation (e.g., short
service-node-port-range).
Investigation and Debugging
1. Inspect Service objects
kubectl get svc -n payment -o wide
Expected output shows a single Service with NODE-PORT 30001. If two Services with the same name or different names both claim nodePort: 30001, a conflict exists.
2. Check events for the Service
kubectl describe svc my-service -n payment | grep -i "NodePort"
Typical event:
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning NodePortConflict 2m service-controller NodePort conflict detected – existing endpoint still bound to port 30001
3. Verify old Pods are still bound to the port
kubectl get pods -n payment -l app=payment-api -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.phase}{"\n"}{end}'
Look for Pods in Running or Terminating state that still have the old Service’s selector.
4. Capture node-level socket usage (optional)
sudo ss -tulnp | grep 30001
If the port appears under LISTEN for a kube-proxy process, it confirms the port is still in use.
5. Review Deployment rolling update settings
kubectl get deployment payment-api -n payment -o yaml | grep -A5 rollingUpdate
Check maxSurge and maxUnavailable. Aggressive surge values can cause both old and new Service objects to coexist longer.
Resolution
The fix consists of ensuring a single Service owns the NodePort throughout the rollout and, if a new Service is required, pre‑allocating the NodePort or using publishNotReadyAddresses: true to allow the old Service to release the port earlier.
Option 1 – Keep a single Service and update only the Deployment
Remove any Service recreation from the rollout pipeline. The Service remains constant; only the Deployment’s podTemplate changes.
# Before (problematic)
apiVersion: v1
kind: Service
metadata:
name: my-service
spec:
type: NodePort
selector:
app: payment-api
ports:
- port: 80
targetPort: 8080
nodePort: 30001 # static port
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: payment-api
spec:
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: payment-api
spec:
containers:
- name: api
image: registry.example.com/payment-api:v2
ports:
- containerPort: 8080
# After (fixed)
# Service unchanged – do NOT delete/recreate during rollout
apiVersion: v1
kind: Service
metadata:
name: my-service
spec:
type: NodePort
selector:
app: payment-api
ports:
- port: 80
targetPort: 8080
nodePort: 30001 # same static port, owned by a single Service
Because the Service object persists, the NodePort allocation never collides.
Option 2 – Pre‑allocate a static NodePort for the new Service
If a separate Service is required (e.g., canary Service), allocate a distinct nodePort that does not overlap with any existing Service.
# Canary Service definition
apiVersion: v1
kind: Service
metadata:
name: my-service-canary
spec:
type: NodePort
selector:
app: payment-api-canary
ports:
- port: 80
targetPort: 8080
nodePort: 30002 # choose an unused port in the service-node-port-range
Validate the port is free before applying:
kubectl get svc -A | grep 30002 || echo "Port 30002 free"
Option 3 – Enable early endpoint release
Set publishNotReadyAddresses: true on the Service so that the old Pods can be removed from the endpoint list before they are fully terminated, allowing kube-proxy to free the NodePort sooner.
# Service with early release
apiVersion: v1
kind: Service
metadata:
name: my-service
spec:
type: NodePort
publishNotReadyAddresses: true
selector:
app: payment-api
ports:
- port: 80
targetPort: 8080
nodePort: 30001
Validation
1. Confirm only one Service holds the NodePort
kubectl get svc -n payment -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.ports[0].nodePort}{"\n"}{end}'
Output should list each Service with a unique NodePort.
2. Verify kube-proxy no longer reports a conflict
kubectl logs -n kube-system -l k8s-app=kube-proxy --tail=20 | grep "NodePort"
No lines containing “already allocated” or “conflict” should appear.
3. Perform a rolling update and observe zero downtime
# Trigger rollout
kubectl set image deployment/payment-api api=registry.example.com/payment-api:v3 -n payment
# Watch rollout status
kubectl rollout status deployment/payment-api -n payment
# Curl external NodePort
curl http://$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}'):30001/healthz
Successful HTTP 200 responses throughout the rollout confirm the issue is resolved.
Prevention and Best Practices
- Never recreate a NodePort Service during a rolling update. Keep the Service object immutable; only update the Deployment.
- Allocate static NodePorts from a reserved range. Document the range in an internal runbook and use
kubectl get svc -Ato verify availability before assignment. - Enable
publishNotReadyAddressesfor services that undergo frequent rollouts. This reduces the time a NodePort remains bound to terminating Pods. - Use readiness probes. Ensure Pods only become part of the Service endpoints after they pass health checks, preventing premature port binding.
- Monitor Service events. Create an alert on events containing “NodePort conflict” using the
kube-apiserveraudit log or Prometheus alert rule:
- alert: NodePortConflict
expr: increase(kube_event_total{reason="NodePortConflict"}[5m]) > 0
for: 1m
labels:
severity: critical
annotations:
summary: "NodePort conflict detected for Service {{ $labels.object_name }}"
description: "A Service attempted to allocate a NodePort that is already in use."
Related Topic Hub: Distributed Systems Troubleshooting Hub
FAQ
- Why does the conflict appear only during canary rollouts?
Canary deployments often create a temporary Service with the samenodePortas the stable Service. Since NodePort allocation is cluster‑wide, the second Service collides with the first. - Can I rely on Kubernetes to reuse a released NodePort automatically?
Kube‑proxy releases the port only after the Service object is deleted. If the old Service remains (e.g., during a rolling update), the port stays allocated. - Is using a LoadBalancer Service a safer alternative?
LoadBalancer services allocate external IPs rather than NodePorts, avoiding this specific conflict. However, they introduce cloud‑provider dependencies and may incur additional cost. - How do I find an unused NodePort programmatically?
Query the API for existing NodePorts and pick a free value within theservice-node-port-range:
used=$(kubectl get svc -A -o jsonpath='{range .items[*].spec.ports[*]}{.nodePort}{" "}{end}')
for p in $(seq 30000 32767); do
[[ " $used " != *" $p "* ]] && echo $p && break
done
externalTrafficPolicy: Local affect NodePort conflicts?No.
externalTrafficPolicy controls source IP preservation, not NodePort allocation. The conflict still occurs if two Services request the same port.