Kubernetes Pod Lifecycle and Phases

Lesson 7 of 39 15 min read Updated

A Pod is not static. From the moment you create it until it is gone, it moves through a well-defined lifecycle: it waits for a node, starts its containers, serves traffic and finally shuts down.

Think of a restaurant shift. Staff arrive and set up before the doors open, serve guests during service, and at closing time stop seating new guests but finish the meals already on the table. A well-behaved Pod does the same: it only takes traffic once it is ready, and it finishes in-flight work before it stops.

If you have seen a Pod stuck in Pending, wondered why a rolling update drops requests, or needed to drain HTTP connections before a Pod dies, the answer is in the Pod lifecycle. This lesson covers the phases, the postStart and preStop hooks and terminationGracePeriodSeconds, and how they combine for zero-downtime deployments of .NET applications.

What you will learn

  1. The five Pod phases, the three container states and the Pod conditions, and how they differ
  2. What happens, in order, when a Pod starts and when it is terminated
  3. How to use the postStart and preStop lifecycle hooks and choose terminationGracePeriodSeconds
  4. How restart policies and CrashLoopBackOff work
  5. How to make an ASP.NET Core app shut down gracefully during a rolling update

Pod phases

Every Pod has a status.phase field that says, at a high level, where it is in its lifecycle. There are exactly five phases:

PhaseMeaning
PendingThe cluster accepted the Pod, but one or more containers are not running yet. This covers waiting for a node, downloading images and running init containers.
RunningThe Pod is bound to a node and all containers have been created. At least one container is running, starting or restarting.
SucceededAll containers exited successfully (exit code 0) and will not be restarted. Typical for Jobs and batch work.
FailedAll containers have stopped, and at least one exited with a non-zero code or was killed by the system.
UnknownThe Pod's state cannot be read, usually because the control plane lost contact with the node.
Running does not mean ready. A Pod in the Running phase may still be booting your .NET application. Kubernetes uses a separate readiness probe to decide when the Pod is added to a Service's endpoints and starts receiving traffic.

You may also see words like ContainerCreating, CrashLoopBackOff or Terminating in the STATUS column of kubectl get pods. Those are not phases. kubectl shows the most useful detail it can find, often a container state reason.

Container states

Inside a Pod, each container has its own state. kubectl describe pod shows it under the State field of each container:

StateMeaning
WaitingNot running yet. The Reason tells you why, for example ContainerCreating, ImagePullBackOff, CreateContainerConfigError (a missing ConfigMap or Secret) or CrashLoopBackOff.
RunningThe container is executing normally.
TerminatedThe container finished, successfully or not. The exitCode and reason fields tell you which.

Pod conditions

Phases are a summary. Pod conditions give more detail as a list of true/false checks. You see them in kubectl describe pod or kubectl get pod -o yaml:

ConditionMeaning
PodScheduledThe Pod has been assigned to a node.
PodReadyToStartContainersThe Pod sandbox and its network are set up (Kubernetes 1.29 and later).
InitializedAll init containers have completed successfully.
ContainersReadyEvery container in the Pod is ready.
ReadyThe Pod can serve traffic and is added to the endpoints of matching Services. This is the condition Services actually check.

The startup sequence

When Kubernetes creates a Pod, these steps happen in order:

  1. Scheduling. The kube-scheduler picks a node. The Pod is Pending.
  2. Image pull. The kubelet on that node pulls the container images, unless they are already cached. Still Pending.
  3. Init containers. If the Pod has init containers, they run one at a time, in order. Each must succeed before the next starts. Still Pending.
  4. Main containers start. The kubelet starts the main containers in the order they are listed, without waiting for one to become ready before starting the next. The Pod moves to Running.
  5. postStart hook. If defined, it runs right after its container starts. The container is not reported as Running until the hook finishes.
  6. Startup probe. If defined, no other probe runs until it succeeds. This protects slow-starting apps.
  7. Liveness and readiness probes. They begin once the startup probe passes, or straight away if there is none.
  8. Ready. When the readiness probe passes, the Ready condition becomes True and the Pod joins the Service endpoints. Traffic starts to flow.

Lifecycle hooks: postStart and preStop

Kubernetes gives you two hooks to run code at specific moments in a container's life:

