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.
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
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.
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.
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
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.
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.
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
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
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.
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 ./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:
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.
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.
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
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
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:
- Run
helm create web-appand replace the generated templates with your Week 11 Deployment, Service and ConfigMap, parameterized throughvalues.yamlfor image tag and replica count. - Add
readinessProbeandlivenessProbeblocks pointing at a real health endpoint (or/if your test image has no dedicated one), with distinctinitialDelaySecondsfor each. - Add an
hpa.yamltemplate gated behindautoscaling.enabledinvalues.yaml, targeting 70% CPU utilization between 2 and 6 replicas, and install the NGINX Ingress Controller plus aningress.yamltemplate routingapp.localto your Service. - Install the chart with
helm install web-app ./web-app, confirm all Pods reachRunningand1/1 Ready, and reach the app through the Ingress viacurl -H "Host: app.local" http://127.0.0.1(after port-forwarding the Ingress controller). - Deliberately break the readiness probe's path in
values.yaml, runhelm upgrade, and confirm the rollout stalls with old Pods still serving traffic — then fix it and confirm the rollout completes. - Run
helm rollback web-app 1and confirm the release returns to its first working revision.
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?
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?
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?
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?
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.