Skip to content

Split View: 프로세스와 시그널 완전 가이드: 종료가 안 되는 이유를 끝까지 추적하기

|

프로세스와 시그널 완전 가이드: 종료가 안 되는 이유를 끝까지 추적하기

들어가며

배포 스크립트에서 kill을 보냈는데 프로세스가 안 죽습니다. kill -9를 보냈는데도 목록에 남아 있습니다. 터미널을 닫았더니 백그라운드 작업이 함께 죽었습니다. 컨테이너를 정지시켰는데 애플리케이션이 데이터를 쓰다 말고 끊겼습니다.

네 가지 모두 같은 지식이 없어서 생기는 문제입니다. 시그널이 프로세스에 어떻게 전달되고, 누가 그것을 무시할 수 있으며, 어떤 상태에서는 아예 전달되지 않는가에 대한 지식입니다.

이 글은 시그널의 목록을 외우는 글이 아닙니다. 시그널이 프로세스 그룹과 세션, 컨트롤 터미널, 부모-자식 관계와 얽혀 만들어 내는 실제 운영 상황을 순서대로 다룹니다. 마지막에는 애플리케이션과 컨테이너에서 우아한 종료를 설계하는 방법까지 이어집니다.

기준은 리눅스 커널 5.x 이상, x86-64 및 ARM 아키텍처입니다. 시그널 번호는 아키텍처마다 다릅니다. 이 글의 번호는 x86/ARM 기준이며, Alpha, MIPS, SPARC 등에서는 다른 값을 씁니다. 스크립트에서는 항상 번호 대신 이름을 쓰는 편이 안전합니다.


1. 프로세스 상태 — ps의 STAT 한 글자

psSTAT 열은 그 프로세스에 무슨 일이 벌어지는지 한 글자로 요약합니다.

ps -eo pid,ppid,stat,wchan:24,etime,args --sort=-pcpu | head -20
코드의미실무적 함의
R실행 중 또는 실행 대기CPU를 쓰거나 받으려고 줄 서 있음
S중단 가능 대기정상적인 대기. 시그널로 깨울 수 있음
D중단 불가 대기SIGKILL도 즉시 통하지 않음. 대개 디스크·NFS
Z좀비이미 죽었고 부모가 회수하지 않은 상태
T정지됨SIGSTOP 또는 SIGTSTP를 받음
t디버거에 의해 정지ptrace 중
I유휴 커널 스레드무시해도 됨

부가 문자도 정보를 줍니다. s는 세션 리더, +는 포그라운드 프로세스 그룹, l은 멀티스레드, <는 높은 우선순위, N은 낮은 우선순위입니다.

D 상태가 보이면 시그널로는 해결되지 않습니다. 커널이 무엇을 기다리는지 확인하세요.

sudo cat /proc/1234/stack
sudo cat /proc/1234/wchan; echo
cat /proc/1234/status | grep -E '^State|^SigQ|^SigPnd|^SigBlk|^SigIgn|^SigCgt'

SigPnd는 대기 중인 시그널, SigBlk는 차단된 시그널, SigIgn은 무시로 설정된 시그널, SigCgt는 핸들러가 등록된 시그널의 비트마스크입니다. 십육진수 비트마스크라 읽기 번거롭지만, "이 프로세스가 SIGTERM을 잡고 있는가"를 확인하는 확실한 방법입니다.


2. 시그널 표 — 번호, 기본 동작, 용도

man 페이지 signal(7)에 정의된 표준 시그널 중 운영에서 실제로 쓰는 것들입니다. 번호는 x86/ARM 기준입니다.

시그널번호기본 동작실무 용도
SIGHUP1Term설정 재적재 관례. 원래는 터미널 연결 끊김
SIGINT2Term키보드 인터럽트
SIGQUIT3Core코어 덤프 유발. JVM은 스레드 덤프를 출력
SIGKILL9Term잡을 수도 차단할 수도 무시할 수도 없음
SIGUSR110Term애플리케이션 정의. nginx는 로그 재오픈
SIGUSR212Term애플리케이션 정의. nginx는 바이너리 교체
SIGPIPE13Term읽는 쪽이 없는 파이프에 쓸 때
SIGTERM15Term정중한 종료 요청. 기본 kill 시그널
SIGCHLD17Ign자식이 종료·정지·재개됨
SIGCONT18Cont정지된 프로세스 재개
SIGSTOP19Stop잡을 수도 차단할 수도 무시할 수도 없음
SIGTSTP20Stop터미널에서 입력한 정지

