CMD, ENTRYPOINT and PID 1

What runs when a container starts, which process gets the stop signal, and when to use --init.

Beginner13 min · lesson 10 of 14
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (10 files, 1 KB): cmdentrypoint.tar.gz. The lab VM shares no folders with your computer, so fetch them inside the VM: cd ~/lab && curl -fsSLO https://secopslog.com/lab-files/docker-fund/cmdentrypoint.tar.gz && tar -xzf cmdentrypoint.tar.gz, which creates ~/lab/cmdentrypoint/. SHA-256: a68c0a62aa6bc9cd1f937e227229c51dd1a194bacfcbf04ec3b869f3918b53ec

Every deploy of one service takes ten seconds longer than it should. docker stop hangs for exactly ten seconds, the container ends with exit status 137, and the application never logs the shutdown message its code prints on SIGTERM. Nothing's wrong with the application. The process that receives the signal isn't the application. Which process it is comes down to two Dockerfile instructions, CMD and ENTRYPOINT, and to how you write each one.

Use the main lab VM and unpack the lesson files into ~/lab/cmdentrypoint. The greet/ folder holds small Alpine images that only echo text, so you can see the effect of each rule in one line of output. signals/ holds the shutdown experiments.

CMD is a default

CMD sets the command a container runs when you give docker run no command of its own. Anything after the image name replaces it completely.

ubuntu@secopslog-docker:~/lab/cmdentrypoint/greet · Docker 29.9.2
$ cat Dockerfile.cmd
FROM alpine:3.24 CMD ["echo", "Hello from CMD"]
$ docker build -q -f Dockerfile.cmd -t lab-greet:cmd .
sha256:677486bf6465187d14d9290ff558288d9d4414bcdf7f179a5a3dfd42fbb96710
$ docker run --rm lab-greet:cmd
Hello from CMD
$ docker run --rm lab-greet:cmd echo "Goodbye instead"
Goodbye instead

ENTRYPOINT is the program

ENTRYPOINT names the program that always runs. Words you put after the image name are appended to it as arguments instead of replacing it. Only --entrypoint on the command line swaps the program, and it takes just the program name. Its arguments still go after the image name.

ubuntu@secopslog-docker:~/lab/cmdentrypoint/greet · Docker 29.9.2
$ cat Dockerfile.ep
FROM alpine:3.24 ENTRYPOINT ["echo", "greeting:"]
$ docker build -q -f Dockerfile.ep -t lab-greet:ep .
sha256:90ad2c575458466ff2a59726ed1c3485d01a34e70e3a7be0b49cd4709be6e434
$ docker run --rm lab-greet:ep hello there
greeting: hello there
$ docker run --rm --entrypoint date lab-greet:ep -u +%Z
UTC

Both together

With both set, the container runs ENTRYPOINT followed by CMD, and arguments on docker run replace only the CMD part. That's the common shape for images that behave like a command-line tool: the program is fixed, and you can swap the default arguments. docker image inspect shows both values the way Docker stores them.

ubuntu@secopslog-docker:~/lab/cmdentrypoint/greet · Docker 29.9.2
$ cat Dockerfile.both
FROM alpine:3.24 ENTRYPOINT ["echo", "greeting:"] CMD ["hello", "world"]
$ docker build -q -f Dockerfile.both -t lab-greet:both .
sha256:b2832d132bb175d8f6af1acae77f6ab583f6a0480c3a6dcef6039dc7691c87a8
$ docker run --rm lab-greet:both docker run --rm lab-greet:both goodbye
greeting: hello world greeting: goodbye
$ docker image inspect -f '{{json .Config.Entrypoint}} {{json .Config.Cmd}}' lab-greet:both
["echo","greeting:"] ["hello","world"]
$ docker run --rm --entrypoint echo lab-greet:both docker run --rm --entrypoint echo lab-greet:both new args
new args

The last two runs show a rule that surprises people. --entrypoint also discards the image's CMD. The first echo printed an empty line, not hello world. Only arguments you pass explicitly reach the new entrypoint. In images without an ENTRYPOINT, CMD itself is the program, which is why CMD ["node", "server.js"] worked on its own in "Writing a Dockerfile". Add an ENTRYPOINT to such an image later and that CMD silently turns into arguments for the new program.

