Skip to content
ioob.dev
Go back

Linux 기초 3편 — 프로세스와 시그널

· 9분 읽기
Linux 시리즈 (3/8)
  1. Linux 기초 1편 — 셸과 파일시스템 구조
  2. Linux 기초 2편 — 파일 권한과 사용자/그룹
  3. Linux 기초 3편 — 프로세스와 시그널
  4. Linux 기초 4편 — 텍스트 처리와 파이프
  5. Linux 기초 5편 — 네트워크 도구
  6. Linux 기초 6편 — Systemd와 서비스 관리
  7. Linux 기초 7편 — 패키지 관리
  8. Linux 기초 8편 — Bash 스크립팅 기초
Table of contents

Table of contents

프로세스는 “살아 있는 프로그램”이다

디스크에 저장된 ls 바이너리 파일은 그냥 비트의 묶음일 뿐이다. 그 파일을 누군가 실행하는 순간, 메모리에 올라가고, CPU가 명령을 수행하고, 파일 디스크립터를 열고, 신호를 받는다. 이 “실행 중인 프로그램의 실체”를 프로세스(Process)라 부른다.

개발자가 매일 다루는 것들이 결국 프로세스다. java -jar app.jar로 띄운 스프링 애플리케이션도, nginx로 떠 있는 웹 서버도, ssh로 접속한 내 셸도 프로세스다. 문제는 이 프로세스들이 “어떻게 살고, 어떻게 죽고, 어떻게 백그라운드로 돌아가는지” 모른 채로 운영에 뛰어들면 엉뚱한 곳에서 막힌다는 것이다. 앱이 왜 종료 시그널을 무시하는지, CI 스크립트에서 띄운 프로세스가 왜 세션 종료와 함께 죽는지 — 이 편은 그런 당혹스러운 순간의 뒷면을 본다.

PID와 프로세스 트리

모든 프로세스는 고유한 PID(Process ID)를 받는다. PID 1은 시스템 부팅 시 가장 처음 뜨는 프로세스의 자리다. 요즘 배포판에선 systemd가 PID 1을 차지하고 있고, Docker 컨테이너 안에서는 사용자가 지정한 최초 실행 프로세스가 PID 1이 된다.

그리고 모든 프로세스(PID 1 제외)는 부모 프로세스를 갖는다. 부모 PID(PPID)를 따라 올라가다 보면 결국 PID 1에 닿는다. 그래서 리눅스의 프로세스는 거대한 트리 구조다.

flowchart TB
    INIT["PID 1<br/>systemd"]
    INIT --> SSHD["sshd<br/>PID 900"]
    INIT --> NGINX["nginx (master)<br/>PID 1200"]
    INIT --> DOCKERD["dockerd<br/>PID 1500"]

    SSHD --> BASH["bash (내 셸)<br/>PID 3100"]
    NGINX --> NW1["nginx (worker)<br/>PID 1201"]
    NGINX --> NW2["nginx (worker)<br/>PID 1202"]

    BASH --> VIM["vim<br/>PID 4200"]
    BASH --> NODE["node app.js<br/>PID 4300"]

이 트리를 직접 눈으로 보려면 pstree를 쓴다.

pstree -p
# systemd(1)─┬─sshd(900)───bash(3100)─┬─vim(4200)
#           │                          └─node(4300)
#           ├─nginx(1200)─┬─nginx(1201)
#           │             └─nginx(1202)
#           └─dockerd(1500)─...

자기 셸의 PID는 $$에, 부모 PID는 $PPID에 담겨 있다.

echo "현재 셸 PID: $$"
echo "부모 PID: $PPID"

부모 프로세스의 역할은 중요하다. 자식이 끝나면 부모가 종료 상태(exit status)를 수거해야 한다. 수거하지 않은 자식은 좀비(zombie) 상태로 남고, 부모가 먼저 죽은 자식은 고아(orphan)가 돼 PID 1이 입양한다. Docker에서 tini 같은 init 프로세스를 쓰는 이유가 바로 여기 있다 — 컨테이너에서 PID 1이 되는 앱이 좀비 수거 기능이 없으면 누수가 생긴다.

ps — 스냅샷으로 보기

지금 이 순간 돌고 있는 프로세스를 한 번에 훑는 도구가 ps(Process Status)다. 옵션이 역사적인 이유로 두 계열(BSD와 System V)이 섞여 있어서 살짝 혼란스럽다. 실무에서 기억할 조합은 몇 개로 충분하다.

# 내 셸에서 실행 중인 프로세스 (BSD 스타일, 가장 짧다)
ps

# 시스템 전체 프로세스를 장황하게 (BSD)
ps aux

# 시스템 전체 + 부모-자식 관계 표시 (System V)
ps -ef

# 특정 이름 검색
ps aux | grep nginx

ps aux의 열을 해독해두면 편하다.

