Two Dockerfiles, one app, one word of difference. CMD python app.py and CMD ["python", "app.py"] look the same, and the container runs the same way until you try to stop it. The first one takes 10.30 seconds to stop and never runs the app's shutdown code. The second stops in 0.27 seconds. The reason is which process ends up as PID 1 inside the container, and whether the signal to stop reaches your app or a shell that ignores it. This post measures that, then does the same for ENTRYPOINT versus CMD arguments and for ADD versus COPY.
What is the difference between CMD and ENTRYPOINT?
Both set what runs when the container starts, and each can be written two ways:
- Exec form (
["python", "app.py"]): Docker runs the program directly. Your program becomes PID 1 and receives signals. - Shell form (
python app.py): Docker runs/bin/sh -c "python app.py". The shell is PID 1; your program is its child.
ENTRYPOINT is the command that always runs. CMD is the default arguments, and you can replace them on docker run. Used together, ENTRYPOINT ["python", "app.py"] + CMD ["--port", "8080"] means the app always runs and --port 8080 is a default you can override.
What problem does getting this right solve?
- Slow, unclean shutdowns. Shell form hides your app behind
/bin/sh, so SIGTERM never reaches it. The runtime waits the full grace period (10 s withdocker stop, 30 s by default in Kubernetes) and then SIGKILLs. Open requests drop, buffers do not flush. - Lost shutdown logic. The
finally, the connection drain, the "shutting down" log line never run. - Arguments that do not do what you think. With only
CMD,docker run img somecmdreplaces the whole command and your app never starts. WithENTRYPOINT, extra args are appended. - ADD surprises.
ADDquietly extracts tar archives and can fetch URLs, which bloats images and breaks the build cache in waysCOPYnever does.
Shell form vs exec form: what you gain and what you pay
Shell form (CMD python app.py) |
Exec form (CMD ["python","app.py"]) |
|
|---|---|---|
| PID 1 inside | /bin/sh |
your app |
| Receives SIGTERM | no (shell does) | yes |
docker stop time |
10.30 s (grace, then SIGKILL) | 0.27 s |
| Runs app shutdown code | no | yes |
Variable expansion ($PORT) |
yes | no (unless you wrap in sh -c) |
| Zombie reaping / PID 1 duties | shell handles some | needs --init/tini |
How it works: where the signal goes

The lab
The app is about eight lines of Python with a SIGTERM handler that logs got SIGTERM, draining and exits. Both images are python:3.13-slim; the only difference is the CMD line. Every number below is from that run.


- Shell form vs exec form.
cat /proc/1/comminside each container: shell form showssh, exec form showspython.docker stopon the shell-form container took 10.30 s, and its log has onlyapp started, pid 7, no SIGTERM line: the shell never forwarded the signal, so Docker waited the 10-second grace and sent SIGKILL. The exec-form container stopped in 0.27 s, and its log showsapp started, pid 1followed bygot SIGTERM, draining. - The wait is the grace period, not the app.
docker stop -t 2on the shell-form container took 2.29 s: the number is whatever grace you set, because nothing inside is listening. - ENTRYPOINT + CMD arguments. With
ENTRYPOINT ["python", "argv.py"]andCMD ["--port", "8080"],docker run imgranargv.py --port 8080;docker run img --port 9090ranargv.py --port 9090. ENTRYPOINT stayed, CMD was replaced. - CMD alone is fragile. With only
CMD ["python", "argv.py", "--port", "8080"],docker run img echo hiprintedhiand the app never started: an argument replaced the whole command. - ADD vs COPY.
COPY payload.tar.gz /dest/left one file, the tarball.ADD payload.tar.gz /dest/left three extracted files and no tarball, and they were owned by uid 501, the id stored in the archive, not a user I set in the image. - The same thing in Kubernetes. I ran both images as pods on kind with the default
terminationGracePeriodSeconds: 30. Deleting the shell-form pod took 30.8 s; the exec-form pod was gone in 1.4 s. Kubernetes waits 30 seconds, not Docker's 10, so a shell-form container costs even more on every rollout. execrescues shell form.CMD ["sh", "-c", "python app.py"]still leftshat PID 1 and stopped in 10.27 s.CMD ["sh", "-c", "exec python app.py"]putpythonback at PID 1 and stopped in 0.27 s: theexecreplaces the shell with your process, so the signal reaches it. This is the pattern to use when you genuinely need a shell for variable expansion.

Where it broke
1. The shell-form container ignored docker stop -t
Setting a shorter grace did not make the shell-form container stop cleanly; it just shortened the wait before SIGKILL. The only fix is to get the app to PID 1.
2. ADD left files owned by a uid I never set
The tar carried uid 501, and ADD preserved it, so /dest ended up owned by a stray numeric id. COPY would have used the build's own ownership.
3. Exec form does not expand variables
CMD ["python", "app.py", "--port", "$PORT"] passed the literal string $PORT, because there is no shell to expand it. The fix is CMD ["sh", "-c", "exec python app.py --port $PORT"]: a shell expands the variable, and exec hands PID 1 back to python so signals still work.
ADD vs COPY: which one, when
COPY |
ADD |
|
|---|---|---|
| Local files/dirs | yes | yes |
| Auto-extracts local tar | no | yes |
| Fetches remote URL | no | yes (not extracted) |
| Recommended for | everything local | only local tar you want extracted |
Rule: use COPY for local files. Use ADD only to auto-extract a local tarball. For a URL, use RUN curl -fsSL … && … && rm, so you verify and clean up in one layer.
When to use which
- Default to exec-form
CMDso your app is PID 1 and gets signals. - Use
ENTRYPOINT(exec form) +CMDwhen the program is fixed and only arguments change. - Add
--init(or tini) when your process does not reap children. - Use shell form only when you genuinely need shell features (variable expansion, pipes), and then run a real init or
execyour app so it becomes PID 1. COPYby default;ADDonly for local tar extraction.
Summary
- Shell form puts
/bin/shat PID 1; SIGTERM never reaches your app;docker stopwaits 10.30 s, then SIGKILL. - Exec form makes your app PID 1; it stops in 0.27 s and runs its shutdown code.
ENTRYPOINTfixes the command,CMDgives overridable default args.COPYfor local files;ADDonly for local tar;RUN curlfor URLs.
Next in the series: Prometheus and Grafana on kind, turning a cluster's raw metrics into a dashboard that shows when a service is actually in trouble.