674 lines
40 KiB
HTML
674 lines
40 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="#c1"><b>Course I</b><span>Docker - 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="#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 at Course I with what an image actually is; finish knowing how a change travels from a
|
|
git commit to a running, secret-fed, digest-pinned workload on a fleet. Every claim comes from
|
|
running these systems for real, and the library at the end links only to official
|
|
documentation.</p>
|
|
</section>
|
|
|
|
<aside class="key" aria-label="reading key">
|
|
<p class="kt">Reading key · this page stands alone - anything it leans on is listed here</p>
|
|
<ul>
|
|
<li><b>C-I</b><span>Docker: an image is a stack of frozen diffs. Referenced by Course VII.</span><a href="#c1">on this page</a></li>
|
|
<li><b>C-II</b><span>Kubernetes: a cluster is a promise - the reconciliation loop. Referenced by Courses VI and VIII.</span><a href="#c2">on this page</a></li>
|
|
<li><b>BLOG</b><span>Consistency by construction beats consistency by discipline - Course VIII's contexts move, lived on a real fleet.</span><a href="https://www.bztmon.com/blog/one-value-many-enforcers/">read it on bztmon.com</a></li>
|
|
</ul>
|
|
</aside>
|
|
|
|
<!-- ============ COURSE I ============ -->
|
|
<section class="course" id="c1" data-scene="docker" aria-labelledby="c1h">
|
|
<header class="chead">
|
|
<p class="eyebrow">Course I · Docker</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 class="bench"></div>
|
|
</div>
|
|
<div class="prose">
|
|
<h3>A stack of diffs wearing a name</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. The base OS is pulled once and shared by every image on
|
|
the host; your dependencies sit on it; your code - usually the smallest layer, always the
|
|
most volatile - sits above that; and the writable layer is created fresh per container and
|
|
thrown away when it dies. Nothing you write there survives.</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 Dockerfile 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 Dockerfile 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://docs.docker.com/get-started/">Docker get-started</a> ·
|
|
<a href="https://docs.docker.com/build/">Docker build + layers</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 class="bench"></div>
|
|
</div>
|
|
<div class="prose">
|
|
<h3>Desired against actual, forever</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">kubectl 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>
|
|
<p class="closeline">Everything the delivery arc teaches from Course VI onward is this loop
|
|
wearing bigger clothes - 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 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 GitOps 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. kubectl is for
|
|
looking; git is for changing.</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 class="bench"></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 audit log is git log; code review <i>is</i> change control.</p>
|
|
<p class="sayit">kubectl apply is for archaeology, not for change.</p>
|
|
<h3>Pull, not push - the security inversion</h3>
|
|
<p>The cluster <b>pulls</b>. No CI system, no laptop, no build pipeline holds a credential that
|
|
can touch it - 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. Hand-edit a live object and the controller flags it OutOfSync - selfHeal puts
|
|
it back, and what leaves git leaves the cluster. 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> Fighting the reconciler over SSH is arm-wrestling a
|
|
machine that does not get tired - C-II's warning, now with a face. The fix is always
|
|
upstream, in git.</p>
|
|
<h3>At fleet scale, the label is the deploy button</h3>
|
|
<p>Run many OpenShift clusters under a hub - RHACM, 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 pulls its assignment. 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 - a secret in git is published, forever, to everyone who
|
|
ever clones. 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 is the image's actual name: same bytes,
|
|
same digest, forever.</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 class="bench"></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 promise somebody keeps, 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 the mirror. The pinning habit is what makes the
|
|
mirror actually catch the traffic.</p>
|
|
<h3>Build once, promote by copy</h3>
|
|
<p>Every rebuild is a different artefact - different digest, 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. 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 being a chain of trust and becomes a chain of
|
|
arithmetic.</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">A chart is a function.<br><em>The values file is the cluster speaking.</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 class="bench"></div>
|
|
</div>
|
|
<div class="prose">
|
|
<h3>It's Go under the braces</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 named
|
|
functions every manifest calls. 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 deploys charts by running
|
|
<span class="mono">helm template</span> - a pure render. Anything needing a live cluster or an
|
|
install lifecycle (<span class="mono">lookup</span>, hooks) is silently dead there. Render the
|
|
way your deployer renders.</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/">BLOG in the key above</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">Fleet consistency is not a discipline problem, it is a construction
|
|
problem. Divergence 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> the cluster does not care - the reconciler enforces
|
|
the last synced state indefinitely. 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.</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. Mirror down on a mirror-only pull path = nothing new schedules, and a rebooting
|
|
node may not come back whole.</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 machinery that keeps 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>The Exploded Cluster · every scene is one generated hero, sliced and scrubbed -
|
|
no diagrams were harmed.</p>
|
|
<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}
|
|
|
|
.key{max-width:760px;margin:0 auto;padding:0 24px 4vh}
|
|
.key .kt{font-family:var(--mono);font-size:.68rem;letter-spacing:.16em;text-transform:uppercase;
|
|
color:var(--dim);text-align:center;margin:0 0 1rem}
|
|
.key ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}
|
|
.key li{display:grid;grid-template-columns:52px 1fr auto;gap:14px;align-items:baseline;
|
|
border:1px solid var(--line);border-radius:8px;padding:10px 16px;background:#0a1020}
|
|
.key b{font-family:var(--mono);font-size:.72rem;letter-spacing:.1em;color:var(--cyan)}
|
|
.key span{font-size:.82rem;line-height:1.5;color:var(--dim)}
|
|
.key i{font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;text-transform:uppercase;
|
|
color:var(--amber);font-style:normal;white-space:nowrap}
|
|
.key a{font-family:var(--mono);font-size:.66rem;letter-spacing:.06em;white-space:nowrap}
|
|
@media (max-width:700px){.key li{grid-template-columns:44px 1fr}.key i,.key a{grid-column:2}}
|
|
|
|
.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}
|
|
|
|
.bench{width:min(720px,100%);margin:16px auto 0;text-align:center}
|
|
.bench input{width:100%;accent-color:var(--cyan)}
|
|
.bench .hud{font-family:var(--mono);font-size:.66rem;letter-spacing:.06em;color:var(--dim);margin:.45rem 0 0}
|
|
|
|
.prose{max-width:66ch;margin:0 auto;padding:5vh 24px 9vh}
|
|
.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}
|
|
body:not(.fx) .bench{display:none}
|
|
</style>
|
|
|
|
<script>
|
|
(() => {
|
|
"use strict";
|
|
const SCENES = { docker: __MAN_DOCKER__, cluster: __MAN_CLUSTER__, 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");
|
|
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.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 pt = sg ? Math.min(1, Math.max(0, t * (1 + sg * (n - 1)) - sg * i)) : t;
|
|
u.slabs[i].style.transform =
|
|
"translate3d(" + (p.dx * (1 - pt)).toFixed(3) + "%," + (p.dy * (1 - pt)).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)));
|
|
};
|
|
// bench: hand scrub + HUD (pilot instrument - strip for the site ship)
|
|
const bench = u.sec.querySelector(".bench");
|
|
bench.innerHTML = '<input type="range" min="0" max="1000" value="0" aria-label="scrub the teardown">' +
|
|
'<p class="hud">boot</p>';
|
|
u.hud = bench.querySelector(".hud");
|
|
u.slider = bench.querySelector("input");
|
|
u.show = v => {
|
|
u.hud.textContent = u.mode + " · " + innerWidth + "x" + innerHeight +
|
|
" · scroll " + Math.round(scrollY) + " · t=" + v.toFixed(2);
|
|
u.slider.value = String(Math.round(v * 1000));
|
|
};
|
|
u.drive = v => { u.apply(v); u.show(v); };
|
|
u.slider.addEventListener("input", () => u.drive(+u.slider.value / 1000));
|
|
});
|
|
|
|
const boot = () => {
|
|
const scrollable = document.documentElement.scrollHeight - innerHeight > 200;
|
|
units.forEach(u => {
|
|
if (RM) { u.mode = "reduced-motion"; u.drive(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.drive(self.progress),
|
|
onRefreshInit: () => u.apply(0)
|
|
});
|
|
u.drive(0);
|
|
} else {
|
|
u.mode = "assemble";
|
|
u.sec.querySelector(".pin").style.minHeight = "0";
|
|
u.drive(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.drive(this.targets()[0].v); } });
|
|
}, { threshold: 0.2 });
|
|
io.observe(u.scene);
|
|
}
|
|
});
|
|
if (!RM) ScrollTrigger.refresh();
|
|
};
|
|
Promise.all(units.map(u => u.flat.decode ? u.flat.decode().catch(() => {}) : Promise.resolve()))
|
|
.then(() => requestAnimationFrame(() => requestAnimationFrame(boot)));
|
|
})();
|
|
</script>
|