HAProxy node port conflict in Kubernetes staging environment

Problem – HAProxy NodePort Conflict in Kubernetes Staging

In the staging environment several AI micro‑services expose a NodePort service. The HAProxy ingress controller attempts to bind each service’s NodePort on the host network interface. When two or more services request the same port, HAProxy pods repeatedly fail to start with errors such as:


2024-06-23T10:12:45Z haproxy[1]: ERROR : bind : address already in use
2024-06-23T10:12:45Z haproxy[1]: Fatal error: cannot bind to 0.0.0.0:30080

Kubernetes also emits validation events:


Warning  InvalidService  10s (x2)  service my‑service is invalid: spec.ports[0].nodePort: Duplicate value

The immediate impact is a cascade of 502/503 responses from HAProxy, health‑check failures, and a temporary loss of inbound traffic for all AI services in staging.

Root Cause – Why the Conflict Happens

NodePort allocation in Kubernetes follows a simple rule set (Kubernetes Service documentation): ports must be unique across the entire cluster and lie in the range 30000‑32767. The following conditions commonly lead to duplication:

  • Static NodePort values in Helm charts – multiple charts reused the same hard‑coded nodePort: 30080 (see the staging outage where three AI micro‑services each defined that value).
  • CI/CD race condition – rapid creation of services can trigger the allocation race described in kubernetes/kubernetes #103456, causing the API server to accept duplicate values before the controller reconciles.
  • Ingress controller binding semantics – HAProxy’s bind directive (HAProxy Configuration Manual, “bind” directive) binds to the host IP/port for each NodePort. When two services share a port, the second HAProxy process hits “address already in use”.

Thus the conflict is not a HAProxy bug but a mis‑alignment between service definitions and the cluster‑wide uniqueness requirement for NodePorts.

Debug – Investigation Steps

Follow these concrete steps to reproduce and isolate the conflict.

  1. Inspect HAProxy pod logs for bind errors.
  2. List all NodePort services and look for duplicate ports.
  3. Cross‑check Helm values for static nodePort entries.
  4. Capture the Kubernetes events that indicate duplicate values.

Example commands:


# HAProxy pod logs (replace pod name)
kubectl logs -n ingress haproxy-ingress-5d9c7f9c9f-abcde

# List NodePort services with their ports
kubectl get svc -A -o jsonpath="{range .items[?(@.spec.type=='NodePort')]}{.metadata.namespace}/{.metadata.name}:{.spec.ports[*].nodePort}{'\n'}{end}" | sort

# Show events for a specific service
kubectl describe svc -n ai my-service | grep -i nodeport

Typical output showing duplicates:


ai/service-a:30080
ai/service-b:30080   <-- duplicate
ai/service-c:30081

If the same port appears more than once, the conflict is confirmed.

Solution – Resolving the NodePort Conflict

The fix consists of ensuring each Service receives a unique NodePort. There are two practical approaches:

Approach 1 – Let Kubernetes Allocate Dynamically

Remove the explicit nodePort field from Helm values. Kubernetes will assign an unused port automatically.

Before (static port):


apiVersion: v1
kind: Service
metadata:
  name: service-a
spec:
  type: NodePort
  ports:
  - port: 80
    targetPort: 8080
    nodePort: 30080   # <-- static

After (dynamic allocation):


apiVersion: v1
kind: Service
metadata:
  name: service-a
spec:
  type: NodePort
  ports:
  - port: 80
    targetPort: 8080
    # nodePort omitted – Kubernetes picks a free value

Approach 2 – Allocate Unique Ports via Helm Values

If a fixed port is required (e.g., external firewall rules), centralise the allocation in a shared values.yaml or a ConfigMap and reference it from each chart.

Shared values file (global-nodeports.yaml)


nodePorts:
  service-a: 30080
  service-b: 30081
  service-c: 30082

Chart snippet using the shared values:


apiVersion: v1
kind: Service
metadata:
  name: {{ .Chart.Name }}
spec:
  type: NodePort
  ports:
  - port: 80
    targetPort: 8080
    nodePort: {{ .Values.global.nodePorts.[.Chart.Name] }}

Redeploy the services


# If using Helm
helm upgrade --install service-a ./service-a -f global-nodeports.yaml
helm upgrade --install service-b ./service-b -f global-nodeports.yaml
# Verify new ports
kubectl get svc -A -o wide | grep NodePort

After redeployment, HAProxy pods start without bind errors and begin listening on distinct ports.

Verify – Confirming the Fix

Perform the following checks:

  • HAProxy pod status – pods should be Running with no restart loop.
  • HAProxy logs – no “address already in use” entries.
  • Service list – each NodePort unique.
  • Functional test – curl the ingress IP on each service’s external port and expect 200 OK.

Example validation commands:


kubectl get pods -n ingress -l app=haproxy-ingress
kubectl logs -n ingress $(kubectl get pods -n ingress -l app=haproxy-ingress -o jsonpath="{.items[0].metadata.name}")

# Simple health‑check
curl -s -o /dev/null -w "%{http_code}" http://:30080/healthz
# Expected output: 200

Prevent – Best Practices to Avoid Future NodePort Collisions

Practice Why it Helps
Never hard‑code nodePort in individual charts Reduces the chance of duplicate values across releases.
Centralise port allocation in a shared values file or ConfigMap Provides a single source of truth for static ports.
Enable service.spec.allocateLoadBalancerNodePorts (Kubernetes 1.20+) Lets the control plane enforce uniqueness during rapid service creation.
Add a pre‑deployment lint step Scripts can query existing NodePorts via kubectl get svc -A -o json and abort if a duplicate is detected.
Monitor HAProxy bind errors Alert on log patterns “address already in use” to catch regressions early.

FAQ – Related Questions

  1. Why does the conflict appear only in staging and not in production?
    Staging often runs multiple feature branches simultaneously, each deploying its own Helm chart with static ports. Production typically uses a single, stable release where ports have been de‑duplicated.
  2. Can I use a LoadBalancer service instead of NodePort to avoid this issue?
    Yes. A LoadBalancer service delegates port allocation to the cloud provider, eliminating the need for manual NodePort management. However, HAProxy still needs unique bind statements for each backend.
  3. What if I must keep a specific external port for compliance?
    Reserve the port at the cluster level by creating a dummy Service with the desired nodePort and annotate it with haproxy.org/ignore so HAProxy does not attempt to bind it.
  4. How do I troubleshoot “cannot bind to 0.0.0.0:<port>” errors in HAProxy logs?
    Check for duplicate NodePorts, verify that no other pod on the node is already listening on that port (e.g., ss -ltnp | grep ), and confirm that the HAProxy process has the necessary capabilities (CAP_NET_BIND_SERVICE).
  5. Is there a way to automate unique NodePort assignment in CI pipelines?
    Yes. Use kubectl get svc -A -o jsonpath="{.items[*].spec.ports[*].nodePort}" to collect used ports, then generate a non‑conflicting value programmatically before applying manifests.

Related Topic Hub: Distributed Systems Troubleshooting Hub