CoursesKustomizeComponents & reuse

Components & reuse

Optional, composable feature sets.

Intermediate12 min · lesson 8 of 12

A stand mixer ships with a bowl and a motor. The pasta roller and the meat grinder are sold separately, and each attachment comes with two things: the new part, and the collar that clamps it onto the machine you already own. You fit the ones you want and leave the rest in the drawer. A Kustomize component is that attachment. Kustomize (the built-in, template-free tool that layers edits onto plain Kubernetes YAML, the text format that describes cluster objects) already gives you a base (the shared manifests everyone starts from) and overlays (the per-environment tweaks stacked on top). A component is the optional attachment that a base and an overlay cannot express cleanly on their own: a feature that carries its own objects and the patches that wire them into whatever you already built.

You reach for one the moment several environments all want the same optional feature. A monitoring sidecar (an extra container that rides alongside your app). A secret pulled in from an outside vault. A hardened securityContext (the block of settings that decides what a container is allowed to do). Copy that YAML into every overlay by hand and you get drift: prod gets the fix, staging keeps a stale copy, dev runs whatever version someone last remembered. A component captures the feature once, and each environment opts in by name.

What a component actually is

A component file looks almost identical to a kustomization.yaml, with two changes. The apiVersion is kustomize.config.k8s.io/v1alpha1, and the kind is Component instead of Kustomization. Inside, it carries the same building blocks a normal kustomization can: resources, patches, generators, and transformers. The difference is not what it holds, it is when it runs. A base you list under resources is rendered on its own, and the finished output is merged into yours. A component is poured onto the caller's running pile of objects, the pile Kustomize builds up as it reads your overlay from top to bottom. Because it runs against that pile, its patch can reach a Deployment (a Kubernetes object that runs and keeps a set of identical pods alive, a pod being one or more containers scheduled together) that the base declared, not only the ones the component shipped itself. That single fact is the whole reason components exist.

The v1alpha1 label looks scary for something you would run in prod. It has been stable for years and is exactly what current Kustomize expects here, so do not talk yourself into changing it. The one real requirement is version: the components field needs Kustomize v3.7 or newer, and the copy embedded in kubectl has been new enough since kubectl 1.21. Check yours with kubectl version, which prints the Kustomize build it carries.

terminal
# the shape of a project that uses components
find . -type f | sort
output
./base/deployment.yaml
./base/kustomization.yaml
./components/hardening/harden.yaml
./components/hardening/kustomization.yaml
./components/vault-agent/external-secret.yaml
./components/vault-agent/inject-agent.yaml
./components/vault-agent/kustomization.yaml
./overlays/dev/kustomization.yaml
./overlays/prod/kustomization.yaml
./overlays/staging/kustomization.yaml

Writing a component

Here is a component that turns on Vault-backed secrets. It brings one object of its own, an ExternalSecret (a custom resource that pulls a value out of an external vault and lands it in the cluster as a normal Secret), and it patches the workload so the Vault agent knows to inject into it.

components/vault-agent/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component # NOT Kustomization
resources:
- external-secret.yaml # the feature's own object
patches:
- path: inject-agent.yaml
target:
kind: Deployment # match by kind, not by name
components/vault-agent/inject-agent.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: web # required to make a valid patch document;
spec: # the target above is what actually selects
template:
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: web

Read the patch carefully, because the selection rule trips people up. This is a strategic merge patch (a partial copy of the object that Kustomize folds field-by-field into the real one). The target block says kind: Deployment, so this patch lands on every Deployment in the pile, whatever it is named. The metadata.name: web in the body is not doing the choosing. It is there only because a strategic merge document has to be a valid partial object, and Kustomize ignores that name when a target is present. Drop the name entirely and the build fails to parse the patch, so keep it, but understand that target is the selector. That is what lets one component patch a base it has never seen: it matches by kind, not by the app's specific name.

Turning it on, per environment

An overlay stays an ordinary Kustomization. It pulls the base in under resources, then lists the features it wants under components, which are applied after everything in resources and in the order you write them. Prod wants Vault secrets and hardening. Staging wants Vault secrets only. Dev wants neither. All three read from one base and one shared set of component directories, with zero copies of the feature YAML.

overlays/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
components:
- ../../components/vault-agent # applied in this order,
- ../../components/hardening # each sees what came before
terminal
kubectl kustomize overlays/prod
output
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 2
selector:
matchLabels:
app: web
template:
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: web
labels:
app: web
spec:
containers:
- image: registry.example.com/web:1.4.2
name: web
ports:
- containerPort: 8080
securityContext:
runAsNonRoot: true
runAsUser: 10001
seccompProfile:
type: RuntimeDefault
---
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: web-db
spec:
data:
- remoteRef:
key: kv/data/web
property: db_password
secretKey: password
secretStoreRef:
kind: ClusterSecretStore
name: vault-backend
target:
name: web-db

One base Deployment came out carrying both features. The Vault annotations sit on the pod template, the securityContext from the hardening component sits below it, and the ExternalSecret rides along as its own object. Nothing in the base file mentions Vault or securityContext. In CI (the pipeline that checks a change before it merges), you do not trust that by eye. You grep the rendered output for the pieces that were supposed to land.

terminal
kubectl kustomize overlays/prod | grep -E 'runAsNonRoot|agent-inject|ExternalSecret'
output
vault.hashicorp.com/agent-inject: "true"
runAsNonRoot: true
kind: ExternalSecret

Three matches, one per feature. That grep is your proof the layers wired in. Run it against the render before the manifests reach the cluster, not after an incident when you are trying to work out why a pod came up as root.

Where a base falls short