Exec form and shell form

Both instructions have two spellings. Exec form is a JSON array, ["echo", "greeting:"], and Docker runs that program directly with those arguments. Shell form is a plain string, echo greeting:. Docker stores it as ["/bin/sh", "-c", "echo greeting:"], so a shell parses the string at start-up. That's what makes $VARIABLE expansion and && work. For ENTRYPOINT, shell form has a side effect worth seeing once.

ubuntu@secopslog-docker:~/lab/cmdentrypoint/greet · Docker 29.9.2
$ cat Dockerfile.shellep
FROM alpine:3.24 ENTRYPOINT echo greeting: CMD ["hello", "world"]
$ docker build -q -f Dockerfile.shellep -t lab-greet:shellep .
sha256:003e5b12651ccad93c2f0e81cdbdae840c30ff9d64c0e246bb37036eafcd4fb0
$ docker run --rm lab-greet:shellep goodbye
greeting:
$ docker image inspect -f '{{json .Config.Entrypoint}} {{json .Config.Cmd}}' lab-greet:shellep
["/bin/sh","-c","echo greeting:"] ["hello","world"]

goodbye never arrived, and neither did the CMD default. Docker did append them, but as extra arguments to /bin/sh -c. That shell ignores arguments after its command string unless the string uses them as $0, $1 and so on. So a shell-form ENTRYPOINT ignores both CMD and anything you pass on docker run, with no warning.

Docker does not check the program at build time

A build only records ENTRYPOINT and CMD in the image configuration. Whether the program exists is checked when a container starts. Dockerfile.missing names a program that Alpine doesn't have:

greet/Dockerfile.missing
FROM alpine:3.24
ENTRYPOINT ["greet"]
CMD ["World"]
ubuntu@secopslog-docker:~/lab/cmdentrypoint/greet · Docker 29.9.2
$ docker build -q -f Dockerfile.missing -t lab-greet:missing .
sha256:a40a7ea4d4299b573dbca8d5e2f180c30ad86901164e1953f976fb3027a9ca89
$ docker run --rm lab-greet:missing
docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: exec: "greet": executable file not found in $PATH Run 'docker run --help' for more information

The build succeeded. The run failed in runc, the low-level runtime that starts the container process, with exec: "greet": executable file not found in $PATH. The CLI exited with 127. When you see 127 from a fresh image, check the ENTRYPOINT or CMD spelling and whether the binary is installed in the image.

PID 1 and docker stop

docker stop sends the image's stop signal (SIGTERM unless the image sets STOPSIGNAL) to the container's main process, the one with PID 1 inside the container. It waits 10 seconds by default, then sends SIGKILL, which can't be caught. A process killed that way exits with 137 (128 + 9). PID 1 is special in one way that matters here. The kernel doesn't apply the default action of a signal to PID 1. An ordinary process without a SIGTERM handler dies on SIGTERM, but PID 1 without a handler ignores it. So the question for every image is which process is PID 1, and whether it handles SIGTERM.

The lesson files contain a stand-in service. It's a shell script that installs a SIGTERM handler with trap, prints its PID, and waits. Two Dockerfiles start it, and they differ only in the base image and in the spelling of the last line:

signals/Dockerfile.exec
FROM alpine:3.24
COPY serve.sh /serve.sh
CMD ["/serve.sh"]
signals/Dockerfile.shell
FROM debian:trixie-slim
COPY serve.sh /serve.sh
CMD /serve.sh

The build step below also builds Dockerfile.nohandler, used in the next section.

