Challenge ·Medium

Kubernetes - Multi-Container Pod Design Patterns

Build five ways to combine containers in one Pod: a sidecar that extends the app, an init container that finishes before the app starts, a native sidecar with a startup guarantee, an ambassador that mutates an outbound call, and an adapter that translates an inbound one.

Container design patterns span three levels: single-container patterns for how one container manages itself, single-node multi-container patterns for containers cooperating in one Pod, and multi-node patterns for distributed algorithms. Sidecar, ambassador, and adapter, the patterns most people picture when they hear the term, fall under the single-node multi-container level, and come from "Design Patterns for Container-Based Distributed Systems", published by Brendan Burns and David Oppenheimer. That paper is not an exhaustive list. Most new scenarios still fall under one of its categories, or a mix of them.

  • Sidecar: no request or response relationship at all, it does not intermediate anyone's call. It just autonomously extends a capability, for example syncing files into a volume. Neither the app nor an external party is calling it.
  • Ambassador: the app inside the Pod is the client. It calls out through the ambassador to reach something external, without knowing the complexity of that external thing.
  • Adapter: something outside the Pod is the client. It calls in expecting a standard interface, and the adapter translates that into whatever native interface the main container actually speaks.

Native sidecar is not a fourth pattern next to these three. It is a Kubernetes mechanism, an initContainers entry with restartPolicy: Always, stable since Kubernetes 1.33, that adds a startup guarantee to whichever pattern uses it: the main container waits for the native sidecar to pass its startupProbe before starting. A service mesh proxy, for example, is usually an ambassador running as a native sidecar, not a sidecar in the sense used above.

A plain initContainers entry, without restartPolicy: Always, is a different thing again: not one of the three patterns and not a native sidecar, but its own well-known building block. CKAD's own curriculum names it alongside sidecar as a pattern worth knowing. It runs to completion once, then exits, useful for one-time setup rather than an ongoing capability.

This challenge covers single-node multi-container patterns: a Pod running several containers that share the same network namespace and, when a volume is defined, the same storage.


Task 1 - Sidecar

An application container often needs a capability it does not implement itself, pulling data from a remote source, rotating credentials, shipping logs, without knowing where any of it comes from. A sidecar container in the same Pod can provide that, using nothing more than a shared volume. A sidecar does not intermediate a request for anyone, it just adds a capability the main container lacks.

This task's Pod has two containers, logger and app, sharing one volume named shared-vol. The logger container appends a new timestamp to log.txt every 2 seconds, the app container only reads what is already there with tail -f.

Pod sidecar-pod with logger and app containers connected through the shared-vol emptyDir volume

Steps:

  • Create a Pod named sidecar-pod:
    • Add a volume named shared-vol, an emptyDir
    • Create container named logger, image busybox, mounts shared-vol at /data, running sh -c "while true; do date >> /data/log.txt; sleep 2; done"
    • Create container named app, image busybox, mounts shared-vol at /data, running sh -c "tail -f /data/log.txt"

tail -f blocks and prints each new line to stdout as soon as it is written.

  • Watch it live with kubectl logs -f sidecar-pod -c app. A new timestamp appears roughly every 2 seconds, as soon as the logger container appends it.

Task 2 - Adapter

Adapter examples are often about reformatting something like logs, taking one line format and rewriting it into another. This one works at the interface level instead: the app container, a go-httpbin instance running in this same Pod, only serves /uuid, it has no idea /legacy-id exists. adapter exposes /legacy-id, coming from the nginx config in adapter.conf, an interface the app container never had, and proxies it over localhost to the real path. That config is already written to ~/adapter.conf in the home directory by the setup task.

Pod adapter-pod with adapter and app containers connected through localhost, showing the client request and response path

