셸 프로세스·잡·시그널: 종료 코드 130/137/143 읽는 법

ps·kill·trap·백그라운드 잡·wait·nohup 을 bash 3.2 와 zsh 5.9 에서 실행. kill -INT 가 안 먹는 이유, 남는 자식 프로세스, xargs -P 실측까지.

시리즈: 커맨드라인 강좌 5/6 (고급). 스크립트로 오래 걸리는 작업을 돌리는 개발자가 작업을 병렬로 띄우고, 깔끔하게 멈추고, 137 같은 종료 코드가 무슨 말을 하는지 읽을 수 있게 되는 회차입니다.

배치를 멈췄는데 자식 프로세스만 살아남아 다음 실행과 겹쳤다, Ctrl-C 대신 보낸 kill -INT 가 듣지 않았다, 젠킨스나 컨테이너 로그에 exit code 137 한 줄만 남았다, 코어가 8개인데 작업이 순서대로 돌아 15초가 걸린다. 이런 문제를 겪는 분을 위한 글입니다. 프로세스 부모·자식 관계를 보고, 직접 띄운 프로세스에만 주요 시그널을 보내 종료 코드를 읽고, xargs -P 가 실제로 얼마나 빨라지는지 잽니다.

환경: macOS 26, Apple Silicon Mac(M1 Pro, 8코어), zsh 5.9 와 GNU bash 3.2.57. BSD 판 ps, kill, pgrep, xargs. GNU/리눅스 형식은 "여기서는 실행하지 않음"으로 표시합니다. 작업 폴더는 ~/demo/jobs. 처음부터 끝까지 지킨 규칙이 하나 있습니다. 시그널은 기록해 둔 PID 로 보내고, 이름으로 보내지 않는다. pkill sleep 은 같은 머신에서 도는 남의 sleep 까지 죽입니다. 공용 개발 서버라면 동료의 작업일 수도 있습니다.

$? 와 set -e 는 4강에서 다뤘습니다. 신입 개발자용 전체 지도는 학습 로드맵에 있습니다.

프로세스, PID, ps

실행 중인 프로그램은 모두 프로세스이고 번호(PID)를 가집니다. 첫 번째를 빼면 모든 프로세스에 부모가 있고, 부모 번호가 PPID 입니다. 셸도 프로세스이고, 셸이 실행하는 명령은 셸의 자식입니다. 필요한 번호는 셸 변수로 얻습니다.

  • $$: 현재 셸의 PID(bash 3.2 에서는 ( 서브셸 ) 안에서도 부모 번호 그대로. BASHPID 는 bash 4.0 부터라 여기서는 unset 이었습니다)
  • $!: 마지막으로 백그라운드에 띄운 잡의 PID
  • $?: 바로 앞 명령의 종료 코드

백그라운드로 sleep 세 개를 띄워 봅니다.

# sig.sh (앞부분)
sleep 300 & p1=$!
sleep 300 & p2=$!
sleep 300 & p3=$!
ps -o pid,ppid,stat,command -p "$p1,$p2,$p3"
## bash
  PID  PPID STAT COMMAND
 3312  3310 S    sleep 300
 3313  3310 S    sleep 300
 3314  3310 S    sleep 300
## zsh
  PID  PPID STAT COMMAND
 3584  3581 SN   sleep 300
 3585  3581 SN   sleep 300
 3586  3581 SN   sleep 300

S 는 잠자는 중. zsh 쪽의 N 은 의외의 발견이었습니다. zsh 의 BG_NICE 옵션이 기본으로 켜져 있어 스크립트 안에서도 백그라운드 잡의 우선순위를 낮춥니다. bash 는 낮추지 않습니다. zsh 에서 병렬 작업이 느리게 느껴진다면 이유 중 하나입니다.

