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: 43d6648f58b7572d13b99b73ffacdc81d7ec1faeb02ad85613f73eab070fd25c

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 is wrong with the application. The process that receives the signal is not the application. Which process that is comes down to two Dockerfile instructions, CMD and ENTRYPOINT, and to how each one is written.

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 the effect of each rule is visible in one line of output; signals/ holds the shutdown experiments.

CMD is a default

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

ubuntu@secopslog-docker:~/lab/cmdentrypoint/greet · Docker 29.8.2
$ cat Dockerfile.cmd
FROM alpine:3.22 CMD ["echo", "Hello from CMD"]
$ docker build -q -f Dockerfile.cmd -t lab-greet:cmd .
sha256:1bfb360c11b2a7b9adb75e14ca454c015c73e21d6a17b5d34d4f71b04661a6b0
$ 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 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.8.2
$ cat Dockerfile.ep
FROM alpine:3.22 ENTRYPOINT ["echo", "greeting:"]
$ docker build -q -f Dockerfile.ep -t lab-greet:ep .
sha256:8f944b98a725a6ad22031f3e5d25878f13e6bde255247537bc8582ba22801aba
$ 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. This is the common shape for images that behave like a command-line tool: the program is fixed, the default arguments can be swapped. docker image inspect shows both values the way Docker stores them.