기본 동작의 뜻은 Term(종료), Core(종료 후 코어 덤프), Ign(무시), Stop(정지), Cont(재개)입니다.

man 페이지가 명시하는 가장 중요한 문장은 이것입니다. SIGKILL과 SIGSTOP은 잡을 수도, 차단할 수도, 무시할 수도 없습니다. 나머지 시그널은 전부 애플리케이션이 처리 방식을 바꿀 수 있습니다. 그래서 kill -TERM이 통하지 않는 프로그램이 존재하는 것이고, 그것은 버그가 아니라 설계일 수 있습니다.

SIGPIPE는 파이프라인에서 자주 문제가 됩니다.

yes | head -1

head가 한 줄을 읽고 종료하면 yes는 읽는 쪽이 없는 파이프에 쓰게 되고 SIGPIPE로 종료됩니다. 이것이 정상 동작입니다. 그런데 SIGPIPE를 무시하도록 설정한 프로그램은 대신 EPIPE 오류를 받게 되므로, 오류 처리를 하지 않으면 무한 루프에 빠질 수 있습니다.


3. kill, pkill, killall — 누구에게 보내는가

kill -l
kill -TERM 1234
kill -s TERM 1234
kill -9 1234
kill -0 1234

kill -0은 시그널을 보내지 않고 전달 가능한지(프로세스 존재 여부와 권한)만 확인합니다. 스크립트에서 프로세스 생존 확인에 쓰기 좋습니다.

이름으로 보내는 도구는 두 종류이며 동작이 다릅니다.

pkill -TERM -f 'java.*myapp'
pkill -u appuser -TERM nginx
killall -TERM nginx
pgrep -a -f 'java.*myapp'
  • pgrep/pkill은 패턴 매칭이며, -f를 주면 명령줄 전체와 비교합니다. -f 없이는 프로세스 이름(보통 15자로 잘린 값)만 봅니다.
  • killall은 정확한 이름 일치입니다. 주의할 점은 일부 유닉스(Solaris 등)에서 killall이 전혀 다른 의미로 동작한다는 것입니다. 이식성을 생각하면 pkill이 안전합니다.

파괴적 명령 경고: pkill -f는 패턴이 넓으면 의도치 않은 프로세스까지 죽입니다. 특히 root로 실행할 때 위험합니다. 반드시 pgrep으로 먼저 대상을 확인한 다음 같은 패턴으로 pkill을 실행하세요.

# 1단계: 대상 확인 (아무것도 죽이지 않음)
pgrep -a -f 'java.*myapp'
# 2단계: 목록이 정확할 때만 실행
pkill -TERM -f 'java.*myapp'

프로세스 그룹 전체에 보내려면 PID 앞에 마이너스를 붙입니다. 음수 PID는 프로세스 그룹 ID를 뜻합니다.

kill -TERM -12345

이 형태는 자식까지 한 번에 정리할 때 유용하지만, 그룹 ID를 잘못 지정하면 광범위하게 영향을 줍니다. 특히 kill -9 -1은 권한이 닿는 모든 프로세스에 SIGKILL을 보내며, root로 실행하면 시스템을 사실상 정지시킵니다. 절대 실행하지 마세요.


4. SIGTERM과 SIGKILL — 유예를 설계하기

올바른 종료 절차는 항상 두 단계입니다. 먼저 SIGTERM을 보내고, 유예 시간을 준 뒤, 그래도 살아 있으면 SIGKILL을 보냅니다.

#!/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은 애플리케이션에 정리할 기회를 주지 않습니다. 열린 파일의 버퍼는 플러시되지 않고, 트랜잭션은 중단되며, 임시 파일과 락 파일은 남습니다. 데이터베이스나 큐 소비자에게 SIGKILL을 보내는 것은 사실상 전원을 뽑는 것과 같습니다. 유예 시간을 넉넉히 주는 편이 언제나 낫습니다.

systemd 서비스에서는 이 절차가 설정으로 제공됩니다.

[Service]
KillSignal=SIGTERM
TimeoutStopSec=60
KillMode=mixed
SendSIGKILL=yes