ps -o ... -p PID 는 BSD 형식으로 macOS 에서 동작합니다. 리눅스에서는 ps -o pid,ppid,stat,cmd -p ... 나 ps -ef --forest 를 흔히 씁니다(여기서는 실행하지 않음). pgrep -P PID 는 그 PID 의 자식을 나열하므로, 이름 없이 "내 스크립트가 띄운 프로세스"를 찾을 수 있습니다.

시그널과 128+N 규칙

시그널은 커널이 프로세스에 전달하는 작은 알림입니다. "멈춰 주세요", "지금 당장 멈춰", "터미널이 사라졌다" 같은 것들입니다. 대부분은 프로세스가 잡아서 핸들러를 돌릴 수 있지만 KILL 과 STOP 은 잡을 수 없습니다. 시그널 N 으로 죽은 프로세스에 대해 셸은 종료 코드 128 + N 을 보고합니다. sig.sh 의 나머지입니다.

kill -TERM "$p1"; wait "$p1"; echo "TERM -> wait status $?"
kill -KILL "$p2"; wait "$p2"; echo "KILL -> wait status $?"
kill -INT "$p3"; sleep 0.5; kill -0 "$p3" && echo "INT ignored: $p3 still alive"
kill -TERM "$p3"; wait "$p3"; echo "then TERM -> $?"
kill -0 "$p1"; echo "kill -0 on finished pid -> $?"
## bash
sig.sh: line 5:  3312 Terminated: 15          sleep 300
TERM -> wait status 143
sig.sh: line 6:  3313 Killed: 9               sleep 300
KILL -> wait status 137
INT ignored: 3314 still alive
sig.sh: line 8:  3314 Terminated: 15          sleep 300
then TERM -> 143
sig.sh: line 9: kill: (3312) - No such process
kill -0 on finished pid -> 1
## zsh
TERM -> wait status 143
KILL -> wait status 137
INT ignored: 3586 still alive
then TERM -> 143
sig.sh:kill:9: kill 3584 failed: no such process
kill -0 on finished pid -> 1

kill -0 PID 는 아무것도 보내지 않고, 프로세스가 있는지와 시그널을 보낼 권한이 있는지만 확인합니다. "아직 돌고 있나?"를 묻는 표준 방법이고, 6강에서 오래된 잠금을 판별할 때 씁니다.

이 실행과 추가 확인(SIGPIPE 는 yes | head -1, SIGHUP 은 nohup)으로 정리한 표입니다.

시그널 번호(macOS) 기본 동작 trap 가능 관측한 종료 코드 흔한 원인
HUP 1 종료 가능 129 터미널 닫힘, SSH 끊김
INT 2 종료 가능 130 Ctrl-C
KILL 9 종료 불가 137 kill -9, 메모리 부족 강제 종료, 타임아웃 뒤 컨테이너 런타임
PIPE 13 종료 가능 141 닫힌 파이프에 쓰기(yes | head -1)
TERM 15 종료 가능 143 kill PID, docker stop, 서비스 관리자
STOP / CONT 17 / 19 일시정지·재개 불가 / 가능 — kill -STOP, 멈춘 동안 ps 에 T

시그널 번호가 전부 이식 가능한 것은 아닙니다. kill -l USR1 은 macOS 에서 30 이었고 리눅스는 10 입니다(여기서는 실행하지 않음). 1, 2, 9, 15 외에는 반드시 이름(kill -TERM, trap ... TERM)으로 씁니다.

흐름도. 스크립트에 시그널이 보내지면 어떤 시그널인지로 갈린다. INT·HUP·TERM 이면 trap 이 돌고 종료 코드는 각각 130·129·143. KILL 이면 trap 도 정리도 없이 137. 스크립트의 백그라운드 잡에 보낸 INT 는 무시되고 잡은 계속 돈다.
스크립트에 시그널이 도착했을 때의 흐름. 종료 코드는 128 + 시그널 번호이고, 정리용 trap 을 건너뛰는 것은 KILL 뿐이며, 백그라운드 잡은 INT 를 무시한다.

흔한 실수: 백그라운드 잡에 kill -INT

