DevOpsIndex

Kubernetes Workloads


Pod Lifecycle

stateDiagram-v2
    [*] --> Pending: Pod created, waiting for scheduler
    Pending --> Running: Node assigned, containers starting
    Running --> Succeeded: All containers exited 0 (Job complete)
    Running --> Failed: Container exited non-zero, restartPolicy=Never
    Running --> Running: Container crash, restartPolicy=Always (CrashLoopBackOff if repeated)
    Pending --> Failed: Image pull error, resource unavailable
graph LR
    classDef phase fill:#3498db,stroke:#2980b9,color:#fff
    classDef cstate fill:#9b59b6,stroke:#8e44ad,color:#fff
    classDef good fill:#2ecc71,stroke:#27ae60,color:#fff
    classDef bad fill:#e74c3c,stroke:#c0392b,color:#fff
    classDef warn fill:#f39c12,stroke:#d68910,color:#000

    PENDING["Pending Scheduled, pulling image"]:::phase
    RUNNING["Running At least 1 container running"]:::phase
    SUCCEEDED["Succeeded All containers exited 0"]:::good
    FAILED["Failed Container exited non-zero"]:::bad
    UNKNOWN["Unknown Node unreachable"]:::warn

    PENDING --> RUNNING
    RUNNING --> SUCCEEDED
    RUNNING --> FAILED
    RUNNING --> UNKNOWN

Container states within a Running pod:

State Meaning
Waiting Starting, pulling image, or CrashLoopBackOff
Running Process is executing
Terminated Process exited (check exitCode: 0=success, 137=OOMKilled, 1=error)

CrashLoopBackOff — container keeps crashing. Kubernetes applies exponential backoff (10s → 20s → 40s → 80s → 160s → 5min cap) between restart attempts. Not a stuck state — it will keep retrying. Check logs with kubectl logs <pod> --previous.


Probes

Probes are health checks kubelet runs against your container. Three types, each with a distinct purpose.

graph TD
    classDef probe fill:#8e44ad,stroke:#6c3483,color:#fff
    classDef action fill:#e74c3c,stroke:#c0392b,color:#fff
    classDef ok fill:#2ecc71,stroke:#27ae60,color:#fff
    classDef note fill:#f39c12,stroke:#d68910,color:#000

    subgraph StartupProbe["Startup Probe"]
        SP["Runs FIRST during container startup Blocks liveness+readiness until it passes For slow-starting apps (legacy, DB migrations)"]:::probe
        SP -->|"fails failureThreshold times"| KILL1["Container killed and restarted"]:::action
        SP -->|"passes"| SP_OK["Liveness + Readiness probes begin"]:::ok
    end

    subgraph LivenessProbe["Liveness Probe"]
        LP["Runs throughout pod lifetime Is the process still alive and not deadlocked?"]:::probe
        LP -->|"fails consecutively"| KILL2["Container killed and restarted kubelet respects restartPolicy"]:::action
        LP -->|"passes"| LP_OK["Container stays running"]:::ok
    end

    subgraph ReadinessProbe["Readiness Probe"]
        RP["Runs throughout pod lifetime Is the process ready to serve traffic?"]:::probe
        RP -->|"fails"| REMOVE["Pod IP removed from Service endpoints Traffic stops flowing to this pod"]:::action
        RP -->|"passes"| ADD["Pod IP added to Service endpoints Traffic flows to pod"]:::ok
    end

Probe implementation types:

# HTTP GET — most common for web services
livenessProbe:
  httpGet:
    path: /healthz
    port: 8080
  initialDelaySeconds: 10   # wait before first probe (startup time)
  periodSeconds: 10          # probe every 10s
  failureThreshold: 3        # fail 3 times before action
  timeoutSeconds: 5

# TCP Socket — for non-HTTP (gRPC, databases)
readinessProbe:
  tcpSocket:
    port: 5432
  initialDelaySeconds: 5

