- Introduction
- 1. Process state — the one letter in the STAT column of ps
- 2. The signal table — number, default action, purpose
- 3. kill, pkill, killall — who are you sending it to
- 4. SIGTERM and SIGKILL — designing the grace period
- 5. PID 1, zombies, and orphans
- 6. Process groups, sessions, and what happens when the terminal closes
- 7. Handling signals in an application
- 8. Tracing a process that will not die, layer by layer
- 9. Priorities and resource limits
- Quiz: check your understanding
- Closing
- References
- Further reading
Introduction
Your deploy script sends kill and the process does not die. You send kill -9 and it is still sitting in the list. You close the terminal and the background job dies along with it. You stop a container and the application is cut off halfway through writing data.
All four come from the same gap in knowledge: how a signal is delivered to a process, who is allowed to ignore it, and in which states it is never delivered at all.
This article is not about memorising a list of signals. It walks in order through the real operational situations that signals produce once they are entangled with process groups and sessions, the controlling terminal, and parent-child relationships. It ends with how to design a graceful shutdown in an application and in a container.
The baseline is Linux kernel 5.x or newer, on x86-64 and ARM. Signal numbers differ per architecture. The numbers in this article are the x86/ARM values; Alpha, MIPS, SPARC and others use different ones. In scripts it is always safer to use names rather than numbers.
1. Process state — the one letter in the STAT column of ps
The STAT column of ps summarises, in a single letter, what is happening to that process.
ps -eo pid,ppid,stat,wchan:24,etime,args --sort=-pcpu | head -20
| Code | Meaning | What it means in practice |
|---|---|---|
R | Running or runnable | Using the CPU, or queued waiting for it |
S | Interruptible sleep | Normal waiting. Can be woken with a signal |
D | Uninterruptible sleep | Not even SIGKILL gets through immediately. Usually disk or NFS |
Z | Zombie | Already dead, not yet reaped by the parent |
T | Stopped | Received SIGSTOP or SIGTSTP |
t | Stopped by a debugger | Under ptrace |
I | Idle kernel thread | Safe to ignore |
The extra characters carry information too. s means session leader, + means foreground process group, l means multi-threaded, < means high priority, and N means low priority.
If you see the D state, signals will not fix it. Find out what the kernel is waiting for.
sudo cat /proc/1234/stack
sudo cat /proc/1234/wchan; echo
cat /proc/1234/status | grep -E '^State|^SigQ|^SigPnd|^SigBlk|^SigIgn|^SigCgt'
SigPnd is the set of pending signals, SigBlk the blocked ones, SigIgn the ones set to be ignored, and SigCgt the bitmask of signals with a handler registered. They are hexadecimal bitmasks and awkward to read, but they are the definitive way to answer "is this process catching SIGTERM?"
2. The signal table — number, default action, purpose
Of the standard signals defined in the signal(7) man page, these are the ones that actually come up in operations. Numbers are the x86/ARM values.
| Signal | No. | Default | Practical use |
|---|---|---|---|
SIGHUP | 1 | Term | Conventionally a config reload. Originally terminal hangup |
SIGINT | 2 | Term | Keyboard interrupt |
SIGQUIT | 3 | Core | Triggers a core dump. The JVM prints a thread dump |
SIGKILL | 9 | Term | Cannot be caught, blocked, or ignored |
SIGUSR1 | 10 | Term | Application defined. nginx reopens its logs |
SIGUSR2 | 12 | Term | Application defined. nginx swaps its binary |
SIGPIPE | 13 | Term | Writing to a pipe with no reader |
SIGTERM | 15 | Term | A polite request to exit. The default kill signal |
SIGCHLD | 17 | Ign | A child exited, stopped, or resumed |
SIGCONT | 18 | Cont | Resume a stopped process |
SIGSTOP | 19 | Stop | Cannot be caught, blocked, or ignored |
SIGTSTP | 20 | Stop | Stop requested from the terminal |
The default actions mean Term (terminate), Core (terminate and dump core), Ign (ignore), Stop (stop), and Cont (resume).
The single most important sentence in the man page is this one. SIGKILL and SIGSTOP cannot be caught, blocked, or ignored. Every other signal can have its handling changed by the application. That is why programs exist for which kill -TERM does nothing, and that may be design rather than a bug.
SIGPIPE frequently causes trouble in pipelines.
yes | head -1
Once head has read one line and exited, yes is writing into a pipe with no reader, and it is terminated by SIGPIPE. That is correct behaviour. A program that has been configured to ignore SIGPIPE gets an EPIPE error instead, so if it does not handle that error it can spin in an infinite loop.
3. kill, pkill, killall — who are you sending it to
kill -l
kill -TERM 1234
kill -s TERM 1234
kill -9 1234
kill -0 1234
kill -0 sends no signal at all and only checks whether delivery would be possible (does the process exist, do you have permission). It is a good fit for liveness checks in scripts.
There are two kinds of tool for sending by name, and they behave differently.
pkill -TERM -f 'java.*myapp'
pkill -u appuser -TERM nginx
killall -TERM nginx
pgrep -a -f 'java.*myapp'
pgrep/pkilldo pattern matching, and with-fthey match against the full command line. Without-fthey only look at the process name, which is usually truncated to 15 characters.killallmatches the exact name. Be aware that on some Unix systems (Solaris and others)killallmeans something entirely different. If portability matters,pkillis the safe choice.
Destructive command warning: if the pattern is too broad, pkill -f will kill processes you did not intend. It is especially dangerous when run as root. Always confirm the targets with pgrep first, then run pkill with exactly the same pattern.
# Step 1: confirm the targets (kills nothing)
pgrep -a -f 'java.*myapp'
# Step 2: only run this once the list is correct
pkill -TERM -f 'java.*myapp'
To send to an entire process group, put a minus sign in front of the PID. A negative PID means a process group ID.
kill -TERM -12345
This form is useful for cleaning up children in one shot, but getting the group ID wrong has very wide-reaching effects. In particular, kill -9 -1 sends SIGKILL to every process your permissions reach, and run as root it effectively halts the system. Never run it.
4. SIGTERM and SIGKILL — designing the grace period
A correct shutdown procedure always has two stages. Send SIGTERM first, allow a grace period, and only send SIGKILL if it is still alive.
#!/usr/bin/env bash
set -euo pipefail
PID="$1"
TIMEOUT="${2:-30}"
kill -TERM "$PID" 2>/dev/null || exit 0
for _ in $(seq "$TIMEOUT"); do
if ! kill -0 "$PID" 2>/dev/null; then
echo "graceful shutdown complete"
exit 0
fi
sleep 1
done
echo "timeout, escalating to SIGKILL" >&2
kill -KILL "$PID"
SIGKILL gives the application no chance to clean up. Buffers on open files are not flushed, transactions are cut off, and temp files and lock files are left behind. Sending SIGKILL to a database or a queue consumer is effectively pulling the power cord. A generous grace period is always the better trade.
For systemd services this procedure is available as configuration.
[Service]
KillSignal=SIGTERM
TimeoutStopSec=60
KillMode=mixed
SendSIGKILL=yes
KillMode=mixed sends SIGTERM only to the main process, and after the timeout sends SIGKILL to every process in the cgroup. It suits applications that manage their own child processes. The exact meaning and defaults of each directive can change between systemd versions, so check the systemd.kill documentation for the version you have installed.
Containers follow the same model. docker stop sends SIGTERM, waits 10 seconds by default, then sends SIGKILL. Kubernetes uses the termination grace period from the Pod spec.
docker stop --time 60 mycontainer
kubectl delete pod mypod --grace-period=60
There is a trap people hit constantly in containers. If you use the shell form (CMD myapp), the shell becomes PID 1 and the application becomes its child. SIGTERM then goes to the shell, and the shell does not forward it to the child. The end result is that the application is killed by SIGKILL with no grace period at all. Use the exec form (CMD ["myapp"]), or add a proper init process.
5. PID 1, zombies, and orphans
When a process exits, the kernel keeps its exit status around. The process entry only disappears once the parent calls wait and reaps it. The state before that reaping is the zombie state (Z).
ps -eo pid,ppid,stat,args | awk '$3 ~ /Z/'
A zombie uses almost no memory, but it occupies a PID. If thousands of zombies pile up, PIDs are exhausted and no new process can be created.
cat /proc/sys/kernel/pid_max
ls /proc | grep -c '^[0-9]'
You cannot kill a zombie. It is already dead. The fix is to make the parent reap it, or to terminate the parent so the zombie is adopted by PID 1. PID 1 is responsible for adopting orphaned processes and reaping them automatically.
If the parent dies first, the child becomes an orphan and is adopted by PID 1 (or by a process registered as a subreaper). If zombies keep accumulating, the parent program has a bug: it is not handling SIGCHLD.
Zombie accumulation is especially common in containers. It happens when the application runs as PID 1 and was never written to reap children. Using the init option provided by the container runtime is the standard fix.
docker run --init myimage
6. Process groups, sessions, and what happens when the terminal closes
Why does closing the terminal kill your background job? There are three layers involved.
- Process group: one pipeline is usually one group. It is the unit of shell job control.
- Session: one login is one session. The session leader owns the controlling terminal.
- Controlling terminal: when the terminal goes away, the kernel sends SIGHUP to the session leader, and the session leader (usually the shell) propagates SIGHUP to its jobs.
This is how you inspect the current structure.
ps -eo pid,ppid,pgid,sid,tty,stat,args | head -20
ps -o pid,pgid,sid,tty,args -p $$
There are several ways to survive a terminal disconnect, and they differ.
nohup ./long-job.sh > /var/log/long-job.log 2>&1 &
setsid ./long-job.sh > /var/log/long-job.log 2>&1 &
disown -h %1
systemd-run --user --unit=long-job ./long-job.sh
nohupsets SIGHUP to be ignored and then runs the command. It still belongs to the same session.setsidcreates a new session, severing the relationship with the controlling terminal entirely. It is more reliable.disown -hremoves an already-running job from the shell list that receives propagated SIGHUP.systemd-runruns the work as a separate unit entirely, making it independent of the session. It is the safest choice for long-running work on a production server.
If you run long jobs over SSH, tmux or screen is the practical answer. The session survives a dropped connection and you can reattach later.
7. Handling signals in an application
In shell scripts you handle them with trap.
#!/usr/bin/env bash
set -euo pipefail
cleanup() {
echo "cleaning up..."
rm -f /tmp/myjob.lock
}
trap cleanup EXIT
trap 'echo "received SIGTERM"; exit 143' TERM
trap 'echo "received SIGINT"; exit 130' INT
while true; do
sleep 1
done
trap ... EXIT runs no matter which path the script exits by, which makes it the right place for cleanup logic. The exit code convention is 128 plus the signal number. Dying from SIGTERM (15) gives 143, from SIGINT (2) gives 130. Once you know this convention you can see 143 in systemctl status or a CI log and immediately read it as "it received a normal shutdown request".
One thing to watch out for: while an external command such as sleep is running, bash does not process the trap immediately — it processes it after that command finishes. A long sleep therefore makes the script slow to react. Running it in the background and using wait gives an immediate response.
sleep 300 &
wait $!
The timeout command is useful too.
timeout 30 ./maybe-hangs.sh
timeout -s KILL 30 ./maybe-hangs.sh
timeout -k 10 30 ./maybe-hangs.sh
-k sends the default signal first and then, if the process is still alive after the extra interval you specify, sends SIGKILL. It can replace the two-stage shutdown script from earlier with a single line.
8. Tracing a process that will not die, layer by layer
When you get a report that "it will not die", narrow it down in this order.
Step 1 — is it actually alive? If it is a zombie, it is already dead.
ps -o pid,ppid,stat,args -p 1234
Step 2 — was the signal delivered? Delivery itself may have failed because of permissions.
kill -0 1234; echo "exit=$?"
If you get Operation not permitted, it is a permissions problem.
Step 3 — is the process catching the signal?
grep -E '^SigIgn|^SigCgt|^SigBlk' /proc/1234/status
Step 4 — is it in the D state? If so, signals are of no use whatsoever.
cat /proc/1234/status | grep '^State'
sudo cat /proc/1234/stack
If the stack shows NFS or block-layer functions, you have to wait for storage to respond. If it is a disconnected NFS mount, a forced unmount is the only way out.
sudo umount -f -l /mnt/nfsshare
Destructive command warning: umount -l (lazy) detaches the mount from the namespace immediately but leaves the in-use references in place. Writes in flight can be lost, so use it only as a last resort.
Step 5 — does it recur after a restart? If it does, this is not a signal problem: it is a structural problem in the application or in storage.
9. Priorities and resource limits
There is also the option of restraining a process instead of killing it.
nice -n 10 ./batch-job.sh
renice -n 10 -p 1234
ionice -c 3 -p 1234
chrt -p 1234
nicevalues run from -20 (highest priority) to 19 (lowest). Lowering it into negative values requires root.ionice -c 3is the idle I/O class: it performs I/O only when no other process is using the disk. Applying it to nightly backups or bulk copy jobs dramatically reduces the impact on production traffic.chrtdeals with real-time scheduling policies. Used incorrectly it can make the system unresponsive, so treat it with care.
Limits are set with ulimit and with systemd directives.
ulimit -a
ulimit -n
cat /proc/1234/limits
In a systemd unit you configure them like this.
[Service]
LimitNOFILE=65535
LimitNPROC=4096
MemoryMax=2G
CPUQuota=200%
When MemoryMax is exceeded, the cgroup OOM killer kills processes only inside that service. That prevents it from spilling over into a system-wide OOM, so it is well worth setting on any memory-hungry service. The available directives and their defaults vary by systemd version, so check the systemd.resource-control documentation for the version you have installed.
Quiz: check your understanding
Quiz 1: You sent kill -9 and the process is still sitting in the list. What do you check first?
Answer: Whether the process state is Z (zombie) or D (uninterruptible sleep)
Why: The two states have completely different causes and completely different responses.
ps -o pid,ppid,stat,wchan:24,args -p 1234
If it is Z, it is already dead and simply not reaped by its parent, so there is nothing left to kill. It disappears once you deal with the parent process. If it is D, the kernel is waiting for I/O to complete and even SIGKILL is not delivered immediately. You have to recover the resource it is waiting on.
sudo cat /proc/1234/stack
Quiz 2: You stopped a container and the application was cut off instantly without running its shutdown logic. What are the likely causes?
Answer: The classic case is a shell becoming PID 1 and not forwarding SIGTERM to its child
Why: If the Dockerfile specifies the command in shell form, the application runs underneath /bin/sh -c. The SIGTERM the runtime sends is received by the shell as PID 1, and the shell does not forward it. Once the grace period expires, SIGKILL is applied to the whole cgroup and the application dies without cleaning anything up. Switch to the exec form, or add an init process.
docker run --init myimage
docker stop --time 60 mycontainer
Also check whether the application really registered a SIGTERM handler.
grep SigCgt /proc/1/status
Quiz 3: You connected over SSH, started a deploy script, and the connection dropped. What should you have used so the script would not be interrupted?
Answer: setsid, systemd-run, or tmux — one of them, to detach it from the session
Why: When the terminal goes away, the kernel sends SIGHUP to the session leader and the shell propagates it to its jobs. nohup makes SIGHUP ignored, but the job still belongs to the same session. The more reliable approach is to create a new session.
setsid ./deploy.sh > /var/log/deploy.log 2>&1 &
systemd-run --unit=deploy-2026-08-15 ./deploy.sh
tmux new -s deploy
On a production server systemd-run is the best of the three, because the logs land in the journal and you can query the state with systemctl status.
Quiz 4: Zombie processes keep increasing over time. What is the real risk, and what do you fix?
Answer: The risk is PID exhaustion, and the cause is a parent program that does not reap its children
Why: Zombies use almost no memory but they occupy PID slots. Once you reach pid_max you cannot create any new process at all, which effectively halts the system.
ps -eo pid,ppid,stat,args | awk '$3 ~ /Z/' | head
cat /proc/sys/kernel/pid_max
Check the parent PID and fix that program so it handles child termination — that is the real solution. As a stopgap, restarting the parent lets PID 1 adopt the zombies and reap them.
Quiz 5: You put a SIGTERM trap in a shell script but it reacts about 10 seconds late every time. Why?
Answer: Because while an external command is running, bash processes the trap only after that command finishes
Why: If the signal arrives while sleep 10 is running, bash runs the trap handler after sleep returns. To get an immediate reaction, run it in the background and use wait.
sleep 10 &
wait $!
wait returns immediately when a signal arrives, so the trap runs right away. Remember the exit code convention as well: exiting via SIGTERM gives 143, via SIGINT gives 130.
Quiz 6: A nightly batch job is wrecking the response times of production traffic. How do you mitigate it without killing the batch?
Answer: Lower its CPU priority with nice and its I/O priority with ionice
Why: The batch job is allowed to finish late; production requests are not. Lowering the priorities means the batch only makes progress when there are resources to spare.
renice -n 19 -p 1234
sudo ionice -c 3 -p 1234
ionice -c 3 is the idle class: it performs I/O only when no other process is using the disk. The more fundamental fix is to apply resource control in the systemd unit.
[Service]
Nice=19
IOSchedulingClass=idle
CPUQuota=50%
Closing
Signals look simple, but process groups, sessions, the controlling terminal, cgroups, and the container runtime are layered on top of them. That is why the answer to "why will it not die" comes from a different layer every time.
Three things are worth remembering in practice. Only SIGKILL and SIGSTOP are absolute; everything else is at the application's mercy. In the D state no signal helps at all, so look at storage. Shutdown always starts with SIGTERM and a grace period, and SIGKILL is the last resort.
That last one matters most for services that handle data. If you run with a 10-second termination grace period and SIGKILL lands in the middle of a large transaction, that day's incident was manufactured by your shutdown procedure.
References
- signal(7) — man7.org (verified 2026-08-15)
- proc_pid_fd(5) — man7.org (verified 2026-08-15)
- dmesg(1) — man7.org (verified 2026-08-15)
- systemd official documentation — freedesktop.org (verified 2026-08-15)
Further reading
- Previous: The complete guide to Linux performance tools
- Next: The complete guide to running SSH
- The complete guide to systemd service management — designing shutdown procedures with unit files
- The complete guide to terminal and shell internals — how PTYs and job control work inside
- Linux terminal — practise signal-related commands
- Linux command quiz — check how well you remember the options
현재 단락 (1/216)
Your deploy script sends `kill` and the process does not die. You send `kill -9` and it is still sit...