Week 12: Kubernetes in Production

Last week got a Deployment and Service running on a local cluster — this week makes that setup survivable on the public internet and under real load. You'll route external traffic in with an Ingress, the same "stable entry point in front of things that change" idea behind the Service you built in Week 11; scale automatically with the Horizontal Pod Autoscaler; package the whole app as a reusable Helm chart instead of a pile of loose YAML files; and configure liveness/readiness probes so Kubernetes can actually tell a broken Pod from a healthy one. Deployment strategies from Week 7 — rolling, blue-green, canary — come back here as concrete Kubernetes mechanics, and the metrics this week's autoscaler reads are the same ones you'll graph in Week 13's Prometheus dashboards.

Module 9 of 22 Week 12 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Route external HTTP traffic to Services with an Ingress and understand how it load-balances
  • Configure Horizontal Pod Autoscaling and package an app as a Helm chart
  • Write liveness/readiness probes and diagnose a Pod stuck in CrashLoopBackOff

1. Ingress & Load Balancing

A ClusterIP Service (the type you used in Week 11) is only reachable from inside the cluster. To let real users hit your app, you need something that terminates external traffic and routes it in — an Ingress does that for HTTP/HTTPS, with host- and path-based routing rules, from a single external entry point instead of one load balancer per Service.

An Ingress resource is just routing rules; it does nothing on its own. You need an Ingress controller — commonly NGINX Ingress Controller or, in a managed cloud cluster, a cloud-provider-native one — running in the cluster to actually read those rules and provision the load balancer.

terminal — install NGINX Ingress Controller (kind)
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.10.1/deploy/static/provider/kind/deploy.yaml
kubectl wait --namespace ingress-nginx \
  --for=condition=ready pod \
  --selector=app.kubernetes.io/component=controller \
  --timeout=90s
ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: web-app
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  ingressClassName: nginx
  rules:
    - host: app.local
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: web-app
                port:
                  number: 80
          - path: /api
            pathType: Prefix
            backend:
              service:
                name: web-api
                port:
                  number: 8080

Notice the shape: the Ingress never talks to Pods directly, only to Services — it sits one more layer above the Service, the same way the Service sits above Pods. Each layer exists to hide instability in the layer below it: Pod IPs are unstable, so Services hide that; the set of Services and their internal ports shouldn't matter to an end user, so Ingress hides that behind one hostname.

Test locally without real DNS

On a local kind cluster, add 127.0.0.1 app.local to your /etc/hosts file and port-forward the Ingress controller's Service to your machine — you get host-based routing working end to end without owning a domain or touching DNS.

2. Horizontal Pod Autoscaling

A fixed replicas: 3 is a guess. The Horizontal Pod Autoscaler (HPA) watches a metric — CPU utilization by default — and adds or removes Pods to keep it near a target, within a min/max bound you set. It needs the metrics-server add-on running in the cluster to see actual usage, and it needs your Deployment to have resources.requests set, because "80% CPU" is meaningless without a requested baseline to measure against.

hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: web-app
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: web-app
  minReplicas: 3
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
terminal
kubectl apply -f hpa.yaml
kubectl get hpa web-app --watch
# NAME      REFERENCE            TARGETS   MINPODS   MAXPODS   REPLICAS
# web-app   Deployment/web-app   42%/70%   3         10        3

The HPA scales up quickly (within seconds of a metric breach) but scales down slowly by design — a default 5-minute stabilization window prevents it from flapping Pods up and down every time traffic dips briefly. You can tune that window explicitly with a behavior block if the defaults are too conservative or too jumpy for your traffic pattern.

Autoscaling can't outrun a resource-limits mistake

If resources.limits.cpu is set too low, a Pod hits its own ceiling and gets throttled long before the HPA's target utilization is reached across the fleet — you'll see slow requests with the HPA reporting comfortable numbers. Load-test with realistic traffic before trusting an HPA configuration in production.

3. Packaging an App as a Helm Chart

By now you have five or six YAML files per app — Deployment, Service, Ingress, HPA, ConfigMap — and every environment (dev, staging, prod) needs slightly different values for each. Copy-pasting and hand-editing those files per environment is exactly the kind of repetition Terraform modules solved for infrastructure in Week 10. Helm is Kubernetes' package manager: it templates your manifests with a values file per environment, and tracks releases so you can upgrade or roll back as one unit.

terminal — scaffold a chart
helm create web-app
# web-app/
#   Chart.yaml         -- chart name, version
#   values.yaml         -- default configuration
#   templates/
#     deployment.yaml
#     service.yaml
#     ingress.yaml
#     hpa.yaml
#     _helpers.tpl       -- reusable template snippets
values.yaml (trimmed)
replicaCount: 3

image:
  repository: ghcr.io/acme/web-app
  tag: "1.4.0"

resources:
  requests:
    cpu: 100m
    memory: 128Mi
  limits:
    cpu: 500m
    memory: 256Mi

autoscaling:
  enabled: true
  minReplicas: 3
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70
templates/deployment.yaml (excerpt)
spec:
  replicas: {{ .Values.replicaCount }}
  template:
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          resources:
            {{- toYaml .Values.resources | nindent 12 }}

A production chart usually gets one values file per environment — values-staging.yaml, values-prod.yaml — overriding just the fields that differ, like replica counts or resource limits, while everything else stays in the shared default.

terminal — install & upgrade a release
helm install web-app ./web-app -f values-staging.yaml
helm upgrade web-app ./web-app -f values-prod.yaml --set image.tag=1.5.0
helm list
helm rollback web-app 1   # revert to release revision 1
helm uninstall web-app
helm template before you helm install

