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.
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
| Phase | Meaning | Common Causes When Stuck |
|---|---|---|
Pending | Accepted but not yet running | No schedulable node, pulling image, PVC not bound |
Running | At least one container running | — |
Succeeded | All containers exited with code 0 | Normal for Jobs |
Failed | All containers terminated, at least one exited non-zero | App crash, OOMKilled |
Unknown | Cannot determine state | Node communication lost |
Container States (within a Pod)
Each container within a Pod has its own state:
| State | Fields | Meaning |
|---|---|---|
Waiting | reason, message | Not yet running (pulling image, crashloop backoff) |
Running | startedAt | Executing |
Terminated | exitCode, reason, startedAt, finishedAt | Finished (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
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 Case | Example |
|---|---|
| Wait for a dependency | Wait for a database to be reachable before starting the app |
| Setup/migration | Run database migrations before the app server starts |
| Clone config/code | Git clone into a shared volume the app container will read |
| Permission fixing | chown/chmod a volume before the non-root app uses it |
| Secrets injection | Fetch 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)
4. Multi-Container Patterns
Three classic patterns for multi-container Pods. Each solves a different composition problem:
Pattern 1: Sidecar
Purpose: Extend the app container with supplementary functionality without modifying it.
| Example | App Container | Sidecar |
|---|---|---|
| Log shipping | Writes logs to a shared volume | Fluentd/Fluent Bit reads and forwards to a log backend |
| Service mesh | Handles business logic | Envoy proxy intercepts all network traffic (Istio pattern) |
| Config reload | Reads config from disk | Watches ConfigMap for changes, signals app to reload |
| TLS termination | Listens on localhost:8080 | Nginx sidecar terminates TLS on :443, proxies to localhost:8080 |
Pattern 2: Ambassador
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
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
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
| Phase | Behavior |
|---|---|
| Startup | Starts in init container order, before app containers |
| Running | Runs alongside app containers (doesn't block them from starting) |
| Shutdown | Stays running until app containers exit, then terminates |
6. Restart Policies
The restartPolicy field controls what kubelet does when a container exits:
| Policy | Behavior | Used By |
|---|---|---|
Always (default) | Always restart, regardless of exit code | Deployments, StatefulSets, DaemonSets |
OnFailure | Restart only if exit code ≠ 0 | Jobs (retry on failure) |
Never | Never restart | Jobs (don't retry), debug pods |
Important: restartPolicy applies to the Pod, not individual containers. All containers in the Pod follow the same policy.
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
| Requests | Limits | |
|---|---|---|
| Purpose | Scheduling guarantee — "I need at least this much" | Hard cap — "Never use more than this" |
| CPU exceeded | N/A (always gets at least requests) | Throttled (not killed) |
| Memory exceeded | N/A (always gets at least requests) | OOMKilled |
| Scheduler uses | ✅ (fits Pod to node) | ❌ (not used for scheduling) |
Summary
| Concept | Key Takeaway |
|---|---|
| Pod | Group of containers sharing network/IPC namespace, co-scheduled |
| Single-container Pod | The default — most Pods have one container |
| Init containers | Run sequentially before app containers, must exit 0 |
| Sidecar pattern | Extend app with helper (logging, proxy, config reload) |
| Ambassador pattern | Proxy outbound connections, hide complexity |
| Adapter pattern | Transform app output to standard format |
| Native sidecars | Init containers with restartPolicy: Always — proper lifecycle |
| Phases | Pending → Running → Succeeded/Failed |
| Restart policy | Always (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?
emptyDir volume shared between both containers.Q2: An init container exits with code 1. What happens to the Pod?
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?
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?
Q5: A Job's Pod completes (exit 0) but the Istio sidecar keeps running. How do native sidecars (K8s 1.28+) solve this?
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?