979 lines
61 KiB
HTML
979 lines
61 KiB
HTML
<header class="masthead">
|
|
<p class="eyebrow">The Exploded Cluster · The Delivery Arc</p>
|
|
<h1>The machine was the easy part.<br><em>Now watch how software reaches it.</em></h1>
|
|
<p class="lede">The foundations first - what an image is, what a cluster is - then the toolchain
|
|
that delivers to them: how change travels, how images are named, and how one definition serves
|
|
a fleet. Scroll, and each machine comes apart.</p>
|
|
<p class="scrollcue" aria-hidden="true">scroll<span></span></p>
|
|
</header>
|
|
|
|
<nav class="toc" aria-label="index">
|
|
<p class="kt">Index</p>
|
|
<ol>
|
|
<li><a href="#c0"><b>Course 00</b><span>Aperitif - three commands, and what actually just happened</span></a></li>
|
|
<li><a href="#c1"><b>Course I</b><span>Podman - an image is a stack of frozen diffs</span></a></li>
|
|
<li><a href="#c2"><b>Course II</b><span>Kubernetes - a cluster is a promise, not a place</span></a></li>
|
|
<li><a href="#c3a"><b>Course IIIa</b><span>The node - where intent becomes a process</span></a></li>
|
|
<li><a href="#c3b"><b>Course IIIb</b><span>The pod - one IP, shared fate</span></a></li>
|
|
<li><a href="#c4"><b>Course IV</b><span>The traffic - pods die constantly, the address does not</span></a></li>
|
|
<li><a href="#c5"><b>Course V</b><span>OpenShift - Kubernetes with opinions, and a security guard</span></a></li>
|
|
<li><a href="#c6"><b>Course VI</b><span>GitOps - nobody deploys anything; the cluster syncs itself</span></a></li>
|
|
<li><a href="#c7"><b>Course VII</b><span>The image supply chain - a tag is a promise, a digest is a fact</span></a></li>
|
|
<li><a href="#c8"><b>Course VIII</b><span>Helm - a chart is a function; the values file is the cluster speaking</span></a></li>
|
|
<li><a href="#apx"><b>Appendix</b><span>The dependency ledger - GitHub, Azure Key Vault, zot</span></a></li>
|
|
<li><a href="#flow"><b>Blueprint</b><span>The delivery flow, end to end</span></a></li>
|
|
<li><a href="#library"><b>Library</b><span>Official documentation for every technology on this page</span></a></li>
|
|
</ol>
|
|
</nav>
|
|
|
|
<section class="purpose" aria-labelledby="purph">
|
|
<p class="kt" id="purph">Purpose of this document</p>
|
|
<p>The Exploded Cluster teaches how modern container platforms work by taking them apart -
|
|
literally. Each course is one machine drawn as a single exploded illustration, sliced into its
|
|
real components and wired to your scroll, so the architecture moves while the words explain it.
|
|
Start with the aperitif's three terminal commands; finish knowing how a change travels from a
|
|
git commit to a running, secret-fed, digest-pinned workload on a fleet. The library at the end links only to official documentation, so every claim here can be
|
|
checked against its source.</p>
|
|
</section>
|
|
|
|
|
|
<!-- ============ COURSE 00 ============ -->
|
|
<section class="course" id="c0" data-scene="surround" aria-labelledby="c0h">
|
|
<header class="chead">
|
|
<p class="eyebrow">Course 00 · Aperitif</p>
|
|
<h2 id="c0h" class="thesis">Three commands.<br><em>What actually just happened?</em></h2>
|
|
<p class="lede">The whole ceremony of shipping software fits in four lines of terminal. They
|
|
work on your first day and stay mysterious for years. Scroll - the shell comes off first.</p>
|
|
</header>
|
|
<div class="pin">
|
|
<div class="viewport solo">
|
|
<figure class="scene">
|
|
<img class="flat" src="__HERO_SURROUND__"
|
|
alt="Six dark armour fragments with neon seams framing a large empty centre - the
|
|
casing of a machine caught the instant before it comes apart.">
|
|
<div class="crt" role="img" aria-label="A terminal transcript: podman build, podman push,
|
|
oc apply, oc get pods showing Running - then the question: but what
|
|
actually just happened?">
|
|
<p class="line">$ podman build -t ghcr.io/shop/api:1.4.2 .</p>
|
|
<p class="line ok">=> exported image sha256:9f2c41...</p>
|
|
<p class="line">$ podman push ghcr.io/shop/api:1.4.2</p>
|
|
<p class="line">$ oc apply -f deploy.yaml</p>
|
|
<p class="line ok">deployment.apps/api created</p>
|
|
<p class="line">$ oc get pods</p>
|
|
<p class="line ok">api-7d4b9c6-x2k4f 1/1 Running</p>
|
|
<p class="line q">...but what actually just happened?</p>
|
|
</div>
|
|
</figure>
|
|
</div>
|
|
</div>
|
|
<div class="prose">
|
|
<p class="aside">A note on the commands: this site uses <span class="mono">oc</span>,
|
|
OpenShift's CLI. The Kubernetes commands here work the same
|
|
under <span class="mono">kubectl</span>; <span class="mono">oc</span> is a superset, and the
|
|
OpenShift-only parts (SCCs, Routes) are its own. Read
|
|
<span class="mono">kubectl</span> in the docs, type whichever your cluster gives you.</p>
|
|
<p>That <span class="mono">1/1</span> reads as containers-ready over containers-wanted: a pod
|
|
can hold more than one, which is Course IIIb.</p>
|
|
<p>An image got <b>built</b> - of what, exactly? <b>Pushed</b> - to where, and what travelled?
|
|
<b>Applied</b> - which is not the same as launched. <b>Running</b> - according to whom?
|
|
Every course below takes one of those words apart. The armour is already loose.</p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ COURSE I ============ -->
|
|
<section class="course" id="c1" data-scene="docker" aria-labelledby="c1h">
|
|
<header class="chead">
|
|
<p class="eyebrow">Course I · Podman</p>
|
|
<h2 id="c1h" class="thesis">An image is not a box.<br><em>It is a stack of frozen diffs.</em></h2>
|
|
<p class="lede">Scroll, and the thing you keep calling "a container image" comes apart in your
|
|
hands. Four layers. Each one only stores what changed from the layer under it - and here they
|
|
rise one at a time, bottom up.</p>
|
|
</header>
|
|
<div class="pin">
|
|
<div class="viewport">
|
|
<figure class="scene">
|
|
<img class="flat" src="__HERO_DOCKER__"
|
|
alt="An exploded isometric view of a container image: four stacked slabs floating
|
|
apart - a heavy metal base, a circuit-etched dependency layer, a magenta-traced
|
|
code layer, and a thin frosted-glass writable layer on top.">
|
|
</figure>
|
|
<ol class="legend"></ol>
|
|
</div>
|
|
</div>
|
|
<div class="prose">
|
|
<h3>A container is a process, not a machine</h3>
|
|
<p>If you arrived here from Linux, take this translation first: a running container is an
|
|
<b>ordinary process on your kernel</b>. No guest OS, no hypervisor. The kernel gives it
|
|
<b>namespaces</b> so it sees its own PID tree, mounts, network and hostname, and
|
|
<b>cgroups</b> so its CPU and memory can be capped. <span class="mono">ps</span> on the host
|
|
lists it. <span class="mono">kill</span> on the host kills it. Podman leans into that: no
|
|
daemon sits in the middle - the container is a child of your own shell - and rootless mode
|
|
maps your user onto root <i>inside</i> the container through a user namespace, so root in
|
|
there is an unprivileged UID out here.</p>
|
|
<p>So what does the image provide? The <b>filesystem that process sees</b>. That is the whole
|
|
job, and it is why an image is a stack of layers rather than a disk image.</p>
|
|
|
|
<p class="fieldnote"><b>Field note.</b> Prove it on your own box.
|
|
<span class="mono">podman run -d --name t alpine sleep 300</span>, then on the
|
|
<i>host</i>: <span class="mono">ps -ef | grep sleep</span> finds the process,
|
|
<span class="mono">lsns -p <pid></span> lists the namespaces it was handed,
|
|
<span class="mono">cat /proc/<pid>/cgroup</span> shows where its limits live, and
|
|
<span class="mono">mount | grep overlay</span> shows the layers stitched together. None of
|
|
it is exotic. It is your kernel, described differently.</p>
|
|
|
|
<h3>What an image is made of</h3>
|
|
<p>An image is not a copy of a machine. It is a stack of <b>read-only</b> layers, each
|
|
recording only what changed from the one beneath. Layers are content-addressed, so an
|
|
identical layer is stored once and reused by every image that references it, so a pull only
|
|
fetches the layers you do not already have. A base sits at the bottom, your dependencies on it,
|
|
your code - usually the smallest layer, always the most volatile - above that.</p>
|
|
<p>Those layers become one filesystem through a <b>union mount</b> - overlayfs, the same
|
|
kernel feature you can mount by hand. The read-only image layers are the lower dirs; the
|
|
container gets a fresh <b>upper dir</b> of its own. Writes land in the upper, and editing an
|
|
existing file copies it up there first, leaving the image layer untouched underneath.</p>
|
|
<p>That upper dir is the top slab in the scene, and it is the odd one out: the <b>writable
|
|
layer is not part of the image</b>. The runtime creates it with the <i>container</i> and
|
|
discards it when that container dies. Nothing written there ships, and nothing written there
|
|
survives - which is the entire reason volumes exist.</p>
|
|
<p class="sayit">Change a layer and every layer above it must be rebuilt.</p>
|
|
<h3>The order is a caching decision</h3>
|
|
<p>The builder caches layer by layer, and a cached layer survives only while everything
|
|
beneath it is unchanged. Put <span class="mono">COPY . .</span> above your dependency
|
|
install and you have told the builder to throw the dependency cache away every time one
|
|
line of code changes. Dependencies first, code last - a Containerfile is a cache policy that
|
|
happens to build software.</p>
|
|
<p class="fieldnote"><b>Field note.</b> A rebuild that takes twenty minutes and one that
|
|
takes twenty seconds are usually the same Containerfile with the lines swapped.</p>
|
|
<p class="closeline">This stack explains the whole ecosystem above it: sharing explains why
|
|
pulls are fast, immutability explains why a digest can name the exact bytes (Course VII),
|
|
and the throwaway top layer explains why state needs volumes. One idea - frozen diffs -
|
|
all the way down.</p>
|
|
<p class="reads"><b>Pre-reads:</b> none - start here
|
|
 <b>Further:</b> <a href="https://podman.io/get-started">Podman get-started</a> ·