위의 INT ignored: ... still alive 에 대부분 놀랍니다. 실제로 일어나는 일: 비대화형 셸(스크립트)이 & 로 잡을 띄우면 그 자식의 SIGINT 와 SIGQUIT 를 "무시"로 설정합니다. 터미널의 Ctrl-C 가 포그라운드에만 닿게 하려는 것입니다. 시작할 때 무시된 시그널은 나중에 되돌릴 수 없어서, 자식 안에서 trap 을 걸어도 잡히지 않습니다.

더 이상한 것은 & 직후에 보냈을 때였습니다.

# 각각 3번
bash -c 'sleep 30 & p=$!; sleep 0.2; kill -INT $p; sleep 0.2; kill -0 $p 2>/dev/null && echo "alive after INT" || echo dead; ...'
bash -c 'sleep 30 & p=$!;            kill -INT $p; sleep 0.2; kill -0 $p 2>/dev/null && echo "nosleep: alive" || echo "nosleep: dead"; ...'
alive after INT
alive after INT
alive after INT
nosleep: dead
nosleep: dead
nosleep: dead

기다리지 않으면 fork() 뒤 자식이 "무시"를 설정하기 전의 짧은 틈에 시그널이 도착해 130 으로 죽었습니다. 경쟁 상태입니다. "빨리 보내서 되는" 스크립트는 바쁜 서버에서 깨집니다. 해결: 백그라운드 잡에는 TERM 을 보냅니다. 무시되지 않고, trap 으로 정리할 수 있고, 서비스 관리자도 TERM 을 보냅니다.

참고로 정말 SIGINT 로 죽은 프로세스는 130 을 돌려줍니다. sh -c 'kill -INT $$' 가 130 이었고, 스스로 인터럽트한 파이썬 원라이너도(KeyboardInterrupt 를 찍고) 130 이었습니다.

백그라운드 잡, wait, 종료 코드 모으기

cmd & 는 바로 돌아오고, wait PID 는 그 잡이 끝나길 기다려 그 잡의 종료 코드를 돌려줍니다. 인자 없는 wait 는 모든 잡을 기다리지만 0 을 돌려주며 코드를 버립니다.

# waitall.sh
job() { sleep "$1"; return "$2"; }
job 0.3 0 & a=$!
job 0.1 3 & b=$!
job 0.2 0 & c=$!
fail=0
for p in $a $b $c; do
  if wait "$p"; then echo "pid $p ok"; else rc=$?; echo "pid $p failed rc=$rc"; fail=1; fi
done
wait; echo "plain wait returns $? (exit codes lost)"
exit $fail
pid 20922 ok
pid 20923 failed rc=3
pid 20926 ok
plain wait returns 0 (exit codes lost)
script exit=1

zsh 5.9 도 같았습니다. 가장 먼저 끝난 b(0.1초)를 두 번째로 기다렸는데도 올바르게 보고됐습니다. 셸은 끝난 자식의 상태를 거둬 갈 때까지 보관합니다. bash 4.3 에서 "다음에 끝나는 것을 기다리는" wait -n 이 생겼지만 bash 3.2 에서는 wait: -n: invalid option 이었습니다. 기본 맥에서는 위처럼 PID 를 반복문으로 기다립니다.

trap: 정리 작업, 그리고 남는 자식 프로세스

trap '명령' 시그널 은 셸이 시그널을 받으면 명령을 실행하고, 가짜 시그널 EXIT 은 셸이 끝날 때 항상 실행됩니다. 먼저 잘못된 버전입니다.

# trap.sh
tmp=$(mktemp "${TMPDIR:-/tmp}/demo.XXXXXX")
cleanup() { rm -f "$tmp"; echo "cleanup: removed temp file"; }
trap cleanup EXIT
trap 'echo "got TERM"; exit 143' TERM
echo "working, pid $$"
sleep 300 &
wait $!
$ bash trap.sh & tp=$!; sleep 0.5; kill -TERM $tp; wait $tp; echo "trap.sh exit=$?"
working, pid 8570
got TERM
cleanup: removed temp file
trap.sh exit=143
$ ps -o pid,ppid,command -p 8576
  PID  PPID COMMAND
 8576     1 sleep 300