ubuntu@secopslog-docker:~/lab/cmdentrypoint/signals · Docker 29.9.2
$ cat serve.sh
#!/bin/sh # A stand-in for a long-running service that shuts down cleanly on SIGTERM. trap 'echo "serve.sh: got SIGTERM, shutting down"; exit 0' TERM echo "serve.sh: running as PID $$" while true; do sleep 1 & wait $! done
$ docker build -q -f Dockerfile.exec -t lab-sig:exec . docker build -q -f Dockerfile.shell -t lab-sig:shell . docker build -q -f Dockerfile.nohandler -t lab-sig:nohandler .
sha256:fbb849befbb209ec771d4abc2dc3d1011997841af578016eddb2702e27806095 sha256:1e4d3cfd76ce49d2e4eb2cff86f3e2a4dcab3c50f2e14ae9134ef0d9f7fb3710 sha256:0ff4ec89e769697d11ee7eef1e0188330b5e7d2ca50e27eb761d3e330fd71d98
$ docker image inspect -f '{{json .Config.Cmd}}' lab-sig:shell
["/bin/sh","-c","/serve.sh"]

lab-sig:exec starts the script in exec form on Alpine. lab-sig:shell starts it in shell form, CMD /serve.sh, on Debian, whose /bin/sh is dash. Docker stored it as /bin/sh -c /serve.sh. Start both and read /proc/1/cmdline in each, the command line of PID 1.

ubuntu@secopslog-docker:~/lab/cmdentrypoint/signals · Docker 29.9.2
$ docker run -d --name lab-sig-exec lab-sig:exec
0c264a98b12585d3aacabcd9c7a1fa0defd3370b95712fbabfd5ad539abb1a07
$ docker run -d --name lab-sig-shell lab-sig:shell
f3e5731f9231b89b0ca3ec238b693d0621ab5a0f334aceabd6e722e78e401cec
$ docker exec lab-sig-exec cat /proc/1/cmdline | tr '\0' ' '; echo
/bin/sh /serve.sh
$ docker exec lab-sig-shell cat /proc/1/cmdline | tr '\0' ' '; echo
/bin/sh -c /serve.sh
$ time docker stop lab-sig-exec
lab-sig-exec real 0m0.111s user 0m0.020s sys 0m0.009s
$ time docker stop lab-sig-shell
lab-sig-shell real 0m10.225s user 0m0.005s sys 0m0.011s
$ docker logs lab-sig-exec
serve.sh: running as PID 1 serve.sh: got SIGTERM, shutting down
$ docker logs lab-sig-shell
serve.sh: running as PID 7

In the exec-form container the script is PID 1 (/bin/sh /serve.sh is the interpreter running the script). It receives SIGTERM, logs its message and exits in about a tenth of a second. In the shell-form container dash stayed at PID 1 and started the script as PID 7. Since dash has no SIGTERM handler, as PID 1 it ignored the signal. The script never received it. After 10 seconds both died from SIGKILL.

Shell form doesn't always end this way. Some shells replace themselves with the command when the -c string is a single simple command, and BusyBox sh on Alpine does. The same Dockerfile line on Alpine leaves the script as PID 1 and stops quickly:

signals/Dockerfile.shell-alpine
FROM alpine:3.24
COPY serve.sh /serve.sh
CMD /serve.sh
ubuntu@secopslog-docker:~/lab/cmdentrypoint/signals · Docker 29.9.2
$ docker build -q -f Dockerfile.shell-alpine -t lab-sig:shell-alpine . docker run -d --name lab-sig-shell-alpine lab-sig:shell-alpine
sha256:aaea58733bff9158b71b5b694fdac2b4692b84cb57adf805120eb660d3019aa0 a80989428946db8a1c476037a60b3931b2f114c53f65b54a6d9a506faae701f8
$ docker exec lab-sig-shell-alpine cat /proc/1/cmdline | tr '\0' ' '; echo
/bin/sh /serve.sh
$ time docker stop lab-sig-shell-alpine
lab-sig-shell-alpine real 0m0.080s user 0m0.005s sys 0m0.008s

Whether the shell stays depends on the shell, its version and the command string. That's exactly why you shouldn't depend on it. Exec form makes the answer the same everywhere: your program is PID 1. Need shell features at start-up? Put them in a script that ends with exec your-program "$@". The shell then hands PID 1 to the program after it has done its work.

Exec form is not enough without a handler

