Linux and Bash for Developers · Processes and Resources

The Service Ignores Stop Signals

See why wrapper scripts swallow SIGTERM, so deploys wait and then kill the app, and fix it with exec and traps.

Create a free account to save this page, take notes and track your progress. Sign in

Scenario

Every deploy takes 30 seconds longer than it should, and in-flight requests fail. The orchestrator sends SIGTERM, waits for the grace period, then sends SIGKILL. The app has a graceful shutdown handler, but it never runs.

What you see in production

  • Stops take exactly the timeout (10 s in Docker, 30 s in Kubernetes, 90 s in systemd by default).
  • Logs never show the app's "shutting down" message.
  • The process list shows a shell script as the parent (or PID 1) and the app as its child.

The naive code

#!/usr/bin/env bash
# start.sh, the container's entrypoint
export APP_ENV=production
python3 server.py           # bash waits here; SIGTERM goes to bash, not to python

Reproduce it

SIGTERM stops the wrapper but not the app
cat > server.py <<'EOF'
import signal, sys, time
signal.signal(signal.SIGTERM, lambda *_: (print("server: graceful shutdown", flush=True), sys.exit(0)))
print("server: running", flush=True)
time.sleep(30)
EOF
cat > start.sh <<'EOF'
#!/usr/bin/env bash
python3 "$PWD/server.py"
EOF
chmod +x start.sh
./start.sh & wrapper=$!
sleep 0.5
kill -TERM $wrapper
sleep 0.5
server=$(pgrep -f "$PWD/server.py")          # this directory's server only
if [[ -n $server ]]; then echo "wrapper is gone, but server.py is still running (orphaned)"; fi
kill $server

Output

server: running
wrapper is gone, but server.py is still running (orphaned)
server: graceful shutdown

Run this on your computer — see “Get Started” for setup.

Root cause

The signal is delivered to the process the supervisor started: the shell. A non-interactive Bash doesn't forward signals to its children, and while it waits for a foreground command it doesn't even run traps until that command finishes. The app never sees SIGTERM, so the supervisor eventually sends SIGKILL, or, as here, the app is left orphaned.

The fix

Make the app replace the shell with exec, so it receives signals directly (and becomes PID 1 in a container):

exec hands the process over to the app
cat > server.py <<'EOF'
import signal, sys, time
signal.signal(signal.SIGTERM, lambda *_: (print("server: graceful shutdown", flush=True), sys.exit(0)))
print("server: running", flush=True)
time.sleep(30)
EOF
cat > start.sh <<'EOF'
#!/usr/bin/env bash
export APP_ENV=production
exec python3 server.py
EOF
chmod +x start.sh
./start.sh & pid=$!
sleep 0.5
ps -o args= -p $pid
kill -TERM $pid
wait $pid; echo "exit status: $?"

Output

server: running
python3 server.py
server: graceful shutdown
exit status: 0

Run this on your computer — see “Get Started” for setup.

  • End wrapper scripts with exec "$@" or exec app ....
  • Use the exec form in Dockerfiles: CMD ["python3", "server.py"], not CMD python3 server.py.
  • When a script must run several processes, trap SIGTERM and forward it, or use a tiny init (tini, docker run --init).

Trade-offs

  • exec means nothing in the script runs after the app; do setup before it.
  • An init process also reaps zombie processes, which matters when the app spawns children.
  • Graceful shutdown still needs the app to stop accepting work and finish in-flight requests within the grace period.

Detect it in production

  • Measure stop duration; stops that always hit the timeout indicate swallowed signals.
  • ps -o pid,ppid,args inside the container: the app should be PID 1 or a direct child of an init.
  • Check for "graceful shutdown" log lines during deploys.

Key points

  • Shell wrappers don't forward signals to their children.
  • exec replaces the shell so the app receives SIGTERM.
  • Use exec-form CMD, or an init like tini when you need several processes.

Further reading: signal(7) · bash(1): exec · Docker: ENTRYPOINT and exec form

Practice questions

Why doesn't SIGTERM reach an app started by a bash wrapper script?

Answer

The signal goes to the shell; bash doesn't forward it to children, and while it waits on a foreground command it doesn't run traps.

What does exec do at the end of a wrapper script?

Answer

It replaces the shell process with the app, so the app gets the same PID and receives signals directly.

What's the difference between CMD python app.py and CMD ["python", "app.py"] in a Dockerfile?

Answer

The shell form runs the command under /bin/sh -c, so the shell is PID 1 and gets signals; the exec form runs the app directly as PID 1.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?