1110 lines
68 KiB
HTML
1110 lines
68 KiB
HTML
<header class="masthead">
|
|
<div class="introbg" aria-hidden="true"><img src="__HERO_INTRO__" alt=""></div>
|
|
<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="Course index">
|
|
<p class="kt">Index</p>
|
|
<ol>
|
|
<li><a href="#c0">Course 00<span>Aperitif - three delivery actions and one check</span></a></li>
|
|
<li><a href="#c1">Course I<span>Podman - an image is a stack of frozen diffs</span></a></li>
|
|
<li><a href="#c2">Course II<span>Kubernetes - a cluster is a promise, not a place</span></a></li>
|
|
<li><a href="#c3a">Course IIIa<span>The node - where intent becomes a process</span></a></li>
|
|
<li><a href="#c3b">Course IIIb<span>The pod - one IP, shared fate</span></a></li>
|
|
<li><a href="#c4">Course IV<span>The traffic - pods die constantly, the address does not</span></a></li>
|
|
<li><a href="#c5">Course V<span>OpenShift - Kubernetes with opinions, and a security guard</span></a></li>
|
|
<li><a href="#c6">Course VI<span>GitOps - nobody deploys anything; the cluster syncs itself</span></a></li>
|
|
<li><a href="#c7">Course VII<span>The image supply chain - a tag is a promise, a digest is a fact</span></a></li>
|
|
<li><a href="#c8">Course VIII<span>Helm - a chart is a function; the values file is the cluster speaking</span></a></li>
|
|
<li><a href="#apx">Appendix<span>The dependency ledger - GitHub, Azure Key Vault, zot</span></a></li>
|
|
<li><a href="#flow">Blueprint<span>The delivery flow, end to end</span></a></li>
|
|
<li><a href="#library">Library<span>Official documentation for every technology on this page</span></a></li>
|
|
</ol>
|
|
</nav>
|
|
|
|
<section class="purpose" aria-labelledby="purph">
|
|
<h2 class="kt" id="purph">What this teaches</h2>
|
|
<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 four terminal commands - three that deliver, one that checks - and
|
|
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 delivery actions. One check.<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>Three of those were delivery actions - build, push, apply, and the
|
|
last, <span class="mono">oc get pods</span>, only checked the result. Built of what, exactly?
|
|
Pushed to where, and what travelled? Applied, which is not the same as launched. Running,
|
|
according to whom? Each course below takes one of those words apart.</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
|
|
ordinary process on your kernel. No guest OS, no hypervisor. The kernel gives it
|
|
namespaces so it sees its own PID tree, mounts, network and hostname, and
|
|
cgroups 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 filesystem that process sees. 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 read-only 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
|
|
fetches just the layers you do not already have. A base sits at the bottom, your dependencies on it,
|
|
your code - usually the smallest layer, and typically the most volatile - above that.</p>
|
|
<p>Those layers become one filesystem through a union mount - overlayfs, the same
|
|
kernel feature you can mount by hand. The read-only image layers are the lower dirs; the
|
|
container gets a fresh upper dir 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 writable
|
|
layer is not part of the image. 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 a lot of what sits above it: shared layers make
|
|
pulls fast, immutable content lets a digest name exact bytes (Course VII), and the throwaway
|
|
top layer is why persistent state needs volumes.</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 continuously 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 reconciliation loop - 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 keeps 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 control
|
|
plane that decides - holds the truth, schedules, reconciles, and worker nodes
|
|
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 SSH into a node to "fix" a workload, a
|
|
controller may reconcile your change away. Change the desired state instead, and let the loop
|
|
carry it.</p>
|
|
<h3>What the control plane is made of</h3>
|
|
<p>The core control plane is the API server, scheduler and
|
|
controller-manager, backed by etcd; cloud deployments may also run a
|
|
cloud-controller-manager. Naming them makes the later error messages readable. The API server
|
|
(<span class="mono">kube-apiserver</span>) is the only door: requests authenticate, are
|
|
authorised and are admitted there, and it is the one component that talks to etcd, the
|
|
key-value store holding Kubernetes API state. Lose etcd and you have lost the cluster, which
|
|
is why backing it up is the homework to not skip. The scheduler decides which node a new pod
|
|
belongs on - packing by the resources a pod <i>requests</i>, not by what it currently uses -
|
|
and records that decision; it does not start containers. The controller-manager runs the
|
|
reconciliation loops. Control-plane state converges through the API server: the scheduler and
|
|
built-in controllers watch API objects and write decisions back through the API rather than
|
|
modifying etcd directly.</p>
|
|
<p>One of those loops is the chain you will debug most: a Deployment creates a
|
|
ReplicaSet, and the ReplicaSet creates pods. 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 durable store for Kubernetes API state; controllers reconcile from it continually.</p>
|
|
<p class="closeline">Everything the delivery arc teaches from Course VI onward is the same
|
|
loop at a larger scale: Git as the desired state, whole fleets as reconciled objects.</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>The scheduler decides which node a pod belongs on; the kubelet on that node
|
|
makes the assigned PodSpec real; the runtime creates and starts the containers. The
|
|
kubelet does not place pods, and does not itself create containers - it speaks CRI (the
|
|
Container Runtime Interface) to containerd or CRI-O, and that runtime pulls the image
|
|
(through the mirror of Course VII) and starts it, handing the low-level work to runc or crun.
|
|
A CNI plugin (Container Network Interface) gives the pod a real IP - called by the
|
|
runtime, not the kubelet, and kube-proxy, or an eBPF datapath replacing it, makes
|
|
Service addresses route to real pods.</p>
|
|
<p class="sayit">The scheduler places the pod. The kubelet realises it. The runtime runs 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 does not start container processes directly. It records
|
|
and reconciles intent; node-side components execute it - the kubelet turning intent into
|
|
instructions, the runtime turning instructions into processes.</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 network namespace: 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. regular init containers run in order and
|
|
complete before the app containers start. Native sidecars are the deliberate exception:
|
|
restartable init containers that keep running alongside the app, for jobs like proxy, logs and
|
|
config reload.</p>
|
|
<h3>Three probes, three different jobs</h3>
|
|
<p>startup owns warm-up, readiness gates traffic, liveness 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 SIGKILLed.
|
|
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
|
|
cgroup ceiling, and the kernel's OOM killer enforces it just 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 Service 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 label selector (say
|
|
<span class="mono">app: api</span>), and a controller continuously matches that against every
|
|
pod in the namespace. That is a Kubernetes namespace, a naming boundary for objects, unrelated
|
|
to the kernel namespaces of Course I; the passing set lands in an EndpointSlice. 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>A ClusterIP is a virtual Service address implemented by the node dataplane rather
|
|
than an application process listening on that IP. In iptables or nftables mode,
|
|
kube-proxy installs rules that steer Service traffic to endpoint IPs; if you have
|
|
written a DNAT rule by hand, that mode will feel familiar. In IPVS mode, kube-proxy binds
|
|
Service IPs to the <span class="mono">kube-ipvs0</span> dummy interface and creates IPVS
|
|
virtual servers. eBPF implementations can replace kube-proxy with their own dataplane. Cluster
|
|
DNS (CoreDNS) resolves <span class="mono">api.myns.svc.cluster.local</span> to the
|
|
Service address.</p>
|
|
|
|
<h3>Readiness decides membership</h3>
|
|
<p>When a configured readiness probe fails, the pod becomes unready and normal
|
|
Kubernetes Service traffic stops selecting it for new connections. There is no error at the
|
|
client - the pod drops out of the endpoint set. That is the feature: broken instances take
|
|
themselves out of rotation. 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 ordinary Service traffic: address the Service, not a pod.</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. For pods with readiness probes, eligibility for Service traffic is continually
|
|
re-evaluated for the life of the pod.</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 SCC - 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. Under OpenShift's restricted
|
|
SCCs, workloads normally run as a non-root UID drawn from the project's allocated range,
|
|
so an image has to work with an arbitrary permitted UID rather than assuming a fixed user.
|
|
Workloads that genuinely need privilege get a dedicated ServiceAccount bound to the minimum
|
|
SCC that grants it, not the stock one.</p>
|
|
<h3>The platform runs itself</h3>
|
|
<p>Routes predate Ingress and still rule here. An Operator is a controller paired with a
|
|
custom resource: you describe what you want in YAML, and its controller builds it and keeps it
|
|
true. An Operator applies the same reconciliation pattern to an application or platform
|
|
capability. The split matters: the platform's own operators are driven by the Cluster Version
|
|
Operator, while OLM installs and upgrades the add-on Operators you choose from
|
|
OperatorHub. MachineConfig is the supported declarative path for node OS configuration;
|
|
ad-hoc SSH changes create drift and are not the intended operating model. And SNO -
|
|
single-node OpenShift - puts a 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 admission, routing and lifecycle opinions on top of it.</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.
|
|
Argo CD applies the same habit to delivery. 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 <span class="mono">git log</span>; code review <i>is</i>
|
|
change control. Git records <i>intent</i> - the cluster's own audit log and Argo CD's sync
|
|
history record what actually happened, including what Git does not see.</p>
|
|
<p class="sayit">oc reads the running system; Git records the change.</p>
|
|
<h3>Pull, not push - the security inversion</h3>
|
|
<p>Here the cluster pulls. Push-based delivery exists and is still GitOps to many; the
|
|
pull variant is worth choosing deliberately, because no CI system, laptop or build pipeline
|
|
holds a credential that can touch the cluster - 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. With self-heal enabled,
|
|
Argo CD can restore the desired state; with prune enabled, objects removed from Git can
|
|
be deleted. Both are opt-in: without them the reconciler reports the drift and waits for a
|
|
sync. 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> With self-heal enabled, a live hand-patch may be
|
|
reverted on the next reconciliation. Without self-heal, Argo CD reports the drift until someone
|
|
syncs it. Either way, land the change in Git in the same hour, or the fix and the reason for it
|
|
go missing.</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
|
|
Placement that selects cluster labels; 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. Labelling a
|
|
cluster changes its placement eligibility; the lifecycle and cleanup of what lands still follow
|
|
the configured propagation and deletion policy, so removing a label does not, on its own,
|
|
guarantee an app is deleted.</p>
|
|
<p class="sayit">Label the cluster; the app follows.</p>
|
|
<h3>Keep plaintext secret values out of Git</h3>
|
|
<p>In this architecture, plaintext secret values do not live in Git: Git stores the
|
|
ExternalSecret reference, and the vault stores the value. Commit a plaintext secret and you
|
|
should treat it as compromised from that moment - deleting it later does not guarantee
|
|
it is gone from history, forks, clones, CI caches or backups. So Git carries an ExternalSecret
|
|
naming a logical key; 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, not as a commit, which is why the vault sits apart from the pipeline in the scene
|
|
above.</p>
|
|
<p class="sayit">Git holds the shape of the secret. The vault holds the secret.</p>
|
|
<p class="closeline">Delivery becomes a property rather than an event: the cluster converges on
|
|
what the repository says. With protected branches and attributable identities, changes should
|
|
be traceable to reviewed commits.</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 reference anyone with push rights can move from one image to another. Registries may
|
|
audit tag updates, but the tag itself carries no immutability guarantee. The digest is the
|
|
sha256 of the image <i>manifest</i> (the small JSON index listing an image's layers, unrelated
|
|
to the YAML manifests you apply to a cluster). It identifies the exact uploaded content.
|
|
A local image ID printed by a build is not necessarily the same value, so pin the registry
|
|
digest that consumers actually pull.</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 a single
|
|
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
|
|
mirror: a pull-through cache like zot. On a cache miss the mirror fetches the artefact
|
|
from upstream; subsequent requests can be served locally while that content stays cached. 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 digest pulls
|
|
only, so a by-tag pull silently skips them - unless you also add an
|
|
ImageTagMirrorSet, 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 central mirror in the cloud fronts
|
|
upstream, and each 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, rather than every site hitting upstream at the same moment.
|
|
With a site configured to consume only its local mirror, its node image traffic stays on the
|
|
local network.</p>
|
|
<p class="sayit">Tiered mirrors fan the load out in layers rather than 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 once, then promote the same digest through environments by copying, registry to
|
|
registry - dev proves the exact bytes prod will run. For a multi-architecture image,
|
|
<span class="mono">skopeo copy --all</span> copies the complete image list; add
|
|
<span class="mono">--preserve-digests</span> when promotion requires the destination to keep
|
|
the source digests, so the copy fails if that cannot be done rather than silently landing a
|
|
different digest. 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 right up until they move. Address
|
|
content by its digest and the supply chain rests on a hash anyone can check, not on trust that
|
|
a tag stayed put.</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> commonly holds
|
|
named templates - reusable partials that manifests can <span class="mono">include</span>
|
|
, rather than ordinary functions invoked directly. 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 rather
|
|
than what went in.</p>
|
|
<p class="sayit">Review the render, not just the template.</p>
|
|
<p class="fieldnote"><b>Field note.</b> Argo CD 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, because 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: one small file per cluster</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 several rendered artefacts: the app config, network
|
|
attachment and two policies can all render from the same field, so those 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. A production-grade
|
|
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 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> existing workloads keep running. Repository-backed
|
|
refreshes and new changes become unavailable; what the controller can still evaluate depends
|
|
on state already available to it locally. Do not design around an assumed cache lifetime.
|
|
What stops is <i>change</i>; the running system is unaffected.</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-materialised Kubernetes Secrets typically
|
|
remain usable while the vault is unavailable. What fails is refresh, rotation and the
|
|
creation of new secret material - 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: a pod whose image is already in the node's store
|
|
can still start under <span class="mono">IfNotPresent</span>, but
|
|
<span class="mono">imagePullPolicy: Always</span> must resolve the reference against the
|
|
registry on each launch, so a mirror outage stops it even when the layers are present.</p>
|
|
</article>
|
|
</div>
|
|
<div class="prose">
|
|
<p class="closeline">None of these outages stop what is already running - they stop change,
|
|
rotation and recovery. Cache what you pull, keep secret values in a vault rather than Git, and
|
|
the cluster can hold its last applied state while a dependency is away.</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, Argo CD
|
|
renders and diffs it, and syncs the cluster - the cluster pulls rather than being pushed to.
|
|
Shape lane: the Helm chart plus a per-cluster values context passes the schema gate
|
|
and renders the manifests Argo CD 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 holds only 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 - in the pull model, the cluster fetches its own desired state
|
|
rather than being pushed to.</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 each push - in this flow it makes
|
|
artefacts and proposes changes, and does not hold 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 does not have 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;
|
|
--dur-fast:.2s; --dur-base:.35s; --dur-slow:.6s; /* house-motion-system tokens */
|
|
--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)}
|
|
:focus-visible{outline:2px solid var(--cyan);outline-offset:3px;border-radius:2px}
|
|
.skip{position:absolute;left:-999px;top:0;z-index:100;background:var(--void);color:var(--cyan);
|
|
font-family:var(--mono);font-size:.8rem;padding:10px 16px;border:1px solid var(--cyan);border-radius:6px}
|
|
.skip:focus{left:12px;top:12px}
|
|
.purpose h2.kt{margin:0 0 1rem}
|
|
b{color:var(--ink)} .mono{font-family:var(--mono);font-size:.9em;color:var(--cyan)}
|
|
|
|
.masthead{position:relative;max-width:64ch;margin:0 auto;padding:16vh 24px 9vh;text-align:center}
|
|
/* The archipelago is anchored BELOW the text block: the words never sit over machinery.
|
|
Top edge starts under the lede; the mask feathers both ends so it dissolves into the void. */
|
|
.introbg{position:absolute;inset:52% -50vw -2vh;z-index:-1;overflow:hidden;pointer-events:none}
|
|
.introbg img{width:100%;height:100%;object-fit:cover;object-position:center 62%;opacity:.55;
|
|
-webkit-mask-image:linear-gradient(180deg,transparent 0%,#000 26%,#000 82%,transparent 100%);
|
|
mask-image:linear-gradient(180deg,transparent 0%,#000 26%,#000 82%,transparent 100%)}
|
|
@media (max-width:900px){.introbg{inset:58% -60vw -2vh}.introbg img{opacity:.4}}
|
|
.masthead > :not(.introbg){position:relative}
|
|
.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;
|
|
transition:opacity var(--dur-base) ease}
|
|
|
|
.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);cursor:default;
|
|
opacity:.34;transition:opacity var(--dur-base) ease,border-color var(--dur-base) ease}
|
|
.legend li.lit{opacity:.72}
|
|
.legend li.act{opacity:1;border-left-color:var(--cyan)}
|
|
.legend li:hover,.legend li:focus-visible{opacity:1}
|
|
.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 var(--dur-base) ease,transform var(--dur-base) 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}
|
|
@media (prefers-reduced-motion: reduce){
|
|
html{scroll-behavior:auto}
|
|
*,*::before,*::after{animation-duration:.001ms !important;animation-iteration-count:1 !important;
|
|
transition-duration:.001ms !important;scroll-behavior:auto !important}
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
(() => {
|
|
"use strict";
|
|
// House motion tokens (skill: house-motion-system). Durations in seconds; nothing inline below.
|
|
const TOK = {
|
|
DUR: { fast: 0.2, base: 0.35, slow: 0.6, epic: 1.6 },
|
|
EASE: { enter: "power3.out", move: "power2.inOut", hover: "power1.out", scrub: "none" },
|
|
STAGGER: { tight: 0.03, base: 0.06, loose: 0.12 },
|
|
SCRUB: 0.6
|
|
};
|
|
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");
|
|
// legend text comes from repo JSON; build with DOM nodes + textContent, never innerHTML
|
|
if (legend) man.parts.forEach((p, i) => {
|
|
if (!p.title && !p.blurb) return;
|
|
const li = document.createElement("li");
|
|
li.dataset.part = String(i);
|
|
li.tabIndex = 0; // keyboard users get the same link as the mouse
|
|
const t = document.createElement("p"); t.className = "t"; t.textContent = p.title;
|
|
const b = document.createElement("p"); b.className = "b"; b.textContent = p.blurb;
|
|
li.append(t, b); legend.appendChild(li);
|
|
});
|
|
units.push({ sec, man, scene, flat, legend });
|
|
});
|
|
|
|
// ---- intro (class: entrance) --------------------------------------------------------
|
|
// Staged masthead reveal. from() sets the start state at runtime, so with JS off the
|
|
// masthead is simply visible - no opacity:0 baked into CSS.
|
|
if (window.gsap) {
|
|
const intro = [...document.querySelectorAll(
|
|
".masthead .eyebrow, .masthead h1, .masthead .lede, .masthead .scrollcue")];
|
|
if (intro.length) {
|
|
if (RM) {
|
|
// reduced: opacity only, fast, no movement
|
|
gsap.from(intro, { opacity: 0, duration: TOK.DUR.fast, ease: TOK.EASE.enter });
|
|
} else {
|
|
gsap.from(intro, {
|
|
opacity: 0, y: 20, duration: TOK.DUR.slow, ease: TOK.EASE.enter,
|
|
stagger: TOK.STAGGER.loose
|
|
});
|
|
// the rule under the scroll cue draws itself once the words have landed
|
|
gsap.from(".masthead .scrollcue span", {
|
|
scaleY: 0, transformOrigin: "top center", duration: TOK.DUR.slow,
|
|
ease: TOK.EASE.move, delay: TOK.STAGGER.loose * 4
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- intro backdrop (class: parallax) ------------------------------------------------
|
|
// Drifts up and fades as the index arrives. Parallax is disabled outright under reduced
|
|
// motion (house rule), which is why this sits after the RM check for its own animation.
|
|
const introBg = document.querySelector(".introbg img");
|
|
if (introBg && !RM && window.gsap && window.ScrollTrigger) {
|
|
gsap.to(introBg, {
|
|
yPercent: -12, opacity: 0.12, ease: TOK.EASE.scrub,
|
|
scrollTrigger: { trigger: ".masthead", start: "top top", end: "bottom top",
|
|
scrub: TOK.SCRUB }
|
|
});
|
|
}
|
|
|
|
// Reduced motion / no GSAP: leave the flat exploded hero in place (it IS the end state).
|
|
if (RM || !window.gsap || !window.ScrollTrigger) return;
|
|
document.body.classList.add("fx");
|
|
gsap.registerPlugin(ScrollTrigger);
|
|
|
|
const wc = (u, on) => u.slabs.forEach(el => el.style.willChange = on ? "transform" : "auto");
|
|
|
|
// Build every scene's slabs up front so pinned ScrollTriggers reserve their pin-spacing in the
|
|
// document flow from the start. Creating pins lazily reflows the page and floats a scene over the
|
|
// following section. The flat hero stays the fallback until its slabs exist.
|
|
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;
|
|
// per-scene motion signature (occlusion-safe scenes only; default is straight translate).
|
|
// "away" is 0 at the hero layout, 1 at collapsed/flown - every signature returns to identity
|
|
// at the hero so the assembled slices always match the source image exactly.
|
|
const M = u.man.motion, RAD = Math.PI / 180;
|
|
u.apply = t => {
|
|
const sg = u.man.stagger || 0, k = u.man.parts.length;
|
|
u.man.parts.forEach((p, i) => {
|
|
const pt = sg ? Math.min(1, Math.max(0, t * (1 + sg * (k - 1)) - sg * i)) : t;
|
|
const e = u.man.reverse ? (1 - pt) : pt;
|
|
const away = 1 - e;
|
|
let ox = p.dx * away, oy = p.dy * away, extra = "";
|
|
if (M === "tumble") { // aperitif: fragments spin as they fly out
|
|
const spin = (p.spin != null ? p.spin : (i % 2 ? 20 : -20)) * away;
|
|
extra = " rotate(" + spin.toFixed(2) + "deg)";
|
|
} else if (M === "arc") { // traffic: slices travel a bowed (curved) path
|
|
const len = Math.hypot(p.dx, p.dy) || 1;
|
|
const bow = (u.man.bow || 9) * 4 * away * (1 - away);
|
|
ox += (-p.dy / len) * bow; oy += (p.dx / len) * bow;
|
|
} else if (M === "orbit") { // OpenShift: satellites spiral in around the core
|
|
const a = (u.man.orbit || 55) * away * RAD, c = Math.cos(a), sn = Math.sin(a);
|
|
ox = (p.dx * c - p.dy * sn) * away; oy = (p.dx * sn + p.dy * c) * away;
|
|
}
|
|
u.slabs[i].style.transform =
|
|
"translate3d(" + ox.toFixed(3) + "%," + oy.toFixed(3) + "%,0)" + extra;
|
|
});
|
|
u.scene.style.transform = "scale(" + (0.95 + 0.05 * t).toFixed(4) + ")";
|
|
// Object <-> text linkage: the row being read is the part that stands out. The scrub
|
|
// walks the list top to bottom; whichever row is current gets .act, its slab stays at
|
|
// full strength and the rest sit back. Reading position and visual focus never diverge.
|
|
if (n) {
|
|
let act = -1;
|
|
lis.forEach((li, i) => {
|
|
const on = t >= 0.9 * ((i + 0.5) / n);
|
|
li.classList.toggle("lit", on);
|
|
if (on) act = i;
|
|
});
|
|
u.act = act;
|
|
if (u.pin == null) u.paint();
|
|
}
|
|
};
|
|
// paint() applies the focus state: pinned (hover/focus) wins over the scroll position.
|
|
u.paint = () => {
|
|
const f = u.pin != null ? u.pin : u.act;
|
|
u.slabs.forEach((el, i) => { el.style.opacity = (f < 0 || i === f) ? "1" : "0.38"; });
|
|
lis.forEach((li, i) => li.classList.toggle("act", i === f));
|
|
};
|
|
u.pin = null; u.act = -1;
|
|
// hover or keyboard focus on a row pins the link to that part - user-initiated, so it
|
|
// stays available even when the scrub is not moving.
|
|
lis.forEach((li, i) => {
|
|
const on = () => { u.pin = i; u.paint(); };
|
|
const off = () => { u.pin = null; u.paint(); };
|
|
li.addEventListener("mouseenter", on);
|
|
li.addEventListener("focus", on);
|
|
li.addEventListener("mouseleave", off);
|
|
li.addEventListener("blur", off);
|
|
});
|
|
});
|
|
|
|
const boot = () => {
|
|
const desktop = document.documentElement.scrollHeight - innerHeight > 200 &&
|
|
matchMedia("(min-width: 901px)").matches;
|
|
units.forEach(u => {
|
|
if (desktop) {
|
|
u.mode = "pin+scrub";
|
|
ScrollTrigger.create({
|
|
trigger: u.sec.querySelector(".pin"),
|
|
start: "top top", end: "+=1800", pin: true, scrub: TOK.SCRUB,
|
|
onToggle: self => wc(u, self.isActive),
|
|
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);
|
|
// assemble once, when the scene scrolls in; keep will-change only for the tween's life
|
|
ScrollTrigger.create({
|
|
trigger: u.scene, start: "top 80%", once: true,
|
|
onEnter: () => { wc(u, true); gsap.to({ v: 0 }, {
|
|
v: 1, duration: TOK.DUR.epic, ease: TOK.EASE.enter,
|
|
onUpdate: function () { u.apply(this.targets()[0].v); },
|
|
onComplete: () => wc(u, false) }); }
|
|
});
|
|
}
|
|
});
|
|
ScrollTrigger.refresh();
|
|
};
|
|
// the aperitif terminal types its transcript once, when it first enters view
|
|
document.querySelectorAll(".crt").forEach(crt => {
|
|
const lines = [...crt.querySelectorAll(".line")];
|
|
const io = new IntersectionObserver(es => {
|
|
if (!es.some(x => x.isIntersecting)) return;
|
|
io.disconnect();
|
|
lines.forEach((l, i) => setTimeout(() => l.classList.add("on"),
|
|
TOK.DUR.base * 1000 * i + TOK.DUR.fast * 1000));
|
|
}, { threshold: 0.4 });
|
|
io.observe(crt);
|
|
});
|
|
|
|
// boot on the next frame; do not gate on decoding lazy images (that stalls with loading=lazy)
|
|
requestAnimationFrame(() => requestAnimationFrame(boot));
|
|
})();
|
|
</script>
|