Kubernetes rolling update node port conflict resolution

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 nodePort values 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: false causing 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 -A to verify availability before assignment.
  • Enable publishNotReadyAddresses for 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-apiserver audit 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

  1. Why does the conflict appear only during canary rollouts?
    Canary deployments often create a temporary Service with the same nodePort as the stable Service. Since NodePort allocation is cluster‑wide, the second Service collides with the first.
  2. 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.
  3. 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.
  4. How do I find an unused NodePort programmatically?
    Query the API for existing NodePorts and pick a free value within the service-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
  • Does setting 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.