Kustomize Overlays, Strategic Merge Patches, and When Helm Is Overkill
Kustomize Overlays, Strategic Merge Patches, and When Helm Is Overkill
The Failure
The team used Helm to template a simple Deployment with 3 environment-specific values: replica count, image tag, and resource limits. The Helm chart had 150 lines of Go templates for a 40-line Deployment. When a new developer asked “what does this service deploy?”, they had to mentally render the templates to understand the output.
Kustomize would have been 40 lines of base YAML (readable Kubernetes manifests) plus 10 lines of patches per environment. No template language. No mental rendering. The base YAML is a valid Kubernetes manifest that can be applied directly.
The Mechanism
Patch Types
| Patch Type | Use Case | Syntax |
|---|---|---|
| Strategic merge | Add/modify fields in existing resources | Standard YAML, merged by name |
| JSON patch | Precise field operations (add, remove, replace) | JSON array of operations |
| Inline patch | Simple patches without separate files | Inline in kustomization.yaml |
Strategic merge patches are the default. They merge by matching resource name and field paths. JSON patches are more precise when you need to add items to arrays or remove specific fields.
The Implementation
Base Resources
# apps/checkout-service/base/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkout-service
spec:
replicas: 1
selector:
matchLabels:
app: checkout-service
template:
metadata:
labels:
app: checkout-service
spec:
containers:
- name: checkout
image: ghcr.io/acme/checkout-service:latest
ports:
- containerPort: 8080
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
envFrom:
- configMapRef:
name: checkout-config
Strategic Merge Patch (Production)
# apps/checkout-service/overlays/production/patch-resources.yaml
# HARDENED: Production resource scaling
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkout-service
spec:
replicas: 3
template:
spec:
containers:
- name: checkout
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: 1000m
memory: 1Gi
JSON Patch (Add Sidecar)
# apps/checkout-service/overlays/production/patch-sidecar.yaml
# HARDENED: Add observability sidecar via JSON patch
- op: add
path: /spec/template/spec/containers/-
value:
name: otel-collector
image: otel/opentelemetry-collector:0.96.0
ports:
- containerPort: 4317
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
# apps/checkout-service/overlays/production/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
patches:
- path: patch-resources.yaml
- path: patch-sidecar.yaml
target:
kind: Deployment
name: checkout-service
images:
- name: ghcr.io/acme/checkout-service
newTag: abc123
configMapGenerator:
- name: checkout-config
behavior: merge
literals:
- ENVIRONMENT=production
- LOG_LEVEL=info
- CATALOG_URL=http://catalog-service.production.svc.cluster.local
Kustomize Components for Cross-Cutting Concerns
# components/observability/kustomization.yaml
# Reusable component: add to any service
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component
patches:
- target:
kind: Deployment
patch: |
- op: add
path: /spec/template/metadata/annotations/prometheus.io~1scrape
value: "true"
- op: add
path: /spec/template/metadata/annotations/prometheus.io~1port
value: "8080"
# apps/checkout-service/overlays/production/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
components:
- ../../../../components/observability
- ../../../../components/network-policy
patches:
- path: patch-resources.yaml
When Kustomize Breaks Down
Kustomize is not appropriate when:
-
More than 5 overlays all modify the same fields differently: At this point, you are fighting the merge semantics. Use a Helm chart with a values file per variant.
-
Consumers outside your team need to configure the manifests: Kustomize requires access to the base YAML. Helm packages everything into a chart that consumers install with
helm install. -
Complex conditional logic: Kustomize has no
if/else. If a resource should only exist in certain environments, you need separate base directories, which duplicates resources. -
Dependency management: Kustomize has no dependency resolution. If your manifests depend on a CRD being installed first, use Helm’s hooks or ArgoCD sync waves.
The Gate
kustomize build for every overlay in CI:
for overlay in apps/*/overlays/*/; do
echo "Building $overlay..."
kustomize build "$overlay" > /dev/null || exit 1
done
Additionally, validate that overlays do not accidentally remove critical fields:
for overlay in apps/*/overlays/*/; do
OUTPUT=$(kustomize build "$overlay")
# Every deployment must have resource limits
if ! echo "$OUTPUT" | yq '.spec.template.spec.containers[0].resources.limits' | grep -q cpu; then
echo "ERROR: $overlay missing CPU limits"
exit 1
fi
done
The Recovery
Patch does not apply: The base resource structure changed. Update the patch to match. Use kustomize build with --enable-alpha-plugins and the --stack-trace flag to see where the merge fails.
Image tag not updating: The images transformer matches by image name, not by tag. Ensure the name in the images list matches the image name in the base Deployment exactly (including the registry prefix).
ConfigMap changes do not trigger pod restart: Kustomize appends a hash suffix to generated ConfigMaps. When the content changes, the name changes, and the Deployment’s ConfigMap reference updates, triggering a rolling update. This only works with configMapGenerator, not with static ConfigMap resources.