Lesson 10 LoadBalancer Deep Dive
Theme:

Lab Guide

Three exercises: deploy a LoadBalancer service with cloud annotations, install MetalLB on a local cluster, and build a cloud-agnostic Helm template.

Prerequisites

Cloud vs local: Exercise 1 works best on a real cloud cluster (EKS, GKE, AKS). If you only have a local cluster, you can still complete it -- the Service will stay in Pending state, which is expected and educational. Exercise 2 addresses exactly this gap with MetalLB.

Exercise 1 — LoadBalancer Service with Cloud Annotations

Goal

Deploy an NGINX pod behind a LoadBalancer Service, then customize it with cloud-specific annotations for health checks and internal/external visibility.

Step 1 — Create the chart scaffold

mkdir -p ~/helm-labs/lab10 && cd ~/helm-labs/lab10
helm create lb-demo
cd lb-demo

Step 2 — Update values.yaml

Open values.yaml and modify the service section:

# values.yaml (relevant section)
service:
  type: LoadBalancer
  port: 80
  annotations: {}
  externalTrafficPolicy: Cluster
  sessionAffinity: None
  loadBalancerIP: ""

Step 3 — Update the Service template

Edit templates/service.yaml to support annotations, traffic policy, and session affinity:

apiVersion: v1
kind: Service
metadata:
  name: {{ include "lb-demo.fullname" . }}
  labels:
    {{- include "lb-demo.labels" . | nindent 4 }}
  {{- with .Values.service.annotations }}
  annotations:
    {{- toYaml . | nindent 4 }}
  {{- end }}
spec:
  type: {{ .Values.service.type }}
  {{- if eq .Values.service.type "LoadBalancer" }}
  externalTrafficPolicy: {{ .Values.service.externalTrafficPolicy }}
  sessionAffinity: {{ .Values.service.sessionAffinity }}
  {{- if .Values.service.loadBalancerIP }}
  loadBalancerIP: {{ .Values.service.loadBalancerIP }}
  {{- end }}
  {{- end }}
  ports:
    - port: {{ .Values.service.port }}
      targetPort: http
      protocol: TCP
      name: http
  selector:
    {{- include "lb-demo.selectorLabels" . | nindent 4 }}

Step 4 — Verify the template renders correctly

# Dry run with default values
helm template lb-demo . | grep -A 20 "kind: Service"

# Dry run with AWS NLB annotations
helm template lb-demo . \
  --set service.annotations."service\.beta\.kubernetes\.io/aws-load-balancer-type"=nlb \
  --set service.externalTrafficPolicy=Local \
  | grep -A 25 "kind: Service"

Step 5 — Install the chart

helm install lb-demo . --namespace lb-lab --create-namespace

Step 6 — Check the Service status

# Watch for the external IP assignment
kubectl get svc -n lb-lab -w

# Inspect the full Service object
kubectl describe svc lb-demo -n lb-lab

On a cloud cluster, you should see an external IP or hostname appear within 1-3 minutes. On a local cluster, the EXTERNAL-IP will remain <pending>.

Verify connectivity (cloud only): Once the IP is assigned, run curl http://<EXTERNAL-IP> to confirm you see the NGINX welcome page.

Step 7 — Upgrade with session affinity

helm upgrade lb-demo . --namespace lb-lab \
  --set service.sessionAffinity=ClientIP

Confirm the change took effect:

kubectl get svc lb-demo -n lb-lab -o yaml | grep -A 3 sessionAffinity

Exercise 2 — MetalLB on a Local Cluster

Goal

Install MetalLB in a kind or Minikube cluster, configure an IPAddressPool, and verify that LoadBalancer Services get real IPs.

Step 1 — Create a kind cluster (if needed)

kind create cluster --name metallb-lab

Step 2 — Install MetalLB via Helm

helm repo add metallb https://metallb.github.io/metallb
helm repo update
helm install metallb metallb/metallb \
  --namespace metallb-system \
  --create-namespace \
  --wait

Step 3 — Determine the Docker network range (kind only)

MetalLB needs IPs on the same network as your kind nodes:

# Get the kind network subnet
docker network inspect kind -f '{{(index .IPAM.Config 0).Subnet}}'
# Example output: 172.18.0.0/16
# Pick a range that won't conflict, e.g. 172.18.255.200-172.18.255.250

Step 4 — Configure IPAddressPool and L2Advertisement

Create a file called metallb-config.yaml:

apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: lab-pool
  namespace: metallb-system
spec:
  addresses:
    - 172.18.255.200-172.18.255.250
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: lab-l2
  namespace: metallb-system
spec:
  ipAddressPools:
    - lab-pool

Apply it:

kubectl apply -f metallb-config.yaml

Step 5 — Deploy a LoadBalancer Service

# Re-use the chart from Exercise 1, or deploy a quick test
kubectl create deployment echoserver --image=registry.k8s.io/echoserver:1.10 -n default
kubectl expose deployment echoserver --type=LoadBalancer --port=80 --target-port=8080 -n default

Step 6 — Verify IP assignment

kubectl get svc echoserver -n default

You should see an IP from the 172.18.255.200-250 range in the EXTERNAL-IP column. Test connectivity:

curl http://172.18.255.200

Connectivity note: On macOS with Docker Desktop, you may not be able to reach the MetalLB IP directly from the host because Docker Desktop runs in a Linux VM. Use docker exec into a kind node to test, or use kubectl port-forward as a workaround.

Step 7 — Clean up

kubectl delete svc echoserver -n default
kubectl delete deployment echoserver -n default

Exercise 3 — Cloud-Agnostic Helm Service Template

Goal

Create per-cloud values files and verify the same chart renders correct annotations for AWS, GCP, and on-prem.

Step 1 — Create cloud-specific values files

In your lb-demo chart directory, create three override files:

# values-aws.yaml
service:
  type: LoadBalancer
  externalTrafficPolicy: Local
  annotations:
    service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
    service.beta.kubernetes.io/aws-load-balancer-scheme: "internet-facing"
    service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: "ip"
    service.beta.kubernetes.io/aws-load-balancer-healthcheck-protocol: "HTTP"
    service.beta.kubernetes.io/aws-load-balancer-healthcheck-path: "/healthz"
# values-gcp.yaml
service:
  type: LoadBalancer
  externalTrafficPolicy: Cluster
  annotations:
    networking.gke.io/load-balancer-type: "Internal"
# values-onprem.yaml
service:
  type: LoadBalancer
  loadBalancerIP: "192.168.1.200"
  externalTrafficPolicy: Local
  annotations: {}

Step 2 — Template each variant and compare

# AWS variant
echo "=== AWS ==="
helm template lb-demo . -f values-aws.yaml | grep -A 30 "kind: Service"

# GCP variant
echo "=== GCP ==="
helm template lb-demo . -f values-gcp.yaml | grep -A 30 "kind: Service"

# On-prem variant
echo "=== On-Prem ==="
helm template lb-demo . -f values-onprem.yaml | grep -A 30 "kind: Service"

Step 3 — Validate

For each rendered output, confirm:

Bonus: Add a helm test that curls the Service endpoint. Define a test pod in templates/tests/test-connection.yaml that runs wget -qO- http://{{ include "lb-demo.fullname" . }}:{{ .Values.service.port }}.

Completion Checklist