Kubernetes multi-container pods: sidecar-, init-, ambassador- en adapter-patronen

Een pod kan meer dan een container bevatten, en Kubernetes behandelt ze als een enkele scheduling-eenheid die netwerk en storage deelt. De vier patronen die uit dit ontwerp zijn ontstaan (sidecar, init, ambassador, adapter) lossen elk een specifiek probleem op, en de verkeerde keuze levert brittle deployments op. Dit artikel loopt door elk patroon met manifesten, legt uit hoe native sidecar containers het beeld veranderden in Kubernetes 1.33, en geeft een beslismatrix om het juiste patroon te kiezen.

Wanneer een pod meer dan een container moet bevatten

Een Kubernetes pod is een groep van een of meer containers die samen op dezelfde node worden gepland en een netwerk-namespace en (optioneel) volumes delen. De meeste workloads zijn een container per pod. De patronen in dit artikel bestaan voor de paar gevallen waarin dat niet zo is.

De vuistregel: zet twee containers in dezelfde pod als ze strak gekoppeld zijn en hun lot delen. Ze starten samen, worden samen gepland, schalen samen en kunnen onderling praten via localhost zonder netwerk-hop. Kun je je voorstellen dat ze op verschillende nodes draaien zonder dat er iets stuk gaat? Dan horen ze in aparte pods.

Vier patronen domineren het multi-container ontwerp:

  • Init containers voor opgesomd opstartwerk dat klaar moet zijn voor de app draait.
  • Sidecar containers voor hulpprocessen die de hele pod-levensduur naast de app draaien.
  • Ambassador containers voor het proxyen van uitgaand netwerkverkeer namens de app.
  • Adapter containers voor het normaliseren van de output van de app (logs, metrics) naar een formaat dat andere systemen verwachten.

De rest van dit artikel loopt door elk patroon, geeft productiewaardige YAML, en legt uit hoe de native sidecar feature die stable werd in Kubernetes 1.33 de resource accounting math veranderde.

Wat een pod echt deelt tussen containers

Begrijpen wat een pod deelt is de basis voor elk patroon. Containers in dezelfde pod delen:

  • De netwerk-namespace van de pod. Elke container ziet dezelfde loopback-interface en deelt het pod-IP. Twee containers in dezelfde pod praten met elkaar via 127.0.0.1:<poort>, niet via een Service. Ze kunnen niet dezelfde poort binden. Dit staat in de pod-conceptpagina: "Pods natively provide two kinds of shared resources for their constituent containers: networking and storage."
  • Volumes die op pod-niveau worden gemount. De meeste multi-container patronen geven data tussen containers door via een emptyDir-volume. Alle containers in de pod kunnen het mounten, vaak op verschillende paden. De emptyDir-documentatie is duidelijk: "All containers in the Pod can read and write the same files in the emptyDir volume."
  • Dezelfde node. De pod is de scheduling-eenheid, dus elke container in de pod komt op dezelfde node terecht en overleeft of sterft als groep.
  • Optionele process-namespace. Zet je shareProcessNamespace: true op de pod spec, dan ziet elke container alle processen van de andere containers. Handig voor debug-sidecars, gevaarlijk als default.

Containers in dezelfde pod delen standaard geen MNT-namespace. Elke container heeft zijn eigen filesystem uit zijn eigen image; het enige sharing op filesystem-niveau is via gemounte volumes.

Het emptyDir-volume is het werkpaard. De levensduur is gelijk aan die van de pod: zodra de pod weg is, is de inhoud weg. Container-crashes vernietigen de inhoud niet. Zet je medium: Memory, dan wordt het volume gebackt door tmpfs in plaats van disk. Dat houdt secrets buiten node-storage, maar telt de inhoud van het volume mee tegen de memory limit van de container.

Patroon 1: Sidecar (klassiek vs native)

Een sidecar draait continu naast de hoofdapp: een log shipper die bestanden naar Loki tailt, een metrics exporter die een Unix socket scrapet, een Envoy-proxy die verkeer onderschept voor een service mesh.

Klassieke sidecar (extra reguliere container)