KillMode=mixed는 메인 프로세스에만 SIGTERM을 보내고, 타임아웃 이후 SIGKILL은 cgroup의 모든 프로세스에 보냅니다. 자식 프로세스를 직접 관리하는 애플리케이션에 적합합니다. 각 지시어의 정확한 의미와 기본값은 systemd 버전에 따라 달라질 수 있으니 설치된 버전의 systemd.kill 문서를 확인하세요.

컨테이너도 같은 모델입니다. docker stop은 SIGTERM을 보내고 기본 10초를 기다린 뒤 SIGKILL을 보냅니다. 쿠버네티스는 Pod 스펙의 종료 유예 기간을 씁니다.

docker stop --time 60 mycontainer
kubectl delete pod mypod --grace-period=60

컨테이너에서 자주 겪는 함정이 있습니다. 셸 형식(CMD myapp)으로 실행하면 셸이 PID 1이 되고 애플리케이션은 자식이 됩니다. 이때 SIGTERM은 셸에게 가고, 셸은 그것을 자식에게 전달하지 않습니다. 결과적으로 애플리케이션은 유예 없이 SIGKILL로 죽습니다. exec 형식(CMD ["myapp"])을 쓰거나 적절한 init 프로세스를 넣어야 합니다.


5. PID 1, 좀비, 고아

프로세스가 종료되면 커널은 종료 상태를 남겨 둡니다. 부모가 wait를 호출해 그것을 회수해야 프로세스 항목이 사라집니다. 회수되기 전까지의 상태가 좀비(Z) 입니다.

ps -eo pid,ppid,stat,args | awk '$3 ~ /Z/'

좀비는 메모리를 거의 쓰지 않지만 PID를 점유합니다. 좀비가 수천 개 쌓이면 PID가 고갈되어 새 프로세스를 만들 수 없게 됩니다.

cat /proc/sys/kernel/pid_max
ls /proc | grep -c '^[0-9]'

좀비는 죽일 수 없습니다. 이미 죽었기 때문입니다. 해결책은 부모가 회수하게 만들거나, 부모를 종료시켜 좀비를 PID 1에 입양시키는 것입니다. PID 1은 고아 프로세스를 입양해 자동으로 회수할 책임이 있습니다.

부모가 먼저 죽으면 자식은 고아가 되고 PID 1(또는 서브리퍼로 등록된 프로세스)에 입양됩니다. 좀비가 계속 쌓인다면 부모 프로그램이 SIGCHLD를 처리하지 않는 버그입니다.

컨테이너에서 좀비가 쌓이는 사례가 특히 흔합니다. 애플리케이션이 PID 1로 실행되는데 그 애플리케이션이 자식을 회수하도록 만들어지지 않은 경우입니다. 컨테이너 런타임이 제공하는 init 옵션을 쓰는 것이 표준 해법입니다.

docker run --init myimage

6. 프로세스 그룹, 세션, 그리고 터미널이 닫힐 때

터미널을 닫으면 왜 백그라운드 작업이 죽을까요? 여기에는 세 겹의 구조가 있습니다.

  • 프로세스 그룹: 파이프라인 하나가 보통 그룹 하나입니다. 셸의 잡 제어 단위입니다.
  • 세션: 로그인 하나가 세션 하나입니다. 세션 리더가 컨트롤 터미널을 가집니다.
  • 컨트롤 터미널: 터미널이 사라지면 커널이 세션 리더에게 SIGHUP을 보내고, 세션 리더(대개 셸)는 자신의 잡들에게 SIGHUP을 전파합니다.

현재 구조는 이렇게 봅니다.

ps -eo pid,ppid,pgid,sid,tty,stat,args | head -20
ps -o pid,pgid,sid,tty,args -p $$

터미널이 끊겨도 살아남게 하는 방법은 여러 가지이며 각각 다릅니다.

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
  • nohup은 SIGHUP을 무시하도록 설정하고 실행합니다. 여전히 같은 세션에 속합니다.
  • setsid새 세션을 만들어 컨트롤 터미널과의 관계 자체를 끊습니다. 더 확실합니다.
  • disown -h는 이미 실행 중인 잡을 셸의 SIGHUP 전파 대상에서 제외합니다.
  • systemd-run은 아예 별도 유닛으로 실행해 세션과 무관하게 만듭니다. 운영 서버에서 장시간 작업을 돌릴 때 가장 안전한 선택입니다.