The line between a component and a base is thin and load-bearing. Anything under resources is rendered as its own independent Kustomize target, and the result is merged into yours, which means a resources entry can only patch objects it declared inside itself. It never sees your base's Deployment. A component is the opposite. It is applied straight into the caller's pile, so it can patch the base's objects, and it can react to whatever earlier components already added. Order therefore matters. Components apply top to bottom, each one sees everything above it, and when two of them write the same field the lower one wins. Reach for a component whenever a feature has to modify a resource it does not own; reach for a base or a plain resources entry when the feature is fully self-contained.

How components accumulate in an overlay
1base
declares Deployment web
2+ vault-agent
adds ExternalSecret, patches pod annotations
3+ hardening
patches securityContext onto the same pod
4rendered manifests
one Deployment carrying every layer
Each component sees everything added before it; on a shared field, the lower one wins.

Order is significant, and the last patch wins without a sound. When two components patch the same field, say both set the pod securityContext, the one listed lower overwrites the one above it. Reorder the components list and your rendered output changes with no error and nothing to flag it. Any time you move entries around, or add a component that touches a field an existing one already sets, rebuild and diff the output before you ship.

Reuse as a control you can prove

The payoff for a defender is that a security baseline becomes a control written once and switched on wherever you name it. Here is the hardening component. It sets runAsNonRoot (the pod refuses to start if its container would run as the root user), a fixed non-root user ID, and a RuntimeDefault seccomp profile. Seccomp, short for secure computing mode, is a kernel feature that filters which system calls a container is allowed to make; RuntimeDefault means the container runtime's own curated blocklist, which shrinks the attack surface a compromised process has to work with.

components/hardening/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component
patches:
- path: harden.yaml
target:
kind: Deployment
components/hardening/harden.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
template:
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
seccompProfile:
type: RuntimeDefault

Because switching it on is a single line in an overlay, coverage is greppable. You can ask, in one command, which environments are missing the control. The flag -rL prints the files that do not contain the pattern, so this list is every overlay that forgot to opt in.

terminal
grep -rL 'components/hardening' overlays/*/kustomization.yaml
output
overlays/dev/kustomization.yaml
overlays/staging/kustomization.yaml

That is a coverage report you can generate on demand and wire into a pipeline gate. An attacker who lands inside a pod cares a great deal whether runAsNonRoot and a RuntimeDefault seccomp profile are set, because those are a big part of what stands between a process that stays boxed inside its container and one with a straight path to the node. This tells you, in plain text, exactly where they are not. One caveat on the patch itself: pod-level fields like these need no container name, so the component stays reusable. Container-level fields, such as readOnlyRootFilesystem or dropping Linux capabilities, merge by container name, so for those you either standardise the container name across your bases or target the patch more narrowly.

List it under components:, never resources:
Point an overlay's resources: at a Component directory and Kustomize refuses it outright: expected kind != 'Component'. Relabel the file to kind: Kustomization but leave it under components: and you get the mirror-image error: expected kind 'Component' ... but got 'Kustomization'. The dangerous case is doing both, relabel to Kustomization and move it to resources:. Now the build succeeds with exit 0, but a directory that only holds patches has no resources of its own to patch, so it renders empty and the feature vanishes without a sound. A hardening control can disappear exactly this way and nothing turns red. Keep the file kind: Component with apiVersion v1alpha1, reference it only under components:, and always check the built output.
Quick check
01Your base declares the web Deployment. You want an optional hardening feature to set its securityContext. Listing that feature under components: works and listing the same directory under resources: does not. What is the reason?
Incorrect — The order runs the other way round: resources are gathered first and components land on top of them. Timing is not the blocker, isolation is.
Incorrect — A plain Kustomization takes patches too. Both files carry the same building blocks; what differs is which objects those patches are able to see.
Correct — Running against the objects already gathered is the entire point of the kind. It lets the feature edit workloads it never shipped itself.
Incorrect — There is no privilege model in a Kustomize build. Every difference here comes from where each file is evaluated, not from what it is allowed to do.
02In components/vault-agent/inject-agent.yaml the patch body says metadata.name: web, and the kustomization targets it with kind: Deployment. Suppose the base workload were named api instead of web. Where do the vault annotations land?
Correct — Selecting by kind is what makes the feature portable. One component can patch a base it has never seen, whatever that base called its workload.
Incorrect — Selection does not begin with the body name. Once a target is present, Kustomize reads the target and pays no attention to the name in the patch.
Incorrect — If that held, no component could be shared, because every overlay would need its own copy with the local workload name filled in by hand.
Incorrect — Nothing ever compares the body name against the objects in the build, so there is no mismatch available to fail on.
03Someone relabels components/hardening/kustomization.yaml to kind: Kustomization and moves its reference from the overlay's components: list into resources:. Then kubectl kustomize overlays/prod exits 0. What became of runAsNonRoot?
Incorrect — The kind decides how the directory is evaluated. Under resources: it becomes a build of its own, and a separate build cannot reach into yours.
Incorrect — Nothing is doubled here. The directory is read exactly once, and in this position it contributes no objects to the output at all.
Incorrect — That message shows up when the kind and the list disagree with each other. Here they agree, which is why the build stays completely quiet.
Correct — Empty output, exit 0, nothing red anywhere. That combination is why a diff of the built manifests belongs in the merge check.

Make kubectl kustomize (or kustomize build) part of the merge, and diff its output against the last known-good render. Components move real behavior, a sidecar, a secret, a securityContext, so a one-line edit to a components list can add or quietly remove a control. The rendered manifests are the only honest record of what you actually shipped.

Try this

Run find . -type f | sort on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: list it under components:, never resources. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related