A Dockerfile ends with `CMD python app.py` instead of `CMD ["python", "app.py"]`. What is the practical difference between those two forms, and why does it matter when someone runs `docker stop` on the container?
answer
- shell form = /bin/sh -c wrapper
- docker stop → SIGTERM to PID 1, 10s, SIGKILL
- sh does not forward signals
- exec form = no variable expansion
- sh -c 'exec app' when you need both
basics
~20 sShell form runs the command via /bin/sh -c, so the shell is PID 1 and usually does not forward the SIGTERM that docker stop sends — the container is SIGKILLed after the timeout. Exec (JSON) form runs the process directly as PID 1, so it receives the signal.
solid answer
~50 s`CMD python app.py` is **shell form**: Docker executes `/bin/sh -c "python app.py"`. `CMD ["python","app.py"]` is **exec form**: the argument vector is executed directly, no shell involved. Three consequences: 1. **Signals.** `docker stop` sends SIGTERM to PID 1, waits (10s by default), then SIGKILL. In shell form PID 1 is `sh`, which typically neither handles SIGTERM itself nor forwards it to the child, so the app never gets a chance to drain connections and shut down — every stop costs the full timeout and ends in a kill. 2. **Shell features.** Shell form gives you `$VAR` expansion, pipes and `&&`; exec form gives none of that — `CMD ["echo","$HOME"]` literally prints `$HOME`. 3. **Argument handling.** A shell-form ENTRYPOINT ignores CMD and any `docker run` arguments entirely. Default to exec form. If you truly need shell features, write `CMD ["sh","-c","exec python app.py $OPTS"]` so the real process still becomes PID 1.
code
dockerfile · 9 linesFROM python:3.12-slim
COPY app.py /app/app.py
WORKDIR /app
# Good: app is PID 1, receives SIGTERM
CMD ["python", "app.py"]
# If expansion is unavoidable, keep exec semantics:
# CMD ["sh", "-c", "exec python app.py --workers $WORKERS"]go deeper
State that the string form goes through /bin/sh -c and the JSON form does not, and that the JSON form is the recommended default for CMD and ENTRYPOINT.
Explain PID 1, the SIGTERM-then-SIGKILL sequence of docker stop, why a shell in between breaks it, and the loss of variable expansion in exec form.
Tie it to real shutdown behaviour: connection draining, deploy latency, the sh -c 'exec …' escape hatch, tini/--init for reaping, and verifying the signal path rather than assuming it.
Position it as a platform default — lint shell-form CMD/ENTRYPOINT out of base images, standardise grace periods against real drain times, and test termination behaviour in CI rather than trusting Dockerfile review.
## Two forms, one difference `RUN`, `CMD` and `ENTRYPOINT` each accept two syntaxes. - **Exec form** — a JSON array: `CMD ["python","app.py"]`. The list is the process argument vector, executed directly by the container runtime. - **Shell form** — a bare string: `CMD python app.py`. The runtime rewrites it as `["/bin/sh","-c","python app.py"]`. Everything below follows from that rewrite. ## PID 1 and the shutdown path The first process in a container's PID namespace is PID 1. `docker stop` (and, in orchestrators, the pod-termination path) sends **SIGTERM to PID 1**, waits for a grace period — 10 seconds by default, `--time`/`-t` to change it — and then sends SIGKILL to everything left. In exec form, your application is PID 1, receives SIGTERM, and can run its shutdown handler: stop accepting connections, finish in-flight requests, flush buffers, close the DB pool. In shell form, PID 1 is `/bin/sh` and your application is its child. POSIX shells do not forward signals to the foreground child while waiting on it, so SIGTERM goes to `sh` and stops there. Ten seconds later everything is SIGKILLed. Symptoms in the wild: every deploy takes ten extra seconds per container, requests are cut mid-flight, and logs show no shutdown message at all. There is a second, sharper PID-1 quirk: the Linux kernel does **not** deliver a signal to PID 1 if that process has not installed a handler for it and the default action would kill it. So a PID 1 that ignores SIGTERM by omission cannot be terminated by SIGTERM at all — only SIGKILL ends it. A caveat worth stating honestly: some shells optimise `sh -c '<single simple command>'` into a bare `exec`, in which case your app *does* end up as PID 1 even in shell form. Whether that happens depends on the shell in the base image (dash, bash, busybox ash all differ) and vanishes as soon as the string contains a pipe, `&&`, a variable or a redirect. Never rely on it. ## Zombie reaping PID 1 also inherits orphaned processes and must `wait()` on them. An application that never expected to be PID 1 will accumulate zombie entries if it spawns subprocesses. `docker run --init` inserts a tiny init (tini) as PID 1 that reaps children and forwards signals; `ENTRYPOINT ["/sbin/tini","--"]` bakes the same behaviour into the image. Use it when your process genuinely forks, not as a substitute for handling SIGTERM. ## Variable expansion and quoting Exec form does no expansion: `CMD ["echo","$HOME"]` prints the literal string `$HOME`, because there is no shell to substitute it. Shell form expands it. If you need environment substitution, be explicit and keep the exec semantics: ``` CMD ["sh","-c","exec python app.py --workers $WORKERS"] ``` The `exec` builtin replaces the shell process image with the application, so the app inherits PID 1 and the signal path is intact. A formatting trap: JSON form requires **double** quotes and proper escaping. `CMD ['python','app.py']` is not valid JSON, so the parser falls back to shell form and you get `/bin/sh -c "['python','app.py']"`. Modern BuildKit emits a `JSONArgsRecommended` warning when it sees shell-form CMD/ENTRYPOINT, which is worth treating as an error in CI. ## Argument handling Shell form also destroys the CMD/ENTRYPOINT argument protocol. With `ENTRYPOINT python app.py`, the command is frozen inside the `sh -c` string; CMD and anything typed after `docker run image` are simply never appended. Candidates often discover this when a `--flag` they pass has no effect at all. ## What about RUN? `RUN` is the one instruction where shell form is usually right — you *want* `&&`, pipes and expansion at build time, and there is no PID 1 or signal story because the build step runs to completion. The exec form of RUN skips the shell entirely, which surprises people who use `&&` in it. ## Practical rules 1. Exec form for CMD and ENTRYPOINT, always; shell form for RUN when you need shell syntax. 2. If shell features are required at start-up, wrap explicitly with `sh -c` and prefix the real command with `exec`. 3. Verify shutdown behaviour: start the container, `docker stop` it, and time it. Instant exit means signals reach the app; a ten-second pause means they do not. 4. Add a SIGTERM handler in the application itself — the correct Dockerfile form only guarantees the signal is *delivered*, not that anything sensible happens next.
- You must keep the shell because the command needs `$PORT`. How do you preserve correct signal handling?Write it explicitly as `CMD ["sh","-c","exec myserver --port $PORT"]`. The `exec` builtin replaces the shell's process image with the server, so the server becomes PID 1 and receives SIGTERM directly. Without `exec`, the shell stays as PID 1 and swallows the signal.
- What does `docker run --init` do, and when is it the right fix?It inserts a minimal init process (tini) as PID 1 that forwards signals to your process and reaps orphaned children. It is the right fix when your application legitimately spawns subprocesses and would otherwise leak zombies, or when you cannot change a third-party entrypoint. It is not a substitute for implementing a SIGTERM handler.
- Is shell form ever the correct choice for CMD?Rarely. It is acceptable for throwaway or debug images where shutdown latency does not matter, and it is normal for RUN, which executes at build time with no signal concerns. For any long-running service, shell form for CMD or ENTRYPOINT is a defect.
Shell form is like handing a message to a receptionist who never passes it on; exec form delivers it to the person who can act on it.
saying these in an interview costs you the question
- Claiming both forms behave identically because 'the command runs either way'.
- Believing the shell always forwards SIGTERM to its child.
- Expecting `CMD ["echo","$HOME"]` to expand the variable.
- Using single quotes in the JSON array and assuming it is still exec form.
- Treating `--init` or tini as a replacement for an application-level shutdown handler.