CoursesKubernetes administrationMulti-container patterns

Multi-container patterns

Sidecar, ambassador, and adapter, and when to use them.

Advanced12 min · lesson 18 of 65
In plain terms
A sidecar is a motorcycle sidecar: a helper riding along with the main bike, sharing the same ride. If the helper doesn’t truly need to ride with this exact bike, give it its own.

A nightly export Job ran clean for months. Then someone added a service-mesh proxy to it, and the Job stopped ever finishing. The export itself took ninety seconds. The Pod sat in Running for hours. The proxy was doing exactly what it was told, staying up and shipping traffic, and nothing had told it the real work was over. That one incident is the whole story of multi-container Pods: a helper in the same box is a gift when it belongs there, and a quiet trap when it doesn't.

A Pod (the smallest thing Kubernetes actually runs) is rarely just one container. Think of it as a small shared apartment. The containers inside share one phone line, meaning one network namespace, so they reach each other over localhost with no Service in between. They can also share a shelf, an emptyDir volume that lives exactly as long as the Pod. What they don't share by default is their own stuff. Each container keeps its own root filesystem from its own image, and its own process table, so the helper only sees the app's files where you deliberately mount a shared volume like that emptyDir. They move in together, get scheduled onto the same node together, and move out together. That shared apartment is the entire reason these patterns exist. A helper can watch the app's files or answer its localhost calls with no network hop, because it's in the same room.

The three patterns

There are three named shapes, and the shape matters more than the name. A sidecar is a helper that rides along for the app's whole life: a log shipper reading the app's log folder and forwarding the lines, a mesh proxy handling mTLS (mutual TLS, where both ends prove who they are) so the app never touches a certificate, a syncer pulling fresh config into a shared folder. An ambassador is an outbound receptionist. The app dials localhost as if the database were sitting right there, and the ambassador container makes the real call, adding retries or sharding or TLS on the way out, so the app stays dumb about the outside world. An adapter runs the other direction. It takes whatever odd thing the app already emits and reshapes it into a standard format. The classic case is metrics: the app prints its own numbers on some port, and the adapter re-exposes them in the Prometheus exposition format (a plain text metrics format, named after the monitoring system that reads it) so your monitoring can scrape them, without anyone touching the app image. These are roles, not rules. A single Pod can carry a sidecar and an adapter at the same time, and plenty of real helpers blur the line between them.

web-with-logshipper.yaml
apiVersion: v1
kind: Pod
metadata:
name: web
spec:
containers:
- name: app
image: registry/app:1.4.2
volumeMounts:
- { name: logs, mountPath: /var/log/app }
- name: log-shipper
image: fluent/fluent-bit:2.1
args: ["-i", "tail", "-p", "path=/var/log/app/*.log", "-o", "stdout"]
volumeMounts:
- { name: logs, mountPath: /var/log/app, readOnly: true }
volumes:
- name: logs
emptyDir: {}
apply and verify
$ kubectl apply -f web-with-logshipper.yaml
pod/web created
$ kubectl get pod web
NAME READY STATUS RESTARTS AGE
web 2/2 Running 0 18s

READY 2/2 is the whole confirmation. Both containers are up, they share the logs volume, and the shipper is tailing files the app writes without a single byte crossing the network.

Native sidecars, and why ordering is the hard part

The Pod above has a bug you can't see yet. A plain second container in spec.containers has no ordering. It starts whenever, right alongside the app, and on shutdown it gets the stop signal at the same time as the app. Two things break from that. During a deploy, the mesh proxy can close its connections while the app is still draining in-flight requests, so every rollout throws a handful of 5xx errors. And in a Job, a sidecar that never exits keeps the Pod alive forever. That is the export that never finished. Native sidecars fix the ordering. You declare the helper as an init container with restartPolicy: Always. That one line changes everything. The kubelet (the agent Kubernetes runs on every node) starts it during the init phase, before the app containers, but because it's marked Always the kubelet doesn't wait for it to exit before moving on, and it keeps running for the Pod's whole life. You can also give that sidecar a startup probe, and the kubelet won't start the app until the probe passes, so the app comes up into a proxy that's already ready rather than one that's merely started. On shutdown, the kubelet sends SIGTERM (the polite 'please stop' signal) to the app containers first and only stops the sidecar once they're gone. A Job counts as complete when the regular containers finish, ignoring the still-running sidecar. On a v1.31 cluster this is on by default (it reached beta in v1.29 and went stable in v1.33).

nightly-export.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: nightly-export
spec:
template:
spec:
restartPolicy: Never
initContainers:
- name: mesh-proxy
image: istio/proxyv2:1.23.0
restartPolicy: Always # this line makes it a native sidecar
containers:
- name: export
image: registry/export:2.0
command: ["/bin/export", "--out=/data/dump.sql"]
the Job now completes
$ kubectl apply -f nightly-export.yaml
job.batch/nightly-export created
$ kubectl get job nightly-export
NAME COMPLETIONS DURATION AGE
nightly-export 1/1 94s 3m

COMPLETIONS 1/1 with a real DURATION is the fix landing. The export ran, exited, and the Job finished even though the proxy was still up, because the kubelet stopped the sidecar once the main container was done.