ubuntu@secopslog-docker:~/lab/cmdentrypoint/greet · Docker 29.8.2
$ cat Dockerfile.both
FROM alpine:3.22 ENTRYPOINT ["echo", "greeting:"] CMD ["hello", "world"]
$ docker build -q -f Dockerfile.both -t lab-greet:both .
sha256:16bd1ec2adef7d6442a6f83a8fa15a3a10afdec8386c8e6d24ffd420ccc52a65
$ 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:, and Docker stores it as ["/bin/sh", "-c", "echo greeting:"], so a shell parses the string at start-up (which is 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.8.2
$ cat Dockerfile.shellep
FROM alpine:3.22 ENTRYPOINT echo greeting: CMD ["hello", "world"]
$ docker build -q -f Dockerfile.shellep -t lab-greet:shellep .
sha256:18150a3d7adbbfc8391261601895e6f299b254a8ab57a1b53bb8699f8f13cb5f
$ 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, which ignores arguments after its command string unless the string uses them as $0, $1 and so on. A shell-form ENTRYPOINT therefore ignores both CMD and anything passed 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 does not have:

greet/Dockerfile.missing
FROM alpine:3.22
ENTRYPOINT ["greet"]
CMD ["World"]
ubuntu@secopslog-docker:~/lab/cmdentrypoint/greet · Docker 29.8.2
$ docker build -q -f Dockerfile.missing -t lab-greet:missing .
sha256:f5beda564c4293a9a7e78a7f09acf5dcd78339bda72909fef44519fbc800e5e0
$ 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, and 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 cannot be caught; a process killed that way exits with 137 (128 + 9). PID 1 is special in one way that matters here: the kernel does not apply the default action of a signal to PID 1. An ordinary process without a SIGTERM handler dies on SIGTERM; 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, a shell script that installs a SIGTERM handler with trap, prints its PID, and waits. Two Dockerfiles start it; they differ only in the base image and in the spelling of the last line:

signals/Dockerfile.exec
FROM alpine:3.22
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.8.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:e0d815f50bf1002da7cc657cb0bcc341f89da46384e3e595cbaf205f212a051d sha256:8ff46d3635402bdc750a87628296b05261ae756f33dede1ec89e46a98037dd5f sha256:85ee990f52c06aac297e5db680330edb222d01e9b42df60f7ad8da4459d43fc0
$ 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.8.2
$ docker run -d --name lab-sig-exec lab-sig:exec
5257b6f4579ce1940dcc313407b8bc1834f11980dfdb115f6f4677579abf424f
$ docker run -d --name lab-sig-shell lab-sig:shell
0161d7425c40d3869876a3b4c59d8f81daa4c590a8db882b9088c2c70ab264f0
$ 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.118s user 0m0.007s sys 0m0.011s
$ time docker stop lab-sig-shell
lab-sig-shell real 0m10.365s user 0m0.018s sys 0m0.016s
$ 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. dash has no SIGTERM handler, so as PID 1 it ignored the signal; the script never received it; after 10 seconds both died from SIGKILL.

Shell form does not 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.22
COPY serve.sh /serve.sh
CMD /serve.sh
ubuntu@secopslog-docker:~/lab/cmdentrypoint/signals · Docker 29.8.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:cda9fd6e861b91c2445faac548f5436d6fd6bdd2d3d74107206d19a83290518e 16b15a1d3864a7a6e1f83096a9feb7fee5ec5a2e3a50e0c1b3f7d3a232b70171
$ 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.082s user 0m0.007s sys 0m0.007s

Whether the shell stays depends on the shell, its version and the command string, which is exactly why you should not depend on it. Exec form makes the answer the same everywhere: your program is PID 1. If you need shell features at start-up, put them in a script that ends with exec your-program "$@", so the shell 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 does not handle SIGTERM, it is PID 1 and ignores it. sleep has no handler, so Dockerfile.nohandler (FROM alpine:3.22 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.8.2
$ docker run -d --name lab-sig-nohandler lab-sig:nohandler docker run -d --init --name lab-sig-init lab-sig:nohandler
1e6ff6b47a247a2742e95d10cffad0401593e3dd448faf782c4cdea179e16347 866be09db2e644470eba7ce7f8df820b897c5e762e114a8de239caa327b6b06f
$ 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.227s user 0m0.006s sys 0m0.012s
$ time docker stop lab-sig-init
lab-sig-init real 0m0.133s user 0m0.013s sys 0m0.021s
$ 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) 26 seconds ago

The STATUS column sums up the experiment. 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 cannot 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.8.2
$ docker image inspect -f '{{json .Config.Entrypoint}} {{json .Config.Cmd}} {{.Config.StopSignal}}' nginx:1.30-alpine
["/docker-entrypoint.sh"] ["nginx","-g","daemon off;"] SIGQUIT
$ docker run --rm nginx:1.30-alpine echo "not nginx this time"
not nginx this time
$ docker run --rm nginx:1.30-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, which keeps that command at PID 1. The traps with such images are setting your own ENTRYPOINT, which replaces their script and its preparation, and passing flags the script interprets itself. 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.8.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:1bfb360c11b2a7b9adb75e14ca454c015c73e21d6a17b5d34d4f71b04661a6b0 Untagged: lab-greet:ep Deleted: sha256:8f944b98a725a6ad22031f3e5d25878f13e6bde255247537bc8582ba22801aba Untagged: lab-greet:both Deleted: sha256:16bd1ec2adef7d6442a6f83a8fa15a3a10afdec8386c8e6d24ffd420ccc52a65 Untagged: lab-greet:shellep Deleted: sha256:18150a3d7adbbfc8391261601895e6f299b254a8ab57a1b53bb8699f8f13cb5f Untagged: lab-greet:missing Deleted: sha256:f5beda564c4293a9a7e78a7f09acf5dcd78339bda72909fef44519fbc800e5e0 Untagged: lab-sig:exec Deleted: sha256:e0d815f50bf1002da7cc657cb0bcc341f89da46384e3e595cbaf205f212a051d Untagged: lab-sig:shell Deleted: sha256:8ff46d3635402bdc750a87628296b05261ae756f33dede1ec89e46a98037dd5f Untagged: lab-sig:shell-alpine Deleted: sha256:cda9fd6e861b91c2445faac548f5436d6fd6bdd2d3d74107206d19a83290518e Untagged: lab-sig:nohandler Deleted: sha256:85ee990f52c06aac297e5db680330edb222d01e9b42df60f7ad8da4459d43fc0
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.

Try this

Work through “Clean up” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

If you keep one thing from cmd, entrypoint and pid 1, keep “Clean up”. Decide now which check you will run when this shows up on a live system, and write it somewhere your team will find it.

Related