When a pod should hold more than one container
A Kubernetes pod is a group of one or more containers scheduled together on the same node, sharing a network namespace and (optionally) volumes. Most workloads are a single container per pod. The patterns in this article exist for the few cases where they should not be.
The rule of thumb: put two containers in the same pod when they are tightly coupled and share fate. They start together, are scheduled together, scale together, and can talk to each other over localhost without a network hop. If you can imagine running them on different nodes without breaking anything, they belong in separate pods.
Four patterns dominate the multi-container design space:
- Init containers for ordered startup work that must finish before the app runs.
- Sidecar containers for auxiliary processes that run alongside the app for the pod's full lifetime.
- Ambassador containers for proxying outbound network traffic on behalf of the app.
- Adapter containers for normalising the app's output (logs, metrics) into a format other systems expect.
The rest of this article walks through each, gives production-ready YAML, and explains how the native sidecar feature that reached stable in Kubernetes 1.33 changed the resource accounting math.
What a pod actually shares between containers
Understanding what a pod shares is the foundation for every pattern. Containers in the same pod share:
- The pod's network namespace. Every container sees the same loopback interface and shares the pod IP. Two containers in the same pod talk to each other via
127.0.0.1:<port>, not via a Service. They cannot bind the same port. This is documented in the pod concept page: "Pods natively provide two kinds of shared resources for their constituent containers: networking and storage." - Volumes mounted at the pod level. Most multi-container patterns hand data between containers via an
emptyDirvolume. All containers in the pod can mount it, often at different paths. The emptyDir documentation is explicit: "All containers in the Pod can read and write the same files in theemptyDirvolume." - The same node. The pod is the unit of scheduling, so every container in it lands on the same node and survives or dies as a group.
- Optional process namespace. When you set
shareProcessNamespace: trueon the pod spec, every container sees every other container's processes. Useful for debugging sidecars, dangerous as a default.
Containers in the same pod do not share an MNT namespace by default. Each container has its own filesystem from its own image; the only filesystem-level sharing is via mounted volumes.
The emptyDir volume is the workhorse. Its lifetime equals the pod's lifetime: when the pod is deleted, the volume's contents are gone. Container crashes do not destroy the contents. Setting medium: Memory backs the volume by tmpfs instead of disk, which keeps secrets out of node-level storage but counts the volume's contents against the container's memory limit.
Pattern 1: Sidecar (classic vs native)
A sidecar runs continuously next to the main app: a log shipper tailing files into Loki, a metrics exporter scraping a Unix socket, an Envoy proxy intercepting traffic for a service mesh.
Classic sidecar (additional regular container)
The classic pattern is to declare the sidecar as a second entry in containers:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-app
spec:
replicas: 3
selector:
matchLabels:
app: web-app
template:
metadata:
labels:
app: web-app
spec:
containers:
- name: app # main container, writes to /var/log/app
image: mycompany/web:1.6.0
volumeMounts:
- name: logs
mountPath: /var/log/app
- name: log-shipper # classic sidecar, tails the same volume
image: grafana/promtail:3.4.0
args:
- "-config.file=/etc/promtail/promtail.yaml"
volumeMounts:
- name: logs
mountPath: /var/log/app
- name: promtail-config
mountPath: /etc/promtail
volumes:
- name: logs
emptyDir: {}
- name: promtail-config
configMap:
name: promtail-config
Both containers start in parallel. There is no startup ordering guarantee. The main container can produce log files before the shipper is ready to read them, and on shutdown the shipper might be killed before flushing the last lines. For a Deployment this is usually fine. For a Job it is a problem: a sidecar in containers keeps the pod alive forever because the Job controller waits for every container to exit, and a tailing log shipper never does.
Native sidecar (init container with restartPolicy: Always)
The native sidecar pattern, stable since Kubernetes 1.33, encodes the sidecar as a special init container with restartPolicy: Always:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-app
spec:
replicas: 3
selector:
matchLabels:
app: web-app
template:
metadata:
labels:
app: web-app
spec:
initContainers:
- name: log-shipper # native sidecar
image: grafana/promtail:3.4.0
restartPolicy: Always # this is what makes it a sidecar
args:
- "-config.file=/etc/promtail/promtail.yaml"
volumeMounts:
- name: logs
mountPath: /var/log/app
- name: promtail-config
mountPath: /etc/promtail
startupProbe:
httpGet:
path: /ready
port: 3101
failureThreshold: 30
periodSeconds: 2
containers:
- name: app
image: mycompany/web:1.6.0
volumeMounts:
- name: logs
mountPath: /var/log/app
volumes:
- name: logs
emptyDir: {}
- name: promtail-config
configMap:
name: promtail-config
What the native form changes:
- Startup ordering. The kubelet starts the sidecar first, waits for its
startupProbeto pass, then starts the app container. The app never sees the volume in a half-initialized state. - Probe support. Native sidecars support
startupProbe,readinessProbe, andlivenessProbe. Classic sidecars-as-extra-containers also support probes (they are regular containers), but native sidecars also gate the main container's start on the startup probe. - Termination ordering. On pod shutdown, main containers receive SIGTERM first, then sidecars. The log shipper has time to flush after the app exits.
- Job completion. A native sidecar does not block Job completion. The Job controller treats only the entries in
containersas the completion signal, so a tailing log shipper can finally live in the same pod as a one-shot batch container. - OOM score. Native sidecars have their kernel OOM score adjusted to align with primary containers, so they are not preferentially killed under memory pressure.
If you are on Kubernetes 1.29 or newer, use native sidecars. The SidecarContainers feature gate has been enabled by default since 1.29, and the API surface is identical.
Pattern 2: Init container
Init containers run before the app starts, sequentially, to completion. Each must exit 0 before the next begins, and the main container only starts when all init containers have finished. Use them for one-time setup: waiting for a dependency, running a database migration, fetching configuration into a shared volume, fixing filesystem permissions.
spec:
initContainers:
- name: wait-for-postgres
image: busybox:1.37 # tested with busybox 1.37
command:
- sh
- -c
- |
until nc -z postgres-service 5432; do
echo "waiting for postgres..."
sleep 2
done
containers:
- name: api
image: mycompany/api:3.2.0
Init containers do not support liveness, readiness, or startup probes. They are not long-lived processes. If you need a process that stays alive but blocks the main container from starting, you want a native sidecar with a startupProbe, not an init container.
The full treatment, including database migration patterns, the emptyDir config-fetching pattern, and the concurrency trap when a Deployment scales to multiple replicas, lives in Kubernetes init containers.
Pattern 3: Ambassador (proxy outbound traffic)
The ambassador pattern puts a proxy container alongside the app to handle outbound network complexity. The app talks plain HTTP to localhost. The ambassador handles TLS, retries, circuit breaking, service discovery, or sharding to a downstream system.
A common case is sharded Redis. The app does not want to know about hash slots and replica topology; it wants a single Redis endpoint. The ambassador runs twemproxy and the app talks to localhost:6379:
spec:
containers:
- name: app
image: mycompany/api:3.2.0
env:
- name: REDIS_URL
value: "redis://localhost:6379" # always local, never sharded
- name: redis-ambassador
image: kuiken/twemproxy:0.5
args:
- "-c"
- "/etc/twemproxy/nutcracker.yaml"
ports:
- containerPort: 6379
volumeMounts:
- name: nutcracker-config
mountPath: /etc/twemproxy
volumes:
- name: nutcracker-config
configMap:
name: nutcracker-config
The same pattern shows up everywhere a service mesh is involved. Istio's Envoy sidecar, Linkerd's linkerd2-proxy, and Consul Connect proxies are all ambassadors. The app sees a flat localhost world; the ambassador handles mTLS, traffic policy, and retries.
When ambassador is the right choice:
- The downstream system has connection management complexity (sharding, leader election, mTLS) that you do not want in every language client your fleet runs.
- You want to upgrade the proxy independently of the app.
- The app is third-party and you cannot modify it.
When ambassador is the wrong choice:
- The proxy becomes a per-pod bottleneck. A single Envoy sidecar on a pod with one busy app instance is fine; a single Envoy sidecar serving as a shared egress proxy for many apps is a different architecture (a separate proxy Deployment, not a sidecar).
- The latency budget cannot absorb an extra hop. Localhost is fast, but it is not free; you still pay TCP setup, serialization, and a context switch.
Pattern 4: Adapter (normalize app output)
The adapter pattern is the inverse of ambassador. Where ambassador rewrites outgoing traffic, adapter rewrites outgoing observable state: logs, metrics, status endpoints. The app produces its own format; the adapter translates that format into what the rest of the platform expects.
The canonical case is a metrics exporter:
spec:
containers:
- name: legacy-app
image: mycompany/legacy:5.4.0
# exposes /jolokia, a JMX-over-HTTP endpoint specific to JVM apps
ports:
- containerPort: 8778
name: jolokia
- name: jmx-exporter
image: bitnami/jmx-exporter:1.0.1
args:
- "5556"
- "/etc/jmx-exporter/config.yaml"
ports:
- containerPort: 5556
name: prom-metrics # what the platform expects to scrape
volumeMounts:
- name: exporter-config
mountPath: /etc/jmx-exporter
volumes:
- name: exporter-config
configMap:
name: jmx-exporter-config
Prometheus scrapes :5556. The legacy app keeps producing JMX. The adapter sits in between. When you upgrade the app, the adapter contract stays stable; when you change Prometheus configuration, the app does not care.
Other adapter use cases:
- A Fluent Bit sidecar reading container logs from
stdoutand shipping them as structured JSON. - A small webserver that exposes a unified
/healthzand/readyzby aggregating multiple legacy app endpoints. - A protocol translator that exposes gRPC for an HTTP-only legacy app, or vice versa.
The adapter pattern is also where pods get into trouble. Every adapter is glue code that has to be kept in sync with both ends. If the legacy app changes its /jolokia schema and the JMX exporter is not updated, metrics silently disappear. Treat the adapter as a separately-versioned dependency, not a forgotten implementation detail.
Native sidecar containers (Kubernetes 1.29+ beta default-on, 1.33 stable)
Native sidecars deserve a section of their own because their lifecycle and resource accounting are subtly different.
Lifecycle
The kubelet starts containers in this order:
- Volume mounts and pod-level setup.
- Native sidecars (init containers with
restartPolicy: Always), in declaration order. The next sidecar starts as soon as the previous one'sstartupProbepasses (or immediately, if no probe is defined). - Regular init containers, in declaration order, each running to completion before the next begins.
- Main containers in
containers, all started in parallel.
On termination the order reverses. Main containers get SIGTERM first; once they exit, sidecars get SIGTERM, then (after the grace period) SIGKILL. The official documentation calls out that sidecar exit codes other than zero are normal during pod termination and should be ignored, because the sidecar's exit is forced by the kubelet, not graceful.
When the feature gate matters
The SidecarContainers feature gate has the following history, per the native sidecar containers blog post and the v1.33 release announcement:
- Alpha in Kubernetes 1.28 (August 2023). Off by default; not for production.
- Beta in Kubernetes 1.29. On by default.
- Stable in Kubernetes 1.33 (April 23, 2025). The gate is locked on and slated for removal.
If you are on a managed Kubernetes service (EKS, GKE, AKS), check the cluster's reported version before relying on native sidecars. EKS, GKE, and AKS support the feature on every supported minor release at this point, but a self-managed cluster pinned to 1.27 or earlier needs to either upgrade or use the classic pattern.
Resource requests, limits, and HPA in multi-container pods
This is where the patterns differ in ways that bite you in production.
Effective request math
The Kubernetes init containers documentation defines two numbers:
- Effective init request for a resource: the highest single request among all init containers (because they run sequentially, only one's footprint matters at a time).
- Effective pod request for a resource: the higher of (sum of all main containers' requests) and (effective init request).
For a pod with classic init containers and no native sidecars, this means:
effective_request = max(
max(initContainer[i].request),
sum(container[i].request)
)
Native sidecars change the formula. Because a sidecar is an init container that keeps running into the main phase, its request must be added to the main-container sum during that phase. The effective request becomes:
# pod with native sidecars
effective_request = max(
max(InitContainerUse(i)), # max single request during init phase
sum(sidecar.request) + sum(container.request) # both phases combined
)
Where InitContainerUse(i) accounts for sidecars already running by the time init container i starts (each subsequent init container sees the sidecars added to its own request). This is documented in the sidecar containers section on resource sharing.
The practical consequence: a pod with three native sidecars at 100m CPU each plus a main container at 500m CPU has an effective request of 800m, not 500m. The scheduler reserves 800m on whichever node accepts the pod. Forgetting this and packing nodes based on the main container's request alone produces overcommit.
HPA per-container scaling
When pods have asymmetric containers (a busy app and a near-idle log shipper), pod-level CPU averages mask the truth. The HPAContainerMetrics feature gate and the ContainerResource metric type let HPA scale on the utilization of one named container instead of the pod average:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: web-app-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: web-app
minReplicas: 3
maxReplicas: 30
metrics:
- type: ContainerResource # not "Resource"
containerResource:
name: cpu
container: app # name of the container to track
target:
type: Utilization
averageUtilization: 70
The timeline for HPAContainerMetrics: alpha in 1.20, beta default-on in 1.27, GA in 1.30. On clusters older than 1.27 the feature gate must be explicitly enabled. On 1.30 and newer it is locked on and removable from the gate list.
For the full HPA configuration story, including custom metrics and scaling behaviour, see Kubernetes Horizontal Pod Autoscaler.
emptyDir Memory and the limit accounting trap
When a pattern uses emptyDir with medium: Memory (a tmpfs mount), the bytes written into that volume count against the memory limit of the container that wrote them, not against ephemeral storage. A 200Mi config file written into a memory-backed emptyDir by an init container, and then read by a main container with a 256Mi memory limit, leaves only 56Mi for the main container's heap. This is a frequent surprise.
For broader guidance on requests, limits, QoS classes, and overcommit strategy, see Kubernetes resource requests and limits.
What multi-container pods are NOT
Five things multi-container pods are commonly misunderstood as.
They are not a way to get sidecars to start after the main container. Sidecars are concurrent, not sequential. A native sidecar starts before the main container, not after. If your sidecar genuinely depends on the main container being up first (rare; usually the inverse is what you want), you need application-level retry logic, not a Kubernetes feature.
They do not give the sidecar a separate network namespace. Every container in the pod shares the pod's network namespace. The sidecar sees the same 127.0.0.1 and the same loopback interface as the main container. Two containers cannot bind the same port. If you assumed the sidecar had its own netns, you misread the architecture.
Native sidecars in 1.29 and later do not replace the classic pattern. The classic pattern (sidecar in containers) still works exactly as before. Native sidecars are an opt-in. Pick native when you need startup ordering, probe gating, or Job-completion semantics; pick classic when you do not, and avoid the cognitive overhead.
Multi-container pods do not scale independently per container. Replicas scale the whole pod. If your sidecar is doing 100x more work than your main container, the answer is not to scale the pod up; it is to extract the sidecar into its own Deployment and talk to it via a Service.
They are not a substitute for proper architecture. I have seen teams put four containers in a pod because the components were "related". Nothing about being related justifies a multi-container pod. The justification is shared lifecycle, shared filesystem, or shared network namespace. If none of those apply, separate pods are simpler and resize independently.
Decision matrix
Pick the pattern from the dominant requirement.
| Need | Pattern | Why |
|---|---|---|
| Run a setup task that must finish before the app starts | Init container | Sequential, run-to-completion semantics |
| Run a long-lived helper alongside the app | Sidecar (native if 1.29+) | Concurrent lifetime, probe support, Job-friendly |
| Proxy outgoing traffic to handle TLS, sharding, mesh | Ambassador | App treats localhost as a flat endpoint |
| Translate the app's output (logs, metrics) into a standard format | Adapter | Glue between app and platform contracts |
| Block app startup until a sidecar is ready | Native sidecar with startupProbe |
Init containers cannot do probes |
| Run something for one batch and finish | Job with init container or Job with native sidecar | Classic sidecars block Job completion |
| Run a fleet-wide proxy with high traffic | Separate Deployment + Service | Sidecars don't scale independently |
| Run two unrelated apps that happen to be in the same team | Separate pods | "Related" is not a multi-container pod justification |
Debugging multi-container pods
kubectl describe pod is the starting point. The Init Containers:, Containers:, and Events: sections each report state separately.
Logs from a specific container:
# main container
kubectl logs <pod> -c app
# init or sidecar (sidecar is in initContainers in the spec)
kubectl logs <pod> -c log-shipper
# previous run after a crash
kubectl logs <pod> -c app --previous
Exec into a specific container:
kubectl exec -it <pod> -c app -- /bin/sh
When two containers seem to disagree about a shared volume's contents, inspect both sides:
kubectl exec <pod> -c init-writer -- ls -la /shared
kubectl exec <pod> -c app -- ls -la /shared
For containers without a shell (distroless images), use ephemeral debug containers. For pods stuck before any container starts, the issue is usually image pull or volume mount; see debugging ContainerCreating-stuck pods.
Where to go next
- Kubernetes init containers for the full init container playbook with database migrations, config fetching, and concurrency traps.
- Kubernetes resource requests and limits for the underlying mechanics of requests, limits, and QoS classes.
- Kubernetes Horizontal Pod Autoscaler for per-container scaling using the
ContainerResourcemetric type.