Init containers
Ordered setup that must finish before the app starts.
A pod that starts its app before the database is even reachable will crash, back off, crash again, and eventually page someone at two in the morning. Init containers exist so that story doesn't happen. They're the setup crew. They run first, finish their job, and get out of the way before your real app is allowed to serve a single request.
Quick grounding first. A Pod is the smallest unit Kubernetes runs, one or more containers that share an IP address and some local disk. Normally every container in a Pod comes up together. Init containers deliberately break that. They run one at a time, in the exact order you list them, and each one must exit successfully (exit code 0) before the next one starts. Only after the last init container finishes does the kubelet, the Kubernetes agent running on every node, boot your app containers.
Same idea as a kitchen before the doors open. The prep cooks chop, portion, and stock the line, and nobody plates a dish for a customer until prep is done. If prep fails, service doesn't start, and that's the point. Your app gets to assume its world is ready instead of carrying nervous retry code for a database that might not exist yet.
Waiting for a dependency is the classic job. It isn't the only one. Init containers mount the same storage the app will use, so they're a handy place to get that storage ready before anyone touches it. A few things people hand them: cloning config from a git repository into a shared scratch volume (an emptyDir, a temporary folder that lives exactly as long as the Pod), pulling a secret and writing it to disk, or fixing the file ownership on a mounted volume so a non-root app can actually write there. The app then starts up to a folder that's already in the right shape.
How the kubelet runs them, step by step
Here's a Pod that waits for a database, runs a schema migration, then starts the app. The wait-for-db step uses netcat (the nc command in the manifest) to poll port 5432 until something answers. The migrate step applies pending migrations with its own tool. Neither lives in the app image, which keeps that image small and keeps the migration tool's credentials out of the long-running app.
apiVersion: v1kind: Podmetadata:name: webspec:initContainers:- name: wait-for-dbimage: busybox:1.36command: ['sh','-c','until nc -z db 5432; do echo waiting for db; sleep 2; done']- name: migrateimage: registry.local/migrator:1.2command: ['/migrate','up']containers:- name: appimage: registry.local/app:1.4.2ports:- containerPort: 8080
$ kubectl apply -f web-pod.yamlpod/web created$ kubectl get pod web -wNAME READY STATUS RESTARTS AGEweb 0/1 Init:0/2 0 2sweb 0/1 Init:1/2 0 13sweb 0/1 PodInitializing 0 15sweb 1/1 Running 0 17s
The STATUS column tells the whole story. Init:0/2 means zero of two init containers have finished and the first is still running. Init:1/2 means the database answered and the migration is now going. PodInitializing is the brief moment the kubelet sets up the app container. Then Running. You can see the same thing from the inside with describe.
$ kubectl describe pod web
Init Containers:wait-for-db:Image: busybox:1.36State: TerminatedReason: CompletedExit Code: 0Started: Tue, 15 Jul 2025 09:14:02 +0000Finished: Tue, 15 Jul 2025 09:14:15 +0000Ready: TrueRestart Count: 0migrate:State: TerminatedReason: CompletedExit Code: 0Ready: TrueContainers:app:State: RunningReady: True
Each init container shows State: Terminated, Reason: Completed, Exit Code: 0. That exit code is the entire contract. Anything non-zero and the kubelet retries the container according to the Pod's restartPolicy, and the app stays parked until it succeeds.
One internal detail that trips up capacity planning: the scheduler sizes the Pod using the larger of two numbers, the biggest single init container's resource request, or the sum of all app container requests. A normal init container that briefly asks for 2 CPUs can therefore make the whole Pod harder to fit on a node even though it runs for ten seconds. Native sidecars behave differently, and we'll get to why in a moment.
When it hangs: reading a Pod stuck in Init
A Pod that sits at Init:0/2 for six minutes isn't broken Kubernetes. It's an init container that never got what it was waiting for. The number after Init tells you which step is stuck: zero means the first one is still spinning. To see why, read that specific container's logs by name with the -c flag, because kubectl logs on its own targets the app container, which hasn't even been created yet. The Events section at the bottom of describe is worth a glance too; an image that won't pull shows up there as a Failed event, and the status reads Init:ErrImagePull instead of sitting quietly at Init:0/2.
$ kubectl get pod webNAME READY STATUS RESTARTS AGEweb 0/1 Init:0/2 0 6m12s$ kubectl logs web -c wait-for-db
waiting for dbwaiting for dbwaiting for dbwaiting for db
There's the smoking gun. The wait loop is still printing waiting for db, so the db Service (the stable network name that stands in for the database) either doesn't exist, has no ready endpoints behind it, or isn't reachable from this node. Fix the dependency and the init container completes on its next poll. If instead you see Init:CrashLoopBackOff, the container is exiting non-zero and the kubelet is backing off between restarts, so read the same logs for the real error message.
Init containers, sidecars, and the modern twist
Normal init containers have one hard rule: they must exit before anything after them runs. That makes them wrong for anything long-lived, like a logging agent or a service-mesh proxy (a helper container that manages network traffic in and out of the app), because a process that never exits would block the app forever. For years people faked this with ordinary app containers that raced to start alongside the app. Kubernetes 1.33 made the clean version stable. A native sidecar is just an init container with restartPolicy: Always. It starts in order like an init container, but Kubernetes only waits for it to start, not finish, before moving on. It then runs for the whole life of the Pod.
spec:initContainers:- name: wait-for-dbimage: busybox:1.36command: ['sh','-c','until nc -z db 5432; do sleep 2; done']- name: log-shipperimage: fluent/fluent-bit:3.1restartPolicy: Always # <- makes this a native sidecarcontainers:- name: appimage: registry.local/app:1.4.2
$ kubectl get pod webNAME READY STATUS RESTARTS AGEweb 2/2 Running 0 25s
Because the log-shipper starts before the app and keeps running, READY shows 2/2: the app and the sidecar are both live, while the completed wait-for-db no longer counts toward the total. Native sidecars also add their resource requests to the app total instead of being compared against it, and they support the three health probes (startup, readiness, liveness) that plain init containers do not. That lets a mesh proxy gate the app on being genuinely ready, not just started.
Shared volumes are how inits drop config or wait scripts for the app. EmptyDir is the usual scratch pad.
A flaky init produces a pod that looks Pending or Init forever. Describe events before you blame the app image.
Keep inits small and deterministic. Network waits belong in probes or Jobs when possible.
Try this
Run a pod with an init container that sleeps then exits, and an app container that starts after. Break the init on purpose and watch the app never start.
$ kubectl apply -f web-pod.yamlpod/web created$ kubectl get pod web -wNAME READY STATUS RESTARTS AGEweb 0/1 Init:0/2 0 2sweb 0/1 Init:1/2 0 13sweb 0/1 PodInitializing 0 15sweb 1/1 Running 0 17s$ kubectl describe pod web$ kubectl get pod webNAME READY STATUS RESTARTS AGEweb 0/1 Init:0/2 0 6m12s$ kubectl logs web -c wait-for-db$ kubectl get pod webNAME READY STATUS RESTARTS AGEweb 2/2 Running 0 25s
Takeaway
Inits run in order, to completion, before app containers. They are setup, not long-lived sidecars.