|
|
<a href="https://docs.podman.io/">Podman docs + builds</a></p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ COURSE II ============ -->
|
|
<section class="course" id="c2" data-scene="cluster" aria-labelledby="c2h">
|
|
<header class="chead">
|
|
<p class="eyebrow">Course II · Kubernetes</p>
|
|
<h2 id="c2h" class="thesis">A cluster is a promise,<br><em>not a place.</em></h2>
|
|
<p class="lede">You never tell Kubernetes <i>how</i> to run your app. You describe what you
|
|
want - declarative intent - and the cluster works, forever, to make it true. Scroll, and
|
|
the formation splits: the half that decides rises, the halves that run spread below.</p>
|
|
</header>
|
|
<div class="pin">
|
|
<div class="viewport">
|
|
<figure class="scene">
|
|
<img class="flat" src="__HERO_CLUSTER__"
|
|
alt="A small fleet mid-explosion: one wide command slab with a strong cyan seam
|
|
hovering above a row of three identical worker blocks, all floating apart in
|
|
the void.">
|
|
</figure>
|
|
<ol class="legend"></ol>
|
|
</div>
|
|
</div>
|
|
<div class="prose">
|
|
<h3>Desired against actual, on a loop</h3>
|
|
<p>The habit underneath everything: the <b>reconciliation loop</b> - compare desired state
|
|
against actual state, fix the difference, repeat. That <span class="mono">oc apply</span>
|
|
didn't launch anything; it filed paperwork. The machine took it from there, and it never
|
|
stops taking it from there: kill a pod and it returns, not because something noticed the
|
|
crash but because the loop noticed the difference.</p>
|
|
<p class="sayit">Kubernetes doesn't run your app - it reconciles it.</p>
|
|
<h3>One half decides, one half runs</h3>
|
|
<p>The split in the scene is the split that makes everything else possible: a <b>control
|
|
plane</b> that decides - holds the truth, schedules, reconciles - and <b>worker nodes</b>
|
|
that run pods. The workers are deliberately interchangeable: identical, replaceable, cattle
|
|
from day one. Authority does not live where the work happens.</p>
|
|
<p class="fieldnote"><b>Field note.</b> If you are SSHing into nodes to "fix" things, you
|
|
are arm-wrestling the reconciler - and it does not get tired. Change the desired state
|
|
instead.</p>
|
|
<h3>What the control plane is made of</h3>
|
|
<p>"Control plane" is four processes and a database, and naming them makes every later error
|
|
message readable. The <b>api-server</b> is the only door: everything authenticates, is
|
|
authorised and is admitted there, and it is the only component allowed to touch <b>etcd</b> -
|
|
the key-value store holding the entire cluster state. Lose etcd and you have lost the
|
|
cluster, which is why backing it up is the homework nobody should skip. The <b>scheduler</b>
|
|
decides which node a new pod belongs on - packing by the resources a pod
|
|
<i>requests</i>, not by what it currently uses - and writes that decision down; it never
|
|
starts anything. The <b>controller-manager</b> runs the reconciliation loops. Nothing talks
|
|
sideways - every component watches the api-server.</p>
|
|
<p>One of those loops is the chain you will debug most: a <b>Deployment</b> creates a
|
|
<b>ReplicaSet</b>, and the ReplicaSet creates <b>pods</b>. That is why the deploy in the
|
|
aperitif printed <span class="mono">deployment.apps/api created</span> and you then went
|
|
looking for a pod - and why, when a rollout is stuck with no pod at all, the answer is
|
|
upstream in that chain rather than on any node.</p>
|
|
<p class="sayit">etcd is the truth. Everything else is a cache of it.</p>
|
|
<p class="closeline">Everything the delivery arc teaches from Course VI onward is the same
|
|
loop at a bigger scale - git as the desired state, whole fleets as reconciled objects.
|
|
Learn the promise once; it repeats all the way up.</p>
|
|
<p class="reads"><b>Pre-reads:</b> C-I
|
|
 <b>Further:</b> <a href="https://kubernetes.io/docs/concepts/overview/">Kubernetes overview</a> ·
|
|
<a href="https://kubernetes.io/docs/concepts/architecture/">cluster architecture</a></p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ COURSE III - THE NODE ============ -->
|
|
<section class="course" id="c3a" data-scene="node" aria-labelledby="c3ah">
|
|
<header class="chead">
|
|
<p class="eyebrow">Course IIIa · The node</p>
|
|
<h2 id="c3ah" class="thesis">Where intent<br><em>becomes a process.</em></h2>
|
|
<p class="lede">Everything so far was decision. This is the machine where a pod stops being
|
|
paperwork and starts being a process - and the chain assembles link by link as you scroll.</p>
|
|
</header>
|
|
<div class="pin">
|
|
<div class="viewport">
|
|
<figure class="scene">
|
|
<img class="flat" src="__HERO_NODE__"
|
|
alt="A diagonal chain of node machinery: a visor-lit kubelet module, a layered
|
|
container-runtime engine, a ported CNI ring, a magenta routing prism - and a
|
|
small glowing pod capsule descending toward the engine.">
|
|
</figure>
|
|
<ol class="legend"></ol>
|
|
</div>
|
|
</div>
|
|
<div class="prose">
|
|
<h3>Where a pod becomes processes</h3>
|
|
<p>Every node runs a <b>kubelet</b> - the agent that owns what should be running there. It does
|
|
not create containers itself. It speaks <b>CRI</b> - the Container Runtime Interface - to <b>containerd</b> or CRI-O, and that
|
|
runtime pulls the image (through the mirror of Course VII) and actually creates and starts
|
|
the container, handing the low-level work to runc or crun. A <b>CNI plugin</b> (Container Network Interface) hands the pod
|
|
a real IP, and <b>kube-proxy</b> - or an eBPF datapath (code running
|
|
inside the kernel itself) replacing it - makes Service
|
|
addresses route to real pods. One correction worth carrying: the runtime calls the CNI plugin,
|
|
not the kubelet.</p>
|
|
<p class="sayit">The kubelet decides what should run. The runtime is what starts it.</p>
|
|
<p class="fieldnote"><b>Field note.</b> Node NotReady? The kubelet is a systemd unit
|
|
like any other: <span class="mono">journalctl -u kubelet</span> on that node, and check it
|
|
can still reach the api-server. A node that cannot phone home is presumed lost.</p>
|
|
<p class="closeline">The control plane never touches your workload. It writes intent; the
|
|
kubelet turns intent into instructions, and the runtime turns instructions into processes. Authority and execution meet exactly here, nowhere
|
|
else.</p>
|
|
<p class="reads"><b>Pre-reads:</b> C-II
|
|
 <b>Further:</b> <a href="https://kubernetes.io/docs/concepts/architecture/">cluster architecture</a> ·
