Init containers

Ordered setup that must finish before the app starts.

Intermediate8 min · lesson 17 of 65
In plain terms
Init containers are the prep cooks. They must finish chopping and setting up before the line cooks — your actual app — are allowed to start service.

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.

web-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: web
spec:
initContainers:
- name: wait-for-db
image: busybox:1.36
command: ['sh','-c','until nc -z db 5432; do echo waiting for db; sleep 2; done']
- name: migrate
image: registry.local/migrator:1.2
command: ['/migrate','up']
containers:
- name: app
image: registry.local/app:1.4.2
ports:
- containerPort: 8080
apply and watch the STATUS column
$ kubectl apply -f web-pod.yaml
pod/web created
$ kubectl get pod web -w
NAME READY STATUS RESTARTS AGE
web 0/1 Init:0/2 0 2s
web 0/1 Init:1/2 0 13s
web 0/1 PodInitializing 0 15s
web 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.

inspect the init container states
$ kubectl describe pod web
describe output (trimmed to the container states)
Init Containers:
wait-for-db:
Image: busybox:1.36
State: Terminated
Reason: Completed
Exit Code: 0
Started: Tue, 15 Jul 2025 09:14:02 +0000
Finished: Tue, 15 Jul 2025 09:14:15 +0000
Ready: True
Restart Count: 0
migrate:
State: Terminated
Reason: Completed
Exit Code: 0
Ready: True
Containers:
app:
State: Running
Ready: 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.

diagnose the stuck Pod
$ kubectl get pod web
NAME READY STATUS RESTARTS AGE
web 0/1 Init:0/2 0 6m12s
$ kubectl logs web -c wait-for-db
the first init container's logs
waiting for db
waiting for db
waiting for db
waiting 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.

native-sidecar-pod.yaml (excerpt)
spec:
initContainers:
- name: wait-for-db
image: busybox:1.36
command: ['sh','-c','until nc -z db 5432; do sleep 2; done']
- name: log-shipper
image: fluent/fluent-bit:3.1
restartPolicy: Always # <- makes this a native sidecar
containers:
- name: app
image: registry.local/app:1.4.2
both the app and the sidecar are counted
$ kubectl get pod web
NAME READY STATUS RESTARTS AGE
web 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.

Init containers rerun on every Pod start, so make them idempotent
A common assumption is that a migration init container runs once, ever. It doesn't. Init containers run every single time the Pod starts, and a Pod can restart for reasons you never triggered: a node reboot, an eviction, a reschedule onto another node. If your migrate step blindly runs 'add column' without checking whether the column already exists, one of those restarts will fail or corrupt state. Write init steps that are safe to run twice, and use a migration tool that tracks what it has already applied.
How a Pod with init containers reaches Running
1Pod scheduledkubelet on the node pulls the…2wait-for-db (Init1/2)blocks until db:5432 answers,…3migrate (Init 2/2)runs the schema migration to…4app startsPodInitializing then Running;…

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.

terminal
$ kubectl apply -f web-pod.yaml
pod/web created
$ kubectl get pod web -w
NAME READY STATUS RESTARTS AGE
web 0/1 Init:0/2 0 2s
web 0/1 Init:1/2 0 13s
web 0/1 PodInitializing 0 15s
web 1/1 Running 0 17s
$ kubectl describe pod web
$ kubectl get pod web
NAME READY STATUS RESTARTS AGE
web 0/1 Init:0/2 0 6m12s
$ kubectl logs web -c wait-for-db
$ kubectl get pod web
NAME READY STATUS RESTARTS AGE
web 2/2 Running 0 25s

Takeaway

Inits run in order, to completion, before app containers. They are setup, not long-lived sidecars.

Quick check
01You add a logging agent to a Pod as a normal init container (no restartPolicy) so it starts before the app. The Pod never reaches Running. What is the structural reason?
Incorrect — That would surface as Init:ErrImagePull, not a Pod that quietly never progresses. The failure here is built into how you declared the container, not a pull problem.
Correct — Normal init containers run to completion in order, so a long-running process blocks everything after it forever. Declare it as a native sidecar (an init container with restartPolicy: Always) so Kubernetes waits only for it to start.
Incorrect — They are. Sidecars are a standard pattern; the issue is purely how this one was declared.
Incorrect — Normal init containers don't run readiness probes at all, so a missing probe cannot be the cause. The block comes from run-to-completion ordering.
02A Pod has one plain init container that requests 2 CPU and runs for about ten seconds, plus two app containers that each request 0.5 CPU. What CPU request does the scheduler use to find a node for the Pod?
Incorrect — init container requests very much count; the scheduler can't ignore a container that needs 2 CPU while it runs.
Incorrect — init containers run before the app containers, not alongside them, so their requests aren't all summed in.
Correct — the scheduler sizes the Pod as max(largest init request, sum of app requests) = max(2, 1), so a brief 2-CPU init container can make the whole Pod harder to place.
Incorrect — app requests are summed because they run together, and that sum is compared against the largest init request; it isn't a plain single-container max.
03A migrate init container runs a raw 'ALTER TABLE ADD COLUMN' and worked fine at first deploy. Weeks later, with no deploy at all, a node reboots and the Pod now fails on that init step. What happened, and what's the fix?
Incorrect — nothing was redeployed, the image didn't change, and a pinned tag wouldn't prevent this.
Correct — init containers aren't run-once-ever; any restart replays them, so a non-idempotent 'add column' fails the second time. Write init steps that are safe to run twice.
Incorrect — this is a Pod, not a Job, and backoffLimit isn't what makes a fresh restart fail on an existing column.
Incorrect — the failure is the column already existing on a rerun, not changed inputs.

Related