Running Multiple Load Balancers With loadBalancerClass
A cloud LB and MetalLB in one cluster will fight over the same Service. spec.loadBalancerClass tells each controller which Services it owns — cleanly and explicitly.
- #kubernetes-helm
- #ai
- #loadbalancer
- #service
- #metallb
We added MetalLB to a cluster that already had a cloud provider’s load balancer controller, and within an hour we had a Service whose external IP kept changing every few seconds. Both controllers had decided the Service was theirs. One assigned a cloud LB, the other assigned a MetalLB address, and they took turns overwriting each other’s status.loadBalancer. The flapping only stopped when we figured out that a type: LoadBalancer Service with no explicit owner is fair game for every controller in the cluster.
The field that resolves this is spec.loadBalancerClass, and it’s recent enough that most operators have never set it. Once you do, each Service is claimed by exactly one controller, and the fights stop.
How loadBalancerClass routes ownership
A type: LoadBalancer Service can carry a spec.loadBalancerClass that names which controller is responsible for it. A controller is expected to reconcile only the Services whose class it recognizes and ignore the rest:
apiVersion: v1
kind: Service
metadata:
name: public-web
spec:
type: LoadBalancer
loadBalancerClass: service.k8s.aws/nlb # claimed by the AWS LB controller
selector:
app: web
ports:
- port: 443
targetPort: 8443
apiVersion: v1
kind: Service
metadata:
name: internal-api
spec:
type: LoadBalancer
loadBalancerClass: metallb.universe.tf/metallb # claimed by MetalLB
selector:
app: api
ports:
- port: 8080
Two Services, two controllers, zero ambiguity. The cloud controller leaves the MetalLB Service alone and vice versa, because each only acts on its own class string.
The class-less default is where conflicts start
The crucial detail: a Service with no loadBalancerClass falls to the default provider — and in a cluster with more than one controller, “the default provider” is exactly the ambiguity that causes flapping. Both controllers may consider class-less Services theirs to handle.
So the real fix isn’t just adding classes to the Services you care about; it’s making sure no Service is left class-less. The robust way is a mutating admission default that stamps a class onto any type: LoadBalancer Service that doesn’t specify one, so nothing is ever ambiguous. Test that default against a non-prod namespace first — it affects every future Service in the cluster.
To find which controller is currently fighting over a class-less Service, look at events and ownership:
kubectl describe svc internal-api | sed -n '/Events/,$p'
kubectl get svc internal-api -o jsonpath='{.status.loadBalancer}{"\n"}'
The immutability gotcha
spec.loadBalancerClass is immutable after the Service is created. You cannot edit a Service to move it from one provider to another. Switching means deleting and recreating the Service, which drops the assigned external IP and breaks any live connections through it. So choosing the class is a one-time, deliberate decision, and a provider migration is a scheduled, disruptive change — not a quick kubectl edit.
This is also why a typo is so annoying: a class string that no controller watches for leaves the Service Pending forever, with no error from anyone, and the only fix is to recreate it with the right string.
Internal versus external, cleanly split
The pattern this unlocks nicely is splitting traffic by exposure. One class routes Services to a public-facing LB, another to an internal-only one, with no annotation guesswork about which is which:
Prompt: We run the AWS LB controller and MetalLB in one cluster. Some Services should be public NLBs and some internal MetalLB addresses, but Services with no class flap between the two. Give me the loadBalancerClass values per group, a mutating default so no Service is ever class-less, and commands to confirm each Service gets its IP from the intended provider. Inspect first — don’t apply.
Output (excerpt): Public Services →
service.k8s.aws/nlb; internal →metallb.universe.tf/metallb. Add a mutating default stampingmetallb.universe.tf/metallbon any class-lesstype: LoadBalancerService so the cloud controller never claims an unintended one. NoteloadBalancerClassis immutable — moving a Service between providers requires recreate (DESTRUCTIVE, drops the IP). Verify withkubectl get svc -o wideand check the assigned address range matches the intended provider.
This suits an AI assistant well: it’s an ownership-routing problem with clear rules, and the model maps providers to class strings and writes the manifests while I inspect current ownership and apply. I keep it advisory because the immutability means a wrong class is a recreate, not an edit — so the assistant proposes, and I schedule any provider move myself. More Service-routing patterns are in the Kubernetes & Helm guides.
Wrapping up
The moment a cluster has more than one LoadBalancer implementation, class-less Services become a battleground where controllers fight over the same status.loadBalancer and external IPs flap. spec.loadBalancerClass assigns each Service to exactly one controller; the discipline that makes it reliable is ensuring no Service is ever left class-less, ideally with a mutating default. Remember the field is immutable — provider changes mean recreating the Service and dropping its IP — so treat class assignment as a deliberate, one-time decision. Let an AI assistant map providers to classes and draft the manifests while you keep the disruptive recreates in human hands. More networking guides are in the Kubernetes & Helm guides, with reusable prompts in the prompt library.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.