USER       PID   %CPU  %MEM   VSZ    RSS   TTY   STAT  START  TIME  COMMAND
alice     4300   1.2   0.5   512M   32M   pts/0  S+   10:30  0:03  node app.js

top, htop — 실시간으로 관찰

ps가 스냅샷이라면, top은 CCTV다. 1초마다 갱신되면서 CPU·메모리 상위 프로세스를 보여준다.

top
# q로 종료, k 누르면 특정 PID에 시그널 전송, M으로 메모리순 정렬, P로 CPU순 정렬

top은 모든 배포판에 기본 설치돼 있지만 UI가 투박하다. 색깔과 마우스 조작을 지원하는 htop을 설치해서 쓰는 게 일반적이다.

# Debian/Ubuntu
sudo apt install htop
# RHEL/CentOS
sudo dnf install htop
# macOS
brew install htop

htop
# F3 검색, F9 시그널 전송, F5 트리 뷰, F6 정렬

프로덕션 서버에서 “뭐가 CPU를 잡아먹는가?”를 파악할 때 가장 먼저 여는 창이 top이나 htop이다. 개발자가 로컬에서 돌려보는 것과는 차원이 다른 “살아 있는” 정보를 준다.

시그널 — 프로세스에게 말을 걸다

Linux가 프로세스에게 보내는 메시지가 시그널(signal)이다. 종류가 30개 넘지만 실무에서 자주 보는 건 대여섯 개 정도다.

flowchart LR
    SENDER["보내는 쪽<br/>(사용자, 커널, 다른 프로세스)"]
    SENDER -->|SIGTERM / SIGKILL / SIGHUP ...| RECEIVER["받는 프로세스"]

    RECEIVER --> REACT{"어떻게 반응?"}
    REACT --> DEFAULT["기본 동작<br/>(대개 종료)"]
    REACT --> HANDLER["핸들러<br/>(정의된 경우 실행)"]
    REACT --> IGNORE["무시<br/>(SIGKILL/SIGSTOP 제외)"]

자주 쓰이는 시그널

이름번호의미핸들러 가능?
SIGHUP1터미널 연결 끊김. 데몬들은 “설정 다시 읽기” 신호로 재활용O
SIGINT2인터럽트. 터미널에서 Ctrl+CO
SIGQUIT3종료 + 코어 덤프. Ctrl+\O
SIGKILL9즉시 강제 종료. 무시·가로채기 불가X
SIGTERM15”정상 종료해달라” 요청. kill의 기본값O
SIGSTOP19강제 일시정지. 무시 불가X
SIGTSTP20터미널에서 Ctrl+ZO
SIGCONT18정지된 프로세스를 재개O

SIGTERM vs SIGKILL. 이 둘의 차이는 DevOps를 하는 사람이라면 반드시 이해해야 한다.

쿠버네티스도 Pod를 내릴 때 이 흐름을 따른다. 먼저 SIGTERM을 보내고, terminationGracePeriodSeconds(기본 30초)를 기다린 뒤에도 살아있으면 SIGKILL로 마무리한다. 그래서 Graceful shutdown을 지원하지 않는 앱은 쿠버네티스 환경에서 배포할 때마다 커넥션 에러를 남긴다.

kill — 이름이 과격하지만 꼭 죽이는 건 아니다

kill 명령의 이름은 역사적 사연이다. 초기 Unix에서 프로세스에게 보낼 수 있는 시그널이 대부분 “종료”였기 때문에 이 이름이 붙었다. 실제로는 아무 시그널이나 보낼 수 있는 도구다.

# 기본은 SIGTERM (15)
kill 4300

# 명시적으로 시그널 지정 (이름 또는 번호)
kill -TERM 4300
kill -15 4300

# 강제 종료 — 정말 필요할 때만
kill -KILL 4300
kill -9 4300

# 설정 재로드 (서비스에 따라 지원)
kill -HUP 4300

# 보낼 수 있는 모든 시그널 보기
kill -l

이름으로 한 번에 죽이고 싶으면 pkill 또는 killall을 쓴다. 단어 매칭이라 조심해서 써야 한다.

# 이름에 "node"가 들어간 모든 프로세스에 SIGTERM
pkill node

# 정확히 이름이 "node"인 것만
killall node

# 특정 사용자의 프로세스만
pkill -u alice

프로덕션에서 pkill -9 java 같은 걸 잘못 치면 여러 앱이 한꺼번에 죽는 대참사가 일어날 수 있다. 보내기 전에 pgrep -a node로 대상이 맞는지 확인하는 습관을 들인다.

pgrep -a node
# 4300 node app.js
# 4450 node worker.js

& — 백그라운드 실행

명령 뒤에 &를 붙이면 해당 명령을 백그라운드로 보낸다. 셸은 그 프로세스가 끝날 때까지 기다리지 않고 바로 다음 프롬프트를 돌려준다.

# 포그라운드 — 명령이 끝날 때까지 셸이 멈춰 있다
./long-task.sh