스크립트는 임시 파일을 지우고 143 으로 끝났으니 설계대로입니다. 그런데 자식 sleep 300 은 PPID 1 로 계속 돌았습니다. launchd(리눅스는 init/systemd)에 입양된 것입니다. 시그널은 프로세스 하나에 가고 자식에게는 가지 않습니다. 실무에서 이 자식은 rsync, curl, 파이썬 워커이고, 다음 실행 때 두 번째 사본이 뜹니다(이 고아 프로세스는 PID 를 지정해 멈췄습니다).

해결은 자식 PID 를 기억해 두고 정리 단계에서 멈추는 것입니다.

# trap2.sh
tmp=$(mktemp "${TMPDIR:-/tmp}/demo.XXXXXX")
child=""
cleanup() {
  [ -z "$child" ] || kill "$child" 2>/dev/null
  rm -f "$tmp"; echo "cleanup: removed temp file, stopped child"
}
trap cleanup EXIT
trap 'echo "got TERM"; exit 143' TERM
sleep 300 & child=$!
wait "$child"
  PID  PPID STAT COMMAND
99609 99607 SN   bash trap2.sh
99613 99609 SN   sleep 300
got TERM
cleanup: removed temp file, stopped child
trap2.sh exit=143
child still alive? no

왜 그냥 sleep 300 이 아니라 sleep 300 & wait $! 일까요. bash 는 trap 을 포그라운드 명령이 끝난 뒤에 실행하기 때문입니다. 포그라운드 sleep 중에 온 TERM 은 sleep 이 끝날 때까지 기다리지만, wait 는 바로 중단됩니다. 6강에서 이것을 잽니다(포그라운드 sleep 5 에서 4초 지연, & wait 에서 0초).

nohup, SIGHUP, 터미널을 닫을 때

터미널이나 SSH 세션이 닫히면 거기서 돌던 프로세스에 SIGHUP 이 갑니다. nohup 은 SIGHUP 을 무시한 상태로 명령을 띄웁니다.

# hup.sh
sleep 300 & plain=$!
nohup sleep 300 & guarded=$!
sleep 0.3
kill -HUP "$plain" "$guarded"; sleep 0.3
for p in "$plain" "$guarded"; do
  if kill -0 "$p" 2>/dev/null; then echo "$p alive after HUP"; else wait "$p"; echo "$p died, status $?"; fi
done
kill "$guarded"; wait "$guarded"; echo "guarded after TERM: $?"
ls nohup.out
hup.sh: line 4: 20654 Hangup: 1               sleep 300
20654 died, status 129
20655 alive after HUP
hup.sh: line 8: 20655 Terminated: 15          nohup sleep 300
guarded after TERM: 143
ls: nohup.out: No such file or directory

세부 사항 두 가지. nohup 이 막아 주는 것은 HUP 뿐입니다(TERM 에는 143 으로 죽었습니다). 그리고 nohup.out 은 표준 출력이 터미널일 때만 생깁니다. 출력이 이미 리다이렉트된 스크립트에서는 파일이 안 생기므로 nohup cmd > job.log 2>&1 & 로 직접 지정합니다. 재부팅 뒤에도 돌아야 하는 것은 nohup 이 아니라 서비스 관리자(macOS 는 launchd, 리눅스는 systemd)에 맡깁니다.

SIGPIPE, 141, pipefail

직접 보내지 않아도 만나는 시그널이 하나 더 있습니다.

$ bash -c 'yes | head -1; echo "PIPESTATUS=${PIPESTATUS[*]}"'
y
PIPESTATUS=141 0
$ bash -c 'set -o pipefail; yes | head -1 >/dev/null; echo "pipefail rc=$?"'
pipefail rc=141

