Managed lifecycle: starting and stopping politely
On your laptop, you decide when your app starts and stops. On Kubernetes, the platform decides, at any moment: during a deploy, when scaling down, when a node is drained. It always tells your container first. A well-behaved app listens to these messages and reacts. This chapter is about how.
The idea: a shop at closing time
A good shop does not lock the door on customers mid-purchase. It announces "we close in 10 minutes", stops letting new people in, serves the people already inside, then turns off the lights. If someone is still dawdling at the deadline, security walks them out.
Health probes (chapter 13) let Kubernetes ask your app how it is doing. Lifecycle events are the opposite direction: Kubernetes tells your app what is about to happen. Reacting is optional, but apps that do react deploy and scale without errors.
When Kubernetes stops a container
- A rolling update replaces old Pods with new ones (chapter 10).
- You scale down, or
kubectl delete pod. - A node is drained for maintenance or an upgrade, or the cluster autoscaler removes it.
- A failed liveness probe makes Kubernetes restart the container.
- The node runs short of memory and evicts Pods (chapter 22).
This happens all the time. Each time, the same polite sequence runs.
SIGTERM: "please finish up"
A signal is a tiny message the operating system delivers to a process. SIGTERM means "please shut down". Your app should: stop accepting new work, finish what it is doing, close database connections, then exit. In Node:
// posts/index.js
const server = app.listen(4000, () => console.log('Posts listening on 4000'));
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down');
server.close(async () => { // stop new connections, wait for open requests
await db.end(); // close the database pool
process.exit(0); // 0 = clean exit
});
});
npm or a shell, it usually does not pass the signal on to Node. Your handler never runs, and the container just waits to be killed.CMD npm start # shell form: PID 1 is /bin/sh, then npm, then node
# or
CMD ["npm", "start"] # PID 1 is npm, which does not forward SIGTERM reliablyCMD ["node", "index.js"] # exec form: PID 1 is node itselfUse the exec form (the JSON array) and start node directly. If you need a shell or several processes, add a tiny init such as tini that forwards signals.
SIGKILL: "time's up"
Kubernetes does not wait forever. After SIGTERM it waits a grace period, 30 seconds by default. If the process is still running, it sends SIGKILL. That one cannot be caught or ignored: the process is stopped instantly, mid-request. The container's exit code is then 137 (128 + 9, the number of SIGKILL).
spec:
terminationGracePeriodSeconds: 60 # per Pod; default 30
containers:
- name: posts
image: micro-posts:1.0
- Set it a little longer than your slowest request or cleanup.
- It is not a guarantee:
kubectl delete pod --grace-period=0 --forceskips it. Design the app so a sudden kill does not corrupt anything. - Best of all: make startup and shutdown fast. Quick to start, quick to stop is what makes Pods truly disposable (chapter 7).
Try the shutdown timeline
What actually happens between "delete this Pod" and the container being gone? Change the settings and read the verdict below the timeline. One detail surprises everyone: removing the Pod from the Service and sending SIGTERM happen at the same time, and the Service update takes a few seconds to reach every node. Meanwhile, new requests still arrive.
Find the setting where everything is green: the app handles SIGTERM, a short preStop sleep covers the Service delay, and the grace period is long enough for both.
The preStop hook
A hook is a command or HTTP call Kubernetes runs at a specific moment in a container's life. preStop runs before SIGTERM is sent, and SIGTERM waits until it finishes.
containers:
- name: posts
image: micro-posts:1.0
lifecycle:
preStop:
exec:
command: ["sleep", "5"] # needs a 'sleep' binary in the imageThe most common use by far. Five seconds of waiting lets every node stop sending traffic to this Pod before the app closes its server. Newer Kubernetes versions also offer a built-in sleep: action that needs no binary in the image.
lifecycle:
preStop:
httpGet:
path: /shutdown
port: 4000Kubernetes calls an endpoint of your own app, which can start a clean shutdown. Useful when the app cannot react to signals, for example when a wrapper process swallows them.
containers:
- name: proxy
image: nginx:1.27
lifecycle:
preStop:
exec:
command: ["nginx", "-s", "quit"] # nginx's own "finish requests, then stop"For third-party software, run its own graceful-stop command.
- Its time counts toward the grace period: preStop + shutdown must fit inside it.
- If preStop fails or hangs, the container is still stopped at the deadline. It cannot prevent shutdown.
The postStart hook
postStart runs right after the container is created, at the same time as your app starts, not before it.
lifecycle:
postStart:
exec:
command: ["sh", "-c", "echo started at $(date) >> /tmp/lifecycle.log"]
| Behaviour | What it means for you |
|---|---|
| Runs in parallel with the app | No ordering guarantee. The app may not be listening yet when the hook runs. |
| Blocks the container from being marked Running | The Pod stays Pending until the hook finishes, a way to hold back a container while it warms up. |
| Fails (non-zero exit) → container is killed | A cheap "do not start unless this check passes". |
| May run more than once | Write it so running twice does no harm. |
No retries for an httpGet that never reaches the app | Do not put critical logic here. |
Honest advice: most apps never need postStart. Warm-up code usually belongs in the app's own startup, and "wait for X before starting" belongs in an init container.
Hooks vs init containers
An init container is a container that runs to completion before the main containers start. If there are several, they run one after another. Example: the posts service should not start until the event bus answers DNS:
spec:
initContainers:
- name: wait-for-event-bus
image: busybox:1.36
command: ["sh", "-c", "until nslookup event-bus-srv; do echo waiting; sleep 2; done"]
containers:
- name: posts
image: micro-posts:1.0
| Lifecycle hooks | Init containers | |
|---|---|---|
| Work on | One container | The whole Pod |
| At startup | postStart, in parallel with the app | Run fully, in order, before any app container |
| At shutdown | preStop | Nothing |
| Guarantee | Weak (timing not guaranteed) | Strong (app waits for success) |
| Good for | Small extras: graceful stop, a log line | Setup steps: wait for a dependency, run migrations, prepare files |
Advanced: wrapping the entrypoint
Tools that build Pods automatically, such as CI pipelines like Tekton, sometimes need even more control: start containers in a strict order while sidecars keep running. Init containers cannot do that, because the main containers only start after all init containers have finished. The trick is to wrap each container's start command:
- An init container copies a small "supervisor" program into a shared
emptyDirvolume. - The main container's
commandis replaced by that supervisor, and the original command becomes its arguments. - The supervisor starts the real program when it is told to, and handles signals, ordering and results.
spec:
volumes:
- name: tools
emptyDir: {}
initContainers:
- name: install-runner
image: example/runner:1.0
command: ["cp", "/runner", "/tools/runner"]
volumeMounts: [{ name: tools, mountPath: /tools }]
containers:
- name: build
image: node:22
command: ["/tools/runner"] # the wrapper starts first
args: ["npm", "run", "build"] # the original command, now its arguments
volumeMounts: [{ name: tools, mountPath: /tools }]
From least to most control, and effort: signal handling in your app → lifecycle hooks → init containers → entrypoint wrapping. Start with the first; it is enough for most services.
Checklist for the blog app
- Dockerfiles use
CMD ["node", "index.js"], notnpm start. - Every service handles SIGTERM: close the server, finish requests, close connections, exit 0.
- A
preStopsleep of about 5 seconds, so no requests hit a closing Pod. terminationGracePeriodSecondscovers preStop + the slowest request.- Optional: an init container that waits for
event-bus-srv.
Recap
- Kubernetes, not you, decides when containers start and stop, and it announces it.
- SIGTERM: finish up and exit. SIGKILL: forced, after the grace period (30s default).
- Start Node with the exec form so SIGTERM actually reaches it.
- preStop runs before SIGTERM; its classic job is a short sleep while traffic drains.
- postStart runs alongside startup with weak guarantees; init containers are the reliable way to run setup first.
Quick check
Show answer
The signal never reaches Node, usually because the container starts with npm start or a shell. Kubernetes waits the full grace period and then sends SIGKILL. Switch to CMD ["node", "index.js"].
Show answer
The Service needs a moment to stop routing to the Pod, but the app closed its server immediately on SIGTERM. Add a preStop sleep of a few seconds.
Container lifecycle and graceful shutdown: common questions
Why does my Node app not receive SIGTERM in Kubernetes?
Usually because the container starts with npm start or a shell, which does not forward the signal. Use the exec form CMD ["node", "index.js"] so Node is the main process.
What is a preStop hook used for?
It runs before SIGTERM. Its most common job is a short sleep so the Service stops sending traffic to the Pod before the app closes its server.