Steps:

  • Create a ConfigMap named adapter-config with a data key adapter.conf from the nginx config at ~/adapter.conf
  • Create a Pod named adapter-pod with label run: adapter-pod:
    • Create container named app, image ghcr.io/mccutchen/go-httpbin
    • Create container named adapter, image nginx:alpine, mounts the ConfigMap at /etc/nginx/conf.d
  • Create a Service named adapter-svc of type ClusterIP with selector run: adapter-pod (matching the Pod's label), port 80 targeting container port 80

To see the translation happen rather than just trust the result:

  • Get the ClusterIP with kubectl get svc adapter-svc -o jsonpath='{.spec.clusterIP}'.
  • Call curl http://<cluster-ip>/legacy-id. The response is a UUID, even though the app container has no /legacy-id path of its own, only /uuid.

Task 3 - Ambassador

In the ambassador pattern, the app container is the client that initiates the request. It only ever talks to localhost, unaware of what is actually handling the connection or where the request ends up. An ambassador does not have to just forward bytes unchanged either, it can also modify the request on the way out.

This task uses three pieces. httpbin is a small HTTP test service, already running as a Service in the infra namespace. It echoes back whatever it receives, so its /headers endpoint reflects every header a request arrived with. The app container sends a plain request to localhost with no headers of its own. ambassador receives that request and adds an X-Api-Key header before forwarding it on to httpbin, using the nginx config already written to ~/default.conf.

Once ambassador is wired up, the response the app container gets back should include a header it never sent, proof the request was modified on the way out rather than just relayed.

Pod ambassador-pod with app and ambassador containers connected through localhost:80, ambassador forwarding the request on to the external httpbin Service

Steps:

  • Create a ConfigMap named ambassador-token-config with a data key default.conf from the nginx config at ~/default.conf
  • Create a Pod named ambassador-pod:
    • Create container named app, image busybox, running sh -c "while true; do wget -qO- http://localhost:80/headers; echo; sleep 3; done"
    • Create container named ambassador, image nginx:alpine, mounts the ConfigMap at /etc/nginx/conf.d

To see the mutation happen rather than just trust the result, compare the two paths directly:

  • Exec into the app container and call httpbin.infra on the Service with kubectl exec ambassador-pod -c app -- wget -qO- http://httpbin.infra/headers, bypassing ambassador entirely. That response has no X-Api-Key header.
  • Call kubectl exec ambassador-pod -c app -- wget -qO- http://localhost:80/headers. The header is there this time.

The only difference between the two calls is whether the request passed through ambassador.


Task 4 - Init Containers

Init containers run in sequential order. Each one must exit 0 before the next one starts. Once every init container has exited, the main containers start. This makes them suited to one-time setup work: pulling configs, rendering templates, etc.

The init1 container and the init2 container both append to the same file, so the file's contents prove the order held. The app container reads the file once, after both init containers have already exited. That read always finds both lines, in order.

Pod init-pod, init1 and init2 initContainers running one after another over shared-vol, each exit code branching through a .spec.restartPolicy decision before app starts

Steps:

  • Create a Pod named init-pod:
    • Add a volume named shared-vol, an emptyDir
    • Create an init container named init1 under initContainers, image busybox, mounts shared-vol at /data, running sh -c "echo init1 >> /data/log.txt"
    • Create a second init container named init2 under initContainers, listed after the init1 container, image busybox, mounts shared-vol at /data, running sh -c "echo init2 >> /data/log.txt"
    • Create the main container named app, image busybox, mounts shared-vol at /data, running sh -c "cat /data/log.txt; sleep infinity"
  • kubectl get pod init-pod -w shows the Pod's status transitions live.

Task 5 - Native Sidecar

A container that keeps streaming a file with tail -f cannot show whether it started before or after the other container wrote anything, the output looks the same either way. A single read makes the ordering guarantee visible instead: the app container here reads the file exactly once, right when it starts, so that read either finds nothing or already finds a line.

The logger container moves to initContainers with restartPolicy: Always, giving it the same startup-ordering guarantee a native sidecar provides. It waits 30 seconds before its first write, so the Pod stays visibly held at Init:0/1 while the app container is blocked from starting. Its startupProbe checks /data/log.txt directly for non-empty content, no separate marker file needed.

Pod native-sidecar-pod, logger initContainer writing the file and being gated by a startupProbe decision before kubelet starts app, which reads the file over the shared-vol volume

Steps:

  • Create a Pod named native-sidecar-pod:
    • Add a volume named shared-vol, an emptyDir
    • Create the native sidecar logger under initContainers, image busybox, restartPolicy: Always, mounts shared-vol at /data, running sh -c "sleep 30; date >> /data/log.txt; sleep infinity"
    • Add a startupProbe on the logger container using exec running test -s /data/log.txt, periodSeconds: 2, failureThreshold: 30
    • Create the main container named app, image busybox, mounts shared-vol at /data, running sh -c "cat /data/log.txt; sleep infinity"
  • kubectl get pod native-sidecar-pod -w shows the Pod's status transitions live.