오전 10시. 슬랙에 긴급 메시지가 왔다. "API 서버가 갑자기 엄청 느려졌어요. CPU가 97%인데 뭐가 먹는지 모르겠어요. 재시작하면 될까요?"
재시작은 마지막 수단입니다. 원인을 모른 채 재시작하면 30분 후에 또 같은 일이 생깁니다. SSH로 접속해서 무엇이 CPU를 먹는지, 좀비가 쌓여 있는지, 파일 디스크립터가 새고 있는지 — 재시작 없이 찾는 방법을 배웁니다.
- 1ps aux와 top으로 CPU 과점유 프로세스를 30초 안에 특정할 수 있다
- 2ps aux의 STAT 컬럼(R/S/D/Z)을 읽고 프로세스 이상 징후를 판단할 수 있다
- 3좀비 프로세스의 원인과 해결 방법을 설명하고 직접 정리할 수 있다
- 4lsof로 파일 디스크립터 누수를 진단하고 한도 설정을 확인할 수 있다
- 5SIGTERM과 SIGKILL의 차이를 알고 프로세스를 안전하게 종료하는 순서를 따를 수 있다
프로세스 상태 코드 — STAT 컬럼 읽기
ps aux의 STAT 컬럼은 프로세스의 현재 상태를 한 글자로 나타냅니다. 이 코드를 읽으면 "왜 느린지"에 대한 첫 번째 단서를 얻을 수 있습니다.
| 상태코드 | 의미 | 장애 신호 |
|---|---|---|
| R | CPU에서 실행 중 또는 실행 대기 | 하나가 100% 점유하면 문제 |
| S | I/O 대기 (인터럽트 가능, 정상) | 대부분의 프로세스가 이 상태 |
| D | I/O 대기 (인터럽트 불가) | NFS 행, 디스크 불량 의심 |
| Z | 좀비 — 종료됐지만 부모가 회수 안 함 | 수백 개 쌓이면 PID 테이블 고갈 |
| T | 정지됨 (Ctrl+Z 또는 SIGSTOP) | 잊힌 백그라운드 프로세스 확인 |
STAT 뒤에 붙는 수식어도 중요합니다: s = 세션 리더, + = 포어그라운드, l = 멀티스레드, < = 높은 우선순위, N = nice 적용(낮은 우선순위)
확대
프로세스가 태어나는 법 — fork·exec 4단계
앞의 STAT 표에서 좀비(Z)는 "종료됐지만 부모가 회수 안 함", 고아는 "부모가 먼저 죽음"이라고 했습니다. 왜 이런 상태가 생기는지는 프로세스가 어떻게 태어나고 죽는지를 알아야 이해됩니다. 리눅스에서 새 프로세스는 무에서 생기지 않고 항상 부모를 복제(fork)한 뒤 다른 프로그램으로 갈아끼우는(exec) 방식으로 만들어집니다. bash에서 ls를 치는 것도, 컨테이너에서 앱이 뜨는 것도 모두 이 4단계 흐름의 결과입니다.
[부모] bash (PID 2000)
│
│ ① fork() ── 부모를 통째로 복제. 메모리는 Copy-on-Write로
│ 일단 공유하다 어느 쪽이 쓰면 그 페이지만 복사
▼
├──────────────────────────────┐
부모(PID 2000) 자식(새 PID 2001)
│ │
│ │ ② exec() ── 자기 프로세스 이미지를
│ │ /bin/ls 로 통째 교체
│ │ (PID는 그대로, 내용만 바뀜)
│ │
│ ③ wait() 로 자식 종료를 대기 │ ④ exit(code) ── 일 끝나면 종료코드 반환
│ ◄──────────────────┘
│ 종료코드 수거 → 자식을 프로세스 테이블에서 제거(완전 소멸)
▼
[부모 계속 실행]
각 단계가 하는 일과, 어긋나면 어떤 증상인가:
| 단계 | 하는 일 | 여기서 어긋나면 |
|---|---|---|
① fork() | 부모가 자신을 복제해 자식을 만든다. 주소공간은 Copy-on-Write로 일단 공유하다 어느 쪽이 쓰는 순간 그 페이지만 복사 — 그래서 복제가 싸고 빠르다 | fork 폭주(fork bomb)로 PID·메모리 고갈 → Resource temporarily unavailable |
② exec() | 자식이 execve()로 자기 프로세스 이미지를 실제 실행할 프로그램(/bin/ls 등)으로 덮어쓴다. PID는 유지되고 코드·데이터만 교체 | 실행 파일 없음·권한 없음 → command not found, Permission denied |
③ wait() | 부모가 wait()/waitpid()로 자식의 종료 코드를 수거하고 프로세스 테이블에서 항목을 제거 | 부모가 수거를 안 함 → 자식이 좀비(Z, <defunct>)로 잔존 |
④ exit() | 자식이 종료 코드를 커널에 반환하고 자원을 반납. 부모가 거둘 때까지 "종료 상태"만 테이블에 남는다 | 부모가 자식보다 먼저 죽음 → 자식은 고아가 되어 PID 1(systemd)이 입양 |
좀비와 고아는 이 흐름의 두 가지 어긋남입니다. 좀비(Z)는 ③이 빠진 경우 — 자식은 이미 exit했는데(④) 부모가 wait로 거두지 않아, 자원은 거의 없이 종료 코드만 프로세스 테이블에 남습니다. 이미 죽었으니 kill이 통하지 않고, 부모가 wait하거나 부모가 사라져야만 정리됩니다. 고아는 ③보다 먼저 부모가 죽은 경우 — 이때 자식은 PID 1(systemd)에게 입양되고, PID 1은 주기적으로 wait하는 특별한 역할을 하므로 입양된 자식이 종료되면 곧바로 거둬 좀비가 영구화되지 않게 합니다. 그래서 "좀비가 안 사라진다"의 해결책은 좀비를 kill하는 게 아니라 부모를 재시작해 PID 1이 입양·회수하게 만드는 것입니다.
프로세스는 이 사이에서 STAT 상태를 오갑니다 — fork/exec 직후 실행 가능해지면 R, I/O를 기다리며 잠들면 S(깨울 수 있음)·D(디스크 등 인터럽트 불가), 정지 신호를 받으면 T, exit 후 미수거 상태가 Z입니다. ps -ef --forest로 부모-자식(PPID) 관계를 트리로 보면, 어느 부모가 자식을 안 거두고 있는지(좀비의 원인)를 바로 짚을 수 있습니다.
장애 접수 후 첫 번째로 실행하는 명령입니다. CPU를 가장 많이 소비하는 프로세스 10개를 내림차순으로 정렬합니다.
--sort=-%cpu의 -는 내림차순입니다. 메모리 기준으로 보려면 --sort=-%mem을 씁니다.
ps aux --sort=-%cpu | head -10- %CPU 컬럼 — 상위 프로세스가 50% 이상을 점유하고 있는가
- STAT 컬럼 — R 상태(실행 중)인 프로세스가 여러 개인가, 한 개인가
- PID와 COMMAND — 어떤 프로세스인지, 언제부터 실행됐는지 (TIME 컬럼으로 누적 CPU 시간 확인)
- USER 컬럼 — 서비스 계정(www-data, node 등)인지 예상치 못한 계정인지 확인
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
www-data 7823 94.3 2.1 512340 43012 ? R 09:47 12:31 node /app/server.js
root 892 1.2 0.3 65432 6108 ? Ss 03:21 0:12 sshd: /usr/sbin/sshd
postgres 1204 0.8 1.4 412340 28890 ? Ss 03:21 1:04 postgres: checkpointer
www-data 7824 0.3 0.2 85340 4120 ? S 09:47 0:02 node /app/worker.js
$8은 STAT 컬럼(8번째 필드)입니다. Z를 포함한 행만 출력합니다.
결과가 비어 있으면 좀비 없음 — 정상입니다. 출력이 있다면 개수와 어떤 프로세스에서 발생했는지 확인합니다.
ps aux | awk '$8 ~ /Z/'- 결과가 없으면 좀비 프로세스 없음 — 이 원인은 제외
- 결과가 있으면 PPID(부모 PID)를 확인한다 — 좀비 자체가 아니라 부모 프로세스가 문제의 원인
- COMMAND에 <defunct> 표시가 있으면 확정 좀비이다
- 좀비 10개 이하는 일반적으로 즉각 위험하지 않지만 수백 개라면 PID 테이블 고갈 위험
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
www-data 8012 0.0 0.0 0 0 ? Z 09:52 0:00 [node] <defunct>
www-data 8013 0.0 0.0 0 0 ? Z 09:52 0:00 [node] <defunct>
lsof -p <PID>로 해당 프로세스가 현재 열어둔 파일, 소켓, 파이프 전체를 조회하고 개수를 셉니다. 정상 프로세스는 수십~수백 개 수준입니다. 수천 개 이상이면 FD 누수를 의심합니다.
lsof -p 7823 | wc -l- 100개 이하: 정상
- 수백 개: 활성 연결이 많은 경우로 트래픽에 비례하면 정상일 수 있음
- 수천 개 이상: FD 누수 강력 의심 — ulimit -n 한도 확인 필요
- lsof -p <PID> | grep socket | wc -l — 소켓만 따로 세어 연결 폭발 여부 확인
# 현재 한도 확인
ulimit -n
cat /proc/7823/limits | grep "open files"
# 어떤 타입의 FD가 많은지 분류
lsof -p 7823 | awk '{print $5}' | sort | uniq -c | sort -rn | head -10
원인을 충분히 파악했을 때 먼저 SIGTERM으로 정상 종료를 요청합니다. 프로세스가 핸들러를 등록했다면 DB 연결 정리, 캐시 플러시 등을 실행하고 종료합니다.
10초 이상 기다려도 살아있으면 그때 SIGKILL을 사용합니다.
kill -SIGTERM 7823- kill -SIGTERM 후 5~10초 대기 — ps aux | grep 7823으로 종료 여부 확인
- 종료됐다면 서비스 재시작 후 CPU 사용률이 정상화됐는지 top으로 확인
- 여전히 살아있다면 kill -0 7823 2>/dev/null && kill -SIGKILL 7823 으로 강제 종료
SIGKILL (강제 종료) — 마지막 수단
안전한 실행 조건: SIGTERM 10초 이상 대기 후에도 프로세스가 살아있을 때만
실행 전 반드시 확인
- SIGTERM으로 먼저 정상 종료를 시도했는가
- 10초 이상 기다렸는가
- 데이터 손실 가능성을 인지했는가 (DB 트랜잭션, 캐시, 파일 쓰기 등)
- 서비스 복구 방법(재시작 방법)을 확인했는가
kill -9 7823위 항목을 모두 확인한 후 복사할 수 있습니다
실제 출력
USER PID %CPU %MEM STAT COMMAND www-data 7823 93.1 2.1 R node /app/server.js www-data 8012 0.0 0.0 Z [node] <defunct> www-data 8013 0.0 0.0 Z [node] <defunct> www-data 8014 0.0 0.0 Z [node] <defunct> $ lsof -p 7823 | wc -l 4821 $ ulimit -n 1024
❓ CPU 93%인 node 프로세스(7823)와 좀비 3개가 동시에 발견됐습니다. 무엇을 먼저 조치해야 하고, 그 이유는 무엇인가요?
좀비 프로세스에 kill -9를 해도 사라지지 않습니다. 정상입니다 — 좀비에게 어떤 시그널도 보낼 수 없습니다.
이유: 좀비는 이미 종료된 프로세스입니다. 커널이 부모 프로세스가 wait()를 호출할 때 전달할 종료 코드만 프로세스 테이블에 남겨둔 것입니다. "살아있는" 프로세스가 아니므로 시그널을 받을 수 없습니다.
해결 방법:
# 1. 좀비의 부모 PID 확인
ps -o ppid= -p <ZOMBIE_PID>
# 또는
ps -el | grep Z | awk '{print $4, $5}' # PPID PID
# 2. 부모에게 SIGCHLD 전송 (wait() 유도)
kill -SIGCHLD <PARENT_PID>
# 3. 그래도 안 되면 부모 프로세스 재시작
# 부모가 죽으면 좀비는 PID 1(systemd)에 입양되고 즉시 회수됨
systemctl restart <서비스명>
# 4. 개수 확인
ps aux | awk '$8 ~ /Z/' | wc -l
예방: 애플리케이션 코드에서 자식 프로세스를 생성하는 경우 SIGCHLD 핸들러를 등록하거나 waitpid(-1, WNOHANG)를 주기적으로 호출해야 합니다.
ps aux에서 %CPU가 낮은데 서버 응답이 느립니다. STAT 컬럼에 D가 많이 보입니다.
D 상태(Uninterruptible Sleep)의 특징:
- 인터럽트 불가능한 I/O 대기 — 디스크, NFS, 블록 디바이스를 기다리는 중
- SIGKILL로도 종료되지 않음 (커널이 다음 스케줄링 때 처리하는데, D 상태에서는 스케줄링 기회 자체가 없음)
- 다수 발생 시 시스템 전체가 I/O에 막힌 상태
# D 상태 프로세스 확인
ps aux | awk '$8 == "D"'
# 어디서 I/O를 기다리는지 확인
cat /proc/<PID>/wchan # 커널에서 어떤 wait 함수에 있는지
# NFS 마운트가 원인인지 확인
mount | grep nfs
# NFS가 있다면: NFS 서버 네트워크 연결 상태 확인
# 디스크 I/O 상태
iostat -x 1 5
dmesg | tail -30 | grep -i "error\|failed\|timeout"
해결: NFS면 umount -l -f /mnt/share로 강제 해제. 디스크 하드웨어 오류면 dmesg에 disk error 로그가 남습니다. I/O 자체 문제라면 서버 재시작이 유일한 해결책인 경우도 있습니다.
컨테이너 안의 프로세스 — PID 네임스페이스와 kill 1의 함정
같은 프로세스라도 호스트와 컨테이너에서 보이는 PID가 다릅니다. 이걸 모르면 엉뚱한 PID를 죽여 다른 컨테이너나 호스트를 통째로 내릴 수 있기 때문에, 컨테이너 환경에서 프로세스를 다루기 전에 반드시 짚어야 합니다.
컨테이너는 PID 네임스페이스로 격리되어, 컨테이너 안에서 ps aux를 하면 그 컨테이너의 메인 프로세스가 PID 1로 보입니다. 하지만 호스트에서 보면 같은 프로세스가 847 같은 다른 번호를 가집니다. 그래서 컨테이너 내부에서 kill 1은 그 컨테이너만 종료하지만, 호스트에서 kill 1은 systemd를 죽여 시스템 전체가 다운됩니다. 호스트에서 컨테이너 프로세스를 확인하려면 cat /proc/<호스트PID>/cgroup으로 어느 컨테이너 소속인지, docker inspect <컨테이너> | grep Pid로 매핑을 먼저 확인합니다.
확대
프로세스 소유권 — 왜 root로 실행하면 안 되는가
ps aux의 USER 컬럼이 root인 서비스 프로세스를 보면 경계해야 합니다. 그 프로세스에 취약점이 하나라도 있으면 공격자가 곧바로 서버 전체 권한을 얻기 때문입니다.
서비스를 전용 계정(예: myapp)으로 실행하면 취약점이 뚫려도 피해가 그 계정이 접근 가능한 범위로 제한됩니다. systemd 서비스에 User=/Group=으로 전용 계정을 지정하고, NoNewPrivileges=yes(SUID 권한 상승 차단)·ProtectSystem=full(/usr·/etc 읽기 전용)·PrivateTmp=yes(/tmp 격리)를 더하면 최소 권한 원칙이 적용됩니다. 핵심은 단일 서비스의 취약점이 서버 전체 장악으로 번지지 않게 막는 것입니다.
확대
심화 — CPU가 한가한데 느릴 때, 원인은 프로세스 밖에 있다
심화: steal time과 cgroup CPU 스로틀링 — %CPU가 낮아도 느린 이유
지금까지 '느린 서버'의 범인을 프로세스 안에서(과점유·좀비·D 상태) 찾았습니다. 그런데 top의 프로세스 %CPU는 낮고 load도 높지 않은데 응답만 느린 경우가 있습니다. 이때는 범인이 프로세스 밖 — 가상화 계층이나 cgroup 제한 — 에 있습니다.
- top의 CPU 줄을 끝까지 읽어야 합니다:
us(유저)·sy(커널) 말고st(steal)와wa(iowait)가 힌트입니다. **st(steal time)**는 '내 VM이 CPU를 쓰려 했는데 하이퍼바이저가 다른 VM에 줘서 뺏긴 시간'입니다. st가 높으면 내 프로세스는 죄가 없고, 물리 호스트가 과매각(oversubscribed)됐거나 시끄러운 이웃(noisy neighbor)이 있는 것입니다. - 컨테이너는 CPU에도 상한이 있습니다: 메모리에 limit이 있듯 CPU에도 cgroup 쿼터(CFS quota)가 걸립니다. 예를 들어 '0.5 코어' 제한은 100ms 주기마다 50ms만 쓰게 하고, 그 안에서 다 쓰면 남은 주기 동안 강제로 멈춥니다(throttle). 이때 컨테이너 안에서 본
%CPU는 낮은데 실제로는 계속 정지당해 지연이 튑니다. - 스로틀링은 평균 사용률에 숨습니다: 1초 평균 CPU가 40%여도, 100ms 창 단위로는 앞 50ms 폭주 후 뒤 50ms 정지가 반복될 수 있습니다. 그래서 '사용률은 낮은데 p99 지연이 나쁜' 전형적 패턴이 됩니다. 증거는 사용률이 아니라
cpu.stat의nr_throttled·throttled_time입니다. - 진단 지점이 다릅니다: steal은
top·vmstat의st,mpstat의%steal로 봅니다. 스로틀링은 컨테이너 cgroup의cpu.stat을 봐야 하고, 호스트에서%CPU만 보면 절대 안 잡힙니다.
핵심은 '느림'의 책임이 항상 내 프로세스에 있지는 않다는 것입니다. 프로세스 안(과점유·I/O)을 봤는데 깨끗하다면, 그 위의 **가상화(steal)와 cgroup 제한(throttle)**으로 시선을 올려야 합니다.
상황: 특정 컨테이너 서비스의 p99 지연이 들쭉날쭉합니다. 컨테이너 안에서 top·ps를 봐도 프로세스 CPU는 30%대로 여유롭고, 호스트 전체 부하도 낮습니다. '자원은 남는데 느린' 모순이라 원인을 못 찾습니다.
원인: 이 컨테이너에 CPU 제한(예: 0.5코어)이 걸려 있었고, 트래픽이 순간적으로 몰릴 때 100ms 주기의 쿼터를 앞부분에서 소진해 남은 주기 동안 CFS 스로틀링으로 강제 정지당했습니다. 컨테이너 안에서 본 30%는 1초 평균이라 낮게 보였을 뿐, 짧은 창에서는 정지가 반복되고 있었습니다. 호스트가 한가한 것과는 무관합니다 — 제한은 호스트 여유가 아니라 이 cgroup에만 적용되기 때문입니다.
진단: 사용률이 아니라 cgroup의 스로틀 카운터를 봅니다.
# cgroup v2: 스로틀 발생 횟수와 누적 정지 시간
cat /sys/fs/cgroup/cpu.stat
# nr_periods 12000
# nr_throttled 3400 ← 주기 중 3400번 스로틀됨
# throttled_usec 91000000 ← 누적 강제 정지 시간
# 컨테이너에 걸린 CPU 상한 확인
cat /sys/fs/cgroup/cpu.max
# 50000 100000 ← 100ms당 50ms = 0.5코어
# docker 레벨에서 스로틀 지표 감 잡기
docker stats --no-stream
nr_throttled와 throttled_usec가 부하 구간에서 계속 증가하면 스로틀링이 지연의 원인입니다.
해결: 워크로드가 순간 버스트가 필요한데 상한이 낮으면 CPU 제한을 올립니다(cpu.max 또는 컨테이너 --cpus·쿠버네티스 CPU limit 상향). 상한을 유지해야 한다면 요청당 CPU 사용을 줄이거나(무거운 동기 작업 분산) 워커 수를 조정해 버스트 폭을 낮춥니다. steal이 원인인 경우(가상 머신에서 st가 높음)는 제한이 아니라 호스트 과매각 문제이므로, 더 여유로운 호스트·인스턴스 타입으로 옮기거나 배치를 분산합니다. 재발 방지로 사용률뿐 아니라 nr_throttled(컨테이너)와 %steal(VM)을 함께 모니터링합니다(OOM Killer 방지를 위한 Swap 메모리 파티션 튜닝의 cgroup 메모리 제한과 같은 층위의 제한입니다).
실무 문의 시나리오: "API 서버 CPU 97%, 재시작할까요?"
금요일 오후 6시, 슬랙 DM이 왔습니다. 신입 개발자가 서버가 갑자기 느려졌다며 재시작 여부를 묻습니다.
# T+0:00 — SSH 접속 후 즉시 현황 파악
top -bn1 | head -20
ps aux --sort=-%cpu | head -10
# T+0:01 — PID 특정 (예: 7823)
# 언제부터? 배포 시각과 일치하는가?
ps -p 7823 -o pid,lstart,etimes,command
# T+0:02 — 좀비, FD 누수 동시 확인
ps aux | awk '$8 ~ /Z/' | wc -l
lsof -p 7823 | wc -l
# T+0:03 — 무엇을 하고 있는지 10초 확인 (프로덕션에서 짧게만)
timeout 10 strace -c -p 7823
# futex 호출이 압도적이면 스레드 락 경합
# write 압도적이면 로그/파일 I/O 폭주
# T+0:05 — Java/Node라면 스레드 덤프로 증거 수집
# Java
kill -SIGUSR1 7823 # JVM 스레드 덤프 → stdout/stderr
# Node.js
kill -SIGUSR1 7823 # V8 inspector 열림
# T+0:10 — 원인 파악 후 SIGTERM으로 정상 종료
kill -SIGTERM 7823
# 10초 대기 후
kill -0 7823 2>/dev/null && kill -SIGKILL 7823
# T+0:12 — 서비스 재시작 및 정상화 확인
systemctl restart myapp
top -bn1 | head -5
슬랙 응답 예시:
"확인해보니 7823 프로세스가 CPU 94% 점유 중이고 FD가 4821개(한도 1024 초과)입니다. 10:47에 배포 후 시작됐고 파일 디스크립터 누수로 보입니다. SIGTERM으로 프로세스 재시작했고 CPU 정상화됐습니다. 배포된 코드에서 파일/소켓 close() 누락 부분 확인이 필요합니다."
원인 없이 "재시작했더니 됐어요"는 같은 장애가 반복됩니다. 30초라도 증거를 수집하면 재발 방지가 됩니다.
명령어·단축키 빠른 참조
이 모듈에서 다룬 프로세스 관리 명령을 실전 옵션과 함께 모았습니다. "예" 열의 조합을 그대로 써도 됩니다.
| 명령어/단축키 | 용도 | 자주 쓰는 예 |
|---|---|---|
ps aux | 전체 프로세스 스냅샷 | ps aux --sort=-%cpu | head (CPU 상위) |
ps -ef --forest | 부모-자식(PPID) 트리로 보기 | 좀비의 부모 추적 시 유용 |
top / htop | 실시간 자원 모니터 | top -o %MEM (메모리순 정렬) |
pgrep / pkill | 이름으로 찾기·종료 | pgrep -a nginx / pkill -f myapp |
kill | 시그널 전송(기본 TERM) | kill -15 PID → 안 죽으면 kill -9 PID |
nice / renice | 실행 우선순위 조절 | renice -n 10 -p PID (낮은 우선순위로) |
jobs / fg / bg | 셸 작업 제어 | Ctrl+Z(정지) → bg(백그라운드) → jobs |
nohup … & | 로그아웃해도 유지되는 백그라운드 실행 | nohup ./run.sh > out.log 2>&1 & |
cat /sys/fs/cgroup/…/cpu.stat | cgroup CPU 스로틀 확인 | nr_throttled가 늘면 CFS 쿼터 제한 |
관련 모듈로 더 깊이:
- kill/pkill 시그널의 종류와 프로세스 안전 종료 규칙 — 이상 프로세스를 안전하게 종료하는 시그널의 종류와 규칙
- OOM Killer 방지를 위한 Swap 메모리 파티션 튜닝 — 메모리 누수·OOM 관점에서 프로세스를 진단하는 법
- vmstat, iostat, sar로 CPU/디스크 IO 성능 병목 진단 — CPU 과점유를 시계열로 추적하는 모니터링 도구
다음 모듈에서는 시그널(Signal) 처리를 더 깊이 다룹니다 — 애플리케이션에서 SIGTERM 핸들러를 작성하는 방법, graceful shutdown 패턴, 컨테이너 환경에서 PID 1의 신호 전달 문제를 배웁니다.