Deployments & StatefulSets
Lesson 07 — Templating the workloads that run your applications
1. The Deployment Template
A Deployment is the most common Kubernetes workload controller. It manages a set of identical, interchangeable pods and provides declarative updates via ReplicaSets. In Helm, the Deployment template is typically the centrepiece of a chart.
Every Deployment needs three key sections: replicas (how many pods to run), a selector (how the controller finds its pods), and a pod template (what each pod looks like).
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "myapp.fullname" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "myapp.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "myapp.selectorLabels" . | nindent 8 }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.containerPort | default 8080 }}
Image tag pattern
The expression {{ .Values.image.tag | default .Chart.AppVersion }} is a Helm best practice. When a user does not specify an explicit tag, the chart's appVersion from Chart.yaml is used, keeping deployments deterministic without forcing every consumer to set a tag.
2. Resource Requests and Limits
Every production container should declare resource requests and limits. Requests tell the scheduler how much CPU and memory a pod needs to be placed; limits cap what it can consume. The standard Helm approach is to pass the entire resources block from values:
resources:
{{- toYaml .Values.resources | nindent 12 }}
The corresponding values.yaml entry:
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "512Mi"
Using toYaml to dump the entire block keeps your template clean and lets consumers supply any valid resource specification without template changes. Be cautious with memory limits: a container exceeding its memory limit is OOM-killed immediately, while exceeding CPU limits results in throttling.
3. Health Probes
Kubernetes supports three probe types, each serving a distinct purpose:
| Probe | Purpose | On Failure |
|---|---|---|
| startupProbe | Detect when a slow-starting container is ready | Restarts the container; other probes disabled until it passes |
| livenessProbe | Detect if the process is stuck or deadlocked | Restarts the container |
| readinessProbe | Detect if the pod can handle traffic | Removes pod from Service endpoints (no restart) |
startupProbe:
httpGet:
path: {{ .Values.probes.startup.path | default "/healthz" }}
port: http
failureThreshold: {{ .Values.probes.startup.failureThreshold | default 30 }}
periodSeconds: {{ .Values.probes.startup.periodSeconds | default 10 }}
livenessProbe:
httpGet:
path: {{ .Values.probes.liveness.path | default "/healthz" }}
port: http
initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds | default 0 }}
periodSeconds: {{ .Values.probes.liveness.periodSeconds | default 10 }}
readinessProbe:
httpGet:
path: {{ .Values.probes.readiness.path | default "/ready" }}
port: http
periodSeconds: {{ .Values.probes.readiness.periodSeconds | default 5 }}
Tip
Use a startup probe for applications with unpredictable boot times (e.g., JVM warm-up). This prevents the liveness probe from killing a container that is still initialising. Without a startup probe, you must set a long initialDelaySeconds on the liveness probe, which delays failure detection once the app is running.
4. Update Strategies
Deployments support two update strategies:
RollingUpdate (default)
New pods are created before old ones are removed. Two parameters control the pace:
- maxSurge — how many extra pods (above
replicas) can exist during the update. Accepts an absolute number or a percentage. - maxUnavailable — how many pods can be unavailable during the update.
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: {{ .Values.strategy.maxSurge | default "25%" }}
maxUnavailable: {{ .Values.strategy.maxUnavailable | default "25%" }}
Setting maxSurge: 1 and maxUnavailable: 0 ensures you always have the full replica count available during a rollout, at the cost of needing extra cluster capacity.
Recreate
All existing pods are terminated before new ones are created. This causes downtime but is necessary when the application cannot tolerate two versions running simultaneously — for example, when it holds an exclusive lock on a database schema migration.
5. StatefulSet — When Order and Identity Matter
While Deployments treat pods as interchangeable, a StatefulSet provides guarantees that certain workloads require:
- Stable network identity — each pod gets a predictable hostname:
<name>-0,<name>-1,<name>-2, resolved via a headless Service. - Ordered deployment and scaling — pods are created sequentially (0, 1, 2...) and terminated in reverse order. The
podManagementPolicy: Paralleloption relaxes this if ordering is unnecessary. - Stable persistent storage — each pod binds to its own PersistentVolumeClaim via
volumeClaimTemplates. The PVC persists across pod restarts and rescheduling.
Use a StatefulSet for databases (PostgreSQL, MySQL), distributed systems (Kafka, ZooKeeper, etcd), and any workload where pod identity or dedicated storage is required.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "myapp.fullname" . }}
spec:
serviceName: {{ include "myapp.fullname" . }}-headless
replicas: {{ .Values.statefulset.replicas }}
podManagementPolicy: {{ .Values.statefulset.podManagementPolicy | default "OrderedReady" }}
selector:
matchLabels:
{{- include "myapp.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "myapp.selectorLabels" . | nindent 8 }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
volumeMounts:
- name: data
mountPath: /var/lib/data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: [ "ReadWriteOnce" ]
storageClassName: {{ .Values.statefulset.storageClass | quote }}
resources:
requests:
storage: {{ .Values.statefulset.storageSize | default "10Gi" }}
Important
Deleting a StatefulSet does not delete its PVCs. This is intentional — it protects data. You must clean up PVCs manually or via a Helm post-delete hook if you want storage removed on uninstall.
6. PodDisruptionBudget
A PodDisruptionBudget (PDB) tells Kubernetes how many pods must remain available during voluntary disruptions — node drains, cluster upgrades, autoscaler scale-downs. It does not protect against involuntary disruptions like hardware failures.
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "myapp.fullname" . }}
spec:
{{- if .Values.pdb.minAvailable }}
minAvailable: {{ .Values.pdb.minAvailable }}
{{- else }}
maxUnavailable: {{ .Values.pdb.maxUnavailable | default 1 }}
{{- end }}
selector:
matchLabels:
{{- include "myapp.selectorLabels" . | nindent 6 }}
Choose one of minAvailable or maxUnavailable, not both. For a 3-replica Deployment, maxUnavailable: 1 and minAvailable: 2 are equivalent. Percentage values are also accepted.
7. Topology Spread Constraints
Topology spread constraints distribute pods evenly across failure domains — availability zones, nodes, or custom topology keys. This improves resilience: if a zone goes down, only a fraction of your pods are affected.
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
{{- include "myapp.selectorLabels" . | nindent 8 }}
maxSkew is the maximum allowed difference in pod count between any two topology domains. whenUnsatisfiable controls behaviour when the constraint cannot be met: DoNotSchedule blocks the pod; ScheduleAnyway places it in the least-skewed domain available.
8. Init Containers
Init containers run to completion before the main containers start. Common uses include waiting for a dependency, running database migrations, or downloading configuration. They are defined in the pod spec:
initContainers:
{{- if .Values.initContainers.waitForDB.enabled }}
- name: wait-for-db
image: busybox:1.36
command: ['sh', '-c', 'until nc -z {{ .Values.database.host }} {{ .Values.database.port }}; do echo waiting; sleep 2; done']
{{- end }}
{{- if .Values.initContainers.migrate.enabled }}
- name: run-migrations
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
command: ['python', 'manage.py', 'migrate', '--noinput']
{{- end }}
Init containers share the same volumes as the main containers, making them useful for pre-populating shared data directories.
9. Environment Variables from Multiple Sources
Helm templates commonly inject environment variables from three sources: plain values, ConfigMaps, and Secrets.
env:
- name: APP_ENV
value: {{ .Values.env.appEnv | quote }}
- name: LOG_LEVEL
valueFrom:
configMapKeyRef:
name: {{ include "myapp.fullname" . }}-config
key: log-level
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "myapp.fullname" . }}-secret
key: db-password
Always use quote on plain string values to prevent YAML type coercion. The string "true" without quotes becomes a boolean, and "1234" becomes an integer — both can cause unexpected behaviour.
Summary
Deployments handle stateless workloads; StatefulSets handle stateful ones. Both benefit from proper resource limits, health probes, disruption budgets, and topology awareness. Helm makes all of these configurable through values, letting a single chart serve development, staging, and production with different parameters.