|
|
|
@@ -7,10 +7,10 @@
|
|
|
|
|
<p class="scrollcue" aria-hidden="true">scroll<span></span></p>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<nav class="toc" aria-label="index">
|
|
|
|
|
<nav class="toc" aria-label="Course index">
|
|
|
|
|
<p class="kt">Index</p>
|
|
|
|
|
<ol>
|
|
|
|
|
<li><a href="#c0"><b>Course 00</b><span>Aperitif - three commands, and what actually just happened</span></a></li>
|
|
|
|
|
<li><a href="#c0"><b>Course 00</b><span>Aperitif - three delivery actions and one check</span></a></li>
|
|
|
|
|
<li><a href="#c1"><b>Course I</b><span>Podman - an image is a stack of frozen diffs</span></a></li>
|
|
|
|
|
<li><a href="#c2"><b>Course II</b><span>Kubernetes - a cluster is a promise, not a place</span></a></li>
|
|
|
|
|
<li><a href="#c3a"><b>Course IIIa</b><span>The node - where intent becomes a process</span></a></li>
|
|
|
|
@@ -27,12 +27,12 @@
|
|
|
|
|
</nav>
|
|
|
|
|
|
|
|
|
|
<section class="purpose" aria-labelledby="purph">
|
|
|
|
|
<p class="kt" id="purph">Purpose of this document</p>
|
|
|
|
|
<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 three terminal commands; finish knowing how a change travels from a
|
|
|
|
|
git commit to a running, secret-fed, digest-pinned workload on a fleet. The library at the end links only to official documentation, so every claim here can be
|
|
|
|
|
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>
|
|
|
|
|
|
|
|
|
@@ -41,7 +41,7 @@
|
|
|
|
|
<section class="course" id="c0" data-scene="surround" aria-labelledby="c0h">
|
|
|
|
|
<header class="chead">
|
|
|
|
|
<p class="eyebrow">Course 00 · Aperitif</p>
|
|
|
|
|
<h2 id="c0h" class="thesis">Three commands.<br><em>What actually just happened?</em></h2>
|
|
|
|
|
<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>
|
|
|
|
@@ -74,9 +74,10 @@
|
|
|
|
|
<span class="mono">kubectl</span> in the docs, type whichever your cluster gives you.</p>
|
|
|
|
|
<p>That <span class="mono">1/1</span> reads as containers-ready over containers-wanted: a pod
|
|
|
|
|
can hold more than one, which is Course IIIb.</p>
|
|
|
|
|
<p>An image got <b>built</b> - of what, exactly? <b>Pushed</b> - to where, and what travelled?
|
|
|
|
|
<b>Applied</b> - which is not the same as launched. <b>Running</b> - according to whom?
|
|
|
|
|
Every course below takes one of those words apart. The armour is already loose.</p>
|
|
|
|
|
<p>Three of those were delivery actions - <b>build</b>, <b>push</b>, <b>apply</b> - 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>
|
|
|
|
|
|
|
|
|
@@ -124,9 +125,9 @@
|
|
|
|
|
<h3>What an image is made of</h3>
|
|
|
|
|
<p>An image is not a copy of a machine. It is a stack of <b>read-only</b> layers, each
|
|
|
|
|
recording only what changed from the one beneath. Layers are content-addressed, so an
|
|
|
|
|
identical layer is stored once and reused by every image that references it, so a pull only
|
|
|
|
|
fetches the layers you do not already have. A base sits at the bottom, your dependencies on it,
|
|
|
|
|
your code - usually the smallest layer, always the most volatile - above that.</p>
|
|
|
|
|
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 <b>union mount</b> - overlayfs, the same
|
|
|
|
|
kernel feature you can mount by hand. The read-only image layers are the lower dirs; the
|
|
|
|
|
container gets a fresh <b>upper dir</b> of its own. Writes land in the upper, and editing an
|
|
|
|
@@ -144,10 +145,9 @@
|
|
|
|
|
happens to build software.</p>
|
|
|
|
|
<p class="fieldnote"><b>Field note.</b> A rebuild that takes twenty minutes and one that
|
|
|
|
|
takes twenty seconds are usually the same Containerfile with the lines swapped.</p>
|
|
|
|
|
<p class="closeline">This stack explains the whole ecosystem above it: sharing explains why
|
|
|
|
|
pulls are fast, immutability explains why a digest can name the exact bytes (Course VII),
|
|
|
|
|
and the throwaway top layer explains why state needs volumes. One idea - frozen diffs -
|
|
|
|
|
all the way down.</p>
|
|
|
|
|
<p class="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>
|
|
|
|
@@ -160,7 +160,7 @@
|
|
|
|
|
<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
|
|
|
|
|
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">
|
|
|
|
@@ -178,8 +178,7 @@
|
|
|
|
|
<h3>Desired against actual, on a loop</h3>
|
|
|
|
|
<p>The habit underneath everything: the <b>reconciliation loop</b> - compare desired state
|
|
|
|
|
against actual state, fix the difference, repeat. That <span class="mono">oc apply</span>
|
|
|
|
|
didn't launch anything; it filed paperwork. The machine took it from there, and it never
|
|
|
|
|
stops taking it from there: kill a pod and it returns, not because something noticed the
|
|
|
|
|
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>
|
|
|
|
@@ -187,28 +186,30 @@
|
|
|
|
|
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="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>"Control plane" is four processes and a database, and naming them makes every later error
|
|
|
|
|
message readable. The <b>api-server</b> is the only door: everything authenticates, is
|
|
|
|
|
authorised and is admitted there, and it is the only component allowed to touch <b>etcd</b> -
|
|
|
|
|
the key-value store holding the entire cluster state. Lose etcd and you have lost the
|
|
|
|
|
cluster, which is why backing it up is the homework nobody should skip. The <b>scheduler</b>
|
|
|
|
|
decides which node a new pod belongs on - packing by the resources a pod
|
|
|
|
|
<i>requests</i>, not by what it currently uses - and writes that decision down; it never
|
|
|
|
|
starts anything. The <b>controller-manager</b> runs the reconciliation loops. Nothing talks
|
|
|
|
|
sideways - every component watches the api-server.</p>
|
|
|
|
|
<p>The core control plane is the <b>API server</b>, <b>scheduler</b> and
|
|
|
|
|
<b>controller-manager</b>, backed by <b>etcd</b>; 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 <b>etcd</b>, 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 <b>Deployment</b> creates a
|
|
|
|
|
<b>ReplicaSet</b>, and the ReplicaSet creates <b>pods</b>. That is why the deploy in the
|
|
|
|
|
aperitif printed <span class="mono">deployment.apps/api created</span> and you then went
|
|
|
|
|
looking for a pod - and why, when a rollout is stuck with no pod at all, the answer is
|
|
|
|
|
upstream in that chain rather than on any node.</p>
|
|
|
|
|
<p class="sayit">etcd is the truth. Everything else is a cache of it.</p>
|
|
|
|
|
<p class="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 bigger scale - git as the desired state, whole fleets as reconciled objects.
|
|
|
|
|
Learn the promise once; it repeats all the way up.</p>
|
|
|
|
|
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>
|
|
|
|
@@ -236,21 +237,21 @@
|
|
|
|
|
</div>
|
|
|
|
|
<div class="prose">
|
|
|
|
|
<h3>Where a pod becomes processes</h3>
|
|
|
|
|
<p>Every node runs a <b>kubelet</b> - the agent that owns what should be running there. It does
|
|
|
|
|
not create containers itself. It speaks <b>CRI</b> - the Container Runtime Interface - to <b>containerd</b> or CRI-O, and that
|
|
|
|
|
runtime pulls the image (through the mirror of Course VII) and actually creates and starts
|
|
|
|
|
the container, handing the low-level work to runc or crun. A <b>CNI plugin</b> (Container Network Interface) hands the pod
|
|
|
|
|
a real IP, and <b>kube-proxy</b> - or an eBPF datapath (code running
|
|
|
|
|
inside the kernel itself) replacing it - makes Service
|
|
|
|
|
addresses route to real pods. One correction worth carrying: the runtime calls the CNI plugin,
|
|
|
|
|
not the kubelet.</p>
|
|
|
|
|
<p class="sayit">The kubelet decides what should run. The runtime is what starts it.</p>
|
|
|
|
|
<p>The <b>scheduler</b> decides which node a pod belongs on; the <b>kubelet</b> on that node
|
|
|
|
|
makes the assigned PodSpec real; the <b>runtime</b> creates and starts the containers. The
|
|
|
|
|
kubelet does not place pods, and does not itself create containers - it speaks <b>CRI</b> (the
|
|
|
|
|
Container Runtime Interface) to <b>containerd</b> or CRI-O, and that runtime pulls the image
|
|
|
|
|
(through the mirror of Course VII) and starts it, handing the low-level work to runc or crun.
|
|
|
|
|
A <b>CNI plugin</b> (Container Network Interface) gives the pod a real IP - called by the
|
|
|
|
|
runtime, not the kubelet - and <b>kube-proxy</b>, 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 never touches your workload. It writes intent; the
|
|
|
|
|
kubelet turns intent into instructions, and the runtime turns instructions into processes. Authority and execution meet exactly here, nowhere
|
|
|
|
|
else.</p>
|
|
|
|
|
<p class="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>
|
|
|
|
@@ -280,9 +281,10 @@
|
|
|
|
|
<h3>The jacket, not the container</h3>
|
|
|
|
|
<p>Everything inside the jacket shares a <b>network namespace</b>: one IP, localhost between
|
|
|
|
|
friends. Volumes are declared once on the pod, but each container mounts the ones it needs -
|
|
|
|
|
sharing storage is opt-in, not automatic. <b>initContainers</b> run first, in order, each to
|
|
|
|
|
completion - nothing else in the pod starts until every one of them has exited successfully. <b>Sidecars</b> ride along with their own containers and their
|
|
|
|
|
own jobs: proxy, logs, reload.</p>
|
|
|
|
|
sharing storage is opt-in, not automatic. regular <b>init containers</b> run in order and
|
|
|
|
|
complete before the app containers start. <b>Native sidecars</b> 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><b>startup</b> owns warm-up, <b>readiness</b> gates traffic, <b>liveness</b> restarts the
|
|
|
|
|
truly hung. Confusing them is how healthy pods get executed - a slow start killed by an
|
|
|
|
@@ -296,7 +298,7 @@
|
|
|
|
|
the number alone: <span class="mono">oc describe pod <name></span> shows both together,
|
|
|
|
|
and <span class="mono">oc logs --previous</span> shows what the dead container said on its
|
|
|
|
|
way out. Worth connecting to what you already know: a memory limit becomes a
|
|
|
|
|
<b>cgroup ceiling</b>, and the kernel's OOM killer enforces it exactly as it would for any
|
|
|
|
|
<b>cgroup ceiling</b>, 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
|
|
|
|
@@ -341,26 +343,30 @@
|
|
|
|
|
everything here finds everything else, from a Service picking pods to a fleet hub picking
|
|
|
|
|
whole clusters (Course VI). Wear the label and you are eligible; readiness decides whether
|
|
|
|
|
you stay.</p>
|
|
|
|
|
<p>And the ClusterIP is worth a Linux translation: <b>no interface owns that address</b>.
|
|
|
|
|
Nothing answers ARP for it. It exists as a rule - <b>kube-proxy</b> programs iptables (or
|
|
|
|
|
IPVS) on every node so packets aimed at the virtual IP are DNATed to one of the ready pod
|
|
|
|
|
IPs, and an eBPF dataplane does the same job further down without the rule tables. Cluster
|
|
|
|
|
DNS - <b>CoreDNS</b> - resolves <span class="mono">api.myns.svc.cluster.local</span> to that
|
|
|
|
|
VIP. If you have ever written a DNAT rule by hand, you have already built a small Service.</p>
|
|
|
|
|
<p>A <b>ClusterIP</b> is a virtual Service address implemented by the node dataplane rather
|
|
|
|
|
than an application process listening on that IP. In iptables or nftables mode,
|
|
|
|
|
<b>kube-proxy</b> 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 (<b>CoreDNS</b>) resolves <span class="mono">api.myns.svc.cluster.local</span> to the
|
|
|
|
|
Service address.</p>
|
|
|
|
|
|
|
|
|
|
<h3>Readiness decides membership</h3>
|
|
|
|
|
<p>A pod failing its readiness probe <b>silently leaves the pool</b>. No error, no event at
|
|
|
|
|
the client - traffic simply stops arriving. That is the feature: broken instances remove
|
|
|
|
|
themselves. It is also the first place to look when traffic "disappears".</p>
|
|
|
|
|
<p>When a configured readiness probe fails, the pod becomes <b>unready</b> 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 application traffic: never talk to a pod; talk to a Service.</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. Membership is re-earned every few seconds, by every pod, for as long as it serves.</p>
|
|
|
|
|
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>
|
|
|
|
@@ -388,24 +394,27 @@
|
|
|
|
|
<div class="prose">
|
|
|
|
|
<h3>The doorman interviews every pod</h3>
|
|
|
|
|
<p>The <b>SCC</b> - Security Context Constraint - is admission deciding what a pod may BE, checked
|
|
|
|
|
by the api-server when the pod is created and before any node sees it. The default, restricted-v2, runs
|
|
|
|
|
your container as a <b>random non-root UID</b> - your image has to cope. Workloads that
|
|
|
|
|
genuinely need privilege get a dedicated ServiceAccount bound to a minimal custom SCC,
|
|
|
|
|
never the stock one.</p>
|
|
|
|
|
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 <b>non-root UID drawn from the project's allocated range</b>,
|
|
|
|
|
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><b>Routes</b> predate Ingress and still rule here. An <b>Operator</b> is a controller paired with a custom
|
|
|
|
|
resource: you describe what you want in YAML, and its controller builds it and keeps it true
|
|
|
|
|
- the reconciliation loop of C-II, sold as a product. The split matters: the platform's own operators are driven by the Cluster Version
|
|
|
|
|
<p><b>Routes</b> predate Ingress and still rule here. An <b>Operator</b> is a controller paired with a
|
|
|
|
|
custom resource: you describe what you want in YAML, and its controller builds it and keeps it
|
|
|
|
|
true - 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 <b>OLM</b> installs and upgrades the add-on Operators you choose from
|
|
|
|
|
OperatorHub. The OS underneath is immutable - changed by <b>MachineConfig</b>, never by SSH.
|
|
|
|
|
And <b>SNO</b> - single-node OpenShift - puts the whole cluster on one box at the edge. At fleet scale the labels
|
|
|
|
|
OperatorHub. <b>MachineConfig</b> is the supported declarative path for node OS configuration;
|
|
|
|
|
ad-hoc SSH changes create drift and are not the intended operating model. And <b>SNO</b> -
|
|
|
|
|
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 a house style with teeth - and admission is where it bites first.</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>
|
|
|
|
@@ -420,7 +429,7 @@
|
|
|
|
|
<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>
|
|
|
|
|
from Git.</p>
|
|
|
|
|
</header>
|
|
|
|
|
<div class="pin">
|
|
|
|
|
<div class="viewport">
|
|
|
|
@@ -436,51 +445,52 @@
|
|
|
|
|
<div class="prose">
|
|
|
|
|
<h3>The loop you already know, one level up</h3>
|
|
|
|
|
<p>C-II taught the reconciliation loop: desired versus actual, fix the difference, repeat.
|
|
|
|
|
ArgoCD is the same habit applied to <b>delivery</b>. An Application names a repo, a path and a
|
|
|
|
|
revision - watch this branch of this repository - and the controller renders what it finds
|
|
|
|
|
there, compares it against the live cluster, and syncs the difference. The deploy button is a
|
|
|
|
|
git commit; the change history is git log; code review <i>is</i> change control. Git is the
|
|
|
|
|
record of <i>intent</i> - the cluster's own audit log and the reconciler's sync history still
|
|
|
|
|
record what actually happened, including everything git never sees.</p>
|
|
|
|
|
<p class="sayit">oc is for archaeology. git is for change.</p>
|
|
|
|
|
<b>Argo CD</b> applies the same habit to <b>delivery</b>. An Application names a repo, a path
|
|
|
|
|
and a revision - watch this branch of this repository - and the controller renders what it
|
|
|
|
|
finds there, compares it against the live cluster, and syncs the difference. The deploy button
|
|
|
|
|
is a Git commit; the change history is <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 <b>pulls</b>. Push-based delivery exists and is still GitOps to many - this
|
|
|
|
|
is the stronger variant, and worth choosing deliberately: no CI system, no laptop, no build
|
|
|
|
|
pipeline holds a credential that can touch the cluster, because the agent inside holds a
|
|
|
|
|
read-only deploy key and the trust arrow points out. Compromise the build system and you can
|
|
|
|
|
propose a change, which is visible; you cannot reach into production <i>through git</i>. It
|
|
|
|
|
can still push images - which is the other half of why a manifest should name the digest, not
|
|
|
|
|
the tag. Hand-edit a live object
|
|
|
|
|
and the controller flags it OutOfSync - <b>with selfHeal enabled</b> it puts the object back,
|
|
|
|
|
and <b>with prune enabled</b> what leaves git leaves the cluster. Both are opt-in: without
|
|
|
|
|
them the reconciler reports the drift and waits for a human. Rollback is <span class="mono">git revert</span>,
|
|
|
|
|
which is why commit hygiene is an operational skill.</p>
|
|
|
|
|
<p class="fieldnote"><b>Field note.</b> A hand-patch on a live object survives exactly until
|
|
|
|
|
the next sync. If you must touch production directly to stop the bleeding, open the pull
|
|
|
|
|
request in the same hour - otherwise the reconciler quietly undoes your fix, and the outage
|
|
|
|
|
returns with nobody able to say why.</p>
|
|
|
|
|
<p>Here the cluster <b>pulls</b>. 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. <b>With self-heal enabled</b>,
|
|
|
|
|
Argo CD can restore the desired state; <b>with prune enabled</b>, 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
|
|
|
|
|
<b>Placement</b> that selects cluster <b>labels</b>; the hub matches placements against the
|
|
|
|
|
labels a cluster wears, and the chosen cluster is given its assignment - push-style from the
|
|
|
|
|
hub by default, or in a pull model where each cluster runs its own reconciler, which is the
|
|
|
|
|
variant this arc teaches. Either way, labelling the cluster <i>is</i> the deploy action: attach the label and the app follows, remove it and the app
|
|
|
|
|
leaves. The same pull model as above, one level bigger - a cluster's labels are its
|
|
|
|
|
entitlements, reconciled like everything else.</p>
|
|
|
|
|
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>The one thing git never holds</h3>
|
|
|
|
|
<p>Git holds everything except secrets. Commit a plaintext secret and you should treat it as
|
|
|
|
|
<b>compromised from that moment</b>: deleting it later does not guarantee it is gone from
|
|
|
|
|
history, forks, clones, CI caches or backups. So the pattern splits the reference from the value: git carries an ExternalSecret
|
|
|
|
|
naming a <b>logical key</b>; a vault - Azure Key Vault in the worked example - holds the value;
|
|
|
|
|
<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 <b>compromised from that moment</b> - deleting it later does not guarantee
|
|
|
|
|
it is gone from history, forks, clones, CI caches or backups. So 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>
|
|
|
|
|
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 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="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>
|
|
|
|
@@ -497,13 +507,12 @@
|
|
|
|
|
<p class="eyebrow">Course VII · The image supply chain</p>
|
|
|
|
|
<h2 id="c7h" class="thesis">A tag is a promise.<br><em>A digest is a fact.</em></h2>
|
|
|
|
|
<p class="lede"><span class="mono">myapp:latest</span> feels like a name. It is a sticky note - a
|
|
|
|
|
mutable pointer anyone with push rights can peel off one image and press onto another, and
|
|
|
|
|
nothing anywhere records that it moved. The digest - sha256 of the image <i>manifest</i>, the
|
|
|
|
|
small JSON index listing an image's layers, and no relation to the YAML manifests you apply
|
|
|
|
|
to a cluster - is
|
|
|
|
|
its actual name: same bytes, same digest, forever. (Careful: the sha256 a build prints is the
|
|
|
|
|
local image ID, a different hash from the manifest digest the registry mints on push - the
|
|
|
|
|
pushed one is what you pin.)</p>
|
|
|
|
|
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 - the
|
|
|
|
|
sha256 of the image <i>manifest</i>, the small JSON index listing an image's layers, and no
|
|
|
|
|
relation to the YAML manifests you apply to a cluster - 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">
|
|
|
|
@@ -521,7 +530,7 @@
|
|
|
|
|
<p>Three ways to name an image, in rising order of honesty: <span class="mono">:latest</span>
|
|
|
|
|
(a moving target), <span class="mono">:1.4.2</span> (a label someone maintains, until they
|
|
|
|
|
re-push it), and <span class="mono">name:1.4.2@sha256:...</span> - a fact. The tag stays for
|
|
|
|
|
human eyes; the digest does the pulling. Pin by digest and "what is running?" has exactly one
|
|
|
|
|
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.
|
|
|
|
@@ -529,33 +538,34 @@
|
|
|
|
|
<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,
|
|
|
|
|
<b>mirror</b>: 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 <b>digest pulls
|
|
|
|
|
only</b>, so a by-tag pull silently skips them - unless you also add an
|
|
|
|
|
<b>ImageTagMirrorSet</b>, which is the rule type built for tag pulls. Pinning by digest is
|
|
|
|
|
still the habit that makes the digest rules catch everything.</p>
|
|
|
|
|
<p>At real fleet scale the mirror itself tiers: a <b>central mirror in the cloud</b> fronts
|
|
|
|
|
upstream once, and every site's mirror pulls from the centre rather than from upstream
|
|
|
|
|
directly. A new image ripples outward in layers - upstream to the centre, centre to each
|
|
|
|
|
site as it asks, site to its nodes over the LAN - instead of every site hammering upstream
|
|
|
|
|
at the same moment. Upstream sees one consumer; each site sees one hop; the nodes never
|
|
|
|
|
leave the building.</p>
|
|
|
|
|
<p class="sayit">Mirrors tier: the load fans out in layers, never all at once.</p>
|
|
|
|
|
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 <b>once</b>, then promote the same digest through environments by copying, registry
|
|
|
|
|
to registry - dev proves the exact bytes prod will run. On a multi-arch image that copy
|
|
|
|
|
(<span class="mono">skopeo copy</span>) needs <span class="mono">--all</span> (and <span class="mono">--preserve-digests</span> to fail
|
|
|
|
|
loudly rather than quietly), or you copy one architecture and the digest you promoted is not
|
|
|
|
|
the digest that lands. Human tags ride along; the digest is
|
|
|
|
|
the through-line.</p>
|
|
|
|
|
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. 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 exactly until they move. Address content
|
|
|
|
|
by what it is, and the supply chain stops resting on trust: it rests on a hash anyone can
|
|
|
|
|
check.</p>
|
|
|
|
|
<p class="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> ·
|
|
|
|
@@ -589,27 +599,29 @@
|
|
|
|
|
<div class="prose">
|
|
|
|
|
<h3>You are writing a program, not YAML</h3>
|
|
|
|
|
<p>Helm templates are Go text/template: <span class="mono">{{ .Values.device.address }}</span>
|
|
|
|
|
is a pipeline walking a values object, <span class="mono">_helpers.tpl</span> holds the
|
|
|
|
|
<b>named templates</b> - the partials every manifest <span class="mono">include</span>s, not
|
|
|
|
|
functions you can call bare. You are not writing YAML - you are writing a program whose
|
|
|
|
|
output is YAML. So render locally, read the output, and lint what came out, not what went in.</p>
|
|
|
|
|
is a pipeline walking a values object. <span class="mono">_helpers.tpl</span> commonly holds
|
|
|
|
|
<b>named templates</b> - 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> ArgoCD renders charts with
|
|
|
|
|
<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 - there is no live cluster at render time. Hooks are not dead, though: Argo
|
|
|
|
|
maps Helm hooks onto its sync phases (pre-install and pre-upgrade become PreSync, post-install
|
|
|
|
|
and post-upgrade become PostSync), while a few - rollback and test hooks - have no equivalent
|
|
|
|
|
at all. Render the way your deployer renders, and check where your hooks actually land.</p>
|
|
|
|
|
<h3>Contexts: the cluster's whole voice is one small file</h3>
|
|
|
|
|
<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 many rendered artefacts - an address appearing in the app config,
|
|
|
|
|
the network attachment and two policies renders from one field, so the copies cannot diverge - the lived version is <a href="https://www.bztmon.com/blog/one-value-many-enforcers/">on the blog</a>.</p>
|
|
|
|
|
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. The grown-up
|
|
|
|
|
<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>
|
|
|
|
@@ -633,7 +645,7 @@
|
|
|
|
|
<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>
|
|
|
|
|
write down what leans on them, and what happens when they are down.</p>
|
|
|
|
|
</header>
|
|
|
|
|
<figure class="still">
|
|
|
|
|
<img src="__HERO_APPENDIX__"
|
|
|
|
@@ -648,10 +660,10 @@
|
|
|
|
|
through a read-only deploy key.</p>
|
|
|
|
|
<p class="leans"><b>Leans on it:</b> sync, rollback, change review, the "who merged this"
|
|
|
|
|
answer.</p>
|
|
|
|
|
<p class="down"><b>When it is down:</b> Kubernetes keeps running the last applied state
|
|
|
|
|
indefinitely. The reconciler keeps self-healing only while its rendered manifests are
|
|
|
|
|
still cached - hours, not forever, and gone after a restart. What stops is <i>change</i>. GitOps degrades to
|
|
|
|
|
read-only, which is the graceful half of the design.</p>
|
|
|
|
|
<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>
|
|
|
|
@@ -659,9 +671,9 @@
|
|
|
|
|
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>
|
|
|
|
|
<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>
|
|
|
|
@@ -672,15 +684,16 @@
|
|
|
|
|
scale-up, recovery.</p>
|
|
|
|
|
<p class="down"><b>When it is down:</b> the sharpest edge. Upstream down + mirror up = nobody
|
|
|
|
|
notices, <i>provided the image is already cached</i> - a cold entry still needs upstream.
|
|
|
|
|
Mirror down on a mirror-only pull path = nothing new schedules unless the node already
|
|
|
|
|
holds the image, and <span class="mono">imagePullPolicy: Always</span> turns a mirror
|
|
|
|
|
outage into a hard stop.</p>
|
|
|
|
|
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, 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>
|
|
|
|
|
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> ·
|
|
|
|
@@ -700,13 +713,13 @@
|
|
|
|
|
</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.
|
|
|
|
|
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 ArgoCD applies. Artefact lane: CI builds once, pushes to
|
|
|
|
|
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 only ever holds the reference. All four lanes converge on the running
|
|
|
|
|
them in - Git holds only the reference. All four lanes converge on the running
|
|
|
|
|
workload.">
|
|
|
|
|
</figure>
|
|
|
|
|
</section>
|
|
|
|
@@ -730,14 +743,14 @@
|
|
|
|
|
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>
|
|
|
|
|
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 every push - in this flow it makes
|
|
|
|
|
artefacts and proposes changes, and never holds a credential that can touch a cluster.</span>
|
|
|
|
|
<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 never has to become a commit.</span>
|
|
|
|
|
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>
|
|
|
|
@@ -761,7 +774,12 @@
|
|
|
|
|
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}
|
|
|
|
|
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{max-width:64ch;margin:0 auto;padding:16vh 24px 9vh;text-align:center}
|
|
|
|
@@ -813,7 +831,7 @@ b{color:var(--ink)} .mono{font-family:var(--mono);font-size:.9em;color:var(--cya
|
|
|
|
|
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}
|
|
|
|
|
.slab{position:absolute;inset:0;background-size:100% 100%;background-repeat:no-repeat}
|
|
|
|
|
|
|
|
|
|
.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);
|
|
|
|
@@ -873,6 +891,11 @@ body.fx .crt .line.on{opacity:1;transform:none}
|
|
|
|
|
.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>
|
|
|
|
@@ -887,19 +910,28 @@ body:not(.fx) .slab{display:none}
|
|
|
|
|
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 => {
|
|
|
|
|
if (!p.title && !p.blurb) return;
|
|
|
|
|
const li = document.createElement("li");
|
|
|
|
|
li.innerHTML = '<p class="t">' + p.title + '</p><p class="b">' + p.blurb + '</p>';
|
|
|
|
|
legend.appendChild(li);
|
|
|
|
|
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 });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!window.gsap || !window.ScrollTrigger) return; // no-JS/no-GSAP: flat heroes + full legends
|
|
|
|
|
// Reduced motion: leave the flat exploded hero in place (it IS the end state), no slabs, no scrub.
|
|
|
|
|
if (RM || !window.gsap || !window.ScrollTrigger) return;
|
|
|
|
|
document.body.classList.add("fx");
|
|
|
|
|
gsap.registerPlugin(ScrollTrigger);
|
|
|
|
|
|
|
|
|
|
units.forEach(u => {
|
|
|
|
|
const wc = (u, on) => u.slabs && u.slabs.forEach(el => el.style.willChange = on ? "transform" : "auto");
|
|
|
|
|
|
|
|
|
|
// Build a scene's slabs lazily, just before it approaches the viewport. The flat image stays the
|
|
|
|
|
// fallback until the slabs are appended, so a scene that never initialises still reads correctly.
|
|
|
|
|
const initScene = u => {
|
|
|
|
|
if (u.slabs) return;
|
|
|
|
|
u.slabs = u.man.parts.map(p => {
|
|
|
|
|
const d = document.createElement("div");
|
|
|
|
|
d.className = "slab";
|
|
|
|
@@ -909,61 +941,59 @@ body:not(.fx) .slab{display:none}
|
|
|
|
|
u.scene.appendChild(d);
|
|
|
|
|
return d;
|
|
|
|
|
});
|
|
|
|
|
u.flat.style.visibility = "hidden";
|
|
|
|
|
const lis = u.legend ? [...u.legend.children] : [];
|
|
|
|
|
const n = lis.length;
|
|
|
|
|
u.apply = t => {
|
|
|
|
|
// stagger > 0 = cascade: parts complete their travel one after another in list order
|
|
|
|
|
const sg = u.man.stagger || 0;
|
|
|
|
|
const sg = u.man.stagger || 0, k = u.man.parts.length;
|
|
|
|
|
u.man.parts.forEach((p, i) => {
|
|
|
|
|
const k = u.man.parts.length;
|
|
|
|
|
const pt = sg ? Math.min(1, Math.max(0, t * (1 + sg * (k - 1)) - sg * i)) : t;
|
|
|
|
|
// reverse scenes sit assembled at rest and FLY APART as the scrub advances
|
|
|
|
|
const e = u.man.reverse ? (1 - pt) : pt;
|
|
|
|
|
u.slabs[i].style.transform =
|
|
|
|
|
"translate3d(" + (p.dx * (1 - e)).toFixed(3) + "%," + (p.dy * (1 - e)).toFixed(3) + "%,0)";
|
|
|
|
|
});
|
|
|
|
|
u.scene.style.transform = "scale(" + (0.95 + 0.05 * t).toFixed(4) + ")";
|
|
|
|
|
// the side list lights in reading order, top to bottom, as the scrub advances
|
|
|
|
|
lis.forEach((li, i) => li.classList.toggle("lit", t >= 0.9 * ((i + 0.5) / n)));
|
|
|
|
|
if (n) lis.forEach((li, i) => li.classList.toggle("lit", t >= 0.9 * ((i + 0.5) / n)));
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
u.flat.style.visibility = "hidden";
|
|
|
|
|
|
|
|
|
|
const desktop = document.documentElement.scrollHeight - innerHeight > 200 &&
|
|
|
|
|
matchMedia("(min-width: 901px)").matches;
|
|
|
|
|
if (desktop) {
|
|
|
|
|
u.mode = "pin+scrub";
|
|
|
|
|
ScrollTrigger.create({
|
|
|
|
|
trigger: u.sec.querySelector(".pin"),
|
|
|
|
|
start: "top top", end: "+=1800", pin: true, scrub: 0.6,
|
|
|
|
|
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); wc(u, true);
|
|
|
|
|
gsap.to({ v: 0 }, { v: 1, duration: 1.6, ease: "power3.out",
|
|
|
|
|
onUpdate: function () { u.apply(this.targets()[0].v); },
|
|
|
|
|
onComplete: () => wc(u, false) });
|
|
|
|
|
}
|
|
|
|
|
ScrollTrigger.refresh();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const boot = () => {
|
|
|
|
|
const scrollable = document.documentElement.scrollHeight - innerHeight > 200;
|
|
|
|
|
units.forEach(u => {
|
|
|
|
|
if (RM) { u.mode = "reduced-motion"; u.apply(1); return; }
|
|
|
|
|
if (scrollable && matchMedia("(min-width: 901px)").matches) {
|
|
|
|
|
u.mode = "pin+scrub";
|
|
|
|
|
ScrollTrigger.create({
|
|
|
|
|
trigger: u.sec.querySelector(".pin"),
|
|
|
|
|
start: "top top",
|
|
|
|
|
end: "+=1800",
|
|
|
|
|
pin: true,
|
|
|
|
|
scrub: 0.6,
|
|
|
|
|
onUpdate: self => u.apply(self.progress),
|
|
|
|
|
onRefreshInit: () => u.apply(0)
|
|
|
|
|
});
|
|
|
|
|
u.apply(0);
|
|
|
|
|
} else {
|
|
|
|
|
u.mode = "assemble";
|
|
|
|
|
u.sec.querySelector(".pin").style.minHeight = "0";
|
|
|
|
|
u.apply(0);
|
|
|
|
|
const io = new IntersectionObserver(es => {
|
|
|
|
|
if (!es.some(e => e.isIntersecting)) return;
|
|
|
|
|
io.disconnect();
|
|
|
|
|
gsap.to({ v: 0 }, { v: 1, duration: 1.6, ease: "power3.out",
|
|
|
|
|
onUpdate: function () { u.apply(this.targets()[0].v); } });
|
|
|
|
|
}, { threshold: 0.2 });
|
|
|
|
|
io.observe(u.scene);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
if (!RM) ScrollTrigger.refresh();
|
|
|
|
|
// initialise each scene shortly before it reaches the viewport; keep the flat image until then
|
|
|
|
|
const near = new IntersectionObserver((es, obs) => {
|
|
|
|
|
es.forEach(e => {
|
|
|
|
|
if (!e.isIntersecting) return;
|
|
|
|
|
const u = units.find(x => x.sec === e.target);
|
|
|
|
|
if (u) initScene(u);
|
|
|
|
|
obs.unobserve(e.target);
|
|
|
|
|
});
|
|
|
|
|
}, { rootMargin: "400px 0px 400px 0px" });
|
|
|
|
|
units.forEach(u => near.observe(u.sec));
|
|
|
|
|
};
|
|
|
|
|
// the aperitif terminal types its transcript once, when it first enters view
|
|
|
|
|
document.querySelectorAll(".crt").forEach(crt => {
|
|
|
|
|
const lines = [...crt.querySelectorAll(".line")];
|
|
|
|
|
if (RM) { lines.forEach(l => l.classList.add("on")); return; }
|
|
|
|
|
const io = new IntersectionObserver(es => {
|
|
|
|
|
if (!es.some(x => x.isIntersecting)) return;
|
|
|
|
|
io.disconnect();
|
|
|
|
|