HookWhen it runsCommon uses
postStartRight after the container is created. It runs alongside the container's entrypoint, with no guarantee that it runs before the entrypoint.Warm up caches, register the instance in a service registry, write a log entry.
preStopRight before the container is stopped. It runs before SIGTERM is sent to the container's process.Drain connections, deregister from a load balancer, flush buffers, finish in-flight requests.

A hook uses one of these handler types:

  1. exec: runs a command inside the container. The image needs that command, and a shell if you call /bin/sh.
  2. httpGet: sends an HTTP GET request to a path and port on the container.
  3. sleep: pauses for a number of seconds, with no shell needed (Kubernetes 1.30 and later).

If a postStart hook fails, the container is killed and then restarted according to the restart policy. If a preStop hook fails, Kubernetes records a FailedPreStopHook event and carries on with the shutdown. A preStop hook that hangs is cut off by terminationGracePeriodSeconds.

The termination sequence

Kubernetes terminates a Pod for many reasons: a rolling update, a scale-down, a node drain or a manual kubectl delete. The sequence is always the same:

  1. Marked for termination. The Pod shows as Terminating and the grace period countdown (terminationGracePeriodSeconds, 30 seconds by default) starts.
  2. Removed from endpoints, preStop runs. At the same time, the Pod is removed from Service endpoints so no new traffic is sent to it, and the preStop hook starts, if there is one.
  3. SIGTERM. When the preStop hook finishes, or straight away if there is none, the kubelet sends SIGTERM to the main process (PID 1) of each container. Your app should catch it and shut down cleanly.
  4. SIGKILL. If a container is still running when the grace period runs out, it gets SIGKILL: an immediate kill that cannot be caught or ignored.
The grace period includes the preStop hook. The countdown starts when the Pod is marked for termination, not when SIGTERM is sent. With a 45-second grace period and a 10-second preStop sleep, your app has about 35 seconds left after SIGTERM.

What .NET does with SIGTERM

The ASP.NET Core host handles SIGTERM out of the box. When it arrives, IHostApplicationLifetime.ApplicationStopping fires, and Kestrel stops accepting new connections while it finishes the requests already in progress.

The host waits up to HostOptions.ShutdownTimeout for this, 30 seconds by default since .NET 6. That matches the Kubernetes default grace period, so if you add a preStop delay, raise terminationGracePeriodSeconds by at least the same amount.

Your app must be PID 1 to receive SIGTERM. Use the exec form ENTRYPOINT ["dotnet", "MyApi.dll"] in your Dockerfile. The shell form ENTRYPOINT dotnet MyApi.dll starts /bin/sh as PID 1, which does not forward the signal, so the app is killed with SIGKILL when the grace period ends.

Choosing terminationGracePeriodSeconds

This Pod-level field sets the total time Kubernetes gives a Pod to stop, from the moment it is marked for termination until SIGKILL. The default is 30 seconds.

spec:
terminationGracePeriodSeconds: 45

How to pick a value:

  1. Too short: the app cannot drain its connections. Clients see connection resets and background work is lost.
  2. Too long: rolling updates and node drains crawl, because Kubernetes waits for every Pod to stop.
  3. Rule of thumb: your longest expected request, plus any preStop delay, plus a small buffer. For a typical .NET web API with requests under 10 seconds, 30 to 45 seconds is usually enough.

The preStop sleep pattern

There is a subtle race during termination. Endpoint removal and the preStop hook start at the same time, but endpoint removal is eventually consistent. kube-proxy on every node and your Ingress controller need a few seconds to notice, and may keep sending requests to the terminating Pod in the meantime.

The common fix is a short sleep in the preStop hook. It keeps the app running normally while the endpoint change spreads, and only then lets SIGTERM start the shutdown:

lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"]

On Kubernetes 1.30 and later you can use the built-in sleep action instead. It needs no shell, so it also works with chiseled .NET images that ship without /bin/sh:

lifecycle:
preStop:
sleep:
seconds: 10

Either way, the 10 seconds give kube-proxy, Ingress controllers and DNS caches time to stop routing traffic to the Pod before the application begins to shut down.

Restart policies

The Pod's restartPolicy field decides what happens when a container exits. It applies to every container in the Pod.

PolicyBehaviorUsed by
AlwaysAlways restart the container, whatever its exit code. This is the default.Deployments, ReplicaSets, StatefulSets, DaemonSets (the only value they allow)
OnFailureRestart only if the container exits with a non-zero code.Jobs and CronJobs
NeverNever restart. A container that exits stays terminated.Jobs, one-off tasks and debugging Pods

