Entrypoint, CMD, and Signal Handling
A container's lifecycle is tied to its main process (PID 1). When PID 1 exits, the container exits. When Docker sends a stop signal, PID 1 must handle it cleanly. If your container ignores stop signals, takes too long to shut down, or leaks zombie processes, the problem is almost always in how ENTRYPOINT, CMD, and signal handling are configured.
How Container Shutdown Works
When you run docker stop, Docker sends signals to the container's main process in two phases:
sequenceDiagram
participant User
participant Docker
participant PID1 as Container (PID 1)
User->>Docker: docker stop my-container
Docker->>PID1: SIGTERM
Note over PID1: Grace period (default 10s)
alt App handles SIGTERM
PID1->>PID1: Stop accepting work
PID1->>PID1: Finish in-flight requests
PID1->>PID1: Close connections
PID1->>Docker: Exit (code 0)
else App ignores SIGTERM
Note over Docker: Timeout expires
Docker->>PID1: SIGKILL (forced)
PID1->>Docker: Exit (code 137)
end
SIGTERM is a polite request to stop. Your application should handle it by finishing current work and exiting cleanly. SIGKILL is a forced kill that cannot be caught -- Docker sends it after the grace period expires.
Exec Form vs Shell Form
This is the most important distinction for signal handling:
Exec Form (Use This)
CMD ["node", "server.js"]
ENTRYPOINT ["python", "app.py"]
The application runs directly as PID 1 and receives signals.
Shell Form (Avoid This)
CMD node server.js
ENTRYPOINT python app.py
Docker wraps this in /bin/sh -c "node server.js". The shell becomes PID 1 and may not forward signals to your application:
flowchart LR
subgraph exec["Exec Form"]
A["PID 1: node server.js"] -->|"Receives SIGTERM"| B["Handles shutdown"]
end
subgraph shell["Shell Form"]
C["PID 1: /bin/sh"] -->|"Receives SIGTERM"| D["Shell may not forward"]
D --> E["node server.js never gets signal"]
end
style exec fill:#e8f5e9,stroke:#2e7d32
style shell fill:#ffebee,stroke:#c62828
Always use exec form for production containers.
ENTRYPOINT vs CMD
| Instruction | Role | Overridable? |
|---|---|---|
ENTRYPOINT | The executable to run | Only with --entrypoint flag |
CMD | Default arguments (or fallback command) | Replaced by any args after docker run <image> |
Pattern 1: CMD Only (Simple Applications)
CMD ["node", "server.js"]
docker run app # Runs: node server.js
docker run app --help # Runs: --help (CMD is entirely replaced)
Pattern 2: ENTRYPOINT + CMD (Flexible Applications)
ENTRYPOINT ["node"]
CMD ["server.js"]
docker run app # Runs: node server.js
docker run app repl.js # Runs: node repl.js (CMD replaced, ENTRYPOINT stays)
When to Use Each
| Scenario | Use |
|---|---|
| Simple app, no argument customization needed | CMD only |
| Fixed executable with variable arguments | ENTRYPOINT + CMD |
| CLI tool with subcommands | ENTRYPOINT (the tool) + CMD (default subcommand) |
Wrapper Scripts
Sometimes you need a startup script to set up the environment before running your app. The critical rule: end the script with exec so your app replaces the shell as PID 1:
Good Wrapper Script
#!/usr/bin/env sh
set -eu
# Setup tasks (run as shell)
echo "Starting with config: $CONFIG_PATH"
# Hand off to the application (replaces shell as PID 1)
exec node server.js "$@"
Bad Wrapper Script
#!/usr/bin/env sh
# Missing exec! Shell stays as PID 1
node server.js "$@"
Without exec, the shell remains PID 1 and may not forward SIGTERM to your application.
Using the Wrapper in a Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY start.sh /usr/local/bin/start.sh
RUN chmod +x /usr/local/bin/start.sh
COPY . .
ENTRYPOINT ["/usr/local/bin/start.sh"]
CMD ["serve"]
Implementing Graceful Shutdown
Your application should handle SIGTERM by:
- Stop accepting new requests/work
- Finish in-flight requests (with a timeout)
- Close database connections and file handles
- Exit with code 0
Node.js Example
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down...');
server.close(() => {
console.log('Server closed');
process.exit(0);
});
});
Python Example
import signal
import sys
def shutdown(signum, frame):
print("Shutting down gracefully...")
# Close connections, flush buffers
sys.exit(0)
signal.signal(signal.SIGTERM, shutdown)
Adjusting the Stop Timeout
If your application needs more than 10 seconds to shut down (e.g., finishing long-running requests), increase the stop timeout:
# Give 30 seconds for graceful shutdown
docker stop --time 30 my-container
In Compose:
services:
api:
image: my-api:1.0.0
stop_grace_period: 30s
If the timeout is too short, Docker sends SIGKILL and your app is force-killed -- potentially losing in-flight work.
Debugging Process Issues
# Check what ENTRYPOINT and CMD are configured
docker inspect -f '{{json .Config.Entrypoint}}' my-container
docker inspect -f '{{json .Config.Cmd}}' my-container
# Check the process tree inside the container
docker exec my-container ps -o pid,ppid,comm
# Test graceful shutdown and watch logs
docker stop my-container && docker logs --tail 50 my-container
What to look for:
- Is PID 1 your application, or is it
/bin/sh? - Does the application log a clean shutdown message, or does it just disappear?
- Does
docker stopcomplete quickly, or does it hang for the full timeout?
Key Takeaways
- Always use exec form (
["node", "server.js"]) forCMDandENTRYPOINT-- shell form breaks signal delivery. docker stopsends SIGTERM first, then SIGKILL after the grace period. Your app must handle SIGTERM.- Wrapper scripts must end with
execto hand PID 1 to the application. - Implement graceful shutdown in your application: stop accepting work, finish in-flight tasks, close connections, exit cleanly.
- If shutdown takes more than 10 seconds, increase
stop_grace_periodor--time.
What's Next
- Return to the Containers and Runtime Management module overview.
- Continue to Module 5: Networking to learn how containers communicate.