Het klassieke patroon is de sidecar als tweede entry in containers declareren:

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                    # hoofdcontainer, schrijft naar /var/log/app
        image: mycompany/web:1.6.0
        volumeMounts:
        - name: logs
          mountPath: /var/log/app
      - name: log-shipper            # klassieke sidecar, tailt hetzelfde 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

Beide containers starten parallel. Er is geen ordergarantie bij startup. De hoofdcontainer kan logbestanden produceren voordat de shipper klaar is om te lezen, en bij shutdown kan de shipper gekild worden voordat hij de laatste regels heeft geflusht. Voor een Deployment is dat meestal prima. Voor een Job is het een probleem: een sidecar in containers houdt de pod oneindig in leven, want de Job-controller wacht tot elke container exit, en een tailende log shipper doet dat nooit.

Native sidecar (init container met restartPolicy: Always)

Het native sidecar patroon, stable sinds Kubernetes 1.33, codeert de sidecar als een speciale init container met 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        # dit maakt het een 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

Wat de native vorm verandert:

  • Startup-volgorde. De kubelet start eerst de sidecar, wacht tot de startupProbe slaagt, en start dan pas de app-container. De app ziet het volume nooit half geinitialiseerd.
  • Probe-support. Native sidecars ondersteunen startupProbe, readinessProbe en livenessProbe. Klassieke sidecars-als-extra-container ondersteunen ook probes (het zijn gewone containers), maar bij native sidecars gate de startup-probe ook de start van de hoofdcontainer.
  • Termination-volgorde. Bij pod-shutdown krijgen hoofdcontainers eerst SIGTERM, daarna sidecars. De log shipper heeft tijd om te flushen nadat de app is gestopt.
  • Job-completion. Een native sidecar blokkeert Job-completion niet. De Job-controller behandelt alleen de entries in containers als completion-signaal, dus een tailende log shipper kan eindelijk in dezelfde pod als een one-shot batch container.
  • OOM score. Native sidecars krijgen hun kernel OOM-score afgestemd op die van de hoofdcontainer, dus ze worden niet preferent gekild bij memory pressure.

Zit je op Kubernetes 1.29 of nieuwer, gebruik dan native sidecars. De SidecarContainers feature gate staat standaard aan sinds 1.29, en de API is identiek.

Patroon 2: Init container

Init containers draaien voordat de app start, sequentieel, tot voltooiing. Elke moet exit 0 voordat de volgende start, en de hoofdcontainer start pas als alle init containers klaar zijn. Gebruik ze voor eenmalige setup: wachten op een dependency, een databasemigratie draaien, configuratie ophalen in een gedeeld volume, filesysteem-permissies fixen.

spec:
  initContainers:
  - name: wait-for-postgres
    image: busybox:1.37            # getest met busybox 1.37
    command:
    - sh
    - -c
    - |
      until nc -z postgres-service 5432; do
        echo "wachten op postgres..."
        sleep 2
      done
  containers:
  - name: api
    image: mycompany/api:3.2.0

Init containers ondersteunen geen liveness-, readiness- of startup-probes. Ze zijn geen langlopende processen. Heb je een proces nodig dat blijft draaien maar de hoofdcontainer blokkeert van starten, dan wil je een native sidecar met een startupProbe, geen init container.

De volledige behandeling, inclusief patronen voor databasemigraties, het emptyDir config-fetching patroon, en de concurrency-val als een Deployment opschaalt naar meerdere replicas, staat in Kubernetes init containers.

Patroon 3: Ambassador (uitgaand verkeer proxyen)

Het ambassador-patroon zet een proxy-container naast de app om uitgaande netwerkcomplexiteit te regelen. De app praat plain HTTP naar localhost. De ambassador regelt TLS, retries, circuit breaking, service discovery of sharding naar een downstream-systeem.

Een veelvoorkomend geval is een sharded Redis. De app wil niets weten van hash slots en replica-topologie; hij wil een enkel Redis-endpoint. De ambassador draait twemproxy en de app praat met localhost:6379:

spec:
  containers:
  - name: app
    image: mycompany/api:3.2.0
    env:
    - name: REDIS_URL
      value: "redis://localhost:6379"   # altijd lokaal, nooit 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

