CoursesKustomizePatches: strategic merge & JSON6902

Patches: strategic merge & JSON6902

Change exactly what differs.

Intermediate14 min · lesson 6 of 12

You changed one number, not the whole file. That is a patch: a short, declarative note that says "in this environment, this one field is different," laid over a base you never edit. Think of a recipe binder in a busy kitchen. The base recipe stays in the binder. For tonight's catering job you don't rewrite the card, you clip a sticky note on top: double the salt, skip the garnish. Everyone still reads the original; the note only records what changed. Kustomize patches are that sticky note for Kubernetes (the system that runs your containers) manifests.

Kustomize gives you two kinds of sticky note. A strategic-merge patch is a partial copy of the manifest where you write only the fields you want to change, and Kustomize folds it into the base like blending two maps. A JSON6902 patch (named after RFC 6902, one of the numbered Request for Comments documents that define internet standards; this one specifies "JSON Patch," where JSON is the JavaScript Object Notation text format) is an ordered list of tiny operations: add this, replace that, remove the other, each aimed at an exact address inside the document. Same goal, two very different failure modes, and for a security overlay the failure mode is the whole story.

Strategic Merge: Write The Diff, Let Kustomize Fold It In

A strategic-merge patch has to say who it is talking about. It carries only enough identity, the apiVersion, the kind, and metadata.name (the group/version/kind/name, or GVKN, that names a resource), so Kustomize can find the matching base resource. Everything else in the patch is the change. Maps merge by key: a field you set is overwritten, a field you leave out is untouched, a field that did not exist is added. Lists are the sharp edge. Some lists carry a merge key, so Kustomize matches items by that key instead of by position. For containers the key is name; for a container's env (environment) variables the key is name too. That is why you can reach into one container by name and change its resources without disturbing the others.

/base/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: payments-api
spec:
replicas: 1
selector:
matchLabels:
app: payments-api
template:
metadata:
labels:
app: payments-api
spec:
containers:
- name: app
image: registry.internal/payments-api:1.4.2
ports:
- containerPort: 8080
- name: debug-sidecar
image: registry.internal/netshoot:latest

Here is the prod overlay's sticky note. It hardens the app container and peels off the debug sidecar that has no business running in production. Notice it names both containers by name, sets fields on one, and deletes the other with the $patch directive.

/overlays/prod/patch-harden.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: payments-api
spec:
replicas: 4
template:
spec:
containers:
- name: app
resources:
limits:
cpu: "2"
memory: 1Gi
requests:
cpu: 500m
memory: 512Mi
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
- name: debug-sidecar
$patch: delete
/overlays/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
patches:
- path: patch-harden.yaml
terminal
kustomize build overlays/prod
output
apiVersion: apps/v1
kind: Deployment
metadata:
name: payments-api
spec:
replicas: 4
selector:
matchLabels:
app: payments-api
template:
metadata:
labels:
app: payments-api
spec:
containers:
- image: registry.internal/payments-api:1.4.2
name: app
ports:
- containerPort: 8080
resources:
limits:
cpu: "2"
memory: 1Gi
requests:
cpu: 500m
memory: 512Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true

Read the render, not the patch. Two things jump out. The debug-sidecar container is gone, because $patch: delete removes a list item by its merge key. And Kustomize prints the fields alphabetically, so image lands before name and the securityContext keys come out sorted, which is why the rendered order never matches the order you typed. The app container kept its image and ports (fields you never mentioned) and gained the resource limits plus the securityContext (the block that tells Kubernetes to run the process as a non-root user, forbid privilege escalation, mount the root filesystem read-only, and drop every Linux capability, the fine-grained root-like powers a process can hold). That securityContext is the reason this patch exists. It is your hardening, expressed as a diff, and it landed on the right container because the name matched.

A Wrong Name Appends, It Does Not Error
Merge-by-name has a trap. If your patch names a container that is not in the base (you write app-server when the base container is app), Kustomize does not warn you. It treats your block as a brand new list item and appends a phantom container. kustomize build succeeds, your hardening lands on a container that never runs, and the real app stays wide open. The same holds for a misspelled field: strategic merge adds unknown keys instead of rejecting them. Never assume a patch applied because the file exists; confirm it in the rendered output.

JSON6902: Edit By Address, Fail Out Loud

A JSON6902 patch does not carry a copy of the manifest. It carries directions. Each operation names an op (add, remove, replace, move, copy, or test) and a path written as a JSON Pointer (RFC 6901: slash-separated keys, where /- means "append to this list" and list items are addressed by number). In Kustomize you attach it to a target selector instead of putting the name in the body. Because the address is literal, JSON6902 is the tool when you must hit a list item by position, delete a field outright, or make a change that merge semantics would fumble. Here is a slice of the same work written as operations.

/overlays/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
patches:
- target:
kind: Deployment
name: payments-api
patch: |-
- op: replace
path: /spec/replicas
value: 6
- op: remove
path: /spec/template/spec/containers/1
terminal
kustomize build overlays/prod
output
apiVersion: apps/v1
kind: Deployment
metadata:
name: payments-api
spec:
replicas: 6
selector:
matchLabels:
app: payments-api
template:
metadata:
labels:
app: payments-api
spec:
containers:
- image: registry.internal/payments-api:1.4.2
name: app
ports:
- containerPort: 8080