A crashing sidecar can pull a healthy app offline
A Pod is only Ready when every container in it is Ready, and a Service only routes traffic to Ready Pods. So a broken helper takes the whole Pod out of rotation even when the real app is fine. Misconfigure the log-shipper and it crashloops, the Pod drops to 1/2 READY, the Service stops sending it traffic, and users see errors from an app that never had a problem. When traffic vanishes from a Pod that otherwise looks alive, check the READY count before you go blaming the app.

When it belongs in one Pod, and how to debug when it breaks

The test for co-location is one question. Does this helper have to live and die with this exact app instance, and does it genuinely need the shared localhost or the shared volume? A log shipper for this app's files, yes. A mesh proxy for this app's traffic, yes. A frontend and its backend, no. Those scale on their own schedules and belong in separate Deployments talking over a Service. If two things could reasonably run on different nodes, at different replica counts, or ship on different release days, they are separate workloads, and stapling them into one Pod just forces them to scale and restart together for no reason. When a multi-container Pod does misbehave, the READY count tells you which side is broken, and kubectl logs with -c names the container you actually care about.

which container is broken
$ kubectl get pod web
NAME READY STATUS RESTARTS AGE
web 1/2 Running 5 (24s ago) 4m
$ kubectl logs web -c log-shipper --previous
[2026/07/16 09:12:04] [error] [config] no input defined, aborting

READY 1/2 with climbing RESTARTS points straight at the second container. Adding -c log-shipper reads that container specifically, and --previous reads the crashed instance rather than the fresh one, so the real error shows up instead of an empty log. The app never entered the story.

Native sidecar lifecycle
1Sidecar startsfirstinit container, restartPolicy:…2App containerstartskubelet moves on once the…3Both runsidecar proxies traffic or…4App stops onshutdownmain containers get SIGTERM…5Sidecar stopslast, Pod/Job endsso a Job can actually complete
A plain sidecar in spec.containers has no ordering and never exits on its own. A native sidecar starts before the app and is stopped after it, which is what lets Jobs finish and deploys drain cleanly.

Resource requests must cover every container. A tiny sidecar with a huge request still blocks scheduling.

Logging sidecars are common and easy to over-privilege. Give them the narrowest mounts and no extra caps.

Native sidecar features evolve by release. Know what your cluster version actually supports before you rely on restart policies.

Try this

Deploy a two-container Pod where the app serves a page on port 8080 and the helper does nothing but sleep. Then exec into each container and prove they share one network namespace: both report the same Pod IP, the helper's netstat lists the app's listening socket, and the helper fetches the page over localhost with no Service in between.

terminal
$ cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: shared-net
spec:
containers:
- name: app
image: busybox:1.36
command: ["sh", "-c", "echo hello from app > /tmp/index.html && httpd -f -p 8080 -h /tmp"]
- name: helper
image: busybox:1.36
command: ["sleep", "3600"]
EOF
pod/shared-net created
$ kubectl exec shared-net -c app -- hostname -i
10.244.1.37
$ kubectl exec shared-net -c helper -- hostname -i
10.244.1.37
$ kubectl exec shared-net -c helper -- netstat -ltn
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 0 0.0.0.0:8080 0.0.0.0:* LISTEN
$ kubectl exec shared-net -c helper -- wget -qO- http://localhost:8080
hello from app

Takeaway

Containers in one Pod share an IP and a fate, so a helper belongs there only when it must live and die with this exact app instance. When it does, declare it as a native sidecar, an init container with restartPolicy: Always, so it starts before the app and stops after it; that ordering is what lets a Job finish and a rollout drain cleanly.

Quick check
01A nightly backup Job runs a backup container plus an Istio proxy in the same Pod. The backup finishes in 90 seconds but the Job never reports Complete. Best fix?
Incorrect — The backup already finished on time. Resources are not what is holding the Pod open.
Correct — The Job then completes when the backup container exits, and the kubelet stops the sidecar afterward.
Incorrect — The proxy has to share the Pod network to intercept the backup's traffic. A separate Deployment cannot do that.
Incorrect — That only stops retries on failure. The Pod is still held open by the proxy that never exits.
02In the three multi-container patterns, what does an 'adapter' container do?
Incorrect — that's the classic init container job, not an adapter.
Incorrect — that's the ambassador, the outbound receptionist, not the adapter.
Incorrect — that's a sidecar such as a log shipper; the adapter reshapes output rather than forwarding logs.
Correct — the adapter runs the other direction from an ambassador: it normalizes the app's existing output (for example into the Prometheus exposition format, the plain text layout scrapers read) so external tooling can consume it.
03A Service suddenly stops sending traffic to one Pod. kubectl get pod shows READY 1/2 with RESTARTS climbing, yet the app container itself is healthy. What's the most likely explanation?
Correct — a Pod is Ready only when every container is Ready, so a broken sidecar pulls a healthy app offline; check the READY count and read the helper's logs with -c and --previous.
Incorrect — 1/2 READY with a healthy app means the second container is the one failing, not the app.
Incorrect — a selector mismatch wouldn't change the READY count or drive climbing RESTARTS; those point at a crashing container.
Incorrect — replica count doesn't govern whether an existing Pod is Ready; a not-Ready Pod is excluded regardless of how many exist.

Related