Hetzelfde patroon zie je overal waar een service mesh in het spel is. Istio's Envoy-sidecar, Linkerd's linkerd2-proxy en Consul Connect proxies zijn allemaal ambassadors. De app ziet een platte localhost-wereld; de ambassador regelt mTLS, traffic policy en retries.

Wanneer ambassador de juiste keuze is:

  • Het downstream-systeem heeft connectiebeheer-complexiteit (sharding, leader election, mTLS) die je niet in elke taalclient van je hele fleet wilt stoppen.
  • Je wilt de proxy onafhankelijk van de app upgraden.
  • De app is third-party en je kunt hem niet aanpassen.

Wanneer ambassador de verkeerde keuze is:

  • De proxy wordt een per-pod-bottleneck. Een enkele Envoy-sidecar op een pod met een drukke app-instance is prima; een enkele Envoy-sidecar als gedeelde egress-proxy voor veel apps is een andere architectuur (een aparte proxy-Deployment, geen sidecar).
  • Het latency-budget kan een extra hop niet hebben. Localhost is snel, maar niet gratis; je betaalt nog steeds TCP-setup, serialisatie en een context switch.

Patroon 4: Adapter (output van de app normaliseren)

Het adapter-patroon is het omgekeerde van ambassador. Waar ambassador uitgaand verkeer herschrijft, herschrijft adapter uitgaande observability-state: logs, metrics, status-endpoints. De app produceert zijn eigen formaat; de adapter vertaalt dat naar wat de rest van het platform verwacht.

Het canonieke geval is een metrics-exporter:

spec:
  containers:
  - name: legacy-app
    image: mycompany/legacy:5.4.0
    # exposeert /jolokia, een JMX-over-HTTP endpoint specifiek voor 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       # wat het platform verwacht te scrapen
    volumeMounts:
    - name: exporter-config
      mountPath: /etc/jmx-exporter
  volumes:
  - name: exporter-config
    configMap:
      name: jmx-exporter-config

Prometheus scrapet :5556. De legacy app blijft JMX produceren. De adapter zit ertussen. Upgrade je de app, dan blijft het adapter-contract stabiel; verander je Prometheus-configuratie, dan merkt de app er niets van.

Andere adapter-use cases:

  • Een Fluent Bit sidecar die container-logs van stdout leest en als gestructureerde JSON wegschrijft.
  • Een kleine webserver die een uniforme /healthz en /readyz aanbiedt door meerdere legacy app-endpoints te aggregeren.
  • Een protocol-vertaler die gRPC exposeert voor een HTTP-only legacy app, of andersom.

Het adapter-patroon is ook waar pods in de problemen komen. Elke adapter is glue code die met beide kanten in sync moet blijven. Verandert de legacy app zijn /jolokia-schema en wordt de JMX-exporter niet bijgewerkt, dan verdwijnen metrics stil. Behandel de adapter als een apart geversionde dependency, geen vergeten implementatiedetail.

Native sidecar containers (Kubernetes 1.29+ beta default-on, 1.33 stable)

Native sidecars verdienen een eigen sectie omdat hun lifecycle en resource accounting subtiel anders zijn.

Lifecycle

De kubelet start containers in deze volgorde:

  1. Volume mounts en pod-level setup.
  2. Native sidecars (init containers met restartPolicy: Always), in declaratievolgorde. De volgende sidecar start zodra de startupProbe van de vorige slaagt (of meteen, als geen probe is gedefinieerd).
  3. Reguliere init containers, in declaratievolgorde, elk volledig draaiend voor de volgende start.
  4. Hoofdcontainers in containers, allemaal parallel gestart.

Bij termination draait de volgorde om. Hoofdcontainers krijgen eerst SIGTERM; zodra die klaar zijn, krijgen sidecars SIGTERM, en daarna (na de grace period) SIGKILL. De officiele documentatie merkt op dat andere exit codes dan nul voor sidecars normaal zijn tijdens pod-termination en in het algemeen genegeerd moeten worden, omdat de exit van de sidecar wordt afgedwongen door de kubelet, niet graceful is.

Wanneer de feature gate ertoe doet