# 백그라운드 — 바로 프롬프트 복귀
./long-task.sh &
# [1] 4600
#   ^   ^
#   │   └─ PID
#   └───── 잡(job) 번호

jobs, fg, bg — 잡 제어

같은 셸에서 띄운 백그라운드 프로세스들을 관리하는 내장 명령 세트가 jobs/fg/bg다.

# 실행
sleep 300 &
# [1] 4700

sleep 500 &
# [2] 4750

# 지금 셸의 백그라운드 잡 목록
jobs
# [1]-  Running   sleep 300 &
# [2]+  Running   sleep 500 &

# 1번 잡을 포그라운드로 가져오기
fg %1

# 포그라운드에서 Ctrl+Z 누르면 SIGTSTP로 멈춘다
# 멈춘 걸 백그라운드에서 다시 실행시키려면
bg %1

잡 번호는 %n 문법으로 가리킨다. kill %1처럼 시그널도 잡 번호로 보낼 수 있다.

단, 이 세계는 셸 세션 안에서만 유효하다. 셸이 종료되면 잡 목록이 날아간다. 그래서 장기 작업은 다음 이야기처럼 다르게 접근해야 한다.

nohup — 세션이 끊겨도 살아남게

SSH로 접속해 ./deploy.sh &로 백그라운드에 작업을 던지고 SSH를 끊으면, 그 작업이 그대로 돌아갈 것 같지만 — 대개 죽는다. 왜 그럴까?

원인은 SIGHUP이다. SSH 연결이 끊기면 커널은 그 터미널에 연결된 자식 프로세스들에게 SIGHUP을 보낸다. 기본 동작이 “종료”이기 때문에 작업이 딸려 죽는다.

해결책이 nohup(NO HangUP)이다. 이 명령으로 감싸면 SIGHUP을 무시하도록 설정된다.

nohup ./deploy.sh > deploy.log 2>&1 &
# nohup: ignoring input and appending output to 'nohup.out'

nohup은 출력을 리디렉션하지 않으면 자동으로 nohup.out 파일로 쌓는다. 로그 파일을 지정하려면 위 예시처럼 > log.txt 2>&1로 직접 리디렉션한다. 여기서 2>&1은 “표준 에러(2)도 표준 출력(1)과 같은 곳으로 보내라”는 의미인데, 자세한 리디렉션은 4편에서 다룬다.

다만 nohup은 옛날 방식이다. 현대의 실무에선 세션 자체를 분리하는 도구를 더 많이 쓴다.

더 나은 대안: tmux, screen, systemd

# tmux 세션 만들기
tmux new -s deploy
# 세션 안에서 ./deploy.sh 실행

# Ctrl+b 누른 뒤 d — detach
# SSH 끊어도 안 죽음

# 다시 붙기
tmux attach -t deploy

disown — 이미 띄운 프로세스를 살려보내기

깜빡하고 nohup 없이 &만 붙여서 프로세스를 띄웠다. 지금 SSH 끊으면 죽는다. 방법이 없을까?

있다. disown을 쓰면 셸의 잡 테이블에서 프로세스를 떼어내고, SIGHUP도 받지 않게 만든다.

./deploy.sh &
# [1] 4800

jobs
# [1]+  Running   ./deploy.sh &

disown %1
# 또는 현재 모든 잡: disown -a

jobs
# (비어 있음 — 떼어짐)

# 이제 SSH 끊어도 4800은 살아남는다

프로세스 관련 실전 한 묶음

자주 쓰는 조합을 모아두자.

# 특정 포트를 누가 쓰고 있는지
sudo lsof -i :8080
sudo ss -ltnp | grep :8080   # 더 현대적

# 특정 프로세스가 연 파일
sudo lsof -p 4300

# 특정 PID의 상세 정보
cat /proc/4300/status | head
cat /proc/4300/cmdline | tr '\0' ' '   # 실행 당시 명령줄 전체
ls -l /proc/4300/cwd                    # 작업 디렉토리
ls -l /proc/4300/exe                    # 실행 파일 경로

# 메모리 과다 사용으로 OOM에 죽었는지 확인
dmesg | grep -i "out of memory"
# 또는 최근 로그
journalctl -k | grep -i oom

/proc/<PID>/ 아래 파일들은 1편에서 얘기했듯 커널이 동적으로 만들어주는 가상 파일이다. 모니터링 도구가 하는 일의 상당 부분이 여기서 값을 읽어 예쁘게 정리하는 것이다.

이번 편 정리

이 글에서 밟은 발자국.

다음 편에서는 리눅스가 50년 동안 유지해온 핵심 철학인 텍스트 처리와 파이프를 다룬다. grep·sed·awk|가 조합될 때 어떤 힘이 나오는지 들여다본다.

4편: 텍스트 처리와 파이프


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Linux 기초 2편 — 파일 권한과 사용자/그룹
Next Post
Linux 기초 4편 — 텍스트 처리와 파이프