Because of this, only Pods with OnFailure or Never ever reach the Succeeded or Failed phase. With Always, a stopped container is simply started again.

When a container keeps crashing, the kubelet waits longer before each restart: 10 seconds, then 20, 40 and so on, up to 5 minutes. The timer resets once the container runs fine for 10 minutes. While it waits, the Pod shows the infamous CrashLoopBackOff status.

Commands for inspecting the lifecycle

The examples use a Pod named notification-api, which you will create in the hands-on section.

Check the Pod phase

kubectl get pod notification-api -o jsonpath='{.status.phase}'

View the Pod conditions

kubectl get pod notification-api -o jsonpath='{range .status.conditions[*]}{.type}{"\t"}{.status}{"\n"}{end}'

View container states

kubectl describe pod notification-api

Look for the State, Last State and Restart Count fields under each container.

Watch status changes in real time

kubectl get pod notification-api --watch

Check the events for a Pod

kubectl events --for pod/notification-api

Events show the sequence Scheduled, Pulling, Pulled, Created, Started, and Killing when the Pod is terminated.

See why a container stopped

kubectl get pod notification-api -o jsonpath='{.status.containerStatuses[0].state.terminated.reason}'
kubectl get pod notification-api -o jsonpath='{.status.containerStatuses[0].state.terminated.exitCode}'

If the container has already been restarted, the details of the crash are under lastState instead of state.

Read the logs of the previous container

kubectl logs notification-api --previous

Force delete a stuck Pod

kubectl delete pod notification-api --grace-period=0 --force
Force deletion skips the graceful shutdown. Kubernetes forgets the Pod immediately, even if its containers are still running on the node. Use it only for a Pod stuck in Terminating that will not go away, and never as a routine way to delete Pods.

Hands-on: watch a Pod's lifecycle

You will create a Pod running a .NET 10 Notification API and follow it through its lifecycle. The image is an example; use your own ASP.NET Core image that exposes /healthz/ready, /healthz/live and a /shutdown endpoint.

Step 1 — Create the Pod with lifecycle hooks

Save this as notification-api-pod.yaml:

apiVersion: v1
kind: Pod
metadata:
name: notification-api
labels:
app: notification-api
spec:
terminationGracePeriodSeconds: 45
containers:
- name: notification-api
image: myregistry.azurecr.io/notification-api:1.0
ports:
- containerPort: 8080
env:
- name: ASPNETCORE_URLS
value: "http://+:8080"
- name: SMTP_HOST
value: "smtp.internal.svc.cluster.local"
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "256Mi"
lifecycle:
postStart:
exec:
command: ["/bin/sh", "-c", "echo Pod started at $(date) >> /tmp/lifecycle.log"]
preStop:
httpGet:
path: /shutdown
port: 8080
readinessProbe:
httpGet:
path: /healthz/ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz/live
port: 8080
initialDelaySeconds: 10
periodSeconds: 15

The postStart hook writes to /tmp because the official .NET 8+ images run as a non-root user that cannot write to /var/log.

Apply it:

kubectl apply -f notification-api-pod.yaml

Step 2 — Watch the Pod move through its phases

In a separate terminal, watch the Pod:

kubectl get pod notification-api --watch

Expected output:

NAME READY STATUS RESTARTS AGE
notification-api 0/1 Pending 0 0s
notification-api 0/1 ContainerCreating 0 1s
notification-api 0/1 Running 0 3s
notification-api 1/1 Running 0 8s

Notice the change from 0/1 to 1/1. That is the moment the readiness probe passed and the Pod became Ready.

Step 3 — Inspect the Pod conditions

kubectl get pod notification-api -o jsonpath='{range .status.conditions[*]}{.type}{"\t"}{.status}{"\t"}{.lastTransitionTime}{"\n"}{end}'

Expected output:

PodReadyToStartContainers True 2026-04-12T10:00:02Z
Initialized True 2026-04-12T10:00:01Z
Ready True 2026-04-12T10:00:08Z
ContainersReady True 2026-04-12T10:00:08Z
PodScheduled True 2026-04-12T10:00:01Z

