The Pod is the atomic unit of scheduling in Kubernetes. Not a container — a Pod. Understanding why Pods exist (not just containers), their lifecycle, and multi-container patterns is foundational to everything that follows.

1. Why Pods, Not Just Containers?

You already know containers. So why does Kubernetes add another layer — the Pod?

Because many real-world applications are not a single process. They're a tightly-coupled group of processes that must:

  • Share a network stack (same IP, communicate over localhost)
  • Share storage volumes
  • Be co-scheduled on the same machine
  • Start/stop together as a unit

A Pod is Kubernetes' answer to this: a group of containers that share a sandbox (network namespace, IPC namespace, optionally PID namespace) and are always co-located.

Pod (shared sandbox) Shared: Network NS (IP: 10.244.1.5) · IPC NS · Volumes App Container nginx:1.25 :80 Sidecar log-forwarder reads /var/log Init Container db-migrator (runs first, exits)
Rule of thumb: Put containers in the same Pod only if they are tightly coupled — must share network/storage and cannot function independently. If containers can be scaled independently or talk over a network API, they belong in separate Pods.

Single-Container Pods are the Norm

The vast majority of Pods contain exactly one container. Multi-container Pods are a pattern for specific use cases (sidecars, adapters, init work). Don't default to multi-container — default to one-container-per-Pod.

2. Pod Lifecycle & Phases

A Pod moves through a strict lifecycle. Understanding this is essential for debugging.

Pod Phases

PhaseMeaningCommon Causes When Stuck
PendingAccepted but not yet runningNo schedulable node, pulling image, PVC not bound
RunningAt least one container running
SucceededAll containers exited with code 0Normal for Jobs
FailedAll containers terminated, at least one exited non-zeroApp crash, OOMKilled
UnknownCannot determine stateNode communication lost

Container States (within a Pod)

Each container within a Pod has its own state:

StateFieldsMeaning
Waitingreason, messageNot yet running (pulling image, crashloop backoff)
RunningstartedAtExecuting
TerminatedexitCode, reason, startedAt, finishedAtFinished (success or failure)

Pod Conditions

kubectl get pod nginx -o yaml | grep -A20 conditions:
# conditions:
# - type: PodScheduled     status: "True"   ← assigned to a node
# - type: Initialized      status: "True"   ← init containers done
# - type: ContainersReady  status: "True"   ← all containers passing readiness
# - type: Ready            status: "True"   ← Pod is ready to serve traffic
Phase vs Conditions: Phase is a high-level summary (one value). Conditions give granular detail (multiple boolean states). For debugging, conditions + containerStatuses are far more useful than phase alone.

The CrashLoopBackOff Pattern

When a container crashes repeatedly, Kubernetes applies exponential backoff:

Crash → restart immediately (0s)
Crash → wait 10s → restart
Crash → wait 20s → restart
Crash → wait 40s → restart
... up to 5 minutes max backoff

The Pod shows state: Waiting, reason: CrashLoopBackOff. Debug with:

kubectl logs pod-name --previous    # see logs from the crashed container
kubectl describe pod pod-name       # check exit code and events

3. Init Containers

Init containers run before app containers start. They run sequentially — each must exit successfully (code 0) before the next one starts. Only after all init containers complete do app containers begin.

Use Cases

Use CaseExample
Wait for a dependencyWait for a database to be reachable before starting the app
Setup/migrationRun database migrations before the app server starts
Clone config/codeGit clone into a shared volume the app container will read
Permission fixingchown/chmod a volume before the non-root app uses it
Secrets injectionFetch secrets from Vault and write them to a shared volume

Example

apiVersion: v1
kind: Pod
metadata:
  name: web-app
spec:
  initContainers:
    - name: wait-for-db
      image: busybox
      command: ['sh', '-c', 
        'until nc -z postgres-svc 5432; do echo waiting; sleep 2; done']
    - name: run-migrations
      image: myapp:latest
      command: ['./migrate', '--up']
      volumeMounts:
        - name: config
          mountPath: /etc/app
  containers:
    - name: app
      image: myapp:latest
      ports:
        - containerPort: 8080
      volumeMounts:
        - name: config
          mountPath: /etc/app
  volumes:
    - name: config
      configMap:
        name: app-config

