Multi-container Pods
When a Pod should hold more than one container. Init containers, native sidecars, the adapter and ambassador patterns, and when to use two Pods.
A Pod can hold several containers, and they get two things no separate Pods can have: a shared network namespace and the ability to share volumes.
That is the entire justification. If two containers do not need either, they belong in separate Pods.
What is shared and what is not
| Shared | Not shared |
|---|---|
Network namespace: one IP, one port space, localhost between them | Filesystem, unless you mount a shared volume |
| Volumes you declare and mount | Process namespace, by default |
| Scheduling and lifecycle (created and deleted together) | Resource limits, which are per container |
| The node | Restarts (containers restart individually) |
The shared port space has a consequence people hit immediately: two containers in a Pod cannot listen on the same port. The second bind fails exactly as it would on a single host.
Init containers
Run to completion, in order, before any application container starts.
apiVersion: v1
kind: Pod
metadata:
name: payments-api
spec:
initContainers:
- name: wait-for-db
image: busybox:1.36
command: ['sh', '-c', 'until nc -z payments-db 5432; do echo waiting; sleep 2; done']
- name: run-migrations
image: ghcr.io/acme/payments-api:1.4.2
command: ['./migrate', 'up']
containers:
- name: api
image: ghcr.io/acme/payments-api:1.4.2
Each must succeed before the next starts. A failure is retried per the Pod's restart policy and the application containers never run, which is usually exactly right for a migration.
Init containers are also a neat security trick: a tool needed only at startup (a secret fetcher, a git clone) lives in the init container's image, so it is absent from the long-running container's attack surface.
A Pod stuck at Init:0/2 means the first init container is failing or waiting:
kubectl logs <pod> -c wait-for-db
Sidecars
A sidecar runs alongside the application for its whole life: a log shipper, a metrics exporter, a service mesh proxy.
Modern Kubernetes models these as init containers with restartPolicy: Always, which is the native sidecar mechanism:
spec:
initContainers:
- name: log-shipper
image: fluent/fluent-bit:3.1
restartPolicy: Always # makes this a sidecar, not a run-once init
volumeMounts:
- name: logs
mountPath: /var/log/app
readOnly: true
containers:
- name: api
image: ghcr.io/acme/payments-api:1.4.2
volumeMounts:
- name: logs
mountPath: /var/log/app
volumes:
- name: logs
emptyDir: {}
The odd-looking placement solves real problems that plain sidecars had:
- Startup ordering. A native sidecar starts before the app containers, so a mesh proxy is ready before the application sends its first request.
- Shutdown ordering. It terminates after the app containers, so final logs are shipped and last requests can still be proxied.
- Jobs actually complete. A plain sidecar kept running after the main container finished, so the Job never completed. This was the long-standing bug the native mechanism fixes.
If you are on an older cluster, sidecars still work as ordinary entries in containers. You just have to handle ordering yourself, typically with a preStop hook.
The other two patterns
Adapter: reshapes output the application already produces. Translating a bespoke metrics format into something Prometheus can scrape, without modifying the application.
Ambassador: proxies the application's outbound connections. The application connects to localhost:5432 and the ambassador handles TLS, connection pooling, or failover to a real database elsewhere. Simplifies the application at the cost of another process.
Sharing data between containers
The shared network namespace means localhost:
spec:
containers:
- name: api
image: ghcr.io/acme/payments-api:1.4.2
env:
- name: CACHE_URL
value: "http://localhost:6379" # the sidecar, no service discovery
- name: cache
image: redis:7-alpine
And emptyDir for files:
volumes:
- name: shared
emptyDir: {}
Set a sizeLimit on any emptyDir you share. Without one, a log file that grows unchecked fills the node's disk and gets every Pod on that node evicted.
When not to use a multi-container Pod
The temptation is to bundle an application with its database, or a frontend with its backend. Do not.
Containers in a Pod scale together, deploy together and are scheduled together. You cannot run three API replicas and one database from the same Pod definition. You get three databases. And every application deploy restarts the database with it.
| Symptom | What it means |
|---|---|
| They need different replica counts | Separate Pods |
| One is stateful, the other is not | Separate Pods, StatefulSet for the stateful one |
| They are deployed on different schedules | Separate Pods |
| They only talk over the network | Separate Pods and a Service |
| One reads files the other writes | Same Pod, shared volume |
| One proxies the other's traffic | Same Pod, shared network |
The test: would they ever need to be on different machines? If yes, separate Pods.
Working with them
kubectl logs <pod> -c api
kubectl logs <pod> --all-containers --prefix
kubectl exec -it <pod> -c api -- sh
kubectl describe pod <pod> # per-container state and events
With more than one container, kubectl logs refuses to guess and errors until you pass -c.
Resource requests and limits are per container, and the Pod's total is their sum, so a Pod with a 512Mi application and a 256Mi sidecar requests 768Mi. Sidecars replicated across hundreds of Pods add up to real money; size them deliberately rather than copying a default.
Quick reference
| Task | Command |
|---|---|
| Logs from one container | kubectl logs <pod> -c <container> |
| Logs from all | kubectl logs <pod> --all-containers --prefix |
| Init container logs | kubectl logs <pod> -c <init-container> |
| Shell into one | kubectl exec -it <pod> -c <container> -- sh |
| Per-container status | kubectl describe pod <pod> |
| List container names | kubectl get pod <n> -o jsonpath='{.spec.containers[*].name}' |
Key takeaways
- A shared network namespace and shared volumes are the only reasons to put containers in one Pod.
- Two containers cannot bind the same port: they share one port space.
- Init containers run to completion in order and block the application if they fail.
- Native sidecars are init containers with
restartPolicy: Always, which fixes startup order, shutdown order, and Jobs that never completed. - Set
sizeLimiton sharedemptyDirvolumes or a runaway log fills the node. - Containers in a Pod scale and deploy together. Different replica counts means different Pods.
- Resources are per container and sidecars multiply across every replica.
Check your understanding
A Job that never finishes
A Job runs a batch task plus a log-shipping sidecar listed under containers. The task completes, but the Job stays active indefinitely. Why?
An application bundled with its database
A team puts their API and its PostgreSQL database in one Pod "so they can talk over localhost", then scales the Deployment to three replicas. What happens?
Frequently Asked Questions
When should a Pod contain more than one container?
Only when the containers need a shared network namespace or a shared volume. A log shipper reading files the application writes needs the volume; a service mesh proxy intercepting the application's traffic needs the network namespace. Those are genuine reasons. If two containers merely communicate over the network, they belong in separate Pods with a Service between them, because containers in a Pod are scheduled, scaled and deployed as a single unit.
What is the difference between an init container and a sidecar?
An init container runs to completion before the application containers start, and is used for migrations, waiting on dependencies, or fetching configuration. A sidecar runs for the whole life of the Pod alongside the application. Modern Kubernetes expresses sidecars as init containers with restartPolicy: Always, which makes them start before the app containers and terminate after them. Solving startup ordering for service mesh proxies and, importantly, letting Jobs with sidecars actually complete.
Why does my Job with a sidecar never complete?
Because a Job is only complete when every container in its Pod has terminated, and a sidecar such as a log shipper has no reason to exit. The Pod stays active forever. The fix is to declare the sidecar as an init container with restartPolicy: Always rather than as an ordinary entry in containers; Kubernetes then recognises it as a sidecar and shuts it down once the main containers finish. On older clusters the workaround was for the main container to signal the sidecar to exit.
Can containers in a Pod use the same port?
No. They share a single network namespace, and therefore one IP address and one port space, exactly as two processes on the same host would. The second container to bind the port fails with "address already in use" and typically ends up in CrashLoopBackOff. Give each container a distinct port. This same shared namespace is what lets them reach each other over localhost with no service discovery at all.
How do containers in a Pod share files?
Through a volume mounted into both, usually an emptyDir. It is created when the Pod is scheduled and deleted when the Pod is removed, which matches the shared lifecycle. Mount it at whatever path each container expects; they can be different paths. Always set a sizeLimit, because an emptyDir consumes node disk and a log file growing unchecked can fill the node and cause every Pod on it to be evicted.
Should I put my application and database in the same Pod?
No. Containers in a Pod scale together, so three replicas means three databases with three separate datasets. They also deploy together, so every application release restarts the database. And they are scheduled together, so both compete for one node's resources. Run the database as a StatefulSet with its own storage and put a Service in front of it. The convenience of localhost is not worth losing independent scaling, independent deployment, and the ability to give each workload the resources it actually needs.