How to Implement Pod Disruption Budgets with Helm in Kubernetes

How to Implement Pod Disruption Budgets with Helm in Kubernetes

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:

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.

  1. 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
  1. 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.
  2. Monitor kube_poddisruptionbudget_status_pod_disruptions_allowed in Prometheus/Grafana (see the companion article on PDBs with Prometheus) after each batch, watching for any PDB stuck at 0 allowed 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

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.

References

Exit mobile version