셸 프로세스·잡·시그널: 종료 코드 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 까지 죽입니다. 공용 개발 서버라면 동료의 작업일 수도 있습니다.
프로세스, 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)으로 씁니다.
흔한 실수: 백그라운드 잡에 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초 |
숫자가 말해 주는 것:
- 대기 위주 작업(네트워크 호출, 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 부하의 영향을 받지 않습니다.
연습 문제
- 스크립트가
./worker.sh &로 띄우고 나중에kill -INT $!로 멈추려 했는데 worker 가 계속 돕니다. 왜 그럴까요? 대신 무엇을 보내야 할까요? - CI 의 한 단계는
exit code 143, 다른 단계는137로 끝났습니다. 각각 무슨 일이 있었고, 정리용 trap 이 돌 수 있었던 쪽은 어디일까요?
정답
- 비대화형 셸의 백그라운드 잡은 SIGINT 를 무시하므로 시그널이 버려집니다.
kill -TERM "$pid"를 보내고wait "$pid"로 기다립니다(핸들러가 없으면 143). - 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배 빨랐습니다.