Exec form only decides who receives the signal. If that process doesn't handle SIGTERM, it's PID 1 and ignores it. sleep has no handler, so Dockerfile.nohandler (FROM alpine:3.24 and CMD ["sleep", "600"], exec form) still waits the full 10 seconds. docker run --init puts a tiny init process, docker-init (Docker's bundled build of tini), at PID 1. It starts your command as a child, forwards signals to it, and reaps exited child processes. Your process is no longer PID 1, so its default signal actions apply again.

ubuntu@secopslog-docker:~/lab/cmdentrypoint/signals · Docker 29.9.2
$ docker run -d --name lab-sig-nohandler lab-sig:nohandler docker run -d --init --name lab-sig-init lab-sig:nohandler
f696d8f8342071d8e291c9b1e4bf2c5e39ed1920c558d94549a28510c9a106fd dca59ce6905db36605bb38945c7868f13e0901b135b6d1da07f38d338f93581e
$ docker exec lab-sig-init ps
PID USER TIME COMMAND 1 root 0:00 /sbin/docker-init -- sleep 600 7 root 0:00 sleep 600 8 root 0:00 ps
$ time docker stop lab-sig-nohandler
lab-sig-nohandler real 0m10.260s user 0m0.018s sys 0m0.007s
$ time docker stop lab-sig-init
lab-sig-init real 0m0.099s user 0m0.009s sys 0m0.012s
$ docker ps -a --filter name=lab-sig --format 'table {{.Names}}\t{{.Status}}'
NAMES STATUS lab-sig-init Exited (143) Less than a second ago lab-sig-nohandler Exited (137) Less than a second ago lab-sig-shell-alpine Exited (0) 12 seconds ago lab-sig-shell Exited (137) 15 seconds ago lab-sig-exec Exited (0) 25 seconds ago

The STATUS column sums up what you just ran. Exit 0: the script handled SIGTERM and exited cleanly. Exit 143 (128 + 15): sleep under docker-init was terminated by SIGTERM, quickly. Exit 137: the container sat out the timeout and was killed. For a real service the best result is the first one, an application that handles SIGTERM by finishing in-flight work and closing connections, like the handler in "Writing a Dockerfile". Use --init (or init: true in a Compose file) when you can't change the application, or when it starts child processes that would otherwise linger as zombies. If a clean shutdown legitimately needs longer than 10 seconds, raise the timeout with docker stop -t or docker run --stop-timeout rather than letting SIGKILL cut it short.

Official images and their entrypoint scripts

Many official images, nginx and postgres among them, set ENTRYPOINT to a script that prepares configuration before the server starts. nginx also sets its own stop signal.

ubuntu@secopslog-docker:~/lab/cmdentrypoint · Docker 29.9.2
$ docker image inspect -f '{{json .Config.Entrypoint}} {{json .Config.Cmd}} {{.Config.StopSignal}}' nginx:1.31-alpine
["/docker-entrypoint.sh"] ["nginx","-g","daemon off;"] SIGQUIT
$ docker run --rm nginx:1.31-alpine echo "not nginx this time"
not nginx this time
$ docker run --rm nginx:1.31-alpine tail -n 2 /docker-entrypoint.sh
exec "$@"

Passing a different command still works. The script ends with exec "$@", so after its preparation it replaces itself with whatever command it was given. That keeps the command at PID 1. With such images there are two traps. Setting your own ENTRYPOINT replaces their script and its preparation. And the script may interpret some flags you pass. Read the image's documentation before you override either. STOPSIGNAL SIGQUIT is why docker stop on nginx triggers a graceful shutdown that lets open connections finish.

What runs when you type docker run IMAGE [ARGS]
docker run IMAGE [ARGS]
the image config holds ENTRYPOINT and CMD
no ENTRYPOINT
ARGS, or CMD if there are none
the first word is the program
exec-form ENTRYPOINT
ENTRYPOINT + (ARGS or CMD)
ARGS replace CMD, never the program
shell-form ENTRYPOINT
/bin/sh -c "ENTRYPOINT string"
CMD and ARGS are ignored
--entrypoint PROG
PROG + ARGS
the image CMD is discarded
Whatever ends up first becomes PID 1, unless --init puts docker-init in front of it.

Clean up

ubuntu@secopslog-docker:~/lab/cmdentrypoint · Docker 29.9.2
$ docker rm lab-sig-exec lab-sig-shell lab-sig-shell-alpine lab-sig-nohandler lab-sig-init docker rmi lab-greet:cmd lab-greet:ep lab-greet:both lab-greet:shellep lab-greet:missing lab-sig:exec lab-sig:shell lab-sig:shell-alpine lab-sig:nohandler
lab-sig-exec lab-sig-shell lab-sig-shell-alpine lab-sig-nohandler lab-sig-init Untagged: lab-greet:cmd Deleted: sha256:677486bf6465187d14d9290ff558288d9d4414bcdf7f179a5a3dfd42fbb96710 Untagged: lab-greet:ep Deleted: sha256:90ad2c575458466ff2a59726ed1c3485d01a34e70e3a7be0b49cd4709be6e434 Untagged: lab-greet:both Deleted: sha256:b2832d132bb175d8f6af1acae77f6ab583f6a0480c3a6dcef6039dc7691c87a8 Untagged: lab-greet:shellep Deleted: sha256:003e5b12651ccad93c2f0e81cdbdae840c30ff9d64c0e246bb37036eafcd4fb0 Untagged: lab-greet:missing Deleted: sha256:a40a7ea4d4299b573dbca8d5e2f180c30ad86901164e1953f976fb3027a9ca89 Untagged: lab-sig:exec Deleted: sha256:fbb849befbb209ec771d4abc2dc3d1011997841af578016eddb2702e27806095 Untagged: lab-sig:shell Deleted: sha256:1e4d3cfd76ce49d2e4eb2cff86f3e2a4dcab3c50f2e14ae9134ef0d9f7fb3710 Untagged: lab-sig:shell-alpine Deleted: sha256:aaea58733bff9158b71b5b694fdac2b4692b84cb57adf805120eb660d3019aa0 Untagged: lab-sig:nohandler Deleted: sha256:0ff4ec89e769697d11ee7eef1e0188330b5e7d2ca50e27eb761d3e330fd71d98

Try this

Pass your own words to the combined image: docker run --rm lab-greet:both hello there, then the same with --entrypoint date before the image name and -u +%Z after it. Predict each line from the ENTRYPOINT and CMD that docker image inspect printed, then check. If the cleanup already removed the image, rebuild it with the same docker build -q -f Dockerfile.both command.

Takeaway

Write ENTRYPOINT and CMD in exec form, and make sure whatever runs as PID 1 handles SIGTERM, or add --init. lab-sig-shell shows the cost: dash held PID 1, docker stop took 10.2 s, and the container ended with 137 without the shutdown message. In a slow deploy, a stop that always takes the full timeout is the clue to look for.

Quick check
01An image has ENTRYPOINT ["echo", "log:"] and CMD ["idle"]. What does docker run --rm myimg started print?
Incorrect — That is the output with no arguments. Arguments on docker run replace CMD.
Incorrect — Only --entrypoint replaces the program. Plain arguments go after it.
Incorrect — CMD and run arguments do not stack; the arguments replace CMD entirely.
Correct — ENTRYPOINT stays, and started replaces the CMD default idle.
02A Python service with CMD ["python", "app.py"] (exec form) takes 10 seconds to stop and exits with 137. Its code has no SIGTERM handler. What fixes the stop?
Incorrect — A shell at PID 1 has no handler either and does not forward signals; on Debian the lab saw exactly that.
Correct — Python is PID 1 and ignores SIGTERM without a handler; a handler or docker-init at PID 1 lets the signal take effect.
Incorrect — CMD and ENTRYPOINT in exec form both start the program as PID 1. The missing handler is the problem.
Incorrect — The process ignores SIGTERM, so it would simply be killed after 60 seconds instead of 10.
03A Dockerfile ends with ENTRYPOINT /app/start.sh and CMD ["--port", "8080"]. The service always starts on its default port, even with docker run myimg --port 9090. Why?
Correct — Docker passes them to /bin/sh -c after the command string, where they are unused. Write ENTRYPOINT ["/app/start.sh"].
Incorrect — CMD in exec form can hold any argument list; the build accepted it and stored it.
Incorrect — Anything after the image name goes to the container, dashes included. Options for docker go before the image.
Incorrect — exec "$@" passes arguments on, but here they never reach start.sh in the first place.

Related