helm template ./web-app -f values-staging.yaml renders the final YAML to your terminal without touching the cluster — always run it after editing a chart to catch templating mistakes before helm upgrade applies them for real.

4. Rolling Updates, Probes & Self-Healing

By default, updating a Deployment's image triggers a rolling update: new Pods come up gradually while old ones terminate, so there's no moment where zero Pods are serving traffic. Two fields control how cautious that rollout is:

deployment.yaml — rollout strategy
spec:
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0   # never drop below the desired replica count
      maxSurge: 1          # allow one extra Pod above desired count during rollout

None of that matters, though, if Kubernetes can't tell whether a new Pod is actually healthy — which is exactly what liveness and readiness probes are for. A liveness probe that fails causes Kubernetes to restart the container; a readiness probe that fails just removes the Pod from the Service's load-balancing pool without restarting it, which matters a lot for an app that's still starting up or briefly overloaded.

deployment.yaml — probes
containers:
  - name: web
    image: ghcr.io/acme/web-app:1.4.0
    ports:
      - containerPort: 8080
    readinessProbe:
      httpGet:
        path: /healthz/ready
        port: 8080
      initialDelaySeconds: 5
      periodSeconds: 10
    livenessProbe:
      httpGet:
        path: /healthz/live
        port: 8080
      initialDelaySeconds: 15
      periodSeconds: 20
      failureThreshold: 3

Combine a rolling update with readiness probes and Kubernetes will hold the rollout open — old Pods keep serving — until each new Pod reports ready, giving you an automatic health gate on every deploy without writing any extra pipeline logic.

When a container keeps failing its liveness probe or exiting non-zero, Kubernetes restarts it with an exponential backoff — 10s, 20s, 40s, capping at 5 minutes — and reports the Pod status as CrashLoopBackOff once it's been restarted repeatedly. That status is a symptom, never the root cause.

terminal — diagnosing CrashLoopBackOff
kubectl get pods
# web-app-6f9c9d5b7-k2n4x   0/1   CrashLoopBackOff   6   14m

kubectl describe pod web-app-6f9c9d5b7-k2n4x   # Events: OOMKilled? liveness probe failing?
kubectl logs web-app-6f9c9d5b7-k2n4x            # what the current container printed
kubectl logs web-app-6f9c9d5b7-k2n4x --previous # logs from the container BEFORE the last restart
--previous is the flag you'll forget and need

By the time you notice a CrashLoopBackOff, kubectl logs often shows an empty or freshly-started container that hasn't crashed yet. kubectl logs <pod> --previous pulls the logs from the terminated container instance right before the current restart — where the actual stack trace usually is.

5. Hands-on Exercise

Hands-on

Package last week's app as a Helm chart with autoscaling and probes

Turn the loose manifests from Week 11 into a production-shaped, autoscaled, self-healing Helm release exposed through Ingress.

Requirements:

  1. Run helm create web-app and replace the generated templates with your Week 11 Deployment, Service and ConfigMap, parameterized through values.yaml for image tag and replica count.
  2. Add readinessProbe and livenessProbe blocks pointing at a real health endpoint (or / if your test image has no dedicated one), with distinct initialDelaySeconds for each.
  3. Add an hpa.yaml template gated behind autoscaling.enabled in values.yaml, targeting 70% CPU utilization between 2 and 6 replicas, and install the NGINX Ingress Controller plus an ingress.yaml template routing app.local to your Service.
  4. Install the chart with helm install web-app ./web-app, confirm all Pods reach Running and 1/1 Ready, and reach the app through the Ingress via curl -H "Host: app.local" http://127.0.0.1 (after port-forwarding the Ingress controller).
  5. Deliberately break the readiness probe's path in values.yaml, run helm upgrade, and confirm the rollout stalls with old Pods still serving traffic — then fix it and confirm the rollout completes.
  6. Run helm rollback web-app 1 and confirm the release returns to its first working revision.
Hint

kubectl rollout status deployment/web-app blocks and reports live progress during step 5 instead of you polling kubectl get pods by hand — it's the same command a CI pipeline would run to decide whether a deploy succeeded.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does an Ingress route to Services rather than to Pods directly?

The Service already solves Pod instability with a stable label-selector-based endpoint, so the Ingress only needs one more stable thing to route to per app. If the Ingress tracked Pods directly it would have to duplicate the Service's own reconciliation logic; layering on top of the Service instead keeps each object doing exactly one job.

Q2

An HPA reports 40% CPU utilization but users are seeing slow responses. What's a likely explanation?

resources.limits.cpu is set too low relative to real load, so individual Pods hit their own throttling ceiling well before the fleet-wide average utilization the HPA measures gets anywhere near its target — the HPA metric looks calm while individual containers are being CPU-throttled. Tightening limits without load-testing is a common cause of this mismatch.

Q3

What's the difference between a failing liveness probe and a failing readiness probe, in terms of what Kubernetes actually does?

A failing liveness probe causes Kubernetes to kill and restart the container — it's saying "this process is unrecoverably stuck." A failing readiness probe just pulls the Pod out of the Service's load-balancing pool without restarting anything — it's saying "this Pod is temporarily not ready to receive traffic," e.g. still warming a cache. Using a liveness probe where a readiness probe belongs causes unnecessary restart loops.

Q4

Why is kubectl logs <pod> --previous often more useful than kubectl logs <pod> when debugging a CrashLoopBackOff?

Once a Pod is stuck in CrashLoopBackOff, kubectl logs without a flag shows output from the current container instance, which may have just restarted and not crashed yet — its logs can be empty or unhelpful. --previous retrieves the logs from the terminated container instance from before the most recent restart, which is where the actual error or stack trace that caused the crash almost always lives.