# Exec — run command inside container
livenessProbe:
  exec:
    command: ["pg_isready", "-U", "postgres"]
  periodSeconds: 30

# gRPC (K8s 1.24+)
livenessProbe:
  grpc:
    port: 8080
    service: "liveness"

Key probe interactions:

  • Liveness failure → container restart (pod stays, container restarts)
  • Readiness failure → pod removed from service endpoints (pod stays running, just gets no traffic)
  • Startup probe present → liveness and readiness are BLOCKED until startup passes

QoS Classes

Kubernetes assigns a QoS class to every pod. This determines OOM kill priority when a node runs out of memory.

graph TD
    classDef guaranteed fill:#2ecc71,stroke:#27ae60,color:#fff
    classDef burstable fill:#f39c12,stroke:#d68910,color:#000
    classDef besteffort fill:#e74c3c,stroke:#c0392b,color:#fff

    subgraph G["Guaranteed — OOM killed LAST"]
        G1["requests == limits for ALL containers cpu: 500m request = 500m limit memory: 256Mi request = 256Mi limit"]:::guaranteed
    end

    subgraph B["Burstable — OOM killed if over request"]
        B1["requests < limits (or only requests set) cpu: 200m request, 1000m limit memory: 128Mi request, 512Mi limit"]:::burstable
    end

    subgraph BE["BestEffort — OOM killed FIRST"]
        BE1["No requests, no limits set Kernel kills these first under memory pressure"]:::besteffort
    end

    G --> B --> BE
    BE -.- NOTE["OOM kill order: BestEffort first, then Burstable over their request, then Guaranteed (almost never)"]

Production recommendation: Set both requests and limits for all containers. Use Guaranteed class for critical services (controllers, databases). Never run production workloads as BestEffort.


Rolling Updates and Rollbacks

graph LR
    classDef v1 fill:#3498db,stroke:#2980b9,color:#fff
    classDef v2 fill:#2ecc71,stroke:#27ae60,color:#fff
    classDef terminating fill:#e74c3c,stroke:#c0392b,color:#fff
    classDef rs fill:#9b59b6,stroke:#8e44ad,color:#fff

    subgraph Start["Start: 3x v1 running"]
        V1A["v1 pod"]:::v1
        V1B["v1 pod"]:::v1
        V1C["v1 pod"]:::v1
    end

    subgraph Step1["maxSurge=1, maxUnavailable=0 Create 1 new v2 pod (surge)"]
        V1D["v1 pod"]:::v1
        V1E["v1 pod"]:::v1
        V1F["v1 pod"]:::v1
        V2A["v2 pod (new)"]:::v2
    end

    subgraph Step2["v2 pod passes readiness Terminate 1 v1 pod"]
        V1G["v1 pod"]:::v1
        V1H["v1 pod"]:::v1
        V1I["v1 pod terminating"]:::terminating
        V2B["v2 pod ✅"]:::v2
    end

    subgraph End["All 3 replaced with v2"]
        V2C["v2 pod"]:::v2
        V2D["v2 pod"]:::v2
        V2E["v2 pod"]:::v2
    end

    subgraph ReplicaSets["Deployment keeps both ReplicaSets"]
        RS1["ReplicaSet v1 replicas: 0 (kept for rollback)"]:::rs
        RS2["ReplicaSet v2 replicas: 3"]:::rs
    end
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1         # max pods ABOVE desired (create new before deleting old)
      maxUnavailable: 0   # max pods BELOW desired (zero-downtime: never remove before replacement ready)

Rollback: The old ReplicaSet is never deleted (kept at 0 replicas). Rollback simply scales old RS back up and new RS back down:

kubectl rollout undo deployment/my-app                    # rollback to previous
kubectl rollout undo deployment/my-app --to-revision=3    # rollback to specific revision
kubectl rollout history deployment/my-app                  # see revision history
kubectl rollout status deployment/my-app                   # watch rollout progress
kubectl rollout pause deployment/my-app                    # pause mid-rollout (canary hold)
kubectl rollout resume deployment/my-app                   # resume

