skip to content

A service built with `CMD npm start` in its Dockerfile never reacts to `docker stop` — it always takes the full grace period and dies hard. Explain why the shell form of CMD/ENTRYPOINT causes this, and show how you would fix it.

level: middleimportance: must knowfreq 62%

answer

  1. Shell form → /bin/sh -c → shell is PID 1
  2. Exec form = JSON array, app is PID 1
  3. Wrapper script must end in exec "$@"
  4. npm start / mvn adds a non-forwarding parent
  5. Shell-form ENTRYPOINT swallows CMD and run args

basics

~20 s

Shell form wraps the command in /bin/sh -c, so the shell is PID 1 and your app is a child. Docker signals PID 1 — the shell — which does not forward SIGTERM. Use exec form (CMD ["npm","start"]) or exec in the entrypoint script so the app is PID 1.

solid answer

~50 s

Docker's CMD/ENTRYPOINT have two syntaxes. **Shell form** (`CMD npm start`) is rewritten to `/bin/sh -c "npm start"`, so `sh` becomes PID 1 and the real process is its child. `docker stop` signals PID 1 only, and a plain POSIX `sh` waiting on a child does not forward SIGTERM — so the app never sees it, the grace period expires, and everything is SIGKILLed with exit 137. **Exec form** (`CMD ["npm","start"]`) uses JSON array syntax and execs the binary directly with no shell in between, so the process itself is PID 1 and receives the signal. Fixes, in order of preference: use exec form; if you need a wrapper script, end it with `exec "$@"` or `exec node server.js` so the script's process image is replaced by the app; and be aware that under `npm start` you also get an extra npm layer that historically did not forward signals — run the binary directly (`CMD ["node","server.js"]`).

code

dockerfile · 5 lines
dockerfile
# Broken: /bin/sh -c "npm start" is PID 1; SIGTERM dies there
CMD npm start

# Correct: node is PID 1 and receives SIGTERM
CMD ["node", "dist/server.js"]

go deeper

for a junior

Know that JSON-array (exec) form makes your program PID 1 and bare-string (shell) form puts a shell in front of it, and that signals only reach PID 1.

for a middle

Explain the /bin/sh -c rewrite, why the shell discards SIGTERM as PID 1, the exec "$@" fix in entrypoint scripts, and the shell-form ENTRYPOINT/CMD interaction.

for a senior

Diagnose from symptoms — stop always takes the timeout, exit 137, missing shutdown logs — and know when --init/tini is the pragmatic wrapper for a binary you cannot change.

for a principal

Make it a platform rule: exec form and exec "$@" in base images and templates, plus a CI or image-lint check, so no team rediscovers this during an incident.