SSH 세션에서 긴 작업을 돌린다면 tmuxscreen을 쓰는 것이 실용적입니다. 연결이 끊겨도 세션이 유지되고 나중에 다시 붙을 수 있습니다.


7. 애플리케이션에서 시그널 다루기

셸 스크립트에서는 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은 어떤 경로로 종료되든 실행되므로 정리 로직을 넣기에 적합합니다. 종료 코드 관례는 128 더하기 시그널 번호입니다. SIGTERM(15)으로 죽으면 143, SIGINT(2)면 130입니다. 이 관례를 알면 systemctl status나 CI 로그에서 143을 보고 "정상적인 종료 요청을 받았구나"라고 바로 읽을 수 있습니다.

주의할 점은 sleep 같은 외부 명령을 실행하는 동안에는 bash가 트랩을 즉시 처리하지 않고 해당 명령이 끝난 뒤에 처리한다는 것입니다. 긴 sleep을 쓰면 반응이 느려집니다. 백그라운드로 돌리고 wait를 쓰면 즉시 반응합니다.

sleep 300 &
wait $!

timeout 명령도 유용합니다.

timeout 30 ./maybe-hangs.sh
timeout -s KILL 30 ./maybe-hangs.sh
timeout -k 10 30 ./maybe-hangs.sh

-k는 먼저 기본 시그널을 보내고, 지정한 시간이 더 지나도 살아 있으면 SIGKILL을 보냅니다. 앞서 만든 두 단계 종료 스크립트를 한 줄로 대체할 수 있습니다.


8. 죽지 않는 프로세스를 계층별로 추적하기

"안 죽는다"는 신고를 받으면 다음 순서로 좁힙니다.

1단계 — 정말 살아 있는가. 좀비라면 이미 죽은 것입니다.

ps -o pid,ppid,stat,args -p 1234

2단계 — 시그널이 전달되었는가. 권한 문제로 전달 자체가 실패했을 수 있습니다.

kill -0 1234; echo "exit=$?"

Operation not permitted가 나오면 권한 문제입니다.

3단계 — 프로세스가 시그널을 잡고 있는가.

grep -E '^SigIgn|^SigCgt|^SigBlk' /proc/1234/status

4단계 — D 상태인가. 이 경우 시그널은 아무 소용이 없습니다.

cat /proc/1234/status | grep '^State'
sudo cat /proc/1234/stack

스택에 NFS나 블록 계층 함수가 보이면 스토리지가 응답할 때까지 기다려야 합니다. 마운트가 끊긴 NFS라면 강제 언마운트가 유일한 탈출구입니다.

sudo umount -f -l /mnt/nfsshare

파괴적 명령 경고: umount -l(lazy)은 마운트를 네임스페이스에서 즉시 분리하되 사용 중인 참조는 남겨 둡니다. 진행 중이던 쓰기가 유실될 수 있으므로 마지막 수단으로만 쓰세요.

5단계 — 재기동 후에도 반복되는가. 그렇다면 시그널 문제가 아니라 애플리케이션이나 스토리지의 구조적 문제입니다.


9. 우선순위와 자원 한도

프로세스를 죽이는 대신 억제하는 방법도 있습니다.

nice -n 10 ./batch-job.sh
renice -n 10 -p 1234
ionice -c 3 -p 1234
chrt -p 1234
  • nice 값은 -20(가장 높은 우선순위)에서 19(가장 낮음)까지입니다. 음수로 낮추려면 root 권한이 필요합니다.
  • ionice -c 3은 유휴 I/O 클래스로, 다른 프로세스가 디스크를 쓰지 않을 때만 I/O를 수행합니다. 야간 백업이나 대용량 복사 작업에 걸어 두면 운영 트래픽에 주는 영향이 크게 줄어듭니다.
  • chrt는 실시간 스케줄링 정책을 다룹니다. 잘못 쓰면 시스템을 응답 불가 상태로 만들 수 있으므로 신중해야 합니다.

한도는 ulimit과 systemd 지시어로 겁니다.

ulimit -a
ulimit -n
cat /proc/1234/limits

systemd 유닛에서는 다음과 같이 설정합니다.

[Service]
LimitNOFILE=65535
LimitNPROC=4096
MemoryMax=2G
CPUQuota=200%

