Lesson 10 of 66 · 15 min read · Updated
Init Containers — Preparing a Pod Before Your App Starts
On this page
In the Pod articles you ran Pods with a single container. Real applications often need a little work done before they can start. The database schema must exist. A configuration file must be rendered. A dependency must be reachable. You could put all of that into the application's startup code, but then every image carries tools it does not need, and a slow dependency turns into a crashing application.
Kubernetes has a dedicated place for this work: init containers. They run to completion, one after the other, before any application container starts. This article shows how they behave, how they share data with the application, and how to debug them when they fail, using a .NET 10 API, a .NET migration tool and PostgreSQL on a multi-node kind cluster.
We will cover the following topics:
- What init containers are and how they differ from application containers
- Ordering and restart semantics
- Waiting for a dependency
- Running a .NET database migration as an init container
- Sharing files with the application through an
emptyDirvolume - How init containers affect resource requests and scheduling
- Debugging a Pod stuck in
Init:ErrorandInit:CrashLoopBackOff
Technical Requirements
- A Kubernetes cluster. The examples run on Docker Desktop's kind-based cluster with one control-plane node and three workers, Kubernetes v1.36.
kubectl, Docker and the .NET 10 SDK.- On a plain kind cluster, load the two images you build below with
kind load docker-image. Docker Desktop serves locally built images to the nodes automatically.
Core Concepts
What an Init Container Is
An init container is declared in the initContainers list of a Pod spec. It looks exactly like a normal container: image, command, environment, volume mounts, resources. The difference is in its life cycle:
| Init container | Application container | |
|---|---|---|
| Declared in | spec.initContainers | spec.containers |
| When it runs | Before every application container | After all init containers succeeded |
| How many at a time | One, in the listed order | All of them, in parallel |
| Must it exit? | Yes, with exit code 0 | No, it runs for the life of the Pod |
| Probes | Not supported (except native sidecars, next article) | Liveness, readiness, startup |
The kubelet starts the first init container and waits. Exit code 0 means success, and the next one starts. When the last one has succeeded, the application containers start together.
Why Use Them
- Separation of concerns. The migration tool,
pg_isreadyor a templating tool lives in its own image. The application image stays small. - Different privileges. An init container can run with a Secret or permissions that the long-running application never sees.
- A clean startup contract. By the time your application starts, the dependency is reachable and the schema exists. Your startup code can assume it.
- Blocking without crashing. Waiting in an init container shows up as
Init:0/3inkubectl get pods, which is far clearer than an application inCrashLoopBackOffbecause the database is not there yet.
Restart Semantics
What happens when an init container fails depends on the Pod's restartPolicy:
| Pod restartPolicy | Failed init container |
|---|---|
Always (Deployments, StatefulSets) | Restarted with exponential back-off until it succeeds |
OnFailure | Restarted with back-off |
Never (some Jobs and bare Pods) | The whole Pod is marked Failed |
Because a failing init container can be retried many times, init containers must be idempotent. Running the migration twice must not break anything. CREATE TABLE IF NOT EXISTS and inserting seed data only when the table is empty are typical examples.
Init containers run again whenever the Pod is recreated, for example on a rollout or after a node failure. They do not run again when an application container restarts inside the same Pod. The step-by-step example proves both.
Sharing Data with emptyDir
Init containers and application containers do not share a filesystem by default. To hand over a file, mount the same volume in both. An emptyDir volume is created empty when the Pod is scheduled, lives as long as the Pod, and is perfect for this.
Resource Accounting
The scheduler needs one number for a Pod's CPU and memory request. Because init containers run one at a time and before the application containers, Kubernetes computes the effective request like this:
- the largest request of any single init container, or
- the sum of the requests of all application containers,
whichever is higher. Limits are calculated the same way.
| Containers | CPU request | Effective Pod request |
|---|---|---|
Init migrate | 500m | |
App catalog-api | 200m | |
App log-agent | 100m | max(500m, 200m + 100m) = 500m |
A heavy init container therefore makes the whole Pod harder to schedule, even if it only runs for a second.
Hands-On: Kubernetes Commands
See init progress at a glance
Init:1/3 means one of three init containers has completed.
Read the logs of a specific init container
Use -c with the init container's name.
Read the logs of the previous attempt
After a restart, --previous shows what the failed attempt printed.
Show init container states and exit codes
describe has a separate "Init Containers" section.
Print each init container's result
A JSONPath query gives a compact table.
Watch the Pod move through its init phases
-w streams every status change.
Step-by-Step Example
The Scenario
A catalog API stores products in PostgreSQL. Before the API starts, three things must happen in order:
- wait-for-db: wait until PostgreSQL accepts connections.
- migrate: run a .NET console tool that creates the tables and seeds data.
- render-config: write a settings file into a shared volume.
We deploy the API before the database on purpose, so we can watch the Pod wait. Then we break the migration and debug it.
Step 1 — The Migration Tool
Create a console app with Npgsql:
Replace Program.cs:
Two details matter here:
- Every statement is idempotent, so a retry or a second Pod running the same migration is harmless.
- The tool catches the exception and returns a non-zero exit code. Do not let an unhandled exception decide the outcome. In our first version the migrator let the exception escape, and the process, running as PID 1 in its container, printed the stack trace and then simply kept running. The Pod sat in
Init:0/1with the init containerRunningforever, and Kubernetes never retried. An explicitreturn 1turns the failure into something the kubelet can see.
In production, EF Core users would call context.Database.MigrateAsync() or run an EF Core migration bundle here instead of raw SQL. The init container pattern is the same.
The Dockerfile uses the small runtime image, because a console app does not need ASP.NET Core:
Step 2 — The API
Program.cs reads the products and the file that the third init container writes:
Build both images from the folder that contains the two projects:
Step 3 — Namespace and Credentials
Both the migrator and the API read the connection string from the same Secret. GSS Encryption Mode=Disable stops Npgsql from probing for Kerberos libraries that the slim .NET images do not contain. Without it every connection prints a harmless but noisy libgssapi_krb5.so.2 error.
Step 4 — The Deployment with Three Init Containers
The wait-for-db container reuses the postgres:17 image only for its pg_isready client. The render-config container and the API mount the same emptyDir, and the API mounts it read-only.
The Pod is not crashing. It is patiently waiting, and the status tells you exactly where: zero of three init containers are done.
Step 5 — Bring Up the Database and Watch the Init Chain
This database has no persistent volume. That is fine for this demo and wrong for anything else; the storage and StatefulSet articles cover the real setup.
The chain ran in order: waiting ended, the migration ran, the config was rendered, and only then did the API start. Read each step's logs:
And check the final state of every init container:
Step 6 — Call the API
In a second terminal:
The products came from the tables the migrator created, and the config file was written by a container that no longer exists. It lives on in the shared emptyDir.
Step 7 — App Restarts Do Not Re-Run Init Containers
Stop the application process inside the running Pod. Here we kill PID 1 in the API container:
The API restarted once. The init containers did not run again, and the config file still carries the original timestamp. Init containers run once per Pod, not once per container start. A kubectl rollout restart, by contrast, creates new Pods and runs the whole chain again.
Step 8 — Break the Migration
Create a Pod whose migrator uses a wrong password:
The status alternates between Init:Error (the last attempt failed) and Init:0/1 (a new attempt is running), while the restart count grows. Depending on timing and the Kubernetes version, kubectl may also show Init:CrashLoopBackOff during the back-off delay; the BackOff event below confirms the same thing. The application container never starts.
Step 9 — Debug It
Start with the logs of the failing init container:
Then confirm what Kubernetes saw:
The events show the back-off:
A checklist for any Pod stuck in an init phase:
| Symptom | Look at | Typical cause |
|---|---|---|
Init:0/N for a long time, container Running | kubectl logs -c <init> | Waiting for a dependency, or a process that never exits |
Init:Error, Init:CrashLoopBackOff | kubectl logs -c <init> and --previous | Bad credentials, wrong host, failing script |
Init:ImagePullBackOff | kubectl describe pod events | Wrong image name or tag, missing pull secret |
Pending before any init runs | kubectl describe pod events | Requests too large, often because of an init container |
Fix the Secret or the manifest, then delete the Pod. A Deployment creates a fresh Pod that runs the whole init chain again.
Step 10 — See Init Requests Block Scheduling
An init container that asks for more than any node has makes the whole Pod unschedulable, even though the application itself is tiny:
The nodes in this cluster have 8 CPUs. The effective request is max(12, 0.1) = 12 CPUs, so no worker fits, and the control-plane node is excluded by its taint. Size init container requests for what they really need.
Step 11 — Clean Up
Summary
- Init containers run one at a time, in order, and must exit with code 0 before any application container starts.
- They are ideal for waiting for dependencies, database migrations and preparing files, and keep those tools out of the application image.
- Share their output with the application through a volume such as emptyDir.
- Failed init containers are retried with back-off under
restartPolicy: Always, so they must be idempotent. - They run once per Pod: an application container restart does not re-run them, a new Pod does.
- A .NET init tool should catch exceptions and return a non-zero exit code explicitly; we saw an unhandled exception leave the process running and the Pod stuck.
- The Pod's effective request is the larger of the biggest init request and the sum of the application requests.
- Debug with
kubectl logs -c <init>,--previous,describeand the Pod's events.
In the next article we look at containers that start before your application but keep running next to it: native sidecar containers, and the classic multi-container patterns built on them.