Kubernetes by Example · Part 14
Three copies of your YAML and a station list that changes at lunchtime. One of those is a templating problem. The other needs you to write a controller.
Two problems that arrive together once airwaves is real.
The first is tedious. You have three environments and one directory of YAML, so you copied it three times. Last week you fixed a probe timeout in dev and forgot staging, and found out during a demo.
The second is more interesting, and templating cannot touch it. The station list lives in a database and changes during the day. Someone adds a station at eleven in the morning and expects a tuner for it.
No amount of YAML solves that. YAML is a snapshot, and you need something that keeps watching.
These are different problems with different answers, and conflating them is common enough to be worth separating up front.
Helm renders text. It runs when you run it and then stops. A controller runs forever, watching for differences and closing them — which is precisely what every component in Part 1 was doing.
It is not a controller. Nothing reconciles your chart. Drift from a manual kubectl edit is invisible to Helm until the next upgrade overwrites it.
It is not a dependency manager for runtime — chart dependencies are install-time only, with no ordering guarantees beyond hooks.
It is a very good templating engine with a release history. That's enough, and expecting more leads to disappointment.
Click any piece of the command.
Click any piece of the command.
Click any piece of the command.
Everything from Parts 1–13, parameterised.
Render it, validate it, send nothing
helm template airwaves ./chart | head -40
helm template airwaves ./chart -f chart/values-prod.yaml \
| kubectl apply --dry-run=server -f -Install it properly
helm upgrade --install airwaves ./chart \
-f chart/values-dev.yaml --wait --atomic
helm list
kubectl get all -l app.kubernetes.io/instance=airwaves Plain Deployments, Services and ConfigMaps. Nothing in that output knows it came from a chart.
A CRD, and some Stations
kubectl apply -f manifests/station-crd.yaml
kubectl explain station.spec
kubectl apply -f manifests/stations.yaml
kubectl get stations Three Stations, listed with your own printer columns, validated against your own schema. And kubectl get pods shows nothing new, because a CRD is storage and nothing else.
The conductor
kubectl apply -f manifests/conductor-rbac.yaml
kubectl apply -f manifests/conductor.yaml
kubectl logs -f deploy/conductor Watch its log. It lists Stations, lists tuner pods, finds three Stations and zero pods, and creates three. Then it looks again and finds nothing to do — Part 1's description of a healthy cluster, now from the inside.
Add a station at eleven in the morning
kubectl apply -f manifests/station-classical.yaml
kubectl get pods -l app=tuner -w A fourth pod, seconds later, with nobody editing a Deployment. That is the problem from the top of the page, solved — and it was never solvable by rendering better YAML.
kubectl scale deploy/conductor --replicas=0
kubectl apply -f manifests/station-ambient.yaml
kubectl get stations
kubectl get pods -l app=tuner The Station exists, validates, lists cleanly. No pod. Nothing is broken and nothing will happen, because the CRD was only ever the vocabulary — the controller was the meaning, and you just switched it off.
kubectl scale deploy/conductor --replicas=1
kubectl get pods -l app=tuner -w It catches up immediately. It didn't miss an event — it recomputed from scratch, which is what makes reconcile loops robust. Part 1's claim that an idle cluster is machinery finding nothing to do, demonstrated by taking the machinery away.
kubectl delete rolebinding conductor
kubectl rollout restart deploy/conductor
kubectl logs -f deploy/conductor
kubectl auth can-i create pods \
--as=system:serviceaccount:airwaves:conductor -n airwaves Part 13's silent 403, now in code you wrote. The controller runs, watches, calculates correctly, and every create is refused. If your error handling is sloppy it looks exactly like doing nothing.
kubectl apply -f manifests/conductor-no-owner.yaml
kubectl apply -f manifests/station-test.yaml
kubectl delete station test
kubectl get pods -l app=tuner The Station is gone; the pod is still running. Without ownerReferences, nothing connects them, so nothing cleans up. One orphan is trivial. Months of this is a cluster full of pods nobody can account for.
| Because… | …this is what happens |
|---|---|
| Helm only renders text | No reconciliation. Manual edits drift silently until the next upgrade overwrites them. |
| Values layer, lists replace | Overriding one list item replaces the whole list. The commonest values surprise. |
| Helm reports on acceptance | Without --wait --atomic, success means recorded, not running. Part 1, one layer up. |
| A CRD has no behaviour | Your resource validates, lists, and does nothing. The controller is the meaning. |
| Controllers need RBAC | A missing binding means silent 403s and a controller that appears to be idle. |
ownerReferences drive GC | Set them and deletion cascades for free. Omit them and you leak, slowly. |
| Status is separate from spec | Spec is intent, status is achievement. Use the subresource so apply can't clobber it. |
• Helm is a templating engine. It renders YAML and applies it. The cluster never hears of it.
• helm template first, always. Most Helm bugs are templating bugs.
• helm upgrade --install --wait --atomic is the command for CI. Plain install isn't idempotent.
• Values layer; lists are replaced, not merged.
• A checksum annotation on the pod template turns a config change into a paced rollout — Parts 4 and 8, joined up.
• A CRD teaches the API server a new kind. You get validation, explain, RBAC and selectors free.
• A CRD alone does nothing. It is storage and schema; the controller supplies behaviour.
• A controller is Part 1's loop with your noun: watch, compare, close the difference, repeat.
• Set ownerReferences on everything you create, or you leak objects quietly.
• Write to status, never to spec. Spec is what they asked for; status is what you managed.
Fourteen parts, and one idea underneath all of them: declare what you want, have something watch the difference, and close it forever.
Everything that looked arbitrary turns out to follow. Pods are disposable, so Services select by label. Selectors are queries, so ownership is a label match and two Deployments can fight. The scheduler reads requests, so a node can be full and idle. apply only records, so you need rollout status to know anything actually worked.
And on the last page, you wrote the loop yourself.