Linux Process Management: Everything About Processes, Signals and Job Control
Every running program on a Linux system is a process. Every Docker container is a process. Understanding how Linux creates, manages, schedules, and terminates processes is foundational knowledge for anyone working with containers, because containers are not virtual machines: they are regular Linux processes with additional isolation. This guide covers the complete process lifecycle, from fork to exit, with particular attention to the concepts that matter most for containerized workloads.
Process Lifecycle
Every process in Linux goes through a well-defined lifecycle:
- Creation (fork): A parent process calls
fork()to create an exact copy of itself. The child process receives a new PID. - Execution (exec): The child typically calls
exec()to replace its memory image with a new program. - Running: The process executes its code, alternating between running on a CPU core and waiting for I/O or scheduling.
- Termination (exit): The process calls
exit()or is terminated by a signal. It becomes a zombie until its parent reads its exit status. - Reaping (wait): The parent calls
wait()orwaitpid()to collect the exit status, fully removing the process from the kernel's process table.
# Observe process states
ps aux | head -20
# USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
# root 1 0.0 0.1 169036 13312 ? Ss Jun03 0:05 /sbin/init
# root 2 0.0 0.0 0 0 ? S Jun03 0:00 [kthreadd]
# STAT column meanings:
# S = Sleeping (waiting for an event)
# R = Running or runnable (on run queue)
# D = Uninterruptible sleep (usually I/O)
# Z = Zombie (terminated but not reaped)
# T = Stopped (by a signal or debugger)
# s = Session leader
# + = Foreground process group
PIDs and the Process Tree
Every process has a unique Process ID (PID) and a Parent Process ID (PPID). Together, they form a tree structure rooted at PID 1 (the init process).
# View the process tree
pstree -p
# systemd(1)─┬─agetty(456)
# ├─dockerd(789)─┬─containerd(800)─┬─containerd-shim(901)─┬─nginx(950)
# │ │ │ ├─nginx(951)
# │ │ │ └─nginx(952)
# │ │ └─containerd-shim(902)───redis-server(960)
# ├─sshd(234)───sshd(5001)───bash(5002)
# └─cron(345)
# Get PID and PPID for a specific process
ps -o pid,ppid,comm -p $$
# PID PPID COMMAND
# 5002 5001 bash
Parent-Child Relationships
When a parent process terminates before its children, the orphaned children are re-parented to PID 1 (or the nearest subreaper). This is a fundamental concept for Docker because the container's entrypoint process runs as PID 1 inside the container's PID namespace.
# Demonstrate re-parenting
# In shell 1:
bash -c 'sleep 300 & echo "Child PID: $!"; exit'
# Parent exits, child (sleep) continues
# Check the child's new parent
ps -o pid,ppid,comm -p $(pgrep "sleep 300")
# PPID will now be 1 (init/systemd)
Zombie and Orphan Processes
Zombie Processes
A zombie process has terminated but its parent has not called wait() to read its exit status. The process entry remains in the kernel's process table, consuming a PID slot but no other resources.
# Create a zombie process (demonstration only)
python3 -c "
import os, time
pid = os.fork()
if pid == 0:
# Child exits immediately
os._exit(0)
else:
# Parent sleeps without calling wait()
print(f'Child PID {pid} is now a zombie')
time.sleep(60)
"
# In another terminal:
ps aux | grep Z
# USER PID %CPU %MEM VSZ RSS TTY STAT
# user 1234 0.0 0.0 0 0 pts/0 Z+ <defunct>
A few zombie processes are harmless. Thousands indicate a parent that is failing to reap its children, which is a common problem in Docker containers (see PID 1 problem below).
Orphan Processes
An orphan process is a child whose parent has terminated. The kernel re-parents orphans to PID 1, which is responsible for reaping them. In a properly configured system, PID 1 (systemd, init, or tini) handles this automatically.
Signals
Signals are software interrupts sent to a process. They are the primary mechanism for process control in Linux and are critically important for Docker container lifecycle management.
| Signal | Number | Default Action | Can Be Caught? | Docker Usage |
|---|---|---|---|---|
| SIGHUP | 1 | Terminate | Yes | Config reload (nginx, haproxy) |
| SIGINT | 2 | Terminate | Yes | Ctrl+C in interactive container |
| SIGQUIT | 3 | Core dump | Yes | Debug dump (Go, Java) |
| SIGTERM | 15 | Terminate | Yes | docker stop (first signal) |
| SIGKILL | 9 | Terminate | No | docker kill / stop timeout |
| SIGSTOP | 19 | Stop | No | docker pause |
| SIGCONT | 18 | Continue | Yes | docker unpause |
| SIGUSR1 | 10 | Terminate | Yes | Application-specific |
| SIGUSR2 | 12 | Terminate | Yes | Application-specific |
# Send signals to processes
kill -SIGTERM 1234 # Graceful termination
kill -15 1234 # Same as above (by number)
kill -SIGKILL 1234 # Forceful termination (cannot be caught)
kill -9 1234 # Same as above
# Send signal to a Docker container
docker kill --signal=SIGHUP mycontainer # Reload config
docker stop --time=30 mycontainer # SIGTERM, wait 30s, then SIGKILL
# List all signals
kill -l
Signal Handling in Code
# Python signal handler for graceful shutdown
import signal
import sys
def shutdown_handler(signum, frame):
print(f"Received signal {signum}, shutting down gracefully...")
# Close database connections, flush buffers, etc.
sys.exit(0)
signal.signal(signal.SIGTERM, shutdown_handler)
signal.signal(signal.SIGINT, shutdown_handler)
// Go signal handler (idiomatic pattern)
func main() {
ctx, cancel := signal.NotifyContext(context.Background(),
syscall.SIGTERM, syscall.SIGINT)
defer cancel()
// Start server...
srv := &http.Server{Addr: ":8080"}
go srv.ListenAndServe()
// Wait for signal
<-ctx.Done()
log.Println("Shutting down gracefully...")
shutdownCtx, shutdownCancel := context.WithTimeout(
context.Background(), 30*time.Second)
defer shutdownCancel()
srv.Shutdown(shutdownCtx)
}
Job Control
Job control allows you to manage multiple processes from a single shell session. While less critical in containers (which typically run a single foreground process), it is essential for interactive Docker sessions.
# Run a command in the background
long-running-task &
# [1] 12345
# List background jobs
jobs -l
# [1]+ 12345 Running long-running-task &
# Bring a background job to the foreground
fg %1
# Suspend a foreground job (sends SIGTSTP)
# Press Ctrl+Z
# [1]+ Stopped long-running-task
# Resume in the background
bg %1
# Disown a job (detach from shell, survives logout)
disown %1
# Run a command immune to hangups
nohup long-running-task &
# Output redirected to nohup.out
nice and renice: Process Priority
The Linux scheduler uses nice values (-20 to 19) to determine CPU time allocation. Lower nice values mean higher priority.
# Start a process with lower priority (higher nice value)
nice -n 10 make -j$(nproc)
# Change priority of a running process
renice -n 5 -p 1234
# View nice values
ps -o pid,ni,comm
# PID NI COMMAND
# 1234 10 make
# 5678 0 nginx
# 9012 -5 database
# In Docker, set CPU scheduling with --cpu-shares
# (relative weight, not directly a nice value)
docker run --cpu-shares 256 myapp # Lower priority (default is 1024)
docker run --cpu-shares 2048 myapp # Higher priority
cgroups for Resource Control
cgroups (control groups) are the kernel feature that Docker uses to limit and account for container resources. Every --memory, --cpus, or --pids-limit flag translates directly to a cgroup setting.
# View a container's cgroup
docker inspect --format '{{.HostConfig.CgroupParent}}' mycontainer
# cgroups v2 (modern systems)
# View the cgroup for a container
CONTAINER_ID=$(docker inspect --format '{{.Id}}' mycontainer)
ls /sys/fs/cgroup/system.slice/docker-${CONTAINER_ID}.scope/
# Read resource limits
cat /sys/fs/cgroup/system.slice/docker-${CONTAINER_ID}.scope/memory.max
cat /sys/fs/cgroup/system.slice/docker-${CONTAINER_ID}.scope/cpu.max
cat /sys/fs/cgroup/system.slice/docker-${CONTAINER_ID}.scope/pids.max
# Read current usage
cat /sys/fs/cgroup/system.slice/docker-${CONTAINER_ID}.scope/memory.current
cat /sys/fs/cgroup/system.slice/docker-${CONTAINER_ID}.scope/cpu.stat
Process Namespaces
PID namespaces are what make containers have their own process tree. Inside a container, the entrypoint process is PID 1, and it cannot see processes outside the namespace.
# View processes from the host perspective
ps aux | grep nginx
# root 5000 ... containerd-shim -namespace moby ...
# root 5050 ... nginx: master process
# www 5051 ... nginx: worker process
# View processes from inside the container
docker exec mycontainer ps aux
# PID USER COMMAND
# 1 root nginx: master process
# 7 nginx nginx: worker process
# 8 nginx nginx: worker process
# The container sees PID 1 for nginx, the host sees PID 5050
# Same process, different namespace views
# Disable PID namespace isolation (share host PID namespace)
docker run --pid=host mycontainer
# Container can now see ALL host processes
# DANGEROUS: only use for monitoring/debugging tools
The Docker PID 1 Problem
This is one of the most important and misunderstood issues in container runtime behavior. PID 1 in Linux has special responsibilities that most application processes do not implement:
- Signal handling: PID 1 gets default signal behaviors that differ from other processes. Specifically, the kernel does not deliver signals to PID 1 unless PID 1 has explicitly registered a handler for that signal. This means
SIGTERMis silently ignored by default. - Zombie reaping: PID 1 is responsible for calling
wait()on orphaned child processes. If it does not, zombies accumulate.
# Problem demonstration
# Dockerfile with shell form (runs under /bin/sh -c)
CMD python app.py
# Process tree inside container:
# PID 1: /bin/sh -c python app.py
# PID 7: python app.py
# docker stop sends SIGTERM to PID 1 (sh), which does NOT forward it to python
# After timeout, docker sends SIGKILL - ungraceful shutdown
# Better: exec form (runs directly as PID 1)
CMD ["python", "app.py"]
# Process tree:
# PID 1: python app.py
# docker stop sends SIGTERM directly to python
# BUT: python must register a SIGTERM handler (see above)
Solution: tini and dumb-init
Use a proper init process designed for containers:
# Using tini (Docker's recommended init)
FROM python:3.12-slim
# Install tini
RUN apt-get update && apt-get install -y tini && rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["tini", "--"]
CMD ["python", "app.py"]
# Process tree:
# PID 1: tini -- python app.py
# PID 7: python app.py
# tini forwards SIGTERM to python AND reaps zombie children
# Using Docker's built-in init (adds tini automatically)
docker run --init myapp
# In Compose
services:
myapp:
image: myapp:latest
init: true
# Using dumb-init (alternative to tini)
FROM python:3.12-slim
RUN pip install dumb-init
ENTRYPOINT ["dumb-init", "--"]
CMD ["python", "app.py"]
docker stop will not gracefully terminate the application.
When You Do NOT Need tini
- Single-process containers where the application properly handles SIGTERM (e.g., Go binaries, well-behaved Java apps)
- Applications that never spawn child processes
- Base images that already include an init system (e.g., phusion/baseimage)
--init. The overhead of tini is negligible (a single 30 KB binary), and it eliminates an entire class of subtle container lifecycle bugs.
Key takeaway: Containers are processes, not virtual machines. Understanding Linux process management, from fork-exec to signals to PID namespaces, is not optional knowledge for anyone working with Docker. The PID 1 problem, in particular, causes real production issues including stuck container stops, orphaned processes, and ungraceful shutdowns that lose data.