StatefulSet vs Deployment

graph TD
    classDef deploy fill:#3498db,stroke:#2980b9,color:#fff
    classDef stateful fill:#9b59b6,stroke:#8e44ad,color:#fff
    classDef pvc fill:#2ecc71,stroke:#27ae60,color:#fff
    classDef storage fill:#e67e22,stroke:#d35400,color:#fff

    subgraph DeploymentModel["Deployment: interchangeable pods"]
        D1["app-7f9d4-abc random name"]:::deploy
        D2["app-7f9d4-def random name"]:::deploy
        D3["app-7f9d4-ghi random name"]:::deploy
        SHARED_STORAGE["Shared storage or no persistent storage"]:::storage
        D1 --- SHARED_STORAGE
        D2 --- SHARED_STORAGE
    end

    subgraph StatefulSetModel["StatefulSet: stable identity"]
        SS1["postgres-0 stable name, index 0"]:::stateful
        SS2["postgres-1 stable name, index 1"]:::stateful
        SS3["postgres-2 stable name, index 2"]:::stateful
        PVC1["PVC: postgres-data-postgres-0 bound only to postgres-0"]:::pvc
        PVC2["PVC: postgres-data-postgres-1 bound only to postgres-1"]:::pvc
        PVC3["PVC: postgres-data-postgres-2"]:::pvc
        SS1 --> PVC1
        SS2 --> PVC2
        SS3 --> PVC3
    end

StatefulSet guarantees:

Guarantee Detail
Stable pod names <name>-0, <name>-1, <name>-2 — never random
Stable DNS postgres-0.postgres-svc.ns.svc.cluster.local (requires Headless Service)
Stable storage Each pod gets its own PVC via volumeClaimTemplates. PVC survives pod deletion.
Ordered startup Pods start in order: 0 → 1 → 2. Each must be Running+Ready before next starts.
Ordered shutdown Pods stop in reverse order: 2 → 1 → 0.

When to use StatefulSet: Databases (PostgreSQL, MySQL), distributed systems with leader election (Kafka, Zookeeper, etcd), any app that needs stable network identity or per-instance storage.


ReplicaSet vs StatefulSet — Deep Comparison

ReplicaSet (via Deployment) — Stateless Pods

A ReplicaSet ensures N identical, interchangeable pod replicas are running. You never create a ReplicaSet directly — you use a Deployment which manages ReplicaSets for you (rolling updates, rollback).

graph TD
    DEP["Deployment<br/>manages ReplicaSets"] --> RS_OLD["ReplicaSet v1<br/>replicas: 0 (kept for rollback)"]
    DEP --> RS_NEW["ReplicaSet v2<br/>replicas: 3"]
    RS_NEW --> P1["api-7f9d4-abc<br/>random suffix"]
    RS_NEW --> P2["api-7f9d4-def<br/>random suffix"]
    RS_NEW --> P3["api-7f9d4-ghi<br/>random suffix"]
    P1 & P2 & P3 --> PVC_SHARED["Shared PVC (optional)<br/>ReadWriteMany<br/>e.g. EFS"]

    classDef pod fill:#3498db,stroke:#2980b9,color:#fff
    classDef rs fill:#e67e22,stroke:#d35400,color:#fff
    class P1,P2,P3 pod
    class RS_OLD,RS_NEW rs

Pod identity: none. Pod api-7f9d4-abc is identical to api-7f9d4-def. If one dies, the ReplicaSet creates a replacement with a new random name — no data is lost because stateless pods don't hold state.

Scale up/down: all pods started/stopped in parallel, any order. Scale from 3→5: 2 new pods created simultaneously.

StatefulSet — Stateful Pods with Identity

