Kubernetes multi-container pods: sidecar, init, ambassador, and adapter patterns

A pod can hold more than one container, and Kubernetes treats them as a single scheduling unit that shares network and storage. The four patterns that emerged from this design (sidecar, init, ambassador, adapter) each solve a specific problem, and choosing wrongly produces brittle deployments. This article walks through each pattern with manifests, explains how native sidecar containers changed the picture in Kubernetes 1.33, and gives a decision matrix for picking the right one.

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 emptyDir volume. 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 the emptyDir volume."
  • 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: true on 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 startupProbe to pass, then starts the app container. The app never sees the volume in a half-initialized state.
  • Probe support. Native sidecars support startupProbe, readinessProbe, and livenessProbe. 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 containers as 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 stdout and shipping them as structured JSON.
  • A small webserver that exposes a unified /healthz and /readyz by 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:

  1. Volume mounts and pod-level setup.
  2. Native sidecars (init containers with restartPolicy: Always), in declaration order. The next sidecar starts as soon as the previous one's startupProbe passes (or immediately, if no probe is defined).
  3. Regular init containers, in declaration order, each running to completion before the next begins.
  4. 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

Recurring server or deployment issues?

I help teams make production reliable with CI/CD, Kubernetes, and cloud—so fixes stick and deploys stop being stressful.

Explore DevOps consultancy

Search this site

Start typing to search, or browse the knowledge base and blog.