Lesson 07 Deployments & StatefulSets
Theme:

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:

ProbePurposeOn Failure
startupProbeDetect when a slow-starting container is readyRestarts the container; other probes disabled until it passes
livenessProbeDetect if the process is stuck or deadlockedRestarts the container
readinessProbeDetect if the pod can handle trafficRemoves 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:

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:

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.