graph TD
    STS["StatefulSet: postgres"] --> HS["Headless Service<br/>clusterIP: None<br/>postgres-svc"]
    STS --> P0["postgres-0<br/>stable index"]
    STS --> P1["postgres-1<br/>stable index"]
    STS --> P2["postgres-2<br/>stable index"]
    P0 --> PVC0["PVC: data-postgres-0<br/>always bound to postgres-0"]
    P1 --> PVC1["PVC: data-postgres-1<br/>always bound to postgres-1"]
    P2 --> PVC2["PVC: data-postgres-2<br/>always bound to postgres-2"]
    HS --> DNS0["DNS: postgres-0.postgres-svc.default.svc.cluster.local"]
    HS --> DNS1["DNS: postgres-1.postgres-svc.default.svc.cluster.local"]

    classDef pod fill:#9b59b6,stroke:#8e44ad,color:#fff
    classDef pvc fill:#2ecc71,stroke:#27ae60,color:#fff
    class P0,P1,P2 pod
    class PVC0,PVC1,PVC2 pvc

Headless Service (clusterIP: None) — required for StatefulSet DNS. Unlike a regular Service (which load-balances), a Headless Service creates individual DNS records for each pod. postgres-0.postgres-svc always resolves to postgres-0's IP — clients can target a specific pod (e.g., write to primary, read from replicas).

Ordered Startup and Deletion

sequenceDiagram
    participant STS as StatefulSet Controller
    participant P0 as postgres-0
    participant P1 as postgres-1
    participant P2 as postgres-2

    Note over STS: Scale up (0 --> 3)
    STS->>P0: create postgres-0
    P0-->>STS: Running + Ready
    STS->>P1: create postgres-1 (only after P0 ready)
    P1-->>STS: Running + Ready
    STS->>P2: create postgres-2 (only after P1 ready)
    P2-->>STS: Running + Ready

    Note over STS: Scale down (3 --> 1)
    STS->>P2: delete postgres-2 (highest index first)
    P2-->>STS: terminated
    STS->>P1: delete postgres-1
    P1-->>STS: terminated
    Note over P0: postgres-0 remains

Why ordered? Databases need the primary (index 0) to be healthy before replicas join. Shutting down the replica before the primary prevents split-brain.


Which Applications Use Which

graph TD
    APP["Your Application"] --> Q1{"Does each instance<br/>need its own<br/>persistent data?"}
    Q1 -->|No| Q2{"Multiple instances<br/>interchangeable?"}
    Q1 -->|Yes| STATEFUL["StatefulSet"]
    Q2 -->|Yes| DEPLOYMENT["Deployment + ReplicaSet"]
    Q2 -->|No singleton| SINGLETON["Deployment replicas:1<br/>or StatefulSet replicas:1"]

    classDef dep fill:#3498db,stroke:#2980b9,color:#fff
    classDef sts fill:#9b59b6,stroke:#8e44ad,color:#fff
    class DEPLOYMENT dep
    class STATEFUL,SINGLETON sts

Deployment (ReplicaSet) — Stateless Workloads

Application Why Deployment
REST API servers All pods identical, share DB connection, any pod can handle any request
GraphQL / gRPC services Stateless request handling
Web frontends No per-pod data
Worker processes (SQS consumer) Any worker can process any message
Batch processors Jobs consume from a queue, no local state
ML inference servers (vLLM, TorchServe) Model loaded in each pod's memory but no unique per-pod data to persist
API gateways, proxies Pure request routing, stateless

StatefulSet — Stateful Workloads

Application Why StatefulSet Key requirement
PostgreSQL Each replica has its own WAL + data directory Per-pod PVC, ordered startup (primary first)
MySQL / MariaDB Master-replica with per-node data Stable names for replication config
Redis (cluster mode) Each shard owns specific key slots Stable identity for cluster topology
Apache Kafka Each broker owns specific partitions on disk Stable broker ID = stable pod name
Zookeeper Quorum requires stable myid zoo-0, zoo-1, zoo-2 fixed identity
Elasticsearch Each node stores specific shards Per-pod PVC, stable cluster node names
Cassandra Each node owns a token range Stable gossip identity
etcd Raft consensus requires stable peer URLs etcd-0.etcd etc.

