Understand how Kubernetes LoadBalancer services provision real infrastructure, how cloud-specific annotations shape that infrastructure, and how to template everything cleanly in Helm.
values.yamlWhen you create a Service of type LoadBalancer, the cloud controller manager detects the new object and calls the cloud provider API to provision a real load balancer (AWS ELB, GCP forwarding rule, Azure LB, etc.).
type: LoadBalancerstatus.loadBalancer.ingressapiVersion: v1
kind: Service
metadata:
name: my-app
spec:
type: LoadBalancer
ports:
- port: 80
targetPort: 8080
selector:
app: my-app
Each cloud provider uses annotations on the Service to customize the load balancer it provisions.
metadata:
annotations:
# NLB instead of Classic LB
service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
# Instance vs IP targets
service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: "ip"
# Internal-only
service.beta.kubernetes.io/aws-load-balancer-scheme: "internal"
# Health check
service.beta.kubernetes.io/aws-load-balancer-healthcheck-protocol: "HTTP"
service.beta.kubernetes.io/aws-load-balancer-healthcheck-path: "/healthz"
# GCP — internal LB
metadata:
annotations:
networking.gke.io/load-balancer-type: "Internal"
# Azure — internal LB
metadata:
annotations:
service.beta.kubernetes.io/azure-load-balancer-internal: "true"
service.beta.kubernetes.io/azure-load-balancer-health-probe-request-path: "/healthz"
No cloud controller in bare-metal clusters means type: LoadBalancer stays in Pending forever. MetalLB fills that gap with two modes: L2 (ARP/NDP) and BGP.
# IPAddressPool — defines which IPs MetalLB can hand out
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: production-pool
namespace: metallb-system
spec:
addresses:
- 192.168.1.200-192.168.1.250
---
# L2Advertisement — announce IPs via ARP
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: l2-advert
namespace: metallb-system
spec:
ipAddressPools:
- production-pool
Two critical Service fields control how traffic reaches pods and whether client identity is preserved.
| Value | Behaviour |
|---|---|
Cluster | Default. Traffic may hop between nodes. Source IP is SNATed. |
Local | Traffic only goes to pods on the receiving node. Source IP is preserved. Uneven distribution possible. |
spec:
sessionAffinity: ClientIP
sessionAffinityConfig:
clientIP:
timeoutSeconds: 10800
Ensures a client always reaches the same pod for the duration of the timeout. Useful for stateful apps, websockets, file uploads.
spec.loadBalancerIP or provider annotations.spec:
type: LoadBalancer
loadBalancerIP: 35.200.10.50 # pre-allocated static IP
ports:
- port: 443
targetPort: 8443
Cloud LBs perform their own health checks (separate from liveness/readiness probes). Configure via annotations:
metadata:
annotations:
service.beta.kubernetes.io/aws-load-balancer-healthcheck-interval: "10"
service.beta.kubernetes.io/aws-load-balancer-healthcheck-timeout: "5"
service.beta.kubernetes.io/aws-load-balancer-healthy-threshold: "2"
service.beta.kubernetes.io/aws-load-balancer-unhealthy-threshold: "3"
Real charts must work across AWS, GCP, Azure, and bare-metal. Template annotations conditionally from values.yaml.
# values.yaml
service:
type: LoadBalancer
annotations: {}
# Override per cloud:
# annotations:
# service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
# service.beta.kubernetes.io/aws-load-balancer-scheme: "internet-facing"
# templates/service.yaml
apiVersion: v1
kind: Service
metadata:
name: {{ include "mychart.fullname" . }}
{{- with .Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
{{- if .Values.service.loadBalancerIP }}
loadBalancerIP: {{ .Values.service.loadBalancerIP }}
{{- end }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
selector:
{{- include "mychart.selectorLabels" . | nindent 4 }}
type: LoadBalancer and real infrastructureexternalTrafficPolicy: Local preserves source IP at the cost of even distribution