Build mechanics¶
The curriculum builds eleven Go artifacts across five phases. It decides the workflow once, and the decision has six parts: two stages per artifact, one guest that compiles them, one registry, one identity convention, one sizing rule, and a named gate per artifact.
This was settled in #12. The measurements in
Measured, not estimated are reproducible from
../research/build-footprints/.
The learner's code lives in build/NN-artifact/, as real Go modules in this repo. The notes
live in journal/NN-name.md. P4 therefore compares a hand-wired operator against a
kubebuilder operator with a literal git diff.
Two stages, and which artifacts get both¶
Every artifact that can run outside the cluster is developed that way first. Then it is re-shipped inside the cluster. Stage 1 is an edit-and-run loop that is measured in seconds. Stage 2 adds the ServiceAccount, the ClusterRole, leader election, image plumbing and resource sizing. Stage 2 is a deliberate second exercise, and not a tax that you pay on every keystroke.
The API server cannot tell the difference between the two stages. That is the point: stage 1 is not a simulation.
| Artifact | Phase | Stage 1 — outside | Stage 2 — inside |
|---|---|---|---|
| Scheduler from scratch | P5 | go run ./cmd/... --kubeconfig | ✔ leader election, schedulerName |
| Scheduler framework plugin | P5 | second scheduler, KubeSchedulerConfiguration | ✔ |
| Operator, hand-wired client-go | P4 | go run, informers against the real API | ✔ |
| Operator, kubebuilder | P4 | make run | ✔ |
| Webhook ×3 | P3 | clientConfig.url pointing at forge | ✔ clientConfig.service |
| kubectl plugin | P3 | it is a client binary | — never has a stage 2 |
| CNI plugin | P7 | none — on-node only | ✔ |
| eBPF program | P7 | none — on-node only | ✔ |
| CSI driver | P8 | csi-sanity over a UNIX socket | ✔ with the sidecars |
Two rows are worth more than their width.
The webhooks get a real stage 1 for one reason: forge is on the lab bridge.
ValidatingWebhookConfiguration.clientConfig is a union. It takes service for an
in-cluster target, or url for anything that the API server can dial. Stage 1 sets url:
https://forge.lab:8443. The API server then calls a go run process on the build guest, so
the whole admission path is live while you are still editing the code. Stage 2 switches to
service, and it discovers what that swap actually costs: a Service, Endpoints, in-cluster
DNS, and a cert with the right SANs. The two halves of clientConfig are the same lesson
from both sides. That lesson is only available because forge sits on 10.10.10.0/24,
rather than behind the bastion.
The stage 1 of CSI is the harness. csi-sanity drives the driver over /tmp/csi.sock,
with no Kubernetes present at all. That is also how P8 gates it. So the gate and the fast
loop are the same tool. The sidecars arrive at stage 2.
The kubectl plugin is the odd one out, and the phase file should say so instead of
letting the learner notice it. P3 builds four artifacts, and one of them never practises
deployment mechanics at all, because it is a client binary. That is correct, and it is not an
omission.
forge — the build guest¶
forge is a dedicated Debian 13 guest, at 1536MB, 2 cores and 25G, on 10.10.10.125. It
is never part of any cluster, and it is never torn down. It is therefore
never a node in a topology. It is only ever co-resident with
one.
| Holds | Why there |
|---|---|
The Go toolchain, GOMODCACHE and GOCACHE |
They survive every teardown. The module graph of k/k is not something to re-download per phase. |
| clang, the libbpf headers, and the target BTF | The C half of the eBPF artifact needs them. They must also match the kernel of the nodes. See kernel lockstep. |
docker with buildx, and the registry container |
This is the machine that pushes. Docker stays off a PVE host. |
| The stage-1 process itself | That means go run, and the url:-mode webhook that the API server dials. |
Why not the control plane of whatever topology is up? That was the more literal reading
of "build where it runs". It fails for two reasons. The cache dies at every teardown. And
build RAM competes with the cluster that you are studying. forge has the same OS, the same
kernel and the same architecture as the nodes, so fidelity survives. You give up co-tenancy
only, and co-tenancy was the part that cost something.
Why 1536MB rather than 2048MB. The spendable figure is about 9.5GB,
and ha at 7.5GB had already spent the margin. The totals below are quoted from
the topology table, which owns them. The arithmetic is what
is being argued here:
| Topology | + forge 1536MB |
Margin in 9.5GB |
|---|---|---|
ha 7.5GB |
9.0GB | 0.5GB |
workhorse 7.0GB |
8.5GB | 1.0GB |
platform 6.0GB · nested 6.0GB |
7.5GB | 2.0GB |
pair 5.0GB |
6.5GB | 3.0GB |
solo 4.0GB |
5.5GB | 4.0GB |
etcd-only 3.0GB · k0s-light 2.0GB |
4.5GB · 3.5GB | 5.0GB+ |
At 2048MB, ha plus forge commits all 9.5GB, with nothing spare. And with balloon 0
there is no reclaim, so staying under budget is binding. Do not trust the 6.1GB of swap.
1536MB keeps "always up" literally true for every topology in the table, and that is what
makes the persistent cache worth having.
forgeis 1536MB by default. It is 2560MB for the two builds that link a Kubernetes binary and keep its debug information: the apiserver in P3 and the schedulers in P5. It goes up at the start of each phase, and back down in that phase's teardown. That is aqm setand a reboot four times in the curriculum, and not twice.GOMODCACHEandGOCACHEare on disk, and they do not notice.
The raise is needed because 1536MB is not enough to link cmd/kube-scheduler with DWARF
kept. See the measurements. And cmd/kube-apiserver is the larger binary,
so 1535 MiB is a floor for it, rather than an estimate of it. That last step is inferred.
The apiserver link has never been sampled, so the honest form of the claim is at least as
large, and that is all that the sizing decision needs.
Every artifact in the table above fits with room to spare. The operator
and webhook shape covers five of the eleven artifacts, and it peaks at 564 MiB. The phases
that build them are P3, P4, P7 and P8, and they run beside pair, which leaves 3.0GB.
What does not fit is not an artifact at all. It is the two phases that compile a
Kubernetes component in order to read it from the inside.
P3 raises it once, with nothing else up¶
P3 compiles cmd/kube-apiserver with -gcflags=all="-N -l", so that you can step through it
under a debugger. That is the same DWARF-heavy link as the schedulers of P5, and it does not
fit 1536MB either. The resemblance ends there. The first three modules of P3 need no
topology at all. So the raise is taken while the ceiling is otherwise empty, and the guest
then stays at 2560MB for the rest of the phase.
| P3 modules | Topology | forge |
Total | Margin |
|---|---|---|---|---|
| Hand-start and instrument an apiserver | none | 2560MB | 2.5GB | 7.0GB |
| From the first provision to the capstone | pair 5.0GB |
2560MB | 7.5GB | 2.0GB |
Read the first row as the argument for deferring that provision, and not as a consequence of
it. The build that wants the largest forge in the curriculum is also the one build that can
have the machine to itself. The teardown of the capstone puts the guest back to 1536MB, so
the raise does not follow P3 into P4. The two operators of P4 are the 564 MiB shape, and they
have never needed it.
P5 splits its lab¶
P5 is assigned workhorse at 7.0GB, because "scoring across
two nodes teaches almost nothing". But workhorse plus a 2560MB forge is the entire
budget, with zero margin. You cannot dodge that by compiling before the cluster is
provisioned, because stage 1 is go run, and go run links on every iteration. The
peak is needed repeatedly, with the cluster up.
The resolution comes from inside that justification, rather than against it. The third node exists for scoring. The two build artifacts do not need it.
| P5 modules | Topology | forge |
Total | Margin |
|---|---|---|---|---|
| Build the two schedulers | pair 5.0GB |
2560MB | 7.5GB | 2.0GB |
| Scoring, preemption, topology spread | workhorse 7.0GB |
1536MB | 8.5GB | 1.0GB |
The image that you build in the first half is pushed to the registry on forge. The second
half simply runs it. So nothing is compiled while workhorse is up. The phase now
separates building a scheduler from watching one make decisions at scale. Those were
always two different exercises, and they shared a topology out of convenience.
Disk is tighter than RAM here. workhorse at 65G plus forge at 25G is 90G out of 95G.
So cache eviction is part of teardown discipline, and not optional hygiene. Put go clean
-modcache and docker system prune in the teardown runbook, next to tofu destroy. The
workhorse phases are where forgetting that costs you a failed provision.
forge must track the nodes' kernel¶
This is the one way in which a dedicated build guest could quietly reintroduce the failure
that it was meant to avoid. CO-RE resolves against /sys/kernel/btf/vmlinux. Suppose that
forge drifts to a different Debian 13 kernel than the lab guests. The eBPF artifact then
compiles against BTF for a kernel that it will never attach to. That is precisely why
"cross-compile everything on the Mac" was rejected, and here it re-enters through the back
door.
So here is the convention. forge is provisioned from the same template as the nodes. Its
kernel is pinned in lockstep. And you compare uname -r on forge and on a lab node
before the eBPF module starts.
A mismatch is a stop-and-fix, and not a warning. The failure that it prevents is either a verifier rejection or a silently wrong field offset, and the second one is much worse than the first. The chaos strand takes the deliberate mismatch as a drill.
Images reach the nodes through one registry, outside the cluster¶
Run registry:3.0.0 as a container on forge. It then costs the cluster nothing, and it
works identically on one, two or three nodes.
build docker buildx build --platform linux/amd64 \
-t forge.lab:5000/toy-scheduler:v3 --push .
nodes containerd insecure-registry entry, Ansible-managed
The alternative is docker save, then scp through the bastion, then ctr -n k8s.io images
import on each node. That path needs zero infrastructure, but it is per-node manual work.
And it degrades exactly where the build track needs it most. A DaemonSet artifact — the CNI
plugin, the CSI node plugin, or the eBPF loader — on pair or workhorse means doing that
work on every node, on every iteration.
But the node-side trust plumbing is not skipped. Configuring containerd to pull from an
insecure registry is a legible piece of trust configuration, and it stays visible in Ansible
rather than hidden. And the ctr import path is done by hand exactly once. Then, when an
image fails to arrive, the content-addressed store is somewhere that the learner has already
been.
scratch, with one distroless artifact for contrast¶
That image is about 12MB. It has no shell, no CA bundle and no /etc/nsswitch.conf.
kubectl exec fails with exec: "sh": not found. The way in is this:
That is an ephemeral container, and it shares the process namespace. It is a mechanism worth needing, rather than a mechanism to read about.
One artifact ships gcr.io/distroless/static:nonroot instead. You then feel the difference
between "empty" and "deliberately minimal but debuggable". It costs 2MB more, and it buys a
CA bundle, tzdata and a nonroot UID. It still has no shell. The artifact that gets
distroless should be one that actually needs the CA bundle. Give it an outbound TLS call to
something outside the cluster. Then x509: certificate signed by unknown authority is the
reason for the change, rather than an illustration of it. Which artifact that is, is a
phase-file decision.
Identity, per artifact¶
The namespace is academy-build. Each artifact gets one ServiceAccount and one
hand-written least-privilege ClusterRole. There is no shared build-track identity, for two
reasons. The least-privilege exercise is CKS material that the curriculum wants anyway. And
an over-broad grant is only visible if the grants are separate.
That command is the check, and it is falsifiable. Run it for a verb that the artifact should
not have, and the expected answer is no.
Sizing, per artifact¶
resources: {} is a live hazard on this node. It disqualified Argo CD, and it forced a
non-optional DeploymentRuntimeConfig override on the function pods of Crossplane. Eleven of
these pods are the learner's own, so the same standard applies to the curriculum's own
output:
- Always set a memory request. Take it from
kubectl top podduring stage 1. Do not guess it. - A memory limit is a decision, and you write the decision down, per artifact, with the reason. The sharpest finding behind this rule was not that limits are good. It was that a 16× request-to-limit ratio is a lie about the working set. So two answers are acceptable: "limit equals request", and "no limit, and here is why". "No limit because the chart didn't have one" is not acceptable.
- No artifact ships
resources: {}.
Webhook TLS: openssl first, cert-manager second¶
Webhook 1 is hand-certed end to end. Generate a CA. Sign a serving cert with the right
SANs. Then base64 the CA into caBundle by hand. Then break it on purpose: change one
SAN, watch admission fail, read the API server log, and recognise x509: certificate signed
by unknown authority from the inside.
Webhook 2 uses cert-manager. That means a Certificate, plus the ca-injector
annotation that writes caBundle for you. It now reads as automation of a shape that you
already understand, rather than as an annotation that works for unexamined reasons. Webhook 3
takes whichever method fits its phase.
This section hands one drill to the chaos strand. Corrupt the
caBundle on a working webhook, and diagnose it from the API server logs alone. With
failurePolicy: Fail that is an outage, and the outage is the honest version. A broken
admission webhook can wedge a cluster, and this is the place where wedging one costs nothing.
What "done" means, per artifact¶
Where a real harness exists, name it. Do not invent a uniform bar. Six of the eleven
artifacts have such a harness. The remaining five fall to the falsifiable-claim tier, which
means a file:line claim that a hostile reader could check and find wrong.
| Artifact | Gate | Kind |
|---|---|---|
| CSI driver | csi-sanity --csi.endpoint=/tmp/csi.sock |
objective harness |
| Scheduler framework plugin | upstream framework test helpers | objective harness |
| Operator, hand-wired | envtest — a real apiserver and etcd, locally |
objective harness |
Operator, kubebuilder |
envtest |
objective harness |
| eBPF program | the verifier accepts it, or it does not | objective, and free |
| CNI plugin | cnitool ADD/DEL against a netns |
objective harness |
| Scheduler from scratch | falsifiable written claim | tier 2 |
| Webhook ×3 | falsifiable written claim | tier 2 |
kubectl plugin |
falsifiable written claim | tier 2 |
envtest is the addition worth flagging. A hand-wired client-go controller had no
objective gate at all. sigs.k8s.io/controller-runtime/pkg/envtest runs genuine
kube-apiserver and etcd binaries out-of-cluster. The reconciler is therefore tested
against real API semantics: optimistic concurrency, watch delivery, defaulting and
validation. It is not tested against a fake client that agrees with whatever the code does.
It also means that both operators are gated by the same harness, and that is what makes
the hand-wired-versus-kubebuilder git diff a comparison rather than an anecdote.
A learner-written test suite was considered, and it was rejected as the gate. A suite that you wrote can be as weak as you like. So it is good practice, and it is bad evidence.
Measured, not estimated: what a build actually costs¶
The sampling measures aggregate RSS across the whole toolchain process tree: the driver,
compile, link and asm. The reason is that what OOMs a small guest is the sum.
Parallelism was pinned to the 2 cores of forge, with CGO_ENABLED=0 GOOS=linux
GOARCH=amd64, on Go 1.26.5.
| Build | Cache | Peak aggregate RSS | Wall |
|---|---|---|---|
| controller-runtime manager + webhook — stands in for both operators and all three webhooks | cold | 564 MiB | 43s |
k8s.io/kubernetes/cmd/kube-scheduler — stands in for both P5 artifacts |
cold | 1076 MiB (undercounted — see below) | 64s |
kube-scheduler, link step only, DWARF kept |
warm | 1535 MiB | 5s |
kube-scheduler, link only, -ldflags="-s -w" |
warm | 1177 MiB | 3s |
This measurement taught three things that an estimate would have got wrong.
1. The link is the peak. The compile fan-out is not. The first cold run reported 1076
MiB, and that number is wrong. Sampling at 1Hz across a 64-second build missed a spike
that lasted a few seconds. Re-sampling at 5Hz against a warm cache, where nothing but the
link runs, gives 1535 MiB. The methodological point is worth keeping. /usr/bin/time -l
reports the RSS of the largest single child, and a Go build forks dozens of compile
processes. So you must sample the aggregate across the process tree.
2. -p is not a lever. The link is a single process. Reducing build parallelism reduces
the aggregate of the compile phase, and that phase was never the constraint. Anyone who
sizes a build guest by throttling -p is tuning the wrong number.
3. GOGC is not a lever either. The constraint is the live set of the linker, and not
garbage that the linker is failing to collect:
GOGC |
Link peak, DWARF kept |
|---|---|
| 100 (default) | 1510 / 1535 MiB across two runs |
| 50 | 1352 MiB |
| 25 | 1426 MiB — worse than 50 |
That is roughly 10% at best, and then it goes backwards. Identical configurations also vary
by about 25 MiB from run to run. So GOGC=50 is worth setting, and it is not worth relying
on.
Two caveats, stated rather than buried. First, this was measured on darwin/arm64,
cross-linking to linux/amd64. A native linux/amd64 link could differ, although not by the
400 MiB or so that would change any conclusion. Second, the P5 plugin builds against
kubernetes-sigs/scheduler-plugins, rather than against k/k directly. But the output of
that project is a kube-scheduler binary with extra plugins linked in, over the same
staging-repo module graph. So cmd/kube-scheduler is the right proxy, and if anything it is
a slight underestimate.
Open, and where it bites¶
- Re-measure the native link peak on
forgeitself, before P5 begins. It is the number that the 2560MB resize rests on, and it is a five-minute check on first boot. Runlink-peak.shon the guest. If a native link exceeds about 2.2GB, then 2560MB is not enough, and-s -wbecomes mandatory. - The margin for
haplusforgeis 0.5GB, which is thin. Ifhaneeds its full 7.5GB in practice, thenforgestops for that topology, and the cache survives the pause. That is a fallback, and not the plan. - The eleven artifacts have a directory layout, and no module convention. This doc puts
the learner's code in
build/NN-artifact/, and the thirteenlabs/directories kept to it. Fourteen such paths are cited, frombuild/00-scratchtobuild/08-csi-driver, and no session invented a second shape. What no ruling covers is what goes inside. Three things are unspecified: the module path that eachgo.moddeclares, whethercmd/is mandatory for a single-binary artifact, and the invocation that the acceptance harness is run by. Thirty-nine exercises carry a**Build**key, and only two state a module path at all. Both of those two sayprobe, for a throwaway. This bites at the second artifact, and not at the first. The two operators of P4 are built to be diffed against each other, and the plugin of P5 is meant to be carried onto the scheduler binary from earlier in the same phase. A convention that is chosen after elevengo.modfiles exist costs eleven edits. Chosen before the first one, it costs one paragraph. It was left open on purpose when map #29 closed. The exercise shape is settled by two prototypes. The scaffold has no evidence behind it yet, because no artifact in this curriculum has been built.