The Key Question to Ask

"If I delete this pod and a new one starts with a different name and no local data,
does the application still work correctly?"

YES → Deployment
NO  → StatefulSet

Redis Standalone (replicas:1, in-memory cache): Deployment — if it restarts, it starts fresh. The cache warms up again. Acceptable.

Redis Cluster (sharded, data must persist): StatefulSet — each node owns data, must reconnect to cluster with same identity.

PostgreSQL primary: StatefulSet — data on disk, WAL, per-node state. Restarting with a different name would break replication setup.


Comparison Table

Feature Deployment (ReplicaSet) StatefulSet
Pod names Random (app-abc123-xyz) Stable (app-0, app-1)
Pod DNS Single Service (load-balanced) Headless Service (per-pod DNS)
Storage Shared PVC or no PVC Per-pod PVC via volumeClaimTemplates
PVC on pod delete PVC deleted with pod PVC survives pod deletion
Scale up order Parallel (all at once) Sequential (0, 1, 2...)
Scale down order Any order Reverse (N, N-1, N-2...)
Rolling update New RS created, old RS scaled down Pod-by-pod, highest index first
Use case Stateless APIs, workers Databases, queues, consensus systems

Init Containers

Init containers run sequentially before app containers start. Each must exit 0 before the next runs. If any init container fails, the pod restarts.

sequenceDiagram
    participant K as kubelet
    participant I1 as init-container-1
    participant I2 as init-container-2
    participant APP as app container

    K->>I1: start (wait-for-db)
    Note over I1: loops until postgres:5432 is reachable
    I1-->>K: exit 0
    K->>I2: start (run-migrations)
    I2-->>K: exit 0 (migrations done)
    K->>APP: start main application
    Note over APP: DB is ready AND migrated before app starts
spec:
  initContainers:
  # 1. Wait for dependency to be ready
  - name: wait-for-db
    image: busybox
    command: ['sh', '-c', 'until nc -z postgres-svc 5432; do echo waiting; sleep 2; done']

  # 2. Run DB migration before app starts
  - name: migrate
    image: my-app:v2
    command: ["/app/server", "migrate"]
    env:
    - name: DATABASE_URL
      valueFrom:
        secretKeyRef:
          name: db-secret
          key: url

  containers:
  - name: app
    image: my-app:v2
    # starts only after both init containers succeed

Common init container patterns:

Pattern Init container does Why
Wait for dependency nc -z svc port loop Prevent app crash on startup
DB migration ./migrate up Run once before N replicas start
Secret injection Fetch secret from Vault, write to emptyDir Avoid mounting Vault agent sidecar
Config rendering Render templates with env vars App expects rendered config, not templates
Permission fix chown shared volume App needs specific UID on data directory

Init containers share volumes with app containers — this is how they pass data:

volumes:
- name: config
  emptyDir: {}
initContainers:
- name: render-config
  image: renderer:v1
  command: ["render", "--output", "/config/app.yaml"]
  volumeMounts:
  - name: config
    mountPath: /config
containers:
- name: app
  volumeMounts:
  - name: config
    mountPath: /etc/app   # reads rendered config written by init container

Ephemeral Containers — Debug Running Pods

Ephemeral containers can be injected into a running pod without restarting it. Used for debugging when the app container has no shell (distroless, scratch).

# Inject a debug container into a running pod
kubectl debug -it my-pod \
  --image=nicolaka/netshoot \    # has tcpdump, curl, dig, netstat
  --target=app \                  # shares app container's process namespace
  -- bash

# Inside netshoot: debug the running app
ps aux              # see app processes (shared PID namespace)
netstat -tlnp       # see what ports app is listening on
curl localhost:8080/health
tcpdump -i any port 8080

