Lab topologies¶
This document describes nine named guest layouts. For each layout it gives three things. It gives the size of each node. It gives the address of each node. It gives the commands that build the layout and remove it. The document also explains how you reach a cluster that runs inside the bastion.
How to use this document. An exercise names one topology. The exercise links to the row for that topology in this document. An exercise never repeats a footprint.
Where these facts come from. This document derives from
../research/lab-topologies.md. The research date is
2026-08-18. The research is verified against k3ii/factory@6f4692f. That tree holds the
full audit of what factory contains. Seven of the sizing decisions come from
#8. The eighth comes from
#14. Some facts have no real tree and
no real measurement behind them. This document marks each of those facts
[UNVERIFIED] where the fact occurs. It also lists them together in
What is not confirmed.
The commands were a contract before they were a description¶
The tofu/labs module landed in k3ii/factory on
2026-08-30, in commit 266dac0. The module declares all nine topologies.
Read the history in the correct order. Every provision command and every teardown
command in this document was written before the module existed. Each command was a
specification. The specification told factory what to build. It did not describe
something that was already built. factory has since implemented the specification.
The commands in this document now match what the module exposes.
This order was deliberate (#49). The curriculum leads, and the homelab follows. The alternative was worse. Approximately 100 exercises would cite an interface that was built for a different purpose.
The exercises no longer point here for that reason. Five lab files held a pointer to
this section. The pointer was named "what does not exist yet".
#75 removed the pointer. The reason is
simple. A provision block that fails today is a bug, and you report it. It is no longer
a gap in factory. The exercises now point to the contents of a node
instead. That is a decision, and not a gap. This section keeps the record of why the
interface came first.
phases/08-storage.md shipped one such block before this document existed. That block
was not fiction. It was this same specification. The author wrote it as though the
module already existed.
The ceiling — you can spend about 9.5GB¶
The node is an i5-8400T. It has 6 cores. Some machines must stay up at all times. After you subtract them, you can spend about 9.5GB of RAM and 95G of disk on a topology.
| What holds RAM | Amount | Status |
|---|---|---|
| The Proxmox host | ~1.6GB | Measured. |
hopper |
2048MB | Must stay up. It is the only machine that runs tofu and Ansible. |
carthage |
1024MB | Must stay up. It holds the OpenTofu state bucket. |
jeremie |
1024MB | Reclaimed (#8). Destroyed on 2026-08-18, for the duration of the academy. |
forge |
1536MB · 2560MB during P3 and P5 | Never torn down. It is not part of any topology. |
| Host page cache | ~1.0GB | Held back deliberately. |
A topology, plus forge |
~9.5GB |
Budget against 9.5GB. Do not budget against the 9.9GB that the host reports as
available. Both figures are the same budget at two points in one subtraction. The
9.9GB figure comes before the page-cache holdback. The 9.5GB figure comes after it. A
footprint that fits in 9.9GB but not in 9.5GB does not fit. jeremie is destroyed, and
forge is declared, so the assumptions behind the figure hold. The figure is still
a subtraction and not an observation.
Run one topology at a time. This rule applies to RAM and also to disk. For example,
workhorse needs 65G and nested needs 40G. The two need 105G together, and only 95G
exists. Their RAM would fit together, but their disks do not. A topology can therefore
fail to provision because of disk while the memory arithmetic says that it fits.
Every guest runs with balloon 0. No guest gives a page back. No guest carries a
virtio-balloon device. Nothing can therefore reclaim a page after the guest touches
it. The configured size is a ceiling. Each guest walks toward that ceiling and never
retreats from it. This behaviour is what makes the arithmetic safe to budget against.
The guest does not take all of its RAM at boot. The guests also run without
prealloc=on. KVM therefore backs guest RAM lazily. The host pays only for the pages
that the guest touches. Measurements on factory after 27 hours of uptime show this
effect:
hopperused 937MB of its 2048MB.forgeused 915MB of its 1536MB.carthageused 879MB of its 1024MB.
Read every row in the table below as an upper bound. A topology that is up costs less than its figure until you work it.
KSM is off, so the multi-node rows get no discount. The file
/sys/kernel/mm/ksm/run contains 0. The host does not share identical pages between
guests. The sum of the nodes is therefore the correct shape for a multi-node figure.
Do not treat swap as a safety valve. The host has 6.1GB of swap. A swapped etcd gives you fsync stalls. The stalls cause missed Raft heartbeats. The cluster then looks broken for reasons that have nothing to do with the lesson. Staying under budget is the whole mechanism.
The nine topologies¶
Read the sizing as an allocation decision, and not as a measurement. This applies to
eight of the nine rows. Only pair has been weighed. For the other rows,
see what is not confirmed.
Link to a row, and not to the table. Each row carries its own anchor.
| Topology | Nodes | Per node | RAM | Disk | For |
|---|---|---|---|---|---|
solo |
1 | 4096MB / 4c / 25G · .180 |
4.0GB | 25G | CKAD drilling, Helm authoring, build-track deploy target |
pair |
2 | CP 3072MB / 2c / 25G · .130worker 2048MB / 2c / 20G · .131 |
5.0GB 5.1GB measured |
45G | The daily driver, and every DaemonSet-heavy module |
workhorse |
3 | CP 3072MB / 2c / 25G · .1402 × worker 2048MB / 1c / 20G · .141–.142 |
7.0GB | 65G | Scheduling at scale, drain/cordon, chaos |
ha |
3 | 3 × stacked CP 2560MB / 2c / 20G, untainted · .150–.152 |
7.5GB | 60G | Quorum loss, etcd member failure, upgrades |
etcd-only |
3 | 3 × 1024MB / 1c / 10G, no Kubernetes · .160–.162 |
3.0GB | 30G | Raft, watch, MVCC, compaction, defrag, backup |
bare |
1 | 2048MB / 2c / 20G, no Kubernetes, no container runtime · .192 |
2.0GB | 20G | P0 — namespaces, cgroups, veth and pivot_root by hand |
k0s-light |
1 | 2048MB / 2c / 20G, single binary · .170 |
2.0GB | 20G | Distro contrast |
nested |
1 | 6144MB / 4c / 40G, kind/k3d inside · .190 |
6.0GB | 40G | Multi-cluster escape hatch |
platform |
1 | 6144MB / 4c / 40G, real kubeadm, untainted · .191 |
6.0GB | 40G | P12 (#14) |
bare is the only topology that is not a cluster. It has no kubeadm, no k0s and no
containerd. It carries the Ansible baseline and nothing more. The reason
is the subject of P0. P0 teaches what a container is before a runtime exists to make
one. bare is also the cheapest item in the curriculum. It uses 2.0GB of a 9.5GB
budget. P0 can therefore fork-bomb it, fill it and OOM it, and you do not need to do
any arithmetic first.
nested and platform have the same shape, but they are not the same topology.
nested runs kind inside a VM. platform is a real single-node kubeadm cluster.
nested is a place to run several throwaway clusters. platform is the machine that
you build a platform on.
The topologies come from three issues. Seven come from
#8. platform comes from
#14. bare comes from
#34. Note one detail of the history:
14 proposed platform as an addition, and nobody edited the resolution of #8. The repo¶
therefore mis-cited the count in two places until this document corrected it.
bare was added for a specific reason. The author generated the P0 exercises and
found that the exercises had no topology to name. solo and k0s-light both install a
cluster, and P0 exists to work without a cluster. forge was also not
an option. P0 would fork-bomb it, fill its disk and pivot_root it, and eleven other
phases depend on that one guest.
Each workhorse worker gets 1 core, and not 2. This is deliberate. The third node
exists so that the scheduler has somewhere to choose between. Six vCPU on a six-core
host is already oversubscribed.
One row is measured: pair costs 5.1GB, and the row says 5.0GB¶
The author provisioned pair on factory and weighed it on 2026-08-31
(#50). The other eight rows are still
arithmetic.
Read the figures below as host-side figures. Each figure is the VmRSS value from
/proc/<pid>/status for the kvm process of one guest. It is what the host pays for
the guest. It is not what the guest reports about itself.
| Stage | pair-cp |
pair-w1 |
Host total | Against the 5.0GB row |
|---|---|---|---|---|
| Fresh boot, cloud-init done | 966MB | 954MB | 1920MB | 38% |
| After the Ansible baseline | 1237MB | 1251MB | 2488MB | 49% |
| containerd and the v1.35 packages, no cluster | 1912MB | 1881MB | 3793MB | 74% |
At rest — both nodes Ready, 10 pods, idle |
2966MB | 2041MB | 5007MB | 98% |
| Under load — 18 pods, 3 PVCs bound | 3106MB | 2079MB | 5185MB | 101% |
The 5.0GB figure is correct to within 1.3%. It is correct for a reason that the
arithmetic never states. The figure is not a forecast of demand that happened to be
right. Both guests fill their configured allocation and then stay there. pair-cp holds
3106MB of its 3072MB. pair-w1 holds 2079MB of its 2048MB. The row predicts
allocation. There is no balloon to hand a page back. Allocation is
therefore what a worked guest eventually costs. Most of what the guests hold is page
cache. At the end of the test, 2.0GB of the 3.0GB in pair-cp was page cache, and only
118MB of MemFree was left.
The error goes upward, by approximately 32MB per guest. A saturated guest costs its
configured size plus the process overhead of QEMU. That overhead was 34MB for
pair-cp and 31MB for pair-w1. Every row in the table above omits this term. Every
row is therefore low. The error is 0.6% for pair. In absolute terms, it is three times
as large for the three-node topologies. The ceiling of approximately 9.5GB absorbs the
error. A footprint that you compute to the last 100MB does not.
Installing the cluster costs as much as running it. containerd and the v1.35
packages alone took the topology to 74% of its row, and nothing was scheduled yet. Host
RSS grew by 675MB on pair-cp and by 630MB on pair-w1. That memory never comes back,
because the page cache of apt holds touched pages like any other cache. Learn from this:
an exercise that budgets from "cluster idle" has already spent three quarters of the row
before it starts.
Neither guest swapped, and neither guest was OOM-killed. The workload had the shape
of P8. It ran a local-path provisioner, the external snapshotter,
csi-driver-host-path, and three writer pods on 1Gi PVCs. The headroom that the row
claims does therefore exist. That headroom is approximately 1% of the row. The kernel
holds it in page cache, and the kernel drops that cache before it kills anything.
The measurement runs in one direction only. RSS never retreats. You cannot read at
rest again after you read under load on the same guest. The order of the rows above
is the only order in which you can take them. To measure again, you must destroy the
topology and start over. Two of the ten at-rest pods were coredns replicas that were
still pending because of #76. That is
why this document does not re-take that row.
Addresses¶
The subnet is 10.10.10.0/24, and the gateway is .1. The convention in factory is
simple: the vm_id is the last octet.
| Range | Use |
|---|---|
.110–.129 |
Persistent infrastructure — hopper 110, carthage 120, forge 125; 111 freed when jeremie went |
.130–.199 |
Lab topologies, one block of ten each — see the table above. .190–.199 is the single-node block, and holds nested, platform and bare rather than one topology's ten |
.200–.250 |
MetalLB, reserved by prose only |
Blocks of ten cost nothing and keep the nodes of each topology contiguous. That property matters because a hundred exercises cite these nodes by address.
Three addresses are refused at plan time. 9000 is the template, and a validation
in tofu/vms refuses 110 and 120. The same guard should also exist in tofu/labs.
Provisioning¶
Provisioning has five steps. You run none of them on the Mac.
ssh hopper # 1. required. See below.
cd factory && git pull
just tofu labs apply -var 'topology=pair' # 2. bring the topology up
just gate <node> # 3. wait for boot and cloud-init
just play # 4. Ansible baseline
ssh zain@10.10.10.130 # 5. from the Mac. Do not use -J.
Step 1 — ssh hopper is not a convenience. Every factory recipe that touches
Proxmox, OpenTofu or Ansible calls factory_require_hopper. That function refuses to
run on macOS in code. It prints this message: "Those run on hopper. This machine
writes code and pushes git." An exercise that omits this line does not run. It exits
with a non-zero status before it provisions anything.
Step 2 — the topology variable is the contract. tofu/labs takes a topology name.
It expands the name to a preset node map. You do not hand-edit a fleet definition. The
state key of the module is labs/terraform.tfstate, and that key is separate from
vms/. This separation is the load-bearing part of the design. It is what makes
teardown structurally unable to touch a persistent guest. Note also that
just tofu <dir> <cmd> was already generic in factory. just tofu labs … therefore
cost nothing when the module landed.
Step 3 — the boot wait is real, and P8 never mentioned it. tofu apply returns when
the clone completes, after approximately 9 seconds. The guest must still boot. An
immediate SSH attempt returns No route to host. just gate <vm> polls SSH for up to
240s. It then waits on cloud-init status.
Step 4 — the Ansible baseline is also real. just play runs site.yml and applies
the common role. A node that skipped this step is not the node that the exercises
assume. A node that had this step is still not a node with Kubernetes on
it, and that is by design.
[UNVERIFIED] — ansible/inventory/hosts.yml is static today, and somebody maintains
it by hand. Ephemeral lab nodes break exactly that pattern. The contract is different:
lab nodes must reach Ansible through a dynamic inventory that is generated from the
tofu output. You must not hand-edit a file for each topology.
Step 5 — the guest account is zain, and you need no jump flag. cloud-init creates
that one account. There is no debian user. factory commits Host 10.10.10.* →
ProxyJump factory in its ssh/config. -J factory is therefore redundant from the
Mac. It is also wrong from hopper, because hopper is already in the subnet.
factory deleted those flags from its own outputs deliberately.
The Kubernetes install belongs to the learner, and not to factory¶
#77 settled this question.
factory delivers a configured Debian guest. The exercises put Kubernetes on it.
The Ansible roles in factory are common, control, carthage and zeko_*. None of
them installs a container runtime, and none installs kubeadm. A check on a freshly
baselined pair-cp on 2026-08-31 found that kubeadm, kubelet, kubectl,
containerd and crictl were all absent. That is the intended shape. It is not a gap.
The alternative was rejected, for two reasons. The alternative was a kubernetes
role in factory, keyed on the role field that the labs state already
publishes. The first reason is the title of
labs/01/01. That exercise is called "A
two-node cluster, stood up by hand". A runtime that somebody else installs takes a
piece of that exercise away. The second reason is
P0. P0 spends three weeks arriving at what a
container is, before a runtime exists to make one. If a learner then arrives in P1 on a
node that already has containerd, those three weeks are wasted.
This document does not pin the Kubernetes version.
certs.md holds the version once. The exams track the newest
Kubernetes minor version, which is v1.35 today. The lab clusters follow the same rule,
and not a frozen version. Read the minor version from certs.md, call it $V, and use
it in the two lines below that name it.
The procedure, stated once¶
Three exercises stand up a cluster from nothing:
The first exercise walks through these commands and comments on them, because that is its subject. The other two exercises run the commands and move on.
Run all of these commands on every node of the topology. This includes the control plane and the workers. Step 5 is the one exception, and it belongs to the control plane alone.
V=<the current Kubernetes minor, per certs.md#tooling> # 0. deliberately not pasteable
sudo swapoff -a # 1. the kubelet refuses to start with swap on
sudo sed -i '/\sswap\s/s/^/#/' /etc/fstab # and again after the next reboot
printf 'overlay\nbr_netfilter\n' | sudo tee /etc/modules-load.d/k8s.conf
sudo modprobe overlay && sudo modprobe br_netfilter
printf 'net.bridge.bridge-nf-call-iptables=1\nnet.bridge.bridge-nf-call-ip6tables=1\nnet.ipv4.ip_forward=1\n' \
| sudo tee /etc/sysctl.d/99-k8s.conf
sudo sysctl --system # 2. bridged traffic must reach iptables
CD=2.2.1; RUNC=1.5.1 # 3. upstream, and not apt. See below.
curl -fsSLO "https://github.com/containerd/containerd/releases/download/v$CD/containerd-$CD-linux-amd64.tar.gz"
curl -fsSLO "https://github.com/containerd/containerd/releases/download/v$CD/containerd-$CD-linux-amd64.tar.gz.sha256sum"
sha256sum -c "containerd-$CD-linux-amd64.tar.gz.sha256sum"
sudo tar Cxzf /usr/local "containerd-$CD-linux-amd64.tar.gz"
sudo curl -fsSL -o /etc/systemd/system/containerd.service \
"https://raw.githubusercontent.com/containerd/containerd/v$CD/containerd.service"
curl -fsSLo runc.amd64 "https://github.com/opencontainers/runc/releases/download/v$RUNC/runc.amd64"
curl -fsSL "https://github.com/opencontainers/runc/releases/download/v$RUNC/runc.sha256sum" \
| grep ' runc.amd64$' | sha256sum -c -
sudo install -m 755 runc.amd64 /usr/local/sbin/runc
sudo mkdir -p /etc/containerd
/usr/local/bin/containerd config default | sudo tee /etc/containerd/config.toml >/dev/null
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
sudo systemctl daemon-reload
sudo systemctl enable --now containerd # see the one edit below
sudo mkdir -p /etc/apt/keyrings
curl -fsSL "https://pkgs.k8s.io/core:/stable:/$V/deb/Release.key" \
| sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/$V/deb/ /" \
| sudo tee /etc/apt/sources.list.d/kubernetes.list
sudo apt-get update && sudo apt-get install -y kubelet kubeadm kubectl
sudo apt-mark hold kubelet kubeadm kubectl # 4. apt must not move a cluster's minor
sudo kubeadm config images pull # 5. control plane only. Silence, spent up front.
Debian's containerd package cannot run Kubernetes 1.37, which is why step 3 takes
the upstream tarball. Trixie ships containerd 1.7.24. Since
KEP-4033
went GA, kubeadm asks the runtime for its cgroup driver over the CRI RuntimeConfig
RPC, and containerd 1.7 does not answer:
E0902 21:43:34.028287 ... "RuntimeConfig from runtime service failed" err="rpc error: code = Unimplemented desc = method RuntimeConfig not implemented"
[WARNING ContainerRuntimeVersion]: You must update your container runtime to a version that supports the CRI method RuntimeConfig.
That warning is not survivable for long, and the wait does not help. kubeadm falls
back to reading cgroupDriver from the kubelet config, and the fallback is removed in
1.38. containerd 1.7 will never gain the RPC either. The backport,
containerd#11346, was closed
unmerged in April 2025, and its author's parting note reads: "When the feature is GA'd
containerd v1.7 becomes incompatible with kubernetes. The kubelet refuses to start." So
apt-get install -y containerd is a dead end. The author walked into it on 2026-09-02,
on an already-baselined pair-cp.
containerd 2.x drags runc along with it. containerd 2.0 requires runc
1.2.0 or
later, and trixie ships 1.1.15. Step 3 therefore installs a runc binary to
/usr/local/sbin, which the default systemd PATH reaches before /usr/sbin.
Step 3 pins two versions, and $V stays unpinned. The reason is the checksum. A
tarball fetched by hand is worth verifying, and a sum cannot be verified against a
version that floats. Bump both deliberately, and re-read the sums when you do.
One sed line against config.toml is load-bearing, and it is the cgroup driver.
SystemdCgroup = true matches the driver that the kubelet uses on a systemd host. If
you leave the value as false, the two components disagree about who owns the cgroup
tree. The kubelet then reports the node as NotReady, and it says why in its own log.
That is the recoverable kind of failure.
The bin_dir edit is gone, and nothing replaces it
(#76). That edit existed because two
defaults disagreed, and not because anything was missing. Debian's containerd patched
bin_dir to /usr/lib/cni. The plugins were in /opt/cni/bin the whole time, put
there by kubernetes-cni, which kubelet depends on and which step 4 therefore
installs without being asked. containerd was looking in the wrong one of two
directories. Upstream containerd defaults to the directory kubernetes-cni uses, and at
config schema version = 3 the field is a list reading bin_dirs = ['/opt/cni/bin'].
The two halves agree on their own, so there is nothing left to sed.
The failure mode outlives the edit, and it is worth keeping in view. containerd looks
in a directory. The conflist names a plugin type that is not in it. Sandbox creation
fails, pods sit in ContainerCreating, SandboxChanged repeats, and nothing names the
directory. The node reaches Ready before any of this, so its own success signal fires
and then no pod ever starts. The author met this the hard way while weighing
pair, and it cost ten minutes of a cluster that had already told him it
was fine. Any future disagreement about that path — a plugin installed by hand, a
bin_dirs someone tidied — arrives dressed in exactly these clothes.
Flannel is not what fills /opt/cni/bin, and the strand used to imply otherwise.
Flannel's DaemonSet drops /opt/cni/bin/flannel and a conflist. That conflist delegates
to bridge and chains portmap, and those two pull in host-local and loopback.
Flannel ships none of the four. kubernetes-cni does, and a check on pair-wk on
2026-09-02 found version 1.9.1-1.1 of it owning /opt/cni/bin/bridge. This is also
why step 3 does not install the CNI plugins itself: step 4 already has.
The curriculum assumes /opt/cni/bin throughout, and kubernetes-cni fills it.
labs/07 installs a hand-written plugin in that directory.
labs/11/06 breaks the
datapath by moving /opt/cni/bin/bridge aside. That break needs bridge to be there
first.
Step 5 pulls the images, and it is not an optimisation. It runs on the control plane
only, because a worker needs kube-proxy and pause and kubeadm join fetches those
two by itself. kubeadm init prints nothing between "This might take a minute or two"
and the [certs] phase, for however long the pull runs. On pair on 2026-09-02 that
was kube-apiserver in 28 seconds and kube-controller-manager in twelve minutes, on
the same link, because the CDN edge had collapsed to 40 KB/s in between. containerd logs
a pull only when it completes, so nothing anywhere moves for minutes at a stretch. Piped
into tee, that is indistinguishable from a hang, and the temptation is to Ctrl-C a
tool that is working. kubeadm config images pull spends the same minutes and names each
image as it lands.
etcd-only, k0s-light and bare want none of this procedure. Each one installs
what it needs, or needs nothing on purpose:
etcd-onlytakes three etcd binaries, copied out fromforge.k0s-lightrunsget.k0s.sh, for a distribution that ships its own runtime.bareinstalls nothing. The whole point is that no runtime exists.
Teardown¶
just destroy-vms is the wrong verb, and it always will be. It is hardcoded to
tofu/vms, which is the persistent fleet. Keeping the labs in their own state
key is what makes the two commands impossible to confuse.
Somebody has tested the separate state key. It is not only asserted. The author ran
just tofu labs destroy against a live pair on 2026-08-31
(#50). It reported 2 destroyed.
hopper, carthage and forge came through the teardown as the same
processes as before. They kept the same PIDs, and their uptimes ran on unbroken. forge
is the guest that matters most here, because eleven phases depend on it.
Teardown has two layers, and different issues rule them at different scopes (#8, #35).
The first layer is the guest layer. #8 settled it, and it is coarse:
Every phase starts from a clean provision — no long-lived clusters accreting across phases. Because both RAM and disk are one-topology-at-a-time, teardown is not hygiene but a hard precondition for the next phase booting at all.
Three guest-layer scopes are settled. All three are coarser than one exercise:
- Per phase.
- Per lab group within a phase. P12 runs two groups, with a teardown between them.
- Everything else down for a big rock, such as a service mesh.
Consecutive exercises may share a live cluster. That is the intended pattern, and not
a shortcut. A provision, a gate and a baseline cost minutes of wall-clock time before
any teaching happens. The line about the guest in an exercise is therefore usually a
continuity marker, such as "leave it up, 8.3 continues on it". A real just tofu
labs destroy appears where the exercise ends a phase, ends a group, or installs a big
rock.
The second layer is finer, and it always runs: each exercise deletes what it
created. This includes its namespace, its CRs, its iptables rules and its loop
devices. The exercise deletes them before the next exercise starts.
Learn from the mistake here. Somebody read #8 as no teardown at all between exercises while the storage phase was being drafted. That reading survived until objects left behind by one exercise started to answer the next exercise's question for it. A cluster that eleven exercises have used is not the cluster that the twelfth was written against. The teardown step of an exercise therefore does two things. It runs the fine layer unconditionally. It then names the coarse layer: the topology stays, or it goes.
Disk needs eviction too, and that is a build-track fact. go clean -modcache and
docker system prune belong in the same runbook. See
build-mechanics#p5-split, where workhorse plus
forge is 90G of 95G.
How to reach a cluster from the Mac¶
The subnet is NAT'd. You can reach it only through the factory bastion. Egress uses
MASQUERADE. Nothing reaches the guests unsolicited.
Services get a real address. MetalLB runs in L2 mode on .200–.250. The choice
was pedagogical. kubectl port-forward bypasses the Service and LoadBalancer datapath
completely. It hides the mechanics that this curriculum exists to teach. L2 mode instead
makes one question concrete through ARP: "how does a LoadBalancer get an IP?" The
bridge vmbr0 is 10.10.10.1/24 with bridge-ports none. It therefore has no physical
port, no competing DHCP and no other L2 speakers. Those conditions are close to ideal
for L2 mode.
kubectl port-forward stays sanctioned where access is incidental rather than the
lesson. That describes most exercises. Use it without ceremony. The rule above governs
what an exercise teaches. It does not govern every time that you need a dashboard.
To open a VIP in a browser on the Mac, run this command:
[UNVERIFIED] — one -L forward per VIP is the convention of this document. It is
not an inherited decision. #8 settled MetalLB and the SSH proxy, but #8 never wrote the
literal command. This document prefers -L over a -D SOCKS proxy for two reasons. It
needs no browser configuration. It also leaves no proxy setting for you to forget
afterwards.
forge is not part of any topology¶
The build guest is always up. It is sized independently. You subtract it from the ceiling before you choose a topology. It is co-resident with whatever runs. It is never a node in a topology. It holds the Go module cache and the registry for one precise reason: a teardown must not cost them.
That is the whole relationship of forge to this document. The rest of its detail
lives in build-mechanics#forge. That document holds four
subjects:
- Why
forgegets 1536MB, and not 2048MB. - The 2560MB resize that P3 and P5 each take and then give back.
- The kernel lockstep with the lab nodes.
- The registry.
What is not confirmed¶
The provenance rule for the strands is that the status of a fact travels with the fact. The facts below have no verified source. Do not write an exercise as though these facts were settled.
- One row of the table is measured. The other eight rows are not. The author stood
pairup and weighed it on 2026-08-31 — one row is measured. Every other RAM figure is an allocation decision. It is the number that a guest will be given. It is not a number observed in use. The disk column is unmeasured for all nine rows, and that includespair. - This document allocates most per-node addresses. It does not observe them. Only
the
.130and.131addresses ofpairpre-date this document. Only.110,.120,.125and.192have ever been occupied..111belonged tojeremie, and it is now free. - How ephemeral nodes enter the Ansible inventory is specified as dynamic-from-tofu, and implemented as neither.
- The range
.200–.250is reserved in prose only.factoryhas no IPAddressPool, no MetalLB config, and no comment that claims the range. - The browser-to-VIP command is chosen in Reaching a cluster. It is not inherited.
- The ceiling of approximately 9.5GB is a subtraction, and not an observation.
jeremieis destroyed, since 2026-08-18, andforgeis declared. The assumptions behind the figure therefore hold now. The host reports 15.49 GiB in total. That leaves 9.4 GiB after you charge every guest that must stay up at its configured ceiling. But note one weakness. One row calls the approximately 1.6GB of the Proxmox host "Measured". That row holds the only measured term in a column of allocations. The two kinds of number are not the same.