Chapter 14 of 47 · Running well on Kubernetes

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.

The container lifecycleThe container is created, the postStart hook runs alongside the app starting, the app runs, then on shutdown the preStop hook runs, SIGTERM is sent, and after the grace period SIGKILL ends it. createdcontainer postStarthook (optional) runningyour app preStophook (optional) SIGTERMfinish up SIGKILLif still alive "opening the shop""closing time … lights off"

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

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
  });
});
The silent trap: your app may never receive SIGTERM. Kubernetes sends it to the container's main process (PID 1). If that process is 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 reliably

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

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 image

The 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.

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"]
BehaviourWhat it means for you
Runs in parallel with the appNo ordering guarantee. The app may not be listening yet when the hook runs.
Blocks the container from being marked RunningThe Pod stays Pending until the hook finishes, a way to hold back a container while it warms up.
Fails (non-zero exit) → container is killedA cheap "do not start unless this check passes".
May run more than onceWrite it so running twice does no harm.
No retries for an httpGet that never reaches the appDo 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 hooksInit containers
Work onOne containerThe whole Pod
At startuppostStart, in parallel with the appRun fully, in order, before any app container
At shutdownpreStopNothing
GuaranteeWeak (timing not guaranteed)Strong (app waits for success)
Good forSmall extras: graceful stop, a log lineSetup 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:

  1. An init container copies a small "supervisor" program into a shared emptyDir volume.
  2. The main container's command is replaced by that supervisor, and the original command becomes its arguments.
  3. 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 }]

You will rarely write this yourself; it is good to recognise when you read the Pods a CI tool creates.

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

  1. Dockerfiles use CMD ["node", "index.js"], not npm start.
  2. Every service handles SIGTERM: close the server, finish requests, close connections, exit 0.
  3. A preStop sleep of about 5 seconds, so no requests hit a closing Pod.
  4. terminationGracePeriodSeconds covers preStop + the slowest request.
  5. Optional: an init container that waits for event-bus-srv.

Recap

Quick check

Every deploy takes exactly 30 extra seconds per Pod, and logs never show your "SIGTERM received" line. Most likely cause?
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"].

The app handles SIGTERM perfectly, yet each deploy causes a few "connection refused" errors. Why, and what fixes it?
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.