|
|
<a href="https://kubernetes.io/docs/concepts/architecture/#node-components">node components</a></p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ COURSE III - THE POD ============ -->
|
|
<section class="course" id="c3b" data-scene="pod" aria-labelledby="c3bh">
|
|
<header class="chead">
|
|
<p class="eyebrow">Course IIIb · The pod</p>
|
|
<h2 id="c3bh" class="thesis">One IP,<br><em>shared fate.</em></h2>
|
|
<p class="lede">A pod is not a container - it is a jacket around one or more. Scroll, and the
|
|
capsule opens like a clamshell: shells apart, contents on display.</p>
|
|
</header>
|
|
<div class="pin">
|
|
<div class="viewport">
|
|
<figure class="scene">
|
|
<img class="flat" src="__HERO_POD__"
|
|
alt="A pod capsule blown open: two frosted shell halves floating apart, a cyan app
|
|
container column standing on an amber-lit init gate, a smaller magenta sidecar
|
|
beside it, and a stack of translucent volume discs.">
|
|
</figure>
|
|
<ol class="legend"></ol>
|
|
</div>
|
|
</div>
|
|
<div class="prose">
|
|
<h3>The jacket, not the container</h3>
|
|
<p>Everything inside the jacket shares a <b>network namespace</b>: one IP, localhost between
|
|
friends. Volumes are declared once on the pod, but each container mounts the ones it needs -
|
|
sharing storage is opt-in, not automatic. <b>initContainers</b> run first, in order, each to
|
|
completion - nothing else in the pod starts until every one of them has exited successfully. <b>Sidecars</b> ride along with their own containers and their
|
|
own jobs: proxy, logs, reload.</p>
|
|
<h3>Three probes, three different jobs</h3>
|
|
<p><b>startup</b> owns warm-up, <b>readiness</b> gates traffic, <b>liveness</b> restarts the
|
|
truly hung. Confusing them is how healthy pods get executed - a slow start killed by an
|
|
impatient liveness probe looks exactly like a crash.</p>
|
|
<p class="sayit">containerPort is documentation - unless a Service targets it by name, or you
|
|
use hostPort. Either way the app still has to bind the port itself.</p>
|
|
<p class="fieldnote"><b>Field note.</b> Exit 137 is 128 + 9: the process was <b>SIGKILLed</b>.
|
|
It does not say by whom. The kernel's OOM killer surfaces as reason OOMKilled; a failed
|
|
liveness probe shows up in the pod's events; eviction and node pressure look different again.
|
|
One exit code, several possible crimes - read the termination reason and the events, never
|
|
the number alone: <span class="mono">oc describe pod <name></span> shows both together,
|
|
and <span class="mono">oc logs --previous</span> shows what the dead container said on its
|
|
way out. Worth connecting to what you already know: a memory limit becomes a
|
|
<b>cgroup ceiling</b>, and the kernel's OOM killer enforces it exactly as it would for any
|
|
other process on the box.</p>
|
|
<p class="closeline">The pod is the smallest schedulable unit - the jacket, never the
|
|
container. Once that distinction lands, half of Kubernetes networking stops being
|
|
mysterious.</p>
|
|
<p class="reads"><b>Pre-reads:</b> C-III's node scene above
|
|
 <b>Further:</b> <a href="https://kubernetes.io/docs/concepts/workloads/pods/">pods</a> ·
|
|
<a href="https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/">the three probes</a></p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ COURSE IV ============ -->
|
|
<section class="course" id="c4" data-scene="traffic" aria-labelledby="c4h">
|
|
<header class="chead">
|
|
<p class="eyebrow">Course IV · The traffic</p>
|
|
<h2 id="c4h" class="thesis">Pods die constantly.<br><em>The address does not.</em></h2>
|
|
<p class="lede">Pods die, respawn and change addresses - and traffic still arrives. Scroll,
|
|
and the delivery route assembles checkpoint by checkpoint; watch what happens to the pod
|
|
that stops answering.</p>
|
|
</header>
|
|
<div class="pin">
|
|
<div class="viewport">
|
|
<figure class="scene">
|
|
<img class="flat" src="__HERO_TRAFFIC__"
|
|
alt="A delivery route in the void: a glowing client orb, a fanned load-balancer
|
|
wedge, an open ingress doorframe, a Service prism with a bright core, a lit
|
|
ready pod - and a dark unlit pod fallen out of the line.">
|
|
</figure>
|
|
<ol class="legend"></ol>
|
|
</div>
|
|
</div>
|
|
<div class="prose">
|
|
<h3>The stable name in front of the churn</h3>
|
|
<p>A <b>Service</b> is the fixed point: a ClusterIP inside, a LoadBalancer at the edge,
|
|
Ingress or the Gateway API doing host- and path-routing above. Clients hold the name; the
|
|
pods behind it come and go without anyone being told.</p>
|
|
<h3>How a Service finds its pods</h3>
|
|
<p>A Service holds no list of pods. It holds a <b>label selector</b> - match
|
|
<span class="mono">app: api</span> - and a controller continuously matches that against every
|
|
pod in the <b>namespace</b> - a Kubernetes namespace this time, a naming boundary for
|
|
objects, no relation to the kernel namespaces of Course I - keeping the passing set in an
|
|
<b>EndpointSlice</b>. Labels are how
|
|
everything here finds everything else, from a Service picking pods to a fleet hub picking
|
|
whole clusters (Course VI). Wear the label and you are eligible; readiness decides whether
|
|
you stay.</p>
|
|
<p>And the ClusterIP is worth a Linux translation: <b>no interface owns that address</b>.
|
|
Nothing answers ARP for it. It exists as a rule - <b>kube-proxy</b> programs iptables (or
|
|
IPVS) on every node so packets aimed at the virtual IP are DNATed to one of the ready pod
|
|
IPs, and an eBPF dataplane does the same job further down without the rule tables. Cluster
|
|
DNS - <b>CoreDNS</b> - resolves <span class="mono">api.myns.svc.cluster.local</span> to that
|
|
VIP. If you have ever written a DNAT rule by hand, you have already built a small Service.</p>
|
|
|
|
<h3>Readiness decides membership</h3>
|
|
<p>A pod failing its readiness probe <b>silently leaves the pool</b>. No error, no event at
|
|
the client - traffic simply stops arriving. That is the feature: broken instances remove
|
|
themselves. It is also the first place to look when traffic "disappears".</p>
|
|
<p>The rule has deliberate exceptions: you address pods directly when debugging a specific
|
|
instance, and headless Services exist precisely so StatefulSet members can be reached
|
|
individually by stable DNS name. For ordinary application traffic, though, the Service is
|
|
the only address worth knowing.</p>
|
|
<p class="sayit">For application traffic: never talk to a pod; talk to a Service.</p>
|
|
<p class="fieldnote"><b>Field note.</b> "The network is broken" after a deploy is usually
|
|
readiness telling the truth about your app - not the network lying about your packets.</p>
|
|
<p class="closeline">The dark pod in the scene is not an error state - it is the system
|
|
working. Membership is re-earned every few seconds, by every pod, for as long as it serves.</p>
|
|
<p class="reads"><b>Pre-reads:</b> C-III's pod scene (readiness lives there)
|
|
 <b>Further:</b> <a href="https://kubernetes.io/docs/concepts/services-networking/">Services and networking</a></p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ COURSE V ============ -->