De SidecarContainers feature gate heeft de volgende historie, volgens de native sidecar containers blogpost en de v1.33 release announcement:

  • Alpha in Kubernetes 1.28 (augustus 2023). Standaard uit; niet voor productie.
  • Beta in Kubernetes 1.29. Standaard aan.
  • Stable in Kubernetes 1.33 (23 april 2025). De gate staat vast aan en gaat eruit.

Zit je op een managed Kubernetes-service (EKS, GKE, AKS), check dan de gerapporteerde clusterversie voordat je op native sidecars rekent. EKS, GKE en AKS ondersteunen de feature inmiddels op elke supported minor release, maar een self-managed cluster vastgepind op 1.27 of ouder moet upgraden of het klassieke patroon gebruiken.

Resource requests, limits en HPA in multi-container pods

Hier verschillen de patronen op manieren die je in productie pijn doen.

Effective request math

De Kubernetes init container documentatie definieert twee getallen:

  • Effective init request voor een resource: de hoogste enkele request van alle init containers (omdat ze sequentieel draaien, telt maar een footprint tegelijk).
  • Effective pod request voor een resource: de hoogste van (som van alle hoofdcontainer-requests) en (effective init request).

Voor een pod met klassieke init containers en geen native sidecars betekent dat:

effective_request = max(
  max(initContainer[i].request),
  sum(container[i].request)
)

Native sidecars veranderen de formule. Omdat een sidecar een init container is die in de hoofdfase blijft draaien, moet de request opgeteld worden bij de hoofdcontainer-som tijdens die fase. De effective request wordt:

# pod met native sidecars
effective_request = max(
  max(InitContainerUse(i)),                            # max enkele request tijdens initfase
  sum(sidecar.request) + sum(container.request)        # beide fases samen
)

Waarbij InitContainerUse(i) rekening houdt met sidecars die al draaien tegen de tijd dat init container i start (elke volgende init container ziet de sidecars opgeteld bij zijn eigen request). Dit staat in de sidecar containers sectie over resource sharing.

Het praktische gevolg: een pod met drie native sidecars van 100m CPU per stuk plus een hoofdcontainer van 500m CPU heeft een effective request van 800m, niet 500m. De scheduler reserveert 800m op welke node ook de pod accepteert. Vergeet je dit en pak je nodes vol op basis van alleen de hoofdcontainer-request, dan krijg je overcommit.

HPA per-container scaling

Hebben pods asymmetrische containers (een drukke app en een bijna-idle log shipper), dan maskeren pod-niveau CPU-gemiddelden de waarheid. De HPAContainerMetrics feature gate en het ContainerResource metric type laten HPA schalen op de utilization van een specifieke container in plaats van het pod-gemiddelde:

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          # niet "Resource"
    containerResource:
      name: cpu
      container: app                 # naam van de container om te volgen
      target:
        type: Utilization
        averageUtilization: 70

De tijdlijn voor HPAContainerMetrics: alpha in 1.20, beta default-on in 1.27, GA in 1.30. Op clusters ouder dan 1.27 moet de feature gate expliciet aangezet. Vanaf 1.30 staat hij vast aan en kan hij van de gate-lijst af.

Voor het volledige verhaal over HPA-configuratie, inclusief custom metrics en scaling behaviour, zie Kubernetes Horizontal Pod Autoscaler.

emptyDir Memory en de limit-accounting val

Gebruikt een patroon emptyDir met medium: Memory (een tmpfs-mount), dan tellen de bytes die naar dat volume worden geschreven mee tegen de memory limit van de container die ze schreef, niet tegen ephemeral storage. Een 200Mi configbestand dat door een init container in een memory-backed emptyDir wordt geschreven en daarna door een hoofdcontainer met 256Mi memory limit wordt gelezen, laat maar 56Mi over voor de heap van de hoofdcontainer. Dit is een veelvoorkomende verrassing.

Voor bredere richtlijnen over requests, limits, QoS-classes en overcommit-strategie, zie Kubernetes resource requests en limits.

Wat multi-container pods NIET zijn

Vijf dingen waarover multi-container pods vaak verkeerd worden begrepen.