MemoryMax를 넘으면 cgroup OOM 킬러가 그 서비스 안에서만 프로세스를 죽입니다. 시스템 전체 OOM으로 번지는 것을 막아 주므로, 메모리를 많이 쓰는 서비스에는 반드시 걸어 두는 편이 좋습니다. 사용 가능한 지시어와 기본값은 systemd 버전마다 다르므로 설치된 버전의 systemd.resource-control 문서를 확인하세요.


퀴즈: 실력을 확인해 보세요

퀴즈 1: kill -9를 보냈는데 프로세스가 목록에 그대로 남아 있습니다. 무엇을 먼저 확인하나요?

정답: 프로세스 상태가 Z(좀비)인지 D(중단 불가 대기)인지 확인합니다

설명: 두 상태는 원인도 대응도 완전히 다릅니다.

ps -o pid,ppid,stat,wchan:24,args -p 1234

Z라면 이미 죽었고 부모가 회수하지 않은 것이므로, 죽일 대상이 없습니다. 부모 프로세스를 처리해야 사라집니다. D라면 커널이 I/O 완료를 기다리는 중이며 SIGKILL도 즉시 전달되지 않습니다. 기다리는 자원을 복구해야 합니다.

sudo cat /proc/1234/stack
퀴즈 2: 컨테이너를 정지시켰는데 애플리케이션이 종료 처리를 실행하지 못하고 즉시 끊깁니다. 원인 후보는?

정답: 셸이 PID 1이 되어 SIGTERM을 자식에게 전달하지 않는 경우가 대표적입니다

설명: Dockerfile에서 셸 형식으로 명령을 지정하면 /bin/sh -c 아래에서 애플리케이션이 실행됩니다. 런타임이 보내는 SIGTERM은 PID 1인 셸이 받고, 셸은 그것을 전달하지 않습니다. 유예 시간이 지나면 SIGKILL이 cgroup 전체에 적용되어 애플리케이션은 아무 정리도 못 하고 죽습니다. exec 형식으로 바꾸거나 init 프로세스를 넣어야 합니다.

docker run --init myimage
docker stop --time 60 mycontainer

애플리케이션이 실제로 SIGTERM 핸들러를 등록했는지도 확인하세요.

grep SigCgt /proc/1/status
퀴즈 3: SSH로 접속해 배포 스크립트를 실행하다 연결이 끊겼습니다. 스크립트가 중단되지 않게 하려면 무엇을 썼어야 할까요?

정답: setsid, systemd-run, tmux 중 하나로 세션에서 분리해 실행했어야 합니다

설명: 터미널이 사라지면 커널이 세션 리더에게 SIGHUP을 보내고 셸이 그것을 잡들에게 전파합니다. nohup은 SIGHUP을 무시하게 만들지만 여전히 같은 세션에 속합니다. 더 확실한 것은 새 세션을 만드는 방법입니다.

setsid ./deploy.sh > /var/log/deploy.log 2>&1 &
systemd-run --unit=deploy-2026-08-15 ./deploy.sh
tmux new -s deploy

운영 서버라면 systemd-run이 가장 낫습니다. 로그가 저널에 남고 상태를 systemctl status로 조회할 수 있기 때문입니다.

퀴즈 4: 좀비 프로세스가 시간이 갈수록 늘어납니다. 어떤 실제 위험이 있고 무엇을 고쳐야 하나요?

정답: PID 고갈 위험이 있으며, 부모 프로그램이 자식을 회수하지 않는 것이 원인입니다

설명: 좀비는 메모리를 거의 쓰지 않지만 PID 슬롯을 점유합니다. pid_max에 도달하면 새 프로세스를 전혀 만들 수 없어 시스템이 사실상 정지합니다.

ps -eo pid,ppid,stat,args | awk '$3 ~ /Z/' | head
cat /proc/sys/kernel/pid_max

부모 PID를 확인해 그 프로그램이 자식 종료를 처리하도록 고치는 것이 근본 해결입니다. 임시 조치로 부모를 재기동하면 좀비들이 PID 1에 입양되어 회수됩니다.

퀴즈 5: 셸 스크립트에 SIGTERM 트랩을 걸었는데 반응이 10초씩 늦습니다. 왜일까요?

정답: 외부 명령이 실행 중일 때 bash는 그 명령이 끝난 뒤에 트랩을 처리하기 때문입니다

설명: sleep 10이 실행되는 동안 시그널이 도착하면, bash는 sleep이 반환된 뒤에 트랩 핸들러를 실행합니다. 즉시 반응하게 하려면 백그라운드로 돌리고 wait를 씁니다.

sleep 10 &
wait $!

wait는 시그널이 오면 즉시 반환되므로 트랩이 바로 실행됩니다. 종료 코드 관례도 함께 기억하세요. SIGTERM으로 종료하면 143, SIGINT면 130입니다.

퀴즈 6: 야간 배치가 운영 트래픽의 응답 시간을 망칩니다. 배치를 죽이지 않고 완화하려면?

정답: nice로 CPU 우선순위를, ionice로 I/O 우선순위를 낮춥니다

설명: 배치 작업은 늦게 끝나도 되지만 운영 요청은 그렇지 않습니다. 우선순위를 낮추면 자원이 남을 때만 배치가 진행됩니다.

renice -n 19 -p 1234
sudo ionice -c 3 -p 1234

ionice -c 3은 유휴 클래스로, 다른 프로세스가 디스크를 쓰지 않을 때만 I/O를 수행합니다. 근본적으로는 systemd 유닛에서 자원 제어를 걸어 두는 편이 좋습니다.

[Service]
Nice=19
IOSchedulingClass=idle
CPUQuota=50%

마치며

시그널은 단순해 보이지만 프로세스 그룹, 세션, 컨트롤 터미널, cgroup, 컨테이너 런타임이 겹겹이 얹혀 있습니다. 그래서 "왜 안 죽는가"라는 질문의 답이 매번 다른 계층에서 나옵니다.

실무에서 기억할 것은 세 가지입니다. SIGKILL과 SIGSTOP만 절대적이고 나머지는 애플리케이션이 마음대로 할 수 있다. D 상태에서는 어떤 시그널도 소용없으므로 스토리지를 봐야 한다. 종료는 항상 SIGTERM과 유예 시간으로 시작하고 SIGKILL은 마지막 수단이다.

마지막 문장은 특히 데이터를 다루는 서비스에서 중요합니다. 종료 유예 시간을 10초로 두고 운영하다가 대량 트랜잭션 중에 SIGKILL이 나가면, 그날의 장애는 종료 절차가 만든 것입니다.


참고 자료


이어서 읽기

Processes and Signals, the Complete Guide: Tracing Why a Process Will Not Exit

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
CodeMeaningWhat it means in practice
RRunning or runnableUsing the CPU, or queued waiting for it
SInterruptible sleepNormal waiting. Can be woken with a signal
DUninterruptible sleepNot even SIGKILL gets through immediately. Usually disk or NFS
ZZombieAlready dead, not yet reaped by the parent
TStoppedReceived SIGSTOP or SIGTSTP
tStopped by a debuggerUnder ptrace
IIdle kernel threadSafe 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.

SignalNo.DefaultPractical use
SIGHUP1TermConventionally a config reload. Originally terminal hangup
SIGINT2TermKeyboard interrupt
SIGQUIT3CoreTriggers a core dump. The JVM prints a thread dump
SIGKILL9TermCannot be caught, blocked, or ignored
SIGUSR110TermApplication defined. nginx reopens its logs
SIGUSR212TermApplication defined. nginx swaps its binary
SIGPIPE13TermWriting to a pipe with no reader
SIGTERM15TermA polite request to exit. The default kill signal
SIGCHLD17IgnA child exited, stopped, or resumed
SIGCONT18ContResume a stopped process
SIGSTOP19StopCannot be caught, blocked, or ignored
SIGTSTP20StopStop 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/pkill do pattern matching, and with -f they match against the full command line. Without -f they only look at the process name, which is usually truncated to 15 characters.
  • killall matches the exact name. Be aware that on some Unix systems (Solaris and others) killall means something entirely different. If portability matters, pkill is 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
  • nohup sets SIGHUP to be ignored and then runs the command. It still belongs to the same session.
  • setsid creates a new session, severing the relationship with the controlling terminal entirely. It is more reliable.
  • disown -h removes an already-running job from the shell list that receives propagated SIGHUP.
  • systemd-run runs 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
  • nice values run from -20 (highest priority) to 19 (lowest). Lowering it into negative values requires root.
  • ionice -c 3 is 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.
  • chrt deals 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


Further reading