|
|
<section class="course" id="c5" data-scene="orbit" aria-labelledby="c5h">
|
|
<header class="chead">
|
|
<p class="eyebrow">Course V · OpenShift</p>
|
|
<h2 id="c5h" class="thesis">Kubernetes with opinions -<br><em>and a security guard.</em></h2>
|
|
<p class="lede">OpenShift is a distribution of Kubernetes: same engine, opinionated chassis.
|
|
Scroll, and the opinions bloom outward from the core they orbit.</p>
|
|
</header>
|
|
<div class="pin">
|
|
<div class="viewport">
|
|
<figure class="scene">
|
|
<img class="flat" src="__HERO_ORBIT__"
|
|
alt="A glowing geodesic core ringed by six satellites: a magenta admission shield,
|
|
an open route arch, interlocking operator rings, a handheld console, an
|
|
amber-lit stack of machine-config plates and a compact single-node box.">
|
|
</figure>
|
|
<ol class="legend"></ol>
|
|
</div>
|
|
</div>
|
|
<div class="prose">
|
|
<h3>The doorman interviews every pod</h3>
|
|
<p>The <b>SCC</b> - Security Context Constraint - is admission deciding what a pod may BE, checked
|
|
by the api-server when the pod is created and before any node sees it. The default, restricted-v2, runs
|
|
your container as a <b>random non-root UID</b> - your image has to cope. Workloads that
|
|
genuinely need privilege get a dedicated ServiceAccount bound to a minimal custom SCC,
|
|
never the stock one.</p>
|
|
<h3>The platform runs itself</h3>
|
|
<p><b>Routes</b> predate Ingress and still rule here. An <b>Operator</b> is a controller paired with a custom
|
|
resource: you describe what you want in YAML, and its controller builds it and keeps it true
|
|
- the reconciliation loop of C-II, sold as a product. The split matters: the platform's own operators are driven by the Cluster Version
|
|
Operator, while <b>OLM</b> installs and upgrades the add-on Operators you choose from
|
|
OperatorHub. The OS underneath is immutable - changed by <b>MachineConfig</b>, never by SSH.
|
|
And <b>SNO</b> - single-node OpenShift - puts the whole cluster on one box at the edge. At fleet scale the labels
|
|
from Course VI decide which of these boxes runs what.</p>
|
|
<p class="sayit">On OpenShift, admission is the interview - the SCC is the dress code.</p>
|
|
<p class="fieldnote"><b>Field note.</b> Deployment stuck at 0/1 with no pod at all? The
|
|
refusal happened above scheduling - read the ReplicaSet events. The error lives a level
|
|
up.</p>
|
|
<p class="closeline">Everything in the ring wraps the same core you already know. What
|
|
OpenShift adds is a house style with teeth - and admission is where it bites first.</p>
|
|
<p class="reads"><b>Pre-reads:</b> C-II
|
|
 <b>Further:</b> <a href="https://docs.redhat.com/en/documentation/openshift_container_platform">OpenShift documentation</a> ·
|
|
<a href="https://www.redhat.com/en/technologies/cloud-computing/openshift">Red Hat OpenShift</a></p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ COURSE VI ============ -->
|
|
<section class="course" id="c6" data-scene="gitops" aria-labelledby="c6h">
|
|
<header class="chead">
|
|
<p class="eyebrow">Course VI · GitOps</p>
|
|
<h2 id="c6h" class="thesis">Nobody deploys anything.<br><em>The cluster syncs itself.</em></h2>
|
|
<p class="lede">The mental model everyone arrives with: someone with credentials pushes manifests
|
|
at the cluster. In the architecture this course teaches, nothing is pushed: a repository holds
|
|
the desired state, an agent inside the cluster watches it, and the cluster pulls its own future
|
|
from git.</p>
|
|
</header>
|
|
<div class="pin">
|
|
<div class="viewport">
|
|
<figure class="scene">
|
|
<img class="flat" src="__HERO_GITOPS__"
|
|
alt="An exploded chain: an etched repository crystal, a twin-ring reconciler engine,
|
|
a stack of rendered manifests and a cluster slab - with a drift shard falling
|
|
away and an armoured secret vault floating deliberately apart.">
|
|
</figure>
|
|
<ol class="legend"></ol>
|
|
</div>
|
|
</div>
|
|
<div class="prose">
|
|
<h3>The loop you already know, one level up</h3>
|
|
<p>C-II taught the reconciliation loop: desired versus actual, fix the difference, repeat.
|
|
ArgoCD is the same habit applied to <b>delivery</b>. An Application names a repo, a path and a
|
|
revision - watch this branch of this repository - and the controller renders what it finds
|
|
there, compares it against the live cluster, and syncs the difference. The deploy button is a
|
|
git commit; the change history is git log; code review <i>is</i> change control. Git is the
|
|
record of <i>intent</i> - the cluster's own audit log and the reconciler's sync history still
|
|
record what actually happened, including everything git never sees.</p>
|
|
<p class="sayit">oc is for archaeology. git is for change.</p>
|
|
<h3>Pull, not push - the security inversion</h3>
|
|
<p>Here the cluster <b>pulls</b>. Push-based delivery exists and is still GitOps to many - this
|
|
is the stronger variant, and worth choosing deliberately: no CI system, no laptop, no build
|
|
pipeline holds a credential that can touch the cluster, because the agent inside holds a
|
|
read-only deploy key and the trust arrow points out. Compromise the build system and you can
|
|
propose a change, which is visible; you cannot reach into production <i>through git</i>. It
|
|
can still push images - which is the other half of why a manifest should name the digest, not
|
|
the tag. Hand-edit a live object
|
|
and the controller flags it OutOfSync - <b>with selfHeal enabled</b> it puts the object back,
|
|
and <b>with prune enabled</b> what leaves git leaves the cluster. Both are opt-in: without
|
|
them the reconciler reports the drift and waits for a human. Rollback is <span class="mono">git revert</span>,
|
|
which is why commit hygiene is an operational skill.</p>
|
|
<p class="fieldnote"><b>Field note.</b> A hand-patch on a live object survives exactly until
|
|
the next sync. If you must touch production directly to stop the bleeding, open the pull
|
|
request in the same hour - otherwise the reconciler quietly undoes your fix, and the outage
|
|
returns with nobody able to say why.</p>
|
|
<h3>At fleet scale, the label is the deploy button</h3>
|
|
<p>Run many OpenShift clusters under a hub - RHACM, Red Hat's fleet manager, with edge clusters
|
|
arriving through zero-touch provisioning - and nobody applies apps to clusters by hand. Each app carries a
|
|
<b>Placement</b> that selects cluster <b>labels</b>; the hub matches placements against the
|
|
labels a cluster wears, and the chosen cluster is given its assignment - push-style from the
|
|
hub by default, or in a pull model where each cluster runs its own reconciler, which is the
|
|
variant this arc teaches. Either way, labelling the cluster <i>is</i> the deploy action: attach the label and the app follows, remove it and the app
|
|
leaves. The same pull model as above, one level bigger - a cluster's labels are its
|
|
entitlements, reconciled like everything else.</p>
|
|
<p class="sayit">Label the cluster; the app follows.</p>
|
|
<h3>The one thing git never holds</h3>
|
|
<p>Git holds everything except secrets. Commit a plaintext secret and you should treat it as
|
|
<b>compromised from that moment</b>: deleting it later does not guarantee it is gone from
|
|
history, forks, clones, CI caches or backups. So the pattern splits the reference from the value: git carries an ExternalSecret
|
|
naming a <b>logical key</b>; a vault - Azure Key Vault in the worked example - holds the value;
|
|
an operator inside the cluster exchanges one for the other at runtime. Rotation happens in the
|
|
vault, never as a commit. That is why the vault floats apart in the scene above: it is never
|
|
absorbed into the pipeline.</p>
|
|
<p class="sayit">Git holds the shape of the secret. The vault holds the secret.</p>
|
|
<p class="closeline">Delivery stops being an event and becomes a property: the cluster is always
|
|
converging on what the repository says. "Who deployed this?" becomes "who merged this?" - and
|
|
that question always has an answer.</p>
|
|
<p class="reads"><b>Pre-reads:</b> C-II ·
|
|
<a href="https://kubernetes.io/docs/concepts/">Kubernetes concepts</a> ·
|
|
<a href="https://docs.github.com/en/get-started">git + pull requests</a>
|
|
 <b>Further:</b> <a href="https://argo-cd.readthedocs.io/">Argo CD</a> ·
