Writing a one-off PDB manifest is easy. Keeping PDBs consistently applied across dozens of services, each with different replica counts, deployed by different teams, is where things fall apart without a template-driven approach. Helm is what I use to make PDBs a default part of every chart rather than something people remember (or forget) to add manually. This article covers templating PDBs properly in Helm charts, handling edge cases like single-replica workloads, and rolling this out safely across an existing fleet of releases.
Why Template PDBs Instead of Writing Them Statically
Once you have more than a handful of services, static YAML per service means:
- Inconsistent
minAvailable/maxUnavailablechoices across teams - PDBs that don’t get updated when replica counts change
- Some services simply never get one because someone forgot
Templating PDBs into your standard Helm chart (or a shared library chart) means every deployment automatically gets sensible disruption protection unless explicitly opted out.
Basic PDB Template
Assuming a standard chart structure with templates/deployment.yaml and templates/pdb.yaml:
# templates/pdb.yaml
{{- if .Values.pdb.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "mychart.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "mychart.labels" . | nindent 4 }}
spec:
{{- if .Values.pdb.minAvailable }}
minAvailable: {{ .Values.pdb.minAvailable }}
{{- else }}
maxUnavailable: {{ .Values.pdb.maxUnavailable | default "25%" }}
{{- end }}
selector:
matchLabels:
{{- include "mychart.selectorLabels" . | nindent 6 }}
{{- end }}
# values.yaml
pdb:
enabled: true
maxUnavailable: "25%"
# minAvailable: 2 # uncomment to use minAvailable instead
This gives every service a working PDB by default, with the choice between minAvailable and maxUnavailable left to the values file per-environment.
Handling the Single-Replica Edge Case
A PDB is meaningless — or actively harmful — on a Deployment with only 1 replica if minAvailable: 1 is set, since it blocks all voluntary disruption of that single pod. I add a guard so PDBs only apply when replicas exceed 1:
# templates/pdb.yaml
{{- if and .Values.pdb.enabled (gt (.Values.replicaCount | int) 1) }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "mychart.fullname" . }}
namespace: {{ .Release.Namespace }}
spec:
maxUnavailable: {{ .Values.pdb.maxUnavailable | default "25%" }}
selector:
matchLabels:
{{- include "mychart.selectorLabels" . | nindent 6 }}
{{- end }}
helm template ./mychart --set replicaCount=1
With replicaCount=1, the PDB template renders nothing at all — verified with helm template before ever hitting the cluster.
Making It a Library Chart for Fleet-Wide Consistency
For organizations running many microservices, I prefer defining this once in a shared library chart rather than copy-pasting into every service’s chart.
# charts/common/templates/_pdb.tpl
{{- define "common.pdb" -}}
{{- if and .Values.pdb.enabled (gt (.Values.replicaCount | int) 1) }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "common.fullname" . }}
namespace: {{ .Release.Namespace }}
spec:
{{- if .Values.pdb.minAvailable }}
minAvailable: {{ .Values.pdb.minAvailable }}
{{- else }}
maxUnavailable: {{ .Values.pdb.maxUnavailable | default "25%" }}
{{- end }}
selector:
matchLabels:
{{- include "common.selectorLabels" . | nindent 6 }}
{{- end }}
{{- end -}}
Each service chart then just calls it:
# templates/pdb.yaml
{{ include "common.pdb" . }}
# Chart.yaml
dependencies:
- name: common
version: 1.0.0
repository: "file://../common"
This means updating disruption logic organization-wide (say, changing the default from 25% to 20%) is a single library chart bump plus a coordinated helm upgrade, rather than dozens of individual PRs.
Deploying and Verifying
helm upgrade --install checkout-service ./mychart \
--namespace production \
--set replicaCount=4 \
--set pdb.maxUnavailable="25%"
kubectl get pdb -n production
helm get manifest checkout-service -n production | grep -A 10 "kind: PodDisruptionBudget"
NAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS AGE
checkout-service N/A 25% 1 10s
Rolling Out to an Existing Fleet Safely
Adding PDBs retroactively to dozens of already-deployed Helm releases needs a careful rollout, since a badly configured PDB can block a cluster upgrade already in progress.
- Dry-run everywhere first:
for release in $(helm list -n production -q); do
echo "=== $release ==="
helm upgrade --install "$release" ./mychart -n production --reuse-values --dry-run | grep -A 8 "kind: PodDisruptionBudget"
done
- Roll out with
maxUnavailable: 100%initially (effectively a no-op PDB, but validates the object renders and applies correctly) before tightening to real values in a follow-up change. - Monitor
kube_poddisruptionbudget_status_pod_disruptions_allowedin Prometheus/Grafana (see the companion article on PDBs with Prometheus) after each batch, watching for any PDB stuck at0allowed disruptions unexpectedly.
Helm Hooks for Pre-Upgrade Validation
To prevent a bad PDB value from ever reaching the cluster, I add a helm lint-time check via a values schema:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"pdb": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"maxUnavailable": { "type": "string" },
"minAvailable": { "type": ["integer", "string"] }
}
},
"replicaCount": {
"type": "integer",
"minimum": 1
}
}
}
Saved as values.schema.json in the chart root, Helm automatically validates values against this schema on every helm install/upgrade, rejecting obviously malformed configuration before it’s templated.
helm lint ./mychart
CI/CD Integration
A GitHub Actions step that fails the pipeline if any chart with replicaCount > 1 doesn’t define a PDB:
- name: Verify PDB coverage
run: |
RENDERED=$(helm template ./mychart)
REPLICAS=$(yq '.replicaCount' values.yaml)
if [ "$REPLICAS" -gt 1 ] && ! echo "$RENDERED" | grep -q "kind: PodDisruptionBudget"; then
echo "ERROR: replicaCount > 1 but no PodDisruptionBudget rendered"
exit 1
fi
Handling StatefulSets Differently from Deployments
StatefulSets deserve a slightly different PDB template, since I generally prefer minAvailable with an absolute floor rather than a percentage — quorum-based systems like etcd, Zookeeper, or Kafka often have hard minimums (e.g., “must keep 2 of 3 for quorum”) that don’t scale proportionally the way stateless service tolerance does.
# templates/pdb-statefulset.yaml
{{- if and .Values.pdb.enabled (gt (.Values.replicaCount | int) 1) }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "mychart.fullname" . }}
namespace: {{ .Release.Namespace }}
spec:
minAvailable: {{ .Values.pdb.quorumMinimum | default (add (div (.Values.replicaCount | int) 2) 1) }}
selector:
matchLabels:
{{- include "mychart.selectorLabels" . | nindent 6 }}
{{- end }}
The default expression (replicaCount / 2) + 1 computes a standard majority-quorum floor automatically — for 3 replicas that’s 2, for 5 replicas that’s 3 — while still allowing an explicit override via pdb.quorumMinimum for systems with different quorum math.
# values.yaml for a 3-node etcd-backed operator
replicaCount: 3
pdb:
enabled: true
quorumMinimum: 2
Testing Chart Changes Before Rollout
Beyond helm template and helm lint, I run helm-unittest for actual assertions on rendered PDB output, catching regressions before they reach any cluster:
helm plugin install https://github.com/helm-unittest/helm-unittest
# tests/pdb_test.yaml
suite: PDB tests
templates:
- templates/pdb.yaml
tests:
- it: should render a PDB when replicaCount > 1
set:
replicaCount: 3
pdb.enabled: true
asserts:
- isKind:
of: PodDisruptionBudget
- equal:
path: spec.maxUnavailable
value: "25%"
- it: should not render a PDB when replicaCount is 1
set:
replicaCount: 1
pdb.enabled: true
asserts:
- hasDocuments:
count: 0
helm unittest ./mychart
Wiring this into CI means a bad change to the shared library chart’s PDB logic fails the pipeline immediately, rather than being discovered during a production rollout weeks later.
Common Mistakes
- Hardcoding
minAvailableas an absolute number in a shared library chart — this breaks the moment any service scales differently than assumed. Prefer percentage-basedmaxUnavailableas the shared default. - Forgetting the single-replica guard, resulting in PDBs that block all voluntary disruption for low-traffic services running one replica.
- Rolling out default-deny-style strict PDBs (
maxUnavailable: 0) fleet-wide in one change — this can stall an in-progress node drain or cluster upgrade across many services simultaneously. - Not validating selector labels match between the PDB template and the Deployment template — a typo in a shared helper silently disables protection for every chart using it.
Documenting PDB Decisions in the Chart
One habit worth adopting: annotate PDBs with a brief rationale for the chosen threshold, so six months from now nobody has to reverse-engineer why maxUnavailable is set to a specific value.
# templates/pdb.yaml
{{- if and .Values.pdb.enabled (gt (.Values.replicaCount | int) 1) }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "mychart.fullname" . }}
namespace: {{ .Release.Namespace }}
annotations:
pdb.rationale: {{ .Values.pdb.rationale | default "Standard 25% tolerance for stateless service" | quote }}
spec:
maxUnavailable: {{ .Values.pdb.maxUnavailable | default "25%" }}
selector:
matchLabels:
{{- include "mychart.selectorLabels" . | nindent 6 }}
{{- end }}
kubectl get pdb checkout-service -n production -o jsonpath='{.metadata.annotations.pdb\.rationale}'
This small addition has saved me real time during audits and incident postmortems, where “why is this set to zero?” is a question that comes up more often than you’d expect.
Summary
Helm turns PDBs from “a thing someone might remember to add” into “a thing every deployment gets by default.” The pattern that works well in practice is a shared library chart with a single _pdb.tpl helper, a single-replica guard to avoid the classic footgun, percentage-based defaults for scaling safety, and a values schema plus CI check to catch misconfiguration before it reaches the cluster. Roll changes out incrementally and watch Prometheus’s disruptions_allowed metric during any fleet-wide change.