Key Properties

  • Run in order — each must succeed before the next starts
  • If an init container fails, the Pod is restarted (subject to restartPolicy)
  • Init containers can have different images than app containers (security: use a privileged image for setup, unprivileged for runtime)
  • They don't count toward resource limits while app containers run (resources are calculated as max of init OR sum of app)
  • They do not have readiness/liveness probes (they're expected to exit)
Init containers are essential for zero-downtime deployments of stateful apps. Pattern: init container runs schema migration → app container starts with new schema. If migration fails, the Pod never starts, preserving the previous working version.

4. Multi-Container Patterns

Three classic patterns for multi-container Pods. Each solves a different composition problem:

Pattern 1: Sidecar

Pod App Container writes /var/log/app.log shared vol Sidecar reads /var/log → ships

Purpose: Extend the app container with supplementary functionality without modifying it.

ExampleApp ContainerSidecar
Log shippingWrites logs to a shared volumeFluentd/Fluent Bit reads and forwards to a log backend
Service meshHandles business logicEnvoy proxy intercepts all network traffic (Istio pattern)
Config reloadReads config from diskWatches ConfigMap for changes, signals app to reload
TLS terminationListens on localhost:8080Nginx sidecar terminates TLS on :443, proxies to localhost:8080

Pattern 2: Ambassador

App connects localhost:5432 Ambassador proxies to correct DB shard → DB cluster

Purpose: Proxy connections to external services, hiding complexity from the app. The app always connects to localhost; the ambassador handles routing, sharding, or failover.

  • Database proxy (pgbouncer, ProxySQL) — connection pooling, routing to read replicas
  • Redis cluster proxy — app sees single-node interface, ambassador handles cluster topology
  • Cloud SQL Auth Proxy — handles OAuth2 authentication to managed databases

Pattern 3: Adapter

App custom metrics format Adapter transforms → Prometheus → Prometheus

Purpose: Normalize output from the app container into a standard format expected by external systems.

  • Prometheus exporter — converts app-specific metrics to Prometheus format on /metrics
  • Log format normalizer — transforms legacy log format to structured JSON
  • Protocol adapter — converts gRPC to REST for legacy consumers
All three patterns share one principle: The app container doesn't know the sidecar/ambassador/adapter exists. It's decoupled — you can swap the sidecar without touching the app image. This is the power of shared-namespace composition.

5. Native Sidecar Containers (K8s 1.28+)

Traditionally, sidecars were just regular containers — they started alongside the app and Kubernetes didn't know they were "helpers." This caused problems:

  • Sidecars didn't start before the app (Istio proxy needs to be ready first)
  • Sidecars didn't shut down after the app (Jobs would never complete)
  • No ordering guarantees

Kubernetes 1.28 introduced native sidecar containers — declared as init containers with restartPolicy: Always:

spec:
  initContainers:
    - name: istio-proxy
      image: istio/proxyv2
      restartPolicy: Always    # ← This makes it a native sidecar
      # Starts before app containers, runs alongside them,
      # shuts down AFTER app containers terminate
  containers:
    - name: app
      image: myapp:latest

Native Sidecar Lifecycle

PhaseBehavior
StartupStarts in init container order, before app containers
RunningRuns alongside app containers (doesn't block them from starting)
ShutdownStays running until app containers exit, then terminates
This solves the Istio + Jobs problem: the Envoy sidecar used to keep the Job Pod running forever after the Job container completed. With native sidecars, the proxy starts first (ready to intercept traffic before the app sends any) and shuts down after the Job exits.

6. Restart Policies

The restartPolicy field controls what kubelet does when a container exits:

PolicyBehaviorUsed By
Always (default)Always restart, regardless of exit codeDeployments, StatefulSets, DaemonSets
OnFailureRestart only if exit code ≠ 0Jobs (retry on failure)
NeverNever restartJobs (don't retry), debug pods

Important: restartPolicy applies to the Pod, not individual containers. All containers in the Pod follow the same policy.

Restart ≠ Reschedule: Restart means the kubelet restarts the container on the same node. If the node itself dies, it's the ReplicaSet/Deployment controller that creates a new Pod on a different node — that's not a "restart," it's a new Pod entirely.

7. Pod Spec Essentials

Key fields every K8s practitioner must know:

apiVersion: v1
kind: Pod
metadata:
  name: web
  labels:
    app: web
spec:
  # --- Scheduling ---
  nodeName: worker-1              # skip scheduler, pin to node
  nodeSelector:                   # simple label-based scheduling
    disktype: ssd
  tolerations:                    # tolerate taints
    - key: "dedicated"
      operator: "Equal"
      value: "gpu"
      effect: "NoSchedule"
  
  # --- Security ---
  serviceAccountName: web-sa      # identity for RBAC
  securityContext:                 # Pod-level security
    runAsUser: 1000
    runAsGroup: 1000
    fsGroup: 2000
  
  # --- Containers ---
  containers:
    - name: app
      image: nginx:1.25
      ports:
        - containerPort: 80
      resources:                  # ALWAYS set in production
        requests:
          cpu: 100m
          memory: 128Mi
        limits:
          cpu: 500m
          memory: 256Mi
      securityContext:            # container-level security
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
      volumeMounts:
        - name: data
          mountPath: /usr/share/nginx/html
  
  # --- Volumes ---
  volumes:
    - name: data
      emptyDir: {}
  
  # --- Lifecycle ---
  restartPolicy: Always
  terminationGracePeriodSeconds: 30   # time for graceful shutdown
  
  # --- DNS ---
  dnsPolicy: ClusterFirst             # use cluster DNS (default)

Resource Requests vs Limits

RequestsLimits
PurposeScheduling guarantee — "I need at least this much"Hard cap — "Never use more than this"
CPU exceededN/A (always gets at least requests)Throttled (not killed)
Memory exceededN/A (always gets at least requests)OOMKilled
Scheduler uses✅ (fits Pod to node)❌ (not used for scheduling)
The most debated topic in production K8s: should you set CPU limits? Arguments against: CPU limits cause throttling even when the node has idle CPU. Many teams set only CPU requests (no limit) to allow bursting, but always set memory limits (to prevent OOM affecting other Pods). This is a valid production pattern.

Summary

ConceptKey Takeaway
PodGroup of containers sharing network/IPC namespace, co-scheduled
Single-container PodThe default — most Pods have one container
Init containersRun sequentially before app containers, must exit 0
Sidecar patternExtend app with helper (logging, proxy, config reload)
Ambassador patternProxy outbound connections, hide complexity
Adapter patternTransform app output to standard format
Native sidecarsInit containers with restartPolicy: Always — proper lifecycle
PhasesPending → Running → Succeeded/Failed
Restart policyAlways (services), OnFailure (jobs), Never (one-shot)

📝 Quiz: Pods from First Principles

Q1: You have a web server and a log forwarder that must share log files. Same Pod or separate Pods? Why?

Same Pod (sidecar pattern). They need to share a volume (logs), must be co-located, and the log forwarder can't function without the web server's output. Use an emptyDir volume shared between both containers.

Q2: An init container exits with code 1. What happens to the Pod?

The Pod's restartPolicy kicks in. With Always or OnFailure, the init container is restarted (with backoff). App containers will never start until all init containers succeed. The Pod stays in Init:Error or Init:CrashLoopBackOff status.

Q3: What's the difference between the sidecar and ambassador patterns?

Sidecar: Extends the app's capabilities (adds functionality — logging, metrics, config watch). Traffic direction: outbound from Pod.
Ambassador: Proxies the app's outbound connections to external services (hides complexity — sharding, pooling, auth). The app connects to localhost; the ambassador routes to the real destination.

Q4: A Pod has requests: 256Mi memory and limits: 512Mi memory. The container uses 400Mi. What happens?

Nothing — it's fine. The container is allowed to use up to its limit (512Mi). It's using 400Mi which is between requests and limits. It would only be OOMKilled if it exceeds 512Mi. The 256Mi request is what the scheduler used to place the Pod — it guarantees at least that much.

Q5: A Job's Pod completes (exit 0) but the Istio sidecar keeps running. How do native sidecars (K8s 1.28+) solve this?

Declare the Istio proxy as an init container with restartPolicy: Always. As a native sidecar, it starts before the Job container and — critically — shuts down automatically after the app container exits. The Pod can then reach the Succeeded phase.

Q6: Container A (app) uses 200m CPU and Container B (sidecar) uses 100m CPU in the same Pod. What CPU request does the scheduler see for this Pod?

300m. For app containers, the scheduler sums their requests: 200m + 100m = 300m. (For init containers, it takes the max of each init container vs the sum of app containers — whichever is larger is the Pod's effective request.)