Two things to notice. First, /spec/template/spec/containers/1 addresses the sidecar by its slot in the list, which is exact but brittle: reorder the base and index 1 is now a different container. Second, JSON6902 fails loudly. Point an op at an address that is not there (change that 1 to a 5, a slot no container occupies) and the render stops cold.

terminal
kustomize build overlays/prod
output
Error: error in remove for path: '/spec/template/spec/containers/5': Unable to access invalid index: 5: invalid index referenced

That is the mirror image of the strategic-merge trap. Strategic merge quietly adds when it cannot find a match; JSON6902 refuses to render. One risk is a silent wrong result, the other is a broken build you notice at once. For a path key that contains a slash, such as an annotation, escape it as ~1 (and a literal ~ as ~0), so config.kubernetes.io/depends-on becomes .../config.kubernetes.io~1depends-on inside the path.

Which One, And When

Reach for strategic merge for most edits: setting resources, adding a securityContext, changing an image or a replica count. It reads like the manifest and merges maps without ceremony. Reach for JSON6902 when you need surgical control: removing a field entirely, targeting a list item by index, or applying one patch across many resources through a target with a labelSelector. Both live in kustomization.yaml, so the overlay stays an honest record of what it changes. The older split fields patchesStrategicMerge and patchesJson6902 still work, but the unified patches list with a target is what current Kustomize prefers.

Picking a patch style
What must this patch do?
Change or add fields
Strategic merge
Write the fields, merge by name; reads like the manifest, but a wrong name silently appends
Remove a field or hit a list item by index
JSON6902
Ordered ops on exact JSON Pointer paths; errors if the path is missing
One change across many resources
patches with a target
Select by labelSelector or GVKN; either style can sit in the body

Prove The Patch Did What You Think

A patch is small, and small changes are exactly what a reviewer skims. That is the opening an attacker wants. A three-line strategic-merge patch can add privileged: true to a container; a one-line JSON6902 remove can strip the securityContext you depend on; an add can mount a hostPath, a slice of the node's own filesystem, straight into the pod. The patch file looks innocent. The rendered result does not. So your control is to review the render, not the note. Build the overlay and check that the fields you care about are actually present, and that the ones you removed are actually gone.

terminal
kustomize build overlays/prod | grep -E 'replicas:|runAsNonRoot|readOnlyRootFilesystem|allowPrivilegeEscalation'
kustomize build overlays/prod | grep -c 'debug-sidecar'
output
replicas: 4
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
0

The grep confirms the hardening rendered (in alphabetical order, the way Kustomize emits it), and the count of 0 proves the sidecar delete took effect. Then push the rendered YAML through a server-side dry run, which validates it against the live API (application programming interface) without changing anything. This is where the phantom-container mistake finally surfaces: a container with no image is rejected here even though kustomize build was perfectly happy to emit it.

terminal
kustomize build overlays/prod | kubectl apply --dry-run=server -f -
output
deployment.apps/payments-api configured (server dry run)
Quick check
01Your prod patch adds the hardening securityContext to a container, but you write the name as app-server while the base calls that container app. You run kustomize build. What comes out?
Incorrect — That loud stop belongs to JSON6902, which refuses to render when an address is missing. A merge patch cannot tell a typo from a container you meant to add.
Correct — The name matched nothing, so your block joined the list as a new entry and the real app kept its original settings. Only the rendered YAML shows it.
Incorrect — Nothing spreads to every container. Fields land where the merge key matches, and app-server matched no item that already existed.
Incorrect — Removing a list item needs $patch: delete or a JSON6902 remove op. A name that misses adds an item, it never takes one away.
02The prod overlay needs a container's securityContext block gone from the rendered output, not replaced with different values. Which approach does this lesson send you to?
Incorrect — In a merge, a field you never mention keeps whatever the base gave it. Silence means leave alone, not take away.
Incorrect — That directive drops a whole list item by its merge key, which is how the prod patch removes debug-sidecar. It is not the tool for clearing one field inside a container.
Incorrect — The two styles part company on exactly this point. Merge can set and add fields, so only the operation list can take one out.
Correct — You address the block by its JSON Pointer path and delete it, which is the kind of surgery merge semantics cannot express.
03A base lists app at index 0 and debug-sidecar at index 1. Your prod overlay drops the sidecar with op: remove at path /spec/template/spec/containers/1. A teammate reorders the base so debug-sidecar sits first. The build still succeeds. Which container is removed?
Correct — The path names a slot, not a container, so after the shuffle slot 1 holds app and the remove takes it. Nothing in the build warns you.
Incorrect — JSON Pointer has no memory of what used to sit there and no notion of the name field. It reads the number and takes whatever occupies that position now.
Incorrect — You would see that error only if the slot were gone, as with a path ending in 5 when no container sits there. Slot 1 still exists, so the render completes.
Incorrect — One remove op takes one addressed item. Clearing the rest of the list would take its own operations.

Wire both checks into continuous integration (CI, the automated tests that run on every change): render the overlay, fail the build if a banned field appears or a required one is missing, and feed that same rendered YAML to a policy scanner. The patch file is only the intent. The rendered manifest is what the cluster actually runs, and it is the one artifact worth trusting.

Try this

Run kustomize build overlays/prod 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: a Wrong Name Appends, It Does Not Error. 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