How Flux is layered¶
Nothing reaches this cluster except by being merged to master. There is no
kubectl apply step, and no way to install something by hand that survives the
next reconcile. What that costs is a structure that has to encode ordering,
blast radius and failure isolation in configuration, because there is no operator
present to sequence anything.
That structure is three layers deep, and the shape is not arbitrary.
The three layers¶
k8s/bootstrap/ applied once by hand: the GitRepository and the umbrellas
├── crds the kinds every layer above it needs
├── namespaces every namespace, and each app's Kustomization beside it
└── platform-<domain> the cluster's machinery, one Kustomization per domain,
each in the namespace it reconciles
The workloads are not a fourth umbrella. Each app's Kustomization is created by
namespaces, in the app's own namespace — see below for what that costs and
what it buys.
The current membership of each layer, with every interval, prune and wait setting, is in Flux Kustomizations.
k8s/bootstrap/ is the seed: a GitRepository pointing at this repo, and
the umbrella Kustomizations. It is the only thing ever applied manually, and
it exists solely so that everything after it can be applied by Flux — which
also means a change to a file in it does nothing until someone runs
kubectl apply -k k8s/bootstrap.
One of its umbrellas, namespaces, creates the namespace every app is deployed
into, from k8s/namespaces/. They sit here rather than with the app for two
reasons: a Namespace is cluster-scoped, so an app reconciling under a
namespace-scoped ServiceAccount could not apply its own; and a namespace's
group.rbac.thaum.xyz/<group> labels are an access grant, which must not live in
a directory its own tenant can change.
The platform-<domain> namespaces sit there too, for a third reason: each is
shared by several components, so keeping it in any one of their directories makes
that component's prune the whole domain's blast radius.
An app's directory there holds two files, or three: the Namespace, the
sync.yaml that is its Kustomization, and a ClusterRoleBinding where the app
applies something cluster-scoped. The other two prerequisites are not files. The
flux.rbac.thaum.xyz/role label on the Namespace makes Kyverno generate the
flux-reconciler identity the Kustomization applies as and the GitRepository
it reads, both from generate-flux-reconciler: a Kustomization can create
neither, and both are the same for every app, so a file per namespace would
only be a place for them to drift. Three properties of the directory are kept on purpose. There is no
kustomization.yaml, so Flux applies every manifest it finds and a namespace
cannot be silently skipped for want of a resources: entry. Every namespaced
object states its own namespace, because nothing transforms it. And every
Namespace carries kustomize.toolkit.fluxcd.io/prune: disabled, because pruning
is inventory-based and a namespace dropped from the inventory would be deleted
with everything in it. What each label on a Namespace means is in
annotations and labels.
platform-cluster is the one whose directory there is also built by hand. It
holds the umbrellas, so it cannot be created by namespaces — that is one of
them — and k8s/bootstrap/ applies the same directory before any Kustomization
exists. Every Namespace is in k8s/namespaces/, including that one; there is no
longer a flux-system, and the controllers run in platform-cluster like any
other component.
Each platform-<domain> is a directory rather than a file, because the Namespace
is not the only thing that has to be there first. A Kustomization reconciling
in that namespace reads a GitRepository in it — --no-cross-namespace-refs
allows nothing else — and applies as the flux-reconciler ServiceAccount there,
which --default-service-account resolves in the Kustomization's own namespace.
Neither can be created by the Kustomization that needs them, so both are
prerequisites of the domain rather than members of it.
That ServiceAccount and that GitRepository are files, where an app's are
generated by generate-flux-reconciler from a label on its Namespace. The difference is the bootstrap path: the Kustomization that
installs Kyverno impersonates platform-security's flux-reconciler and reads
its GitRepository, and the one that installs the CNI reads
platform-network's. An object produced by the policy engine cannot be one Flux
needs in order to install that engine — on a rebuild nothing would ever
reconcile. So the platform namespaces carry no flux.rbac.thaum.xyz/role label,
and the files are not something to tidy away.
platform is what a workload assumes is already there — CNI, storage drivers,
ingress controllers, cert-manager, admission control, the observability
collectors. Breaking something here breaks things that do not mention it.
The apps are the workloads. An app can fail entirely without taking anything else with it, which is the property the split exists to preserve — and now that each reconciles under its own namespace's identity, one cannot reach another's objects even deliberately.
Why the Kustomizations live apart from the manifests¶
An app appears in two places, and the split is by who owns each.
k8s/namespaces/<app>/ is platform-owned: the Namespace, the sync.yaml that
is that app's Kustomization, and a ClusterRoleBinding where the app needs one.
k8s/apps/<app>/ is what sync.yaml reconciles, and belongs to whoever owns the
app.
That line is the review boundary rather than a convention, because it is also
the privilege boundary. A Kustomization reconciles as the flux-reconciler in
its own namespace, so putting the object in the app's namespace is what
confines it there — and putting the object itself somewhere the app's owner
cannot edit is what stops them widening it.
It also separates how a component is reconciled — its interval, its dependencies, whether it prunes — from what the component is. Those change for different reasons.
There is no apps umbrella. Each app's Kustomization is created by
namespaces, alongside the namespace it runs in. That costs an ordering edge:
apps used to dependsOn platform, and --no-cross-namespace-refs=true
forbids a Kustomization in an app namespace naming one in platform-cluster. On
a cold start an app therefore fails until the platform component it needs
exists, and retries. That is the price of the confinement the same flag buys.
The platform is arranged the other way round, and the difference is deliberate.
A platform component has no Kustomization of its own; it is a directory named in
its domain's k8s/platform/<domain>/kustomization.yaml, and one Kustomization
reconciles the whole domain. The unit an app needs is isolation — one app failing
must not reach another. The unit the platform needs is a namespace: that is
what the reconciler identity, the group RoleBindings and a prune's blast radius
are all keyed to, and one Kustomization per namespace is what makes those the
same question rather than three that can drift apart.
Each domain Kustomization is in the namespace it reconciles, reading the
GitRepository there and applying as that namespace's flux-reconciler. The
umbrellas reconcile from platform-cluster, which makes that namespace the seed
the rest of the cluster is built from — and where the controllers themselves run,
installed by the flux component like any other release.
Ordering, where it genuinely matters¶
Almost nothing declares dependsOn — the
reference page lists what does. Everything
else is order-independent by construction; the exceptions are all cases where an
object cannot be accepted by the API server until something else exists:
| Object | Waits for | Why |
|---|---|---|
platform-cluster |
crds |
its PrometheusRules need monitoring.coreos.com established |
crds |
namespaces |
its own releases render into platform-<domain> |
homer-services |
homer |
it adds entries to a dashboard that must exist |
HelmRelease piraeus-operator |
HelmRelease topolvm |
its storage pool is a topolvm thin pool |
HelmRelease linstor-cluster |
HelmRelease piraeus-operator |
the operator reconciles the cluster |
HelmRelease cnpg-versity-gw |
HelmRelease csi-nfs |
its claim is unifi-nas, and a policy rejects the PVC until the class exists |
Three of those are between HelmReleases rather than Kustomizations because the
domains cannot depend on each other: --no-cross-namespace-refs lets a Flux
object name a dependsOn target only in its own namespace, and each domain is in
a different one.
The same rule is why only platform-cluster waits for crds. It is the one
domain sharing a namespace with that layer, so it is the one that can — the
edge is an accident of where the object lives rather than a statement that its
kinds matter more. Read it as an asymmetry to leave alone, not a pattern to
spread; it also buys less than it looks, because the NodeFeatureRules in that
same domain need CRDs from a chart the domain installs itself.
For the other four, ordering against the kinds they need is not declared
anywhere, because it cannot be. On a cold start an object whose kind has not been
established fails its dry-run, and so does the pass it is part of: Flux applies a
Kustomization in stages and abandons a stage on its first error rather than
collecting them per object, so the whole domain waits for the retry, which runs
on retryInterval and defaults to interval.
The kyverno policies were the sharpest case of that, and are the reason
kyverno's CRDs are not installed by the kyverno chart. csi-nfs,
piraeus-datastore and cnpg-system ship policies.kyverno.io objects, and so
does platform-security. k8s/crds/kyverno establishes those kinds through
kyverno-policy-crds, a chart in thaum-xyz/helm-charts whose only content is
upstream's separate kyverno-api chart as a dependency -- the same chart the
kyverno chart pulls in, so the CRDs are the ones the controllers expect.
The wrapper is there because kyverno-api cannot be a release by itself. Helm
stores a release's own templates and its rendered manifest in one Secret; for
that chart both are the same eleven CRDs, and gzipped together they exceed what
a Secret may hold. A dependency's templates are not stored, so behind the
wrapper the CRDs are kept once, as they are inside the kyverno release.
Four of the five domains still cannot declare that they wait for it, so this buys convergence rather than ordering: what a cold start retries against is a CRD-only release with no dependencies of its own, instead of the whole kyverno stack with its controllers and webhooks.
The kyverno chart gates that subchart and its own on a single crds.install,
with no way to disable half, so the kyverno release drops those eleven CRDs from
its manifest with a $patch: delete post-renderer instead. Everything outside
policies.kyverno.io it still installs and upgrades itself: those kinds are
used by no other domain, so nothing needs them earlier, and leaving them with
the chart keeps them in lockstep with the controllers that serve them without a
vendored copy to go stale.
crds is declared in k8s/bootstrap/ and applies k8s/crds/, rather than being
a component of one domain, because more than one domain uses what it ships and
no domain can be ordered after another. Its manifests sit outside
k8s/platform/ for a second reason: everything under k8s/platform/<domain>/
belongs to that domain's Kustomization, and a directory there owned by another
layer is a rule with an exception.
crds is the only Kustomization with wait: true: a dependency that is merely
applied is not yet usable, and a CRD has to be established before an object
of that kind will be accepted.
Pruning, and the exceptions¶
prune: true is the default here — nearly every Kustomization has it, which is what
makes deleting a directory delete the objects, and what makes the tutorial's
cleanup step work. The ones that opt out are listed on the
reference page, and they divide into two kinds.
The layers (crds, namespaces) do not prune because a transient failure to
render one of them would otherwise be read as "these components are gone" and
cascade into deleting everything in the layer. For namespaces the stake is higher still: deleting a Namespace takes
everything inside it, so removing one is deliberately two acts — drop the file,
then delete the object.
Everything else prunes, including all five platform domains — and including
Flux's own manifests, which platform-cluster reconciles like any other
component. What makes that survivable is not a prune: false but the two
mechanisms below, which is the same trade one level down: the guard sits on the
object that carries the risk rather than on a switch covering everything near
it. What used to be a
component-wide prune: false on the handful whose loss is unrecoverable —
cluster networking, the drivers behind every volume, the ingress path to
everything — is now kustomize.toolkit.fluxcd.io/prune: disabled on the
individual objects that carry the risk. Those are the HelmReleases, because
pruning one uninstalls the release behind it, and each says what its own loss
would cost.
That is narrower in both directions. A component-wide switch also protected the HelmRepository and values ConfigMap sitting beside the release — the orphans that had to be cleared out by hand after the traefik and topolvm namespace moves — and it protected them anonymously, leaving the next reader to infer from what the component is why it was exempt.
Those values ConfigMaps keep stable names, which is what lets a valuesFrom
entry reference one directly. What makes a change to one reach its release is a
label rather than a new name. See
why Helm values live in a file.
What reconciliation actually costs¶
Every namespace has its own GitRepository, each polling the same repository
every 60 seconds, so a merge lands within a minute. That is the cost of
--no-cross-namespace-refs: a Kustomization may read only a source beside it.
There is no webhook either, for the same reason — a Receiver may only name
resources in its own namespace, so one of them could refresh one namespace and
no other.
The trap is that reconciling in the wrong order reports success while doing
nothing. flux reconcile kustomization <name> acts on whatever revision the
source currently holds — which, minutes after a merge, may still be the one before
it. The source has to be refreshed first, and it is the source in that
Kustomization's own namespace; the CLI defaults to a flux-system that no
longer exists, so every command takes -n:
flux -n <app> reconcile source git ankhmorpork # an app, in its own namespace
flux -n <app> reconcile kustomization <app>
flux -n platform-<domain> reconcile source git ankhmorpork # a platform domain
flux -n platform-<domain> reconcile kustomization platform-<domain>
flux -n platform-cluster reconcile source git ankhmorpork # a layer: crds or namespaces
flux -n platform-cluster reconcile kustomization crds
--with-source does both in one command, and is the shorter way to say it:
There is no third step for a component whose values come from a
configMapGenerator. There used to be: the ConfigMap's contents changed while
its name did not, so the HelmRelease spec never moved and helm-controller had no
event. Those ConfigMaps now carry reconcile.fluxcd.io/watch: Enabled and
helm-controller reconciles on the write; see
why Helm values live in a file.
What still reconciles green without your change in it¶
Two cases survive, and in neither will the status show anything wrong.
An app's Kustomization is applied by namespaces, not by itself. A change to
sync.yaml — its interval, its path, its dependsOn — is picked up when that
layer next runs, so reconciling the app reports success against the spec it
already had. Reconcile namespaces first.
A change to k8s/bootstrap/ is not reconciled at all. Nothing watches that
directory; it takes effect when someone runs kubectl apply -k k8s/bootstrap,
and until then every umbrella keeps reconciling happily on the spec it was
created with.
The same shape catches the objects Kyverno generates — a namespace's
flux-reconciler, its RoleBinding, its GitRepository. They are in no
Kustomization's inventory, so an edit to one is reverted rather than applied,
and generate-flux-reconciler recreates it from the Namespace's label. Edit the
label, or the policy; see annotations and labels.