# Debug with a copy of the pod (for crashlooping pods)
kubectl debug my-crashlooping-pod \
  --copy-to=debug-copy \
  --image=busybox \
  --container=app \              # override the crashing container's image
  -- sleep 3600
# Now exec into debug-copy to inspect filesystem, env vars, etc.
graph LR
    POD["Running Pod<br/>(distroless — no shell)"] -->|"kubectl debug inject"| EPH["Ephemeral container<br/>nicolaka/netshoot<br/>shares: network NS, PID NS"]
    EPH -->|"can see"| PROC["App processes"]
    EPH -->|"can curl"| NET["App network"]
    EPH -->|"cannot modify"| POD
    NOTE["Pod is NOT restarted<br/>Ephemeral container auto-removed when exited"]

Ephemeral vs exec:

  • kubectl exec — needs a shell already in the container
  • kubectl debug — injects a new container with tools, works on distroless images

DaemonSet

Runs exactly one pod per node (or per matching node). When new nodes join the cluster, DaemonSet automatically schedules a pod on them.

graph LR
    classDef ds fill:#1abc9c,stroke:#16a085,color:#fff
    classDef node fill:#34495e,stroke:#2c3e50,color:#fff

    DS["DaemonSet: fluentd-logger"]:::ds

    subgraph N1["Node 1"]
        P1["fluentd pod (auto-scheduled)"]:::ds
    end
    subgraph N2["Node 2"]
        P2["fluentd pod"]:::ds
    end
    subgraph N3["Node 3 (new node joins)"]
        P3["fluentd pod (auto-added)"]:::ds
    end

    DS --> P1 & P2 & P3

Common DaemonSet use cases: Log collectors (Fluentd, Fluent Bit), metrics agents (node-exporter), security agents (Falco), network plugins (CNI DaemonSets like aws-node), storage drivers.

DaemonSets can be constrained to specific nodes using nodeSelector or affinity — e.g., run GPU monitoring DaemonSet only on GPU nodes.


Jobs and CronJobs

graph TD
    classDef job fill:#e67e22,stroke:#d35400,color:#fff
    classDef cron fill:#9b59b6,stroke:#8e44ad,color:#fff
    classDef pod fill:#3498db,stroke:#2980b9,color:#fff
    classDef done fill:#2ecc71,stroke:#27ae60,color:#fff

    subgraph JobFlow["Job: run to completion"]
        J["Job: db-migrate completions: 1 parallelism: 1"]:::job
        J --> JP["Pod created"]:::pod
        JP --> DONE["Pod exits 0 Job status: Complete"]:::done
        JP --> FAIL["Pod exits non-zero Job retries (backoffLimit: 4)"]:::job
    end

    subgraph CronFlow["CronJob: scheduled Jobs"]
        CJ["CronJob: nightly-report schedule: 0 2 * * *"]:::cron
        CJ -->|"2 AM daily"| JOB1["Job created (new Job each trigger)"]:::job
        CJ -->|"next night"| JOB2["Job created"]:::job
    end
apiVersion: batch/v1
kind: Job
metadata:
  name: db-migrate
spec:
  completions: 1       # how many successful completions needed
  parallelism: 1       # how many pods run in parallel
  backoffLimit: 4      # retry up to 4 times on failure
  activeDeadlineSeconds: 300  # kill if takes > 5 min
  template:
    spec:
      restartPolicy: Never    # Job pods must use Never or OnFailure
      containers:
        - name: migrate
          image: my-app:v2
          command: ["/app/server", "migrate"]
---
apiVersion: batch/v1
kind: CronJob
metadata:
  name: nightly-report
spec:
  schedule: "0 2 * * *"
  concurrencyPolicy: Forbid       # don't start new job if previous still running
  successfulJobsHistoryLimit: 3   # keep last 3 successful job records
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: reporter
              image: my-app:v2
              command: ["/app/server", "report"]