Ze zijn geen manier om sidecars na de hoofdcontainer te laten starten. Sidecars zijn parallel, niet sequentieel. Een native sidecar start voor de hoofdcontainer, niet erna. Hangt je sidecar er echt van af dat de hoofdcontainer eerst up is (zeldzaam; meestal wil je het omgekeerde), dan heb je retry-logica op applicatieniveau nodig, geen Kubernetes-feature.

Ze geven de sidecar geen aparte netwerk-namespace. Elke container in de pod deelt de pod's netwerk-namespace. De sidecar ziet dezelfde 127.0.0.1 en dezelfde loopback-interface als de hoofdcontainer. Twee containers kunnen niet dezelfde poort binden. Dacht je dat de sidecar zijn eigen netns had, dan had je de architectuur verkeerd gelezen.

Native sidecars in 1.29+ vervangen het klassieke patroon niet. Het klassieke patroon (sidecar in containers) werkt nog steeds precies hetzelfde. Native sidecars zijn opt-in. Kies native als je startup-ordering, probe-gating of Job-completion semantics nodig hebt; kies klassiek als je dat niet hebt, en bespaar de cognitieve overhead.

Multi-container pods schalen niet onafhankelijk per container. Replicas schalen de hele pod. Doet je sidecar 100x meer werk dan je hoofdcontainer, dan is het antwoord niet de pod opschalen, maar de sidecar uit de pod halen en in een eigen Deployment zetten met een Service ertussen.

Ze zijn geen vervanging voor goede architectuur. Ik heb teams gezien die vier containers in een pod stopten omdat de componenten "gerelateerd" waren. Niets aan gerelateerd zijn rechtvaardigt een multi-container pod. De rechtvaardiging is gedeelde lifecycle, gedeeld filesystem of gedeelde netwerk-namespace. Geldt geen van die drie, dan zijn aparte pods simpeler en ze schalen onafhankelijk.

Beslismatrix

Kies het patroon op basis van de dominante eis.

Behoefte Patroon Waarom
Een setup-taak draaien die af moet zijn voor de app start Init container Sequentieel, run-to-completion semantiek
Een langlopende helper naast de app draaien Sidecar (native als 1.29+) Parallelle levensduur, probe-support, Job-vriendelijk
Uitgaand verkeer proxyen voor TLS, sharding, mesh Ambassador App ziet localhost als plat endpoint
Output van de app (logs, metrics) naar standaardformaat vertalen Adapter Glue tussen app en platformcontracten
App-startup blokkeren tot een sidecar klaar is Native sidecar met startupProbe Init containers ondersteunen geen probes
Iets eenmalig draaien voor een batch en dan stoppen Job met init container of Job met native sidecar Klassieke sidecars blokkeren Job-completion
Een fleet-brede proxy met veel verkeer draaien Aparte Deployment + Service Sidecars schalen niet onafhankelijk
Twee niet-gerelateerde apps draaien die toevallig in hetzelfde team zitten Aparte pods "Gerelateerd" is geen multi-container pod-rechtvaardiging

Multi-container pods debuggen

kubectl describe pod is het startpunt. De secties Init Containers:, Containers: en Events: rapporteren elke state apart.

Logs van een specifieke container:

# hoofdcontainer
kubectl logs <pod> -c app

# init of sidecar (sidecar staat in initContainers in de spec)
kubectl logs <pod> -c log-shipper

# vorige run na een crash
kubectl logs <pod> -c app --previous

Exec naar een specifieke container:

kubectl exec -it <pod> -c app -- /bin/sh

Lijken twee containers het oneens te zijn over de inhoud van een gedeeld volume, kijk dan aan beide kanten:

kubectl exec <pod> -c init-writer -- ls -la /shared
kubectl exec <pod> -c app -- ls -la /shared

Voor containers zonder shell (distroless images) gebruik je ephemeral debug containers. Voor pods die vasthangen voordat een container start, ligt het meestal aan image pull of volume mount; zie pods debuggen die vasthangen in ContainerCreating.

Waar je verder kijkt

Terugkerende server- of deploymentproblemen?

Ik help teams productie betrouwbaar maken met CI/CD, Kubernetes en cloud—zodat fixes blijven en deploys geen stress meer zijn.

Bekijk DevOps consultancy

Doorzoek deze site

Begin met typen om te zoeken, of blader door de kennisbank en blog.