## Two syntaxes, two very different process trees `ENTRYPOINT` and `CMD` in a Dockerfile each accept two forms: - **Exec form** — a JSON array: `CMD ["node", "server.js"]`. Docker execs that binary directly as the container's first process. Note the syntax is strict JSON: double quotes only, no trailing commas. - **Shell form** — a bare string: `CMD node server.js`. Docker rewrites this to `["/bin/sh", "-c", "node server.js"]`. Inside the container, `docker exec <c> ps -ef` (or `pstree`) makes the difference visible. With exec form, PID 1 *is* `node server.js`. With shell form, PID 1 is `/bin/sh -c ...` and `node` is PID 7-ish underneath it. ## Why that breaks shutdown `docker stop` sends the stop signal to PID 1 only. Under shell form PID 1 is the shell. A POSIX `sh` that has spawned a child and is blocked in `wait()` does not, by default, forward signals it receives to that child — it simply has no handler and, as PID 1, no default disposition either (the kernel special-cases PID 1: signals with no installed handler are discarded). So SIGTERM lands on the shell and evaporates. The application keeps running, oblivious. Ten seconds later Docker sends SIGKILL, the kernel destroys PID 1, the PID namespace is torn down, and every process in it dies instantly — no drain, no flush, exit code 137. The user-visible symptoms are consistent and diagnosable: `docker stop` always takes exactly the timeout; `docker inspect -f '{{.State.ExitCode}}'` reports 137; and the application's own "shutting down" log line never appears. ## The fixes **1. Prefer exec form.** `CMD ["node", "server.js"]` or `ENTRYPOINT ["/usr/bin/myserver"]`. This is the default recommendation for any long-running process. **2. If you need a wrapper script, `exec` the real process.** Entrypoint scripts are legitimate — they render config from environment variables, wait on dependencies, run migrations. The rule is that the last thing the script does must be `exec`, which replaces the shell's process image with the target program *in the same PID*. So the app becomes PID 1 and inherits the signal. ```sh #!/bin/sh set -e envsubst < /tpl/app.conf > /etc/app.conf exec "$@" # not: "$@" ``` Pair that with `ENTRYPOINT ["/entrypoint.sh"]` and `CMD ["node","server.js"]` so `"$@"` expands to the CMD. **3. Beware process managers you did not intend.** `npm start`, `yarn start`, `mvn spring-boot:run`, `python -m something` and shell one-liners with pipes all insert a parent between PID 1 and your code. Older npm versions were notorious for not forwarding SIGTERM. In production images, invoke the runtime binary directly: `CMD ["node", "dist/server.js"]`, `ENTRYPOINT ["java","-jar","/app.jar"]`. **4. If you genuinely need a shell and cannot exec** — say you must run two processes, or you need shell features like variable expansion mid-command — add a real init: `docker run --init`, which inserts Docker's bundled tini as PID 1. tini forwards signals to its child and reaps zombies. But multiple processes in one container is a design smell worth challenging first. ## Shell form is not always wrong Shell form has a real use: you need shell expansion at runtime. `CMD ["echo", "$HOME"]` prints the literal `$HOME` because there is no shell to expand it; `CMD echo $HOME` expands it. If you need expansion *and* correct signals, use exec form with an explicit shell that execs: `CMD ["sh","-c","exec node server.js --port $PORT"]`. The inner `exec` is what saves you — without it you are back to a shell parent. Also note `ENTRYPOINT` in shell form has a second, nastier consequence: it causes `CMD` and any command-line arguments passed to `docker run` to be **ignored entirely**, because the whole thing collapses into `/bin/sh -c "<entrypoint>"`. That surprises people who expect `docker run image --flag` to work. ## How to verify Build the image, run it, then `docker top <c>` or `docker exec <c> ps -o pid,comm` and confirm PID 1 is your program. Then `time docker stop <c>` — a correct setup exits in well under a second and reports exit code 0 (or 143 if the app lets the default action run).

  • You must expand an environment variable inside the command but still want correct signal handling. How?
    Use exec form with an explicit shell and an inner `exec`: `CMD ["sh","-c","exec node server.js --port $PORT"]`. The shell starts as PID 1, performs the expansion, then `exec` replaces itself with node in the same PID, so node ends up as PID 1 and receives SIGTERM directly.
  • Besides signal handling, what else changes when ENTRYPOINT is written in shell form?
    Shell-form ENTRYPOINT causes CMD and any arguments passed after the image name in `docker run` to be ignored, because the whole entrypoint collapses into `/bin/sh -c "<string>"` with no `"$@"` to receive them. Exec-form ENTRYPOINT plus exec-form CMD is the combination that lets CMD supply overridable default arguments.
  • When is `docker run --init` the right answer instead of restructuring the entrypoint?
    When the container legitimately runs more than one process, or the top-level process is a third-party binary you cannot change that neither forwards signals nor reaps children. `--init` inserts tini as PID 1, which forwards signals to the child and reaps orphaned zombies. It is a wrapper around the problem, not a substitute for making the app itself handle SIGTERM.

Shell form is like sending an eviction notice to the landlord and hoping he mentions it to the tenant. Exec form addresses the tenant directly.

saying these in an interview costs you the question

  • Believing `/bin/sh -c` forwards SIGTERM to its child by default
  • Thinking exec form versus shell form is purely a style preference
  • Saying `exec` in a script "runs the command" without knowing it replaces the process image and keeps the PID
  • Assuming `npm start` or `mvn spring-boot:run` is fine as a production entrypoint
  • Reaching for --init as the first fix rather than making the app PID 1

context