|
|
<a href="https://docs.redhat.com/en/documentation/openshift_container_platform">OpenShift GitOps</a> ·
|
|
<a href="https://external-secrets.io/">External Secrets</a> ·
|
|
<a href="https://learn.microsoft.com/azure/key-vault/">Azure Key Vault</a></p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ COURSE VII ============ -->
|
|
<section class="course" id="c7" data-scene="supply" aria-labelledby="c7h">
|
|
<header class="chead">
|
|
<p class="eyebrow">Course VII · The image supply chain</p>
|
|
<h2 id="c7h" class="thesis">A tag is a promise.<br><em>A digest is a fact.</em></h2>
|
|
<p class="lede"><span class="mono">myapp:latest</span> feels like a name. It is a sticky note - a
|
|
mutable pointer anyone with push rights can peel off one image and press onto another, and
|
|
nothing anywhere records that it moved. The digest - sha256 of the image <i>manifest</i>, the
|
|
small JSON index listing an image's layers, and no relation to the YAML manifests you apply
|
|
to a cluster - is
|
|
its actual name: same bytes, same digest, forever. (Careful: the sha256 a build prints is the
|
|
local image ID, a different hash from the manifest digest the registry mints on push - the
|
|
pushed one is what you pin.)</p>
|
|
</header>
|
|
<div class="pin">
|
|
<div class="viewport">
|
|
<figure class="scene">
|
|
<img class="flat" src="__HERO_SUPPLY__"
|
|
alt="The image journey: a layered image stack, an upstream registry tower, a squat
|
|
pull-through mirror and a node core - beneath a ghost tag plate and an engraved
|
|
digest seal floating side by side.">
|
|
</figure>
|
|
<ol class="legend"></ol>
|
|
</div>
|
|
</div>
|
|
<div class="prose">
|
|
<h3>Say the name properly</h3>
|
|
<p>Three ways to name an image, in rising order of honesty: <span class="mono">:latest</span>
|
|
(a moving target), <span class="mono">:1.4.2</span> (a label someone maintains, until they
|
|
re-push it), and <span class="mono">name:1.4.2@sha256:...</span> - a fact. The tag stays for
|
|
human eyes; the digest does the pulling. Pin by digest and "what is running?" has exactly one
|
|
answer.</p>
|
|
<p class="fieldnote"><b>Field note.</b> <span class="mono">:latest</span> is how two nodes run
|
|
different code from one manifest - the second node pulled an hour later, after a re-push.
|
|
Nobody changed the YAML.</p>
|
|
<h3>Why a fleet pulls once</h3>
|
|
<p>Between the build and the node sits the registry chain. Upstream, a managed registry - Azure
|
|
Container Registry in the worked example - holds what CI built. In front of the cluster sits a
|
|
<b>mirror</b>: a pull-through cache like zot. The fleet asks the mirror, the mirror asks
|
|
upstream once, everything after is local. Rate limits, egress cost, disconnected sites,
|
|
control - one place to gate and audit what enters. OpenShift formalises the re-route with
|
|
image mirror rules, and carries a sharp edge: digest-mirror rules rewrite <b>digest pulls
|
|
only</b>, so a by-tag pull silently skips them - unless you also add an
|
|
<b>ImageTagMirrorSet</b>, which is the rule type built for tag pulls. Pinning by digest is
|
|
still the habit that makes the digest rules catch everything.</p>
|
|
<p>At real fleet scale the mirror itself tiers: a <b>central mirror in the cloud</b> fronts
|
|
upstream once, and every site's mirror pulls from the centre rather than from upstream
|
|
directly. A new image ripples outward in layers - upstream to the centre, centre to each
|
|
site as it asks, site to its nodes over the LAN - instead of every site hammering upstream
|
|
at the same moment. Upstream sees one consumer; each site sees one hop; the nodes never
|
|
leave the building.</p>
|
|
<p class="sayit">Mirrors tier: the load fans out in layers, never all at once.</p>
|
|
<h3>Build once, promote by copy</h3>
|
|
<p>Every rebuild is a <i>new</i> artefact - in practice a different digest (reproducible builds
|
|
are the deliberate exception), untested by the stages before it.
|
|
So build <b>once</b>, then promote the same digest through environments by copying, registry
|
|
to registry - dev proves the exact bytes prod will run. On a multi-arch image that copy
|
|
(<span class="mono">skopeo copy</span>) needs <span class="mono">--all</span> (and <span class="mono">--preserve-digests</span> to fail
|
|
loudly rather than quietly), or you copy one architecture and the digest you promoted is not
|
|
the digest that lands. Human tags ride along; the digest is
|
|
the through-line.</p>
|
|
<p class="sayit">If the digest changed, it is not a promotion - it is a new candidate.</p>
|
|
<p class="closeline">Names that can move are convenient exactly until they move. Address content
|
|
by what it is, and the supply chain stops resting on trust: it rests on a hash anyone can
|
|
check.</p>
|
|
<p class="reads"><b>Pre-reads:</b> C-I ·
|
|
<a href="https://kubernetes.io/docs/concepts/containers/images/">Kubernetes images</a>
|
|
 <b>Further:</b> <a href="https://zotregistry.dev/">zot</a> ·
|
|
<a href="https://learn.microsoft.com/azure/container-registry/">Azure Container Registry</a> ·
|
|
<a href="https://docs.redhat.com/en/documentation/openshift_container_platform">OpenShift image mirroring</a> ·
|
|
<a href="https://github.com/containers/skopeo">skopeo</a></p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ COURSE VIII ============ -->
|
|
<section class="course" id="c8" data-scene="helm" aria-labelledby="c8h">
|
|
<header class="chead">
|
|
<p class="eyebrow">Course VIII · Helm</p>
|
|
<h2 id="c8h" class="thesis">Stop copying YAML between clusters.<br><em>Ship the function instead.</em></h2>
|
|
<p class="lede">The default way to run one app on five clusters is five copies of the YAML, and
|
|
the default result is five slightly different apps. The inversion: stop copying outputs and
|
|
ship the function. A chart is a template with holes; each cluster supplies one small values
|
|
file that fills them.</p>
|
|
</header>
|
|
<div class="pin">
|
|
<div class="viewport">
|
|
<figure class="scene">
|
|
<img class="flat" src="__HERO_HELM__"
|
|
alt="The Helm press: an engraved chart plate with empty sockets, four values crystals
|
|
feeding in, three rendered sheets fanned out in different hues, and a schema gate
|
|
wedge with a rejected grey sheet stopped behind it.">
|
|
</figure>
|
|
<ol class="legend"></ol>
|
|
</div>
|
|
</div>
|
|
<div class="prose">
|
|
<h3>You are writing a program, not YAML</h3>
|
|
<p>Helm templates are Go text/template: <span class="mono">{{ .Values.device.address }}</span>
|
|
is a pipeline walking a values object, <span class="mono">_helpers.tpl</span> holds the
|
|
<b>named templates</b> - the partials every manifest <span class="mono">include</span>s, not
|
|
functions you can call bare. You are not writing YAML - you are writing a program whose
|
|
output is YAML. So render locally, read the output, and lint what came out, not what went in.</p>
|
|
<p class="sayit">Review the render, not just the template.</p>
|
|
<p class="fieldnote"><b>Field note.</b> ArgoCD renders charts with
|
|
<span class="mono">helm template</span> rather than running <span class="mono">helm
|
|
install</span>, so the lifecycle differs from Helm's own. <span class="mono">lookup</span>
|
|
comes back empty - there is no live cluster at render time. Hooks are not dead, though: Argo
|
|
maps Helm hooks onto its sync phases (pre-install and pre-upgrade become PreSync, post-install
|
|
and post-upgrade become PostSync), while a few - rollback and test hooks - have no equivalent
|
|
at all. Render the way your deployer renders, and check where your hooks actually land.</p>
|
|
<h3>Contexts: the cluster's whole voice is one small file</h3>
|
|
<p>The chart owns everything structural - resources, probes, security, policy. Each cluster owns
|
|
one values file: names, addresses, sizes, flags. The context is deliberately values-only; the
|
|
moment it carries its own manifests there are two owners for one object, and they will
|
|
disagree. One value can feed many rendered artefacts - an address appearing in the app config,
|
|
the network attachment and two policies renders from one field, so the copies cannot diverge - the lived version is <a href="https://www.bztmon.com/blog/one-value-many-enforcers/">on the blog</a>.</p>
|
|
<p class="sayit">The chart owns the shape. The context owns the numbers.</p>
|
|
<h3>Make the template refuse</h3>
|
|
<p>A template that renders whatever it is given just moves the failure downstream. The grown-up
|
|
chart carries a <span class="mono">values.schema.json</span>: a context missing a required
|
|
value fails at render time, in the pipeline, with a message naming the field - not months
|
|
later as enforcement pointed at nothing.</p>
|
|
<p class="fieldnote"><b>Field note.</b> The failure you want is the render that refuses. It
|
|
costs a red pipeline. The alternative reports healthy the whole time.</p>
|
|
<p class="closeline">Consistency stops being something you police and becomes something the
|
|
tooling cannot express.</p>
|
|
<p class="reads"><b>Pre-reads:</b> C-II ·
|
|
<a href="https://kubernetes.io/docs/concepts/overview/working-with-objects/">Kubernetes objects</a>
|
|
 <b>Further:</b> <a href="https://helm.sh/docs/">Helm docs</a> ·