The order of the lines can differ between clusters, and older clusters do not show PodReadyToStartContainers. Compare the timestamps: the Pod was scheduled and initialized first, and became ready a few seconds later.

Step 4 — Verify the postStart hook

kubectl exec notification-api -- cat /tmp/lifecycle.log

You should see the line with the timestamp written by the postStart hook.

Step 5 — Delete the Pod and watch it terminate

kubectl delete pod notification-api

In the terminal running --watch you will see something like:

notification-api 1/1 Terminating 0 5m
notification-api 0/1 Terminating 0 5m20s

While the Pod was Terminating, Kubernetes removed it from Service endpoints and called the preStop hook (an HTTP GET to /shutdown) at the same time, then sent SIGTERM, and would have sent SIGKILL if the Pod was still running 45 seconds after the delete.

Hands-on: graceful shutdown in a Deployment

In production you run Pods through a Deployment rather than creating them by hand. Here you add the preStop sleep pattern so rolling updates do not drop requests.

Step 1 — Deploy with a preStop sleep

Save this as notification-api-deployment.yaml. It holds a Deployment with two replicas and a Service in front of them:

apiVersion: apps/v1
kind: Deployment
metadata:
name: notification-api
labels:
app: notification-api
spec:
replicas: 2
selector:
matchLabels:
app: notification-api
template:
metadata:
labels:
app: notification-api
spec:
terminationGracePeriodSeconds: 45
containers:
- name: notification-api
image: myregistry.azurecr.io/notification-api:1.0
ports:
- containerPort: 8080
env:
- name: ASPNETCORE_URLS
value: "http://+:8080"
- name: SMTP_HOST
value: "smtp.internal.svc.cluster.local"
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "256Mi"
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"]
readinessProbe:
httpGet:
path: /healthz/ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz/live
port: 8080
initialDelaySeconds: 10
periodSeconds: 15
---
apiVersion: v1
kind: Service
metadata:
name: notification-api-service
spec:
type: ClusterIP
selector:
app: notification-api
ports:
- port: 80
targetPort: 8080

If your image has no shell, replace the exec block with sleep: { seconds: 10 } as shown earlier. Apply the file:

kubectl apply -f notification-api-deployment.yaml

Step 2 — Trigger a rolling update

Change the image to start a rolling update. Every old Pod now goes through the termination sequence:

kubectl set image deployment/notification-api notification-api=myregistry.azurecr.io/notification-api:2.0

Follow the rollout until it finishes:

kubectl rollout status deployment/notification-api

Each old Pod is removed from the endpoints, sleeps for 10 seconds in preStop, receives SIGTERM and shuts down gracefully. The sleep gives kube-proxy time to update its rules before the app stops. Meanwhile, each new Pod must pass its readiness probe before the next old Pod is terminated.

Step 3 — Verify the timeline

kubectl events --for deployment/notification-api

You will see ScalingReplicaSet events: the Deployment scales the new ReplicaSet up and the old one down, one step at a time.

That is a zero-downtime rollout. Readiness probes control when new Pods get traffic, and the preStop sleep plus a large enough grace period control how old Pods leave. Together they let you update a .NET API without dropping a single request.

Clean up when you are done:

kubectl delete -f notification-api-deployment.yaml

Summary

A Pod moves from Pending to Running and, for finite workloads, to Succeeded or Failed. Hooks, probes and the grace period let you control exactly how it starts taking traffic and how it stops.

  1. The five phases are Pending, Running, Succeeded, Failed and Unknown. Each container also has its own state: Waiting, Running or Terminated.
  2. Pod conditions give the detail. The Ready condition decides when a Pod receives Service traffic.
  3. postStart runs right after a container starts, alongside the entrypoint. preStop runs before SIGTERM during termination.
  4. terminationGracePeriodSeconds (default 30) is the total time from termination to SIGKILL, and it includes the preStop hook.
  5. The preStop sleep pattern covers the delay while endpoint removal spreads through the cluster.
  6. restartPolicy is Always (the default, required by Deployments), OnFailure or Never. Repeated crashes lead to CrashLoopBackOff.
  7. ASP.NET Core handles SIGTERM natively through IHostApplicationLifetime.ApplicationStopping, and Kestrel drains connections within HostOptions.ShutdownTimeout.

With these pieces in place, your Pods start cleanly, receive traffic only when ready and leave without dropping requests.