06-scenarios-and-interviews

Scenario: The Deploy That Always Took Ten Seconds

A suspiciously round number is a diagnosis. Why /bin/sh at PID 1 swallows SIGTERM, and how it turns every rolling deploy into dropped requests.

September 13, 2026
dockerscenariointerviewpid1sigtermgraceful-shutdownzero-downtime

The Symptom

A rolling deploy of twelve containers takes just over two minutes. The team assumes that's what deploys cost.

Then someone times a single container:

bash
time docker stop api-1
# real    0m10.043s

Ten seconds. Not nine, not eleven. And it's ten seconds every single time, on every service, regardless of load.

That consistency is the whole diagnosis. Real work varies — draining connections takes longer under load, faster when idle. A number that never moves isn't your application doing anything. It's a timeout expiring.

💡

Ten seconds is Docker's default grace period on Linux. If your containers stop in exactly that, they are not shutting down — they're being killed after failing to respond.

The Investigation

Two more observations sharpen it.

The application's shutdown log never appears:

js
process.on("SIGTERM", () => {
  console.log("SIGTERM received, draining connections");
  // ...
});

Nothing. Ever. In any container.

And the exit code:

bash
docker inspect --format '{{.State.ExitCode}}' api-1
# 137

137 is 128 + 9 — killed by SIGKILL. A container that shut down cleanly on SIGTERM would report 143 (128 + 15).

So: Docker sent SIGTERM, the application never saw it, the grace period expired, and the kernel killed the process outright.

Now look at where PID 1 is:

bash
docker exec api-1 ps -ef
# UID   PID  PPID  CMD
# node    1     0  /bin/sh -c node dist/server.js
# node    7     1  node dist/server.js

There it is. /bin/sh is PID 1. The application is its child at PID 7.

The Mechanism

The Dockerfile ended with:

dockerfile
CMD node dist/server.js

That's shell form. Docker wraps it as /bin/sh -c "node dist/server.js", so the shell becomes PID 1 and your application is spawned beneath it.

docker stop sends SIGTERM to PID 1 — the shell. And sh does not forward signals to its children. It simply waits.

The cost isn't the ten seconds. It's that every deploy kills containers mid-request. In-flight HTTP requests are severed, database transactions are abandoned rather than rolled back cleanly, and queue messages are left unacknowledged. Users see occasional 502s during deploys, which the team has learned to describe as "the deploy blip."

The Fix

Use exec form — the JSON array — so your application is PID 1:

dockerfile
CMD ["node", "dist/server.js"]
bash
docker exec api-1 ps -ef
# UID   PID  PPID  CMD
# node    1     0  node dist/server.js
bash
time docker stop api-1
# real    0m0.312s

If you need an entrypoint script

Plenty of images legitimately need setup before starting — migrations, config templating. The trap is that a script is itself a shell at PID 1, reproducing the bug. The fix is exec, which replaces the shell process rather than spawning a child under it:

bash
#!/bin/sh
set -e
./run-migrations.sh
exec node dist/server.js "$@"   # exec: node REPLACES the shell, keeping PID 1

Without exec on that last line, you're back where you started.

⚠️

Exec form requires valid JSON — double quotes only. CMD ['node', 'server.js'] uses single quotes, isn't valid JSON, and Docker silently treats it as shell form. You get the bug back with a Dockerfile that looks correct.

Handling the signal once it arrives

Getting SIGTERM to the process is half the job. The application has to drain:

js
const server = app.listen(3000);
 
process.on("SIGTERM", () => {
  console.log("SIGTERM received, draining connections");
  server.close(() => {        // stop accepting new, finish in-flight
    db.end();
    process.exit(0);
  });
});

If draining legitimately takes longer than ten seconds, extend the budget rather than accepting the kill:

bash
docker run --stop-timeout 30 myapp

Check yourself

A container's Dockerfile uses exec form correctly, yet docker stop still takes the full grace period and exits 137. The application has no SIGTERM handler. What's happening?

Worth knowing because it comes from the same root and is a frequent follow-up.

When a child process exits, its parent must call wait() to collect the status. Until then the child lingers as a zombie — a process table entry with nothing behind it. Normally PID 1 (init) adopts and reaps orphans automatically.

In a container your application is PID 1, and most applications were never written to adopt arbitrary orphans. If yours spawns subprocesses, zombies accumulate and consume process table entries inside the container's PID namespace.

bash
docker exec myapp ps aux | grep -c defunct

The fix is a real init process, which both reaps zombies and forwards signals:

bash
docker run --init myapp

You don't need --init for a single-process application that never forks. You do want it for anything running a process manager, spawning workers, or shelling out to tools. It's cheap enough that many teams set it by default.

In an Interview

"Your containers take ten seconds to stop. Why?" is a favourite because it has a precise answer and rewards systematic thinking.

What's being tested

  • Do you read the number as evidence? Recognising a suspiciously round figure as a timeout is the move.
  • Do you understand PID 1? This is the concept the question exists to probe.
  • Do you connect it to user impact? Slow deploys are the symptom; dropped requests are the actual problem.

How to answer

Lead with the diagnosis from the number: "Exactly ten seconds is Docker's default grace period, so the container isn't shutting down — it's being SIGKILLed after ignoring SIGTERM. I'd confirm with the exit code: 137 means SIGKILL, 143 means it stopped cleanly."

Then the mechanism: shell form puts /bin/sh at PID 1, and sh doesn't forward signals. Then the fix: exec form, plus exec in any entrypoint script. Then the impact: this is what turns rolling deploys into dropped connections.

Follow-ups to expect

"What does exec do in a shell script?" It replaces the current process image rather than forking a child, so your application inherits PID 1 and the signal.

"How does this relate to Kubernetes?" Directly — the same mechanism, higher stakes. Kubernetes sends SIGTERM, waits terminationGracePeriodSeconds, then SIGKILLs. A container that ignores signals breaks rolling updates the same way, and it's a large part of why pods get killed mid-request.

"What about --init?" Different problem, same PID 1 root: signal forwarding plus zombie reaping for applications that spawn children. Distinguishing the two problems cleanly is a good signal.

Check yourself

What do exit codes 137 and 143 tell you about how a container stopped?

Next Scenario

This failure was noisy once you measured it. The next one produced no symptom at all — a build that succeeded, a review that passed, and a credential published to anyone who pulled the image.