|
|
<a href="https://helm.sh/docs/chart_template_guide/">chart template guide</a> ·
|
|
<a href="https://pkg.go.dev/text/template">Go text/template</a> ·
|
|
<a href="https://docs.redhat.com/en/documentation/openshift_container_platform">Helm on OpenShift</a></p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ APPENDIX ============ -->
|
|
<section class="course appendix" id="apx" aria-labelledby="apxh">
|
|
<header class="chead">
|
|
<p class="eyebrow">Appendix · The dependency ledger</p>
|
|
<h2 id="apxh" class="thesis">Every toolchain stands on<br><em>services it does not run.</em></h2>
|
|
<p class="lede">The arc reads like a closed machine: repo to reconciler to registry to node. It
|
|
is not closed. Three load-bearing pieces live outside the cluster - and the honest move is to
|
|
write down what leans on them, and what actually happens when they are down.</p>
|
|
</header>
|
|
<figure class="still">
|
|
<img src="__HERO_APPENDIX__"
|
|
alt="Three familiar machines at rest: the etched repository crystal, the armoured secret
|
|
vault, and the mirror way-station - the supporting cast of the delivery arc.">
|
|
<figcaption>You have met these three before.</figcaption>
|
|
</figure>
|
|
<div class="cards">
|
|
<article class="card">
|
|
<h3>GitHub</h3>
|
|
<p class="what">Where the desired state lives - the system of record the whole loop watches,
|
|
through a read-only deploy key.</p>
|
|
<p class="leans"><b>Leans on it:</b> sync, rollback, change review, the "who merged this"
|
|
answer.</p>
|
|
<p class="down"><b>When it is down:</b> Kubernetes keeps running the last applied state
|
|
indefinitely. The reconciler keeps self-healing only while its rendered manifests are
|
|
still cached - hours, not forever, and gone after a restart. What stops is <i>change</i>. GitOps degrades to
|
|
read-only, which is the graceful half of the design.</p>
|
|
</article>
|
|
<article class="card">
|
|
<h3>Azure Key Vault</h3>
|
|
<p class="what">Where the secret values live - git carries the reference, the vault carries
|
|
the value, an operator keeps them synced.</p>
|
|
<p class="leans"><b>Leans on it:</b> secret sync, rotation, the first deploy of anything that
|
|
needs a credential.</p>
|
|
<p class="down"><b>When it is down:</b> already-synced Secrets keep working - values are
|
|
materialised in-cluster. What stops is <i>rotation</i> and new secrets. Survivable - unless
|
|
you are inside a rotation window.</p>
|
|
</article>
|
|
<article class="card">
|
|
<h3>zot</h3>
|
|
<p class="what">Where the fleet pulls from - a pull-through mirror between the cluster and the
|
|
internet, and the control point for what enters. At fleet scale it tiers: one central
|
|
mirror in the cloud fans out to per-site mirrors, layering the load.</p>
|
|
<p class="leans"><b>Leans on it:</b> every image pull on every node - boot, reschedule,
|
|
scale-up, recovery.</p>
|
|
<p class="down"><b>When it is down:</b> the sharpest edge. Upstream down + mirror up = nobody
|
|
notices, <i>provided the image is already cached</i> - a cold entry still needs upstream.
|
|
Mirror down on a mirror-only pull path = nothing new schedules unless the node already
|
|
holds the image, and <span class="mono">imagePullPolicy: Always</span> turns a mirror
|
|
outage into a hard stop.</p>
|
|
</article>
|
|
</div>
|
|
<div class="prose">
|
|
<p class="closeline">None of these outages stop what is already running - they stop change,
|
|
rotation and recovery, in that order of pain. Cache what you pull, split references from
|
|
values, and let the cluster hold its last known truth without asking anyone's permission.</p>
|
|
<p class="reads"><b>Further:</b> <a href="https://docs.github.com/">GitHub docs</a> ·
|
|
<a href="https://learn.microsoft.com/azure/key-vault/">Azure Key Vault</a> ·
|
|
<a href="https://zotregistry.dev/">zot</a> ·
|
|
<a href="https://kubernetes.io/docs/">Kubernetes</a> ·
|
|
<a href="https://helm.sh/docs/">Helm</a> ·
|
|
<a href="https://docs.redhat.com/en/documentation/openshift_container_platform">Red Hat OpenShift</a></p>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="course" id="flow" aria-labelledby="flowh">
|
|
<header class="chead">
|
|
<p class="eyebrow">The whole arc · end to end</p>
|
|
<h2 id="flowh" class="thesis">One flow, <em>no gaps.</em></h2>
|
|
<p class="lede">Every course above is one stretch of the same journey. Here is the full run,
|
|
drawn in the house blueprint style: the change lane, the shape lane, the artefact lane and
|
|
the secret lane, all converging on one running workload.</p>
|
|
</header>
|
|
<figure class="blueprint">
|
|
<img src="__DIAG_FLOW__"
|
|
alt="End-to-end delivery flow. Change lane: a commit lands in the GitHub repository, ArgoCD
|
|
renders and diffs it, and syncs the cluster - the cluster pulls, nothing pushes.
|
|
Shape lane: the Helm chart plus a per-cluster values context passes the schema gate
|
|
and renders the manifests ArgoCD applies. Artefact lane: CI builds once, pushes to
|
|
Azure Container Registry, the zot mirror caches it, and the node pulls by digest.
|
|
Secret lane: Azure Key Vault holds the values, the External Secrets operator syncs
|
|
them in - git only ever holds the reference. All four lanes converge on the running
|
|
workload.">
|
|
</figure>
|
|
</section>
|
|
|
|
<section class="course" id="library" aria-labelledby="libh">
|
|
<header class="chead">
|
|
<p class="eyebrow">The library</p>
|
|
<h2 id="libh" class="thesis">Go to <em>the sources.</em></h2>
|
|
<p class="lede">Every technology this site teaches, one sentence each, official documentation
|
|
only.</p>
|
|
</header>
|
|
<ul class="library">
|
|
<li><b>Kubernetes</b><span>The container orchestrator underneath everything here: declarative
|
|
state, the reconciliation loop, pods, services, and the controllers that keep the promises.</span>
|
|
<a href="https://kubernetes.io/docs/">kubernetes.io/docs</a></li>
|
|
<li><b>Helm</b><span>The package manager and Go-template engine that turns one chart plus a
|
|
per-cluster values file into the rendered manifests a cluster actually runs.</span>
|
|
<a href="https://helm.sh/docs/">helm.sh/docs</a></li>
|
|
<li><b>Red Hat OpenShift</b><span>Kubernetes as an opinionated, supported distribution -
|
|
SCCs, Routes and Operators, plus the fleet tooling (RHACM, zero-touch provisioning) that
|
|
assigns apps to clusters by label.</span>
|
|
<a href="https://docs.redhat.com/en/documentation/openshift_container_platform">docs.redhat.com</a></li>
|
|
<li><b>Argo CD</b><span>The GitOps reconciler that watches a repository and keeps the cluster
|
|
converged on what it finds there - the pull model that means nothing ever pushes to
|
|
production.</span>
|
|
<a href="https://argo-cd.readthedocs.io/">argo-cd.readthedocs.io</a></li>
|
|
<li><b>GitHub Actions</b><span>CI that builds and tests on every push - in this flow it makes
|
|
artefacts and proposes changes, and never holds a credential that can touch a cluster.</span>
|
|
<a href="https://docs.github.com/en/actions">docs.github.com/actions</a></li>
|
|
<li><b>Azure Key Vault</b><span>The managed vault holding secret values, so git carries only
|
|
references and rotation never has to become a commit.</span>
|
|
<a href="https://learn.microsoft.com/azure/key-vault/">learn.microsoft.com/key-vault</a></li>
|
|
<li><b>Azure Container Registry</b><span>The managed upstream registry CI pushes to - where
|
|
the digest is minted that everything downstream pins and promotes.</span>
|
|
<a href="https://learn.microsoft.com/azure/container-registry/">learn.microsoft.com/container-registry</a></li>
|
|
</ul>
|
|
</section>
|
|
|
|
<footer class="outro">
|
|
<p>© 2026 Jonathon Wright · <a href="https://creativecommons.org/licenses/by-nc-sa/4.0/">CC BY-NC-SA 4.0</a>
|
|
· built with GSAP, shipped via GitOps · <a href="https://www.bztmon.com/">bztmon.com</a></p>
|
|
</footer>
|
|
|
|
<style>
|
|
:root{
|
|
--void:#070b14; --ink:#c9d7ef; --dim:#7286a8; --line:#1b2740;
|
|
--cyan:#3fbaf5; --magenta:#e879f9; --amber:#e8b44a;
|
|
--sans:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
|
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
|
}
|
|
*{box-sizing:border-box}
|
|
body{margin:0;background:var(--void);color:var(--ink);font-family:var(--sans);
|
|
-webkit-font-smoothing:antialiased;overflow-x:hidden}
|
|
a{color:var(--cyan);text-decoration:none;border-bottom:1px solid var(--line)}
|
|
a:hover,a:focus-visible{border-bottom-color:var(--cyan);outline:none}
|
|
b{color:var(--ink)} .mono{font-family:var(--mono);font-size:.9em;color:var(--cyan)}
|
|
|
|
.masthead{max-width:64ch;margin:0 auto;padding:16vh 24px 9vh;text-align:center}
|
|
.eyebrow{font-family:var(--mono);font-size:.72rem;letter-spacing:.22em;text-transform:uppercase;
|
|
color:var(--cyan);margin:0 0 1.5rem}
|
|
.masthead h1,.thesis{font-size:clamp(1.9rem,4.8vw,3.2rem);line-height:1.1;margin:0 0 1.3rem;
|
|
font-weight:600;letter-spacing:-.02em;text-wrap:balance}
|
|
.masthead h1 em,.thesis em{font-style:normal;color:var(--magenta)}
|
|
.lede{font-size:clamp(.98rem,1.6vw,1.12rem);line-height:1.65;color:var(--dim);margin:0 auto;max-width:56ch}
|
|
.scrollcue{font-family:var(--mono);font-size:.7rem;letter-spacing:.2em;text-transform:uppercase;
|
|
color:var(--dim);margin-top:4.5rem;display:flex;flex-direction:column;align-items:center;gap:.7rem}
|
|
.scrollcue span{display:block;width:1px;height:46px;background:linear-gradient(var(--cyan),transparent)}
|
|
|
|
.toc{max-width:760px;margin:0 auto;padding:0 24px 5vh}
|
|
.toc .kt,.purpose .kt{font-family:var(--mono);font-size:.68rem;letter-spacing:.16em;
|
|
text-transform:uppercase;color:var(--dim);text-align:center;margin:0 0 1rem}
|
|
.toc ol{list-style:none;margin:0;padding:0;counter-reset:toc;display:flex;flex-direction:column;gap:8px}
|
|
.toc li{counter-increment:toc}
|
|
.toc a{display:grid;grid-template-columns:34px 110px 1fr;gap:12px;align-items:baseline;
|
|
border:1px solid var(--line);border-radius:8px;padding:10px 16px;background:#0a1020;
|
|
border-bottom-width:1px;text-decoration:none}
|
|
.toc a::before{content:counter(toc,decimal-leading-zero);font-family:var(--mono);
|
|
font-size:.68rem;color:var(--dim)}
|
|
.toc a:hover,.toc a:focus-visible{border-color:var(--cyan)}
|
|
.toc b{font-family:var(--mono);font-size:.72rem;letter-spacing:.08em;color:var(--cyan);font-weight:600}
|
|
.toc span{font-size:.82rem;line-height:1.5;color:var(--dim)}
|
|
@media (max-width:640px){.toc a{grid-template-columns:34px 1fr}.toc span{grid-column:2}}
|
|
|
|
.purpose{max-width:66ch;margin:0 auto;padding:0 24px 5vh}
|
|
.purpose p:not(.kt){font-size:.95rem;line-height:1.75;color:var(--dim);margin:0;text-align:left}
|
|
|
|
.library{max-width:860px;margin:2vh auto 6vh;padding:0 24px;list-style:none;
|
|
display:flex;flex-direction:column;gap:12px}
|
|
.library li{display:grid;grid-template-columns:190px 1fr auto;gap:16px;align-items:baseline;
|
|
border:1px solid var(--line);border-radius:8px;padding:14px 18px;background:#0a1020}
|
|
.library b{font-family:var(--mono);font-size:.78rem;letter-spacing:.06em;color:var(--cyan)}
|
|
.library span{font-size:.85rem;line-height:1.6;color:var(--dim)}
|
|
.library a{font-family:var(--mono);font-size:.68rem;letter-spacing:.04em;white-space:nowrap}
|
|
@media (max-width:760px){.library li{grid-template-columns:1fr}.library a{justify-self:start}}
|
|
|
|
.blueprint{max-width:1220px;margin:2vh auto 4vh;padding:0 24px}
|
|
.blueprint img{width:100%;height:auto;display:block;border-radius:12px}
|
|
|
|
.course{position:relative;padding-top:10vh}
|
|
.chead{max-width:64ch;margin:0 auto;padding:4vh 24px 2vh;text-align:center}
|
|
.chead .thesis{font-size:clamp(1.6rem,4vw,2.6rem)}
|
|
.pin{min-height:100svh;display:grid;place-items:center;padding:3vh 20px;align-content:center}
|
|
.viewport{width:min(1220px,100%);display:grid;grid-template-columns:minmax(0,1fr) 290px;
|
|
gap:26px;align-items:center}
|
|
.scene{position:relative;margin:0;line-height:0}
|
|
.scene .flat{display:block;width:100%;height:auto}
|
|
.slab{position:absolute;inset:0;background-size:100% 100%;background-repeat:no-repeat;will-change:transform}
|
|
|
|
.legend{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:13px}
|
|
.legend li{padding-left:22px;border-left:1px solid var(--line);
|
|
opacity:.34;transition:opacity .35s ease,border-color .35s ease}
|
|
.legend li.lit{opacity:1;border-left-color:var(--cyan)}
|
|
.legend .t{font-size:.92rem;font-weight:600;margin:0 0 .22rem;letter-spacing:-.01em}
|
|
.legend .b{font-size:.76rem;line-height:1.5;color:var(--dim);margin:0}
|
|
|
|
.viewport.solo{grid-template-columns:1fr;max-width:980px}
|
|
.crt{position:absolute;inset:18% 22%;display:flex;flex-direction:column;justify-content:center;
|
|
gap:.3rem;padding:clamp(10px,2.5vw,26px);border:1px solid var(--line);border-radius:10px;
|
|
background:rgba(5,8,16,0.88);box-shadow:0 0 40px rgba(63,186,245,0.07) inset}
|
|
.crt .line{font-family:var(--mono);font-size:clamp(.55rem,1.35vw,.85rem);line-height:1.5;
|
|
color:var(--ink);margin:0;white-space:nowrap;padding-bottom:.12em}
|
|
.crt .line.ok{color:var(--dim)}
|
|
.crt .line.q{color:var(--magenta);margin-top:.5rem}
|
|
body.fx .crt .line{opacity:0;transform:translateY(4px);transition:opacity .3s ease,transform .3s ease}
|
|
body.fx .crt .line.on{opacity:1;transform:none}
|
|
@media (max-width:700px){.crt{inset:10% 8%;gap:.2rem}.crt .line{line-height:1.7}}
|
|
|
|
|
|
.prose{max-width:66ch;margin:0 auto;padding:5vh 24px 9vh}
|
|
.prose .aside{font-size:.88rem !important;border-left:2px solid var(--line);padding-left:18px;
|
|
color:var(--dim) !important}
|
|
.prose h3{font-size:1.18rem;font-weight:600;letter-spacing:-.01em;margin:2.6rem 0 .8rem;color:var(--ink)}
|
|
.prose p{font-size:.98rem;line-height:1.75;color:var(--dim);margin:0 0 1.1rem}
|
|
.sayit{font-weight:600;color:var(--cyan) !important;border-left:2px solid var(--cyan);padding-left:18px}
|
|
.fieldnote{border-left:2px solid var(--amber);padding-left:18px;font-size:.92rem !important}
|
|
.fieldnote b{color:var(--amber);font-family:var(--mono);font-size:.7rem;letter-spacing:.16em;
|
|
text-transform:uppercase;display:block;margin-bottom:.35rem}
|
|
.closeline{color:var(--ink) !important;margin-top:2.2rem !important}
|
|
.reads{font-size:.8rem !important;line-height:1.9 !important;border-top:1px solid var(--line);
|
|
padding-top:1.2rem;margin-top:2.4rem !important}
|
|
.reads b{font-family:var(--mono);font-size:.68rem;letter-spacing:.14em;text-transform:uppercase;color:var(--dim)}
|
|
|
|
.appendix .still{max-width:900px;margin:2vh auto 0;padding:0 24px}
|
|
.appendix .still img{width:100%;height:auto;display:block}
|
|
.appendix .still figcaption{font-family:var(--mono);font-size:.7rem;letter-spacing:.14em;
|
|
text-transform:uppercase;color:var(--dim);text-align:center;margin-top:.9rem}
|
|
.cards{max-width:1100px;margin:5vh auto 0;padding:0 24px;display:grid;
|
|
grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:20px}
|
|
.card{border:1px solid var(--line);border-radius:10px;padding:22px 22px 18px;background:#0a1020}
|
|
.card h3{margin:0 0 .7rem;font-size:1.05rem;letter-spacing:-.01em}
|
|
.card p{font-size:.85rem;line-height:1.65;color:var(--dim);margin:0 0 .9rem}
|
|
.card .leans{border-left:2px solid var(--cyan);padding-left:14px}
|
|
.card .down{border-left:2px solid var(--amber);padding-left:14px;margin-bottom:0}
|
|
.card .leans b,.card .down b{font-family:var(--mono);font-size:.66rem;letter-spacing:.14em;
|
|
text-transform:uppercase;display:block;margin-bottom:.3rem}
|
|
.card .leans b{color:var(--cyan)} .card .down b{color:var(--amber)} .card i{color:var(--ink);font-style:normal;font-weight:600}
|
|
|
|
.outro{max-width:64ch;margin:0 auto;padding:6vh 24px 14vh;text-align:center}
|
|
.outro p{font-family:var(--mono);font-size:.7rem;letter-spacing:.12em;color:var(--dim);line-height:1.9}
|
|
|
|
@media (max-width:900px){
|
|
.viewport{grid-template-columns:1fr;gap:20px}
|
|
.pin{min-height:0;padding:3vh 18px 5vh}
|
|
.masthead{padding:9vh 22px 6vh}
|
|
}
|
|
body:not(.fx) .slab{display:none}
|
|
</style>
|
|
|
|
<script>
|
|
(() => {
|
|
"use strict";
|
|
const SCENES = { surround: __MAN_SURROUND__, docker: __MAN_DOCKER__, cluster: __MAN_CLUSTER__, node: __MAN_NODE__, pod: __MAN_POD__, traffic: __MAN_TRAFFIC__, orbit: __MAN_ORBIT__, gitops: __MAN_GITOPS__, supply: __MAN_SUPPLY__, helm: __MAN_HELM__ };
|
|
const RM = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
const units = [];
|
|
|
|
document.querySelectorAll("section[data-scene]").forEach(sec => {
|
|
const man = SCENES[sec.dataset.scene];
|
|
const scene = sec.querySelector(".scene");
|
|
const flat = scene.querySelector(".flat");
|
|
const legend = sec.querySelector(".legend");
|
|
if (legend) man.parts.forEach(p => {
|
|
const li = document.createElement("li");
|
|
li.innerHTML = '<p class="t">' + p.title + '</p><p class="b">' + p.blurb + '</p>';
|
|
legend.appendChild(li);
|
|
});
|
|
units.push({ sec, man, scene, flat, legend });
|
|
});
|
|
|
|
if (!window.gsap || !window.ScrollTrigger) return; // no-JS/no-GSAP: flat heroes + full legends
|
|
document.body.classList.add("fx");
|
|
gsap.registerPlugin(ScrollTrigger);
|
|
|
|
units.forEach(u => {
|
|
u.slabs = u.man.parts.map(p => {
|
|
const d = document.createElement("div");
|
|
d.className = "slab";
|
|
d.style.backgroundImage = "url(" + u.flat.src + ")";
|
|
d.style.clipPath = "polygon(" + p.points.map(q => q[0] + "% " + q[1] + "%").join(",") + ")";
|
|
if (p.z) d.style.zIndex = String(p.z);
|
|
u.scene.appendChild(d);
|
|
return d;
|
|
});
|
|
u.flat.style.visibility = "hidden";
|
|
const lis = u.legend ? [...u.legend.children] : [];
|
|
const n = lis.length;
|
|
u.apply = t => {
|
|
// stagger > 0 = cascade: parts complete their travel one after another in list order
|
|
const sg = u.man.stagger || 0;
|
|
u.man.parts.forEach((p, i) => {
|
|
const k = u.man.parts.length;
|
|
const pt = sg ? Math.min(1, Math.max(0, t * (1 + sg * (k - 1)) - sg * i)) : t;
|
|
// reverse scenes sit assembled at rest and FLY APART as the scrub advances
|
|
const e = u.man.reverse ? (1 - pt) : pt;
|
|
u.slabs[i].style.transform =
|
|
"translate3d(" + (p.dx * (1 - e)).toFixed(3) + "%," + (p.dy * (1 - e)).toFixed(3) + "%,0)";
|
|
});
|
|
u.scene.style.transform = "scale(" + (0.95 + 0.05 * t).toFixed(4) + ")";
|
|
// the side list lights in reading order, top to bottom, as the scrub advances
|
|
lis.forEach((li, i) => li.classList.toggle("lit", t >= 0.9 * ((i + 0.5) / n)));
|
|
};
|
|
});
|
|
|
|
const boot = () => {
|
|
const scrollable = document.documentElement.scrollHeight - innerHeight > 200;
|
|
units.forEach(u => {
|
|
if (RM) { u.mode = "reduced-motion"; u.apply(1); return; }
|
|
if (scrollable && matchMedia("(min-width: 901px)").matches) {
|
|
u.mode = "pin+scrub";
|
|
ScrollTrigger.create({
|
|
trigger: u.sec.querySelector(".pin"),
|
|
start: "top top",
|
|
end: "+=1800",
|
|
pin: true,
|
|
scrub: 0.6,
|
|
onUpdate: self => u.apply(self.progress),
|
|
onRefreshInit: () => u.apply(0)
|
|
});
|
|
u.apply(0);
|
|
} else {
|
|
u.mode = "assemble";
|
|
u.sec.querySelector(".pin").style.minHeight = "0";
|
|
u.apply(0);
|
|
const io = new IntersectionObserver(es => {
|
|
if (!es.some(e => e.isIntersecting)) return;
|
|
io.disconnect();
|
|
gsap.to({ v: 0 }, { v: 1, duration: 1.6, ease: "power3.out",
|
|
onUpdate: function () { u.apply(this.targets()[0].v); } });
|
|
}, { threshold: 0.2 });
|
|
io.observe(u.scene);
|
|
}
|
|
});
|
|
if (!RM) ScrollTrigger.refresh();
|
|
};
|
|
// the aperitif terminal types its transcript once, when it first enters view
|
|
document.querySelectorAll(".crt").forEach(crt => {
|
|
const lines = [...crt.querySelectorAll(".line")];
|
|
if (RM) { lines.forEach(l => l.classList.add("on")); return; }
|
|
const io = new IntersectionObserver(es => {
|
|
if (!es.some(x => x.isIntersecting)) return;
|
|
io.disconnect();
|
|
lines.forEach((l, i) => setTimeout(() => l.classList.add("on"), 350 * i + 200));
|
|
}, { threshold: 0.4 });
|
|
io.observe(crt);
|
|
});
|
|
|
|
Promise.all(units.map(u => u.flat.decode ? u.flat.decode().catch(() => {}) : Promise.resolve()))
|
|
.then(() => requestAnimationFrame(() => requestAnimationFrame(boot)));
|
|
})();
|
|
</script>
|