Lesson 11 of 66 · 16 min read · Updated
Native Sidecar Containers and Multi-Container Pod Patterns
On this page
In the previous article init containers prepared a Pod and then disappeared. Many helpers should not disappear. A log shipper must keep reading log files for as long as the application writes them. A proxy must keep forwarding requests. A metrics converter must answer every scrape. These helpers are sidecars: containers that run next to the application, in the same Pod, for its whole life.
For years Kubernetes had no real concept of a sidecar. People simply added a second container to containers and lived with the consequences: no start order, sidecars that died before the application during shutdown, and Jobs that never finished. Native sidecar containers, stable since Kubernetes 1.33, fix all three. This article shows how they work and builds the three classic multi-container patterns on top of them, around a .NET 10 API.
We will cover the following topics:
- Why a Pod has more than one container at all
- Native sidecars:
initContainerswithrestartPolicy: Always - Start-up and shutdown order, measured on a real cluster
- Why Jobs with classic sidecars never complete, and how native sidecars fix it
- The sidecar, ambassador and adapter patterns
- Handling SIGTERM in sidecars
Technical Requirements
- Kubernetes 1.33 or later. The examples run on Docker Desktop's kind-based cluster, Kubernetes v1.36, with three worker nodes.
kubectl, Docker and the .NET 10 SDK.- On plain kind, load the image you build with
kind load docker-image.
Core Concepts
Why Several Containers in One Pod
Containers in the same Pod share three things:
- the network namespace: they reach each other on
localhost, and share one IP address, - volumes that both mount,
- the Pod's life cycle and node: they are scheduled, started and deleted together.
That makes a Pod the right unit for a process and its tightly coupled helpers. The rule of thumb: put containers in one Pod only if they must run on the same machine and scale together. Two independent services belong in two Deployments.
Classic Sidecars and Their Problems
Before native sidecars, a helper was just a second entry in containers. That caused three well-known problems:
| Problem | Why it happens |
|---|---|
| No start order | All containers start in parallel, so the app may call a proxy that is not listening yet |
| Wrong shutdown order | All containers get SIGTERM at the same time, so a log shipper may stop before the app writes its last lines |
| Jobs never finish | A Job completes when all containers have exited; a sidecar that runs forever keeps the Pod alive |
Native Sidecars
A native sidecar is declared in initContainers with one extra field:
That single line changes its life cycle:
| Regular init container | Native sidecar | Application container | |
|---|---|---|---|
| Declared in | initContainers | initContainers + restartPolicy: Always | containers |
| Start | In order, must exit | In order, keeps running | After all init containers |
| Blocks the next container until | It exits with 0 | It has started (or its startupProbe passed) | |
| Probes | No | Yes: startup, liveness, readiness | Yes |
| Restarted if it exits | Depends on Pod policy | Always, without restarting the app | Depends on Pod policy |
| Shutdown | Already gone | After all application containers have stopped, in reverse order | First |
| Counts toward Job completion | No | Yes |
The feature appeared in 1.28, was enabled by default from 1.29, and became stable (GA) in 1.33. On 1.33 and later the feature gate is locked on.
Start-up Order and startupProbe
The kubelet starts init containers in order. For a native sidecar it waits until the container has started before moving on. If the sidecar has a startupProbe, it waits until the probe succeeds. This is how you guarantee that a proxy is actually listening before the application starts.
Shutdown Order
When a Pod is deleted, the kubelet first sends SIGTERM to the application containers and waits for them to exit. Only then does it stop the sidecars, in the reverse order of their declaration. A log shipper therefore sees the application's last log lines.
All of this happens inside the Pod's terminationGracePeriodSeconds (30 seconds by default). If the sidecar ignores SIGTERM, the kubelet waits until the grace period runs out and then kills it. The step-by-step example measures both cases.
The Three Classic Patterns
| Pattern | What the helper does | Example |
|---|---|---|
| Sidecar | Extends the app without changing it | Ships log files, refreshes certificates, syncs files from Git |
| Ambassador | Proxies the app's outgoing connections | The app calls localhost:9000; the ambassador forwards to the real backend, adds TLS, retries or auth |
| Adapter | Converts the app's output into a standard format | Turns the app's JSON stats into Prometheus metrics |
All three are "sidecars" in the broad sense. The names describe the direction of the data.
Hands-On: Kubernetes Commands
See how many containers are ready
READY counts native sidecars too: 4/4 means three sidecars plus the app.
Read the logs of a sidecar
Native sidecars are addressed by name with -c, like any container.
Read all containers' logs at once
--all-containers includes init containers and sidecars.
Check which init containers are sidecars
The container-level restartPolicy field marks them.
Show start times and restart counts per container
Useful to verify ordering and independent restarts.
Step-by-Step Example
The Scenario
An orders-api written in .NET 10 writes its log to a file, exposes its own JSON statistics and calls an inventory service. We add:
- a log-shipper sidecar that streams the log file to stdout,
- an inventory-ambassador that the app reaches on
localhost:9000, - a metrics-adapter that serves the app's statistics in Prometheus format.
Along the way we measure start-up and shutdown order, and compare Jobs with classic and native sidecars.
Step 1 — The Application
Program.cs:
The app knows nothing about Kubernetes, log collectors or Prometheus. It writes a file, serves JSON and calls localhost:9000. That is the point: the helpers adapt it to the platform.
The Dockerfile:
Step 2 — A Native Log-Shipper Sidecar
Start with just the log shipper. The app and the sidecar share an emptyDir mounted at /var/log/orders:
The trap 'exit 0' TERM ... & wait wrapper is explained in Step 5. In a real cluster the log shipper would be Fluent Bit or the OpenTelemetry Collector. busybox tail keeps the example small.
Check the start order:
The sidecar started first. In this run the app started three minutes later only because the node was busy pulling its image. The order is what matters: the sidecar was already running when the first log line was written.
Place two orders and read the sidecar's output:
A file-based log has become a container log stream that any cluster log collector can pick up.
Step 3 — Measure the Shutdown Order
To see the order precisely, use a Pod where each container prints a timestamp when it receives SIGTERM. The app takes 5 seconds to drain:
Follow both logs in the background, then delete the Pod:
The app received SIGTERM first and drained for five seconds. The sidecar got its SIGTERM only after the app had exited. With a classic sidecar in containers, both would have received SIGTERM at 08:40:09.
The same thing happened to the real log shipper from Step 2. When its Pod was replaced, the sidecar was still running when the app wrote its final line:
Step 4 — Jobs: Classic versus Native Sidecars
Run the same five-second report job twice. First with the helper in containers:
Then with the helper as a native sidecar:
The report finished in both Pods. The classic Job is still Running with 1/2 containers, and it will stay like that forever because its log shipper never exits. The native Job is Complete: once the main container finished, the kubelet stopped the sidecar. Before native sidecars, teams had to make the main container signal the sidecar through a shared file or a special endpoint to get this behaviour.
Delete both Jobs:
Step 5 — Why the trap Matters
The log shipper in Step 2 wraps tail in trap 'exit 0' TERM ... & wait. Without it, the shell runs tail as PID 1 of the container, PID 1 has no SIGTERM handler, and the signal is ignored. The kubelet then waits for the full grace period before killing the container.
We measured both versions by deleting a Pod and timing kubectl delete:
32 seconds is the default 30-second grace period plus overhead. Every rollout, scale-down and node drain pays that cost for every Pod.
One slow sidecar is enough to cause it. In the full Deployment of Step 6 we first wrapped only the log shipper, and deleting the Pod still took 31 seconds. The culprit was the metrics adapter: busybox httpd -f running as PID 1 ignores SIGTERM too. After wrapping it the same way, the Pod was gone in 1 second. Make sure every sidecar, whether it is a shell script, Fluent Bit or a .NET process, exits promptly on SIGTERM.
Step 6 — The Ambassador and the Adapter
Add a small inventory backend that the app will reach through the ambassador:
The adapter is a CGI script served by busybox httpd, wrapped in the same SIGTERM trap as the log shipper. It reads the app's JSON and prints Prometheus text format:
Now the full Deployment with all three helpers as native sidecars:
The ambassador has a startupProbe on port 9000. Because it is a native sidecar, the kubelet does not start the next container until that probe passes, so the app can never call localhost:9000 before socat is listening.
Four of four: three sidecars and the app. The start times follow the declaration order:
Step 7 — Exercise All Three Patterns
Forward the app port and the adapter port:
Place an order, call the inventory through the ambassador, and scrape the adapter:
The app called localhost:9000 and got the inventory from another Service. It never learned that Service's name. The adapter turned {"ordersPlaced":1,...} into metrics that Prometheus understands, without a single change to the .NET code. In a real cluster you would point a Prometheus scrape config or a ServiceMonitor at port 9100.
Step 8 — Sidecars Restart Independently
Stop the log-shipper container through the container runtime on its node. On kind, the node is a Docker container, so crictl is one docker exec away:
The kubelet restarted only the sidecar. The app kept serving. A native sidecar is always restarted, regardless of the Pod's own restartPolicy.
Step 9 — Clean Up
Summary
- Containers in one Pod share localhost, volumes and life cycle; use that only for tightly coupled helpers.
- A native sidecar is an init container with
restartPolicy: Always. Stable since Kubernetes 1.33. - Native sidecars start before the app (after their
startupProbepasses, if they have one), stop after it, and restart on their own. - Jobs complete normally with native sidecars; with classic sidecars they hang in
Running. - The sidecar pattern extends the app, the ambassador proxies its outgoing calls, the adapter converts its output. The .NET code stayed unaware of all three.
- Every sidecar must exit on SIGTERM. A shell sidecar without a trap cost 32 seconds per Pod deletion in our test, against 1 second with one.
This completes the multi-container patterns. The next topics build on them: namespaces and quotas to organise workloads, and later, service meshes and observability agents that are delivered as exactly these kinds of sidecars.