head 는 한 줄 읽고 끝나고, yes 는 닫힌 파이프에 쓰다가 SIGPIPE(141)로 죽습니다. 정상이고 무해하지만 set -o pipefail(4강) 아래에서는 파이프라인 전체가 141 로 "실패"합니다. strict 모드 스크립트가 ... | head 에서 멈춘다면 이것 때문입니다. PIPESTATUS(bash)나 pipestatus(zsh, 역시 141 0)를 보고 앞쪽의 141 은 허용합니다.

실측: xargs -P 로 얼마나 빨라지나

xargs -P N 은 명령을 최대 N 개까지 동시에 돌립니다. awk 의 srand(42) 로 만든 로그 파일 16개(각 약 11MB, 합계 176MB)를 gzip -9 로 압축했고, 따로 sleep 0.5 만 하는 작업도 16개 돌렸습니다.

printf '%s\0' in/*.log | xargs -0 -P "$P" -n 1 sh -c 'gzip -9 -c "$1" > /dev/null' _
-P gzip -9 ×16 (3회 중앙값) 배속 sleep 0.5 ×16 (1회)
1 15.59초 1.0배 8.19초
2 7.86초 2.0배 —
4 4.19초 3.7배 2.05초
8 3.89초 4.0배 —
16 3.96초 3.9배 0.54초
gzip 16건 전체 시간의 막대그래프. -P 1: 15.59초. -P 2: 7.86초. -P 4: 4.19초. -P 8: 3.89초. -P 16: 3.96초.
약 11MB 파일 16개를 xargs -P 로 gzip -9. 8코어 M1 Pro(다른 작업도 실행 중)에서 3회 중앙값. 코어 수 근처에서 더 빨라지지 않는다.

숫자가 말해 주는 것:

  • 대기 위주 작업(네트워크 호출, sleep)은 거의 이상적으로 늘어 16개 동시 실행이 0.54초, 약 15배였습니다.
  • CPU 작업은 4개까지 잘 늘다가 평평해졌습니다. M1 Pro 는 8코어(성능 6, 효율 2)이고 측정 중 다른 작업도 돌고 있어서(로드 평균 8.6~12.8) 8개의 gzip 이 빈 코어 8개를 얻지 못했습니다. -P 8 은 6회 동안 2.53~4.64초로 흔들렸습니다(처음 3회는 ls in/*.log | xargs -P 8 -n 1 ..., 나중 3회는 위의 printf ... -0 형식. 파일 이름에 공백이 없어 넘어가는 인자는 같습니다).
  • 코어 수를 넘는 16개는 빨라지지 않고 메모리만 더 썼습니다.

에러 처리 메모: 16개 중 하나가 실패하면 macOS 의 BSD xargs 는 1 로 끝났습니다(man 페이지도 "기타 에러"는 1). GNU xargs 는 "명령이 1~125 로 끝남"에 123 을 씁니다(여기서는 실행하지 않음). 123 을 검사하는 스크립트는 맥에서 실패를 놓칩니다.

어떤 시그널, 어떤 도구

하고 싶은 것 쓸 것 이유
정리하고 멈춰 달라고 요청 kill -TERM PID trap 가능, 서비스 관리자도 TERM
TERM 뒤 몇 초가 지나도 안 멈춤 kill -KILL PID 잡을 수 없음, 정리 안 됨(137)
PID 가 살아 있는지 확인 kill -0 PID 아무것도 보내지 않음
일시정지·재개 kill -STOP / kill -CONT 멈춘 동안 ps 에 T
터미널을 닫아도 계속 nohup cmd > log 2>&1 & HUP 만 무시
N 개를 동시에 xargs -0 -P N, 또는 & + wait PID wait PID 는 각 종료 코드를 남김
내 자식 프로세스 찾기 pgrep -P $$ 이름으로 찾지 않음

솔직한 한계와 버전 메모

  • 실행은 macOS(bash 3.2.57, zsh 5.9)에서만 했습니다. timeout, setsid, flock 은 macOS 에 설치되어 있지 않습니다(command -v 로 하나도 찾지 못했습니다). 리눅스의 timeout 10 cmd 는 시간 초과 시 124 를 돌려줍니다(여기서는 실행하지 않음).
  • 128+N 은 bash 와 zsh 가 보고하는 값입니다. Docker 나 쿠버네티스의 "137" 은 컨테이너 메인 프로세스가 SIGKILL 을 받았다는 뜻이고 메모리 부족이 원인인 경우가 많지만, 그것은 각 도구의 관례입니다(여기서는 실행하지 않음).
  • wait -n(bash 4.3)과 BASHPID(bash 4.0)는 bash 3.2 에 없고 에러를 확인했습니다. 버전 정보는 bash NEWS 기준이고 bash 4/5 는 실행하지 않았습니다.
  • 벤치마크 머신은 다른 작업과 함께 쓰였습니다. 중앙값과 범위를 적었고, sleep 측정은 CPU 부하의 영향을 받지 않습니다.

연습 문제

  1. 스크립트가 ./worker.sh & 로 띄우고 나중에 kill -INT $! 로 멈추려 했는데 worker 가 계속 돕니다. 왜 그럴까요? 대신 무엇을 보내야 할까요?
  2. CI 의 한 단계는 exit code 143, 다른 단계는 137 로 끝났습니다. 각각 무슨 일이 있었고, 정리용 trap 이 돌 수 있었던 쪽은 어디일까요?

정답

  1. 비대화형 셸의 백그라운드 잡은 SIGINT 를 무시하므로 시그널이 버려집니다. kill -TERM "$pid" 를 보내고 wait "$pid" 로 기다립니다(핸들러가 없으면 143).
  2. 143 = 128 + 15, SIGTERM 을 받았습니다(타임아웃이나 취소로 인한 정상적인 중지). trap ... TERM 이나 trap ... EXIT 이 돌 수 있습니다. 137 = 128 + 9, SIGKILL 입니다. TERM 이 무시된 뒤이거나 메모리 부족 강제 종료인 경우가 많고, trap 은 돌지 않습니다.

이전: 4강 변수·따옴표·종료 코드 · 다음: 6강 튼튼한 스크립트·cron·로그 · 그 전: 3강 find·xargs·sed·awk

자주 묻는 질문

종료 코드 130, 137, 143 은 무슨 뜻인가요?

128 + 시그널 번호입니다. 130 은 SIGINT(2, 보통 Ctrl-C), 137 은 SIGKILL(9, kill -9 나 메모리 부족 강제 종료), 143 은 SIGTERM(15, 일반 kill)입니다. 백그라운드 sleep 에 각각 보내고 wait 로 세 값을 모두 확인했습니다. 129(SIGHUP), 141(SIGPIPE)도 같은 규칙입니다.

스크립트에서 띄운 백그라운드 잡에 kill -INT 가 왜 안 먹나요?

비대화형 셸은 & 로 띄운 잡의 SIGINT 와 SIGQUIT 를 무시하도록 설정하기 때문입니다. 띄우고 0.2초 뒤 보낸 kill -INT 에는 세 번 모두 잡이 살아남았습니다. fork 직후 바로 보내면 자식이 무시를 설정하기 전에 도착해 죽었습니다(경쟁 상태). 백그라운드 잡에는 SIGTERM 을 보냅니다.

xargs -P 로 병렬화하면 얼마나 빨라지나요?

약 11MB 파일 16개 gzip -9 에서 -P 1 의 15.59초가 -P 2 에서 7.86초, -P 4 에서 4.19초, -P 8 에서 3.89초가 됐습니다(다른 작업도 돌던 8코어 M1 Pro, 3회 중앙값). 대기 위주 작업(sleep 0.5)은 -P 16 이 -P 1 보다 15배 빨랐습니다.

  • #셸
  • #bash
  • #zsh
  • #시그널
  • #프로세스
  • #xargs
  • #고급

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