오래된 주제, 새로운 무대
유닉스 시그널은 운영체제 수업 첫 단원에서 가볍게 훑고 지나가는 주제다. 그런데 Docker, Kubernetes, systemd, PM2 같은 프로세스 오케스트레이터들이 일상이 된 지금, 이 네 개의 시그널은 오히려 운영 사고의 주요 원인이 되고 있다.
- “Docker stop을 했는데 왜 10초 뒤에야 죽나요?”
- “K8s rolling update 중 request가 잘립니다”
- “Ctrl+C가 안 먹히는데 뭐가 잘못됐죠?”
- “nohup으로 돌렸는데 SSH 끊기니까 죽었어요”
전부 시그널 이해와 직결된 질문이다. 한 번 제대로 정리해두면 두고두고 보인다.
네 시그널 개요
| 시그널 | 번호 | 기본 동작 | 핸들 가능 | 대표 발생 경로 |
|---|---|---|---|---|
| SIGINT | 2 | 프로세스 종료 | ✅ 캐치 가능 | 터미널 Ctrl+C |
| SIGTERM | 15 | 프로세스 종료 | ✅ 캐치 가능 | kill PID, docker stop, K8s pod 종료 |
| SIGHUP | 1 | 프로세스 종료 | ✅ 캐치 가능 | 터미널 세션 종료, 관용적으로 “config reload” |
| SIGKILL | 9 | 즉시 종료 | ❌ 불가 | kill -9, Docker grace period 초과, OOM killer |
핵심 차이는 마지막 칼럼(핸들 가능 여부) 이다. SIGKILL만 프로세스가 가로챌 수 없다. 이게 왜 중요한지는 뒤에서 자세히 본다.
SIGINT — 우리가 가장 자주 만나는 시그널
터미널에서 Ctrl+C를 누르면 현재 포그라운드 프로세스 그룹에 SIGINT가 날아간다. “사용자 인터럽트"의 약자로 interrupt에서 왔다.
프로세스는 이 시그널을 받아서:
- 기본적으로는 즉시 종료
- 핸들러를 등록하면 정리 후 종료 가능 (열린 파일, DB 트랜잭션 롤백 등)
- 완전히 무시할 수도 있음 (일부 REPL이 이렇게 동작)
Ctrl+C가 안 먹는 경우들
- 자식 프로세스가 별도 세션으로 분리돼 있을 때 (
setsid) - 프로세스가 I/O 블록 상태(디스크, 네트워크)라 인터럽트 후에도 시스템 콜 복귀가 늦을 때
- 핸들러가 SIGINT를 잡아놓고 일부러 안 끝낼 때
- TUI 앱(vim, tmux 등)이 터미널 raw 모드로 Ctrl+C를 키 입력으로 받아먹을 때
Ctrl+C가 안 먹히면 보통 SIGTERM을 쏴보는 게 다음 수순이다. kill PID (기본값이 SIGTERM).
SIGTERM — 정중한 종료 요청
가장 중요한 시그널이자, 현대 운영 환경의 기본 종료 경로다.
kill PID— 인자 없이 쓰면 기본이 SIGTERMdocker stop CONTAINER— 컨테이너 PID 1에 SIGTERM 전송- Kubernetes의 Pod 종료 시작 시점 — 각 컨테이너에 SIGTERM
- systemd가 unit을 멈출 때 — 기본이 SIGTERM
- PM2, foreman, supervisor 등 프로세스 매니저의 기본 stop 시그널
SIGTERM은 “그만 종료해 주세요” 라는 의사 표현이다. 프로세스가 캐치할 수 있고, 원하는 시간 동안 정리할 수 있다.
Graceful shutdown 패턴 (Go 예제)
프로덕션 서버가 SIGTERM을 받았을 때 해야 할 일은 대체로 이렇다.
- 신규 요청 수신을 중단
- 진행 중인 요청을 완료할 때까지 기다림
- DB 커넥션, 파일, 임시 자원 정리
- 종료
Go 1.16+에서 signal.NotifyContext를 쓰면 깔끔하다.
package main
import (
"context"
"log"
"net/http"
"os/signal"
"syscall"
"time"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer stop()
srv := &http.Server{Addr: ":8080"}
go func() {
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
}()
<-ctx.Done() // SIGINT/SIGTERM 대기
shutdownCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown error: %v", err)
}
log.Println("bye")
}
25초 타임아웃은 K8s 기본 grace period(30초)보다 살짝 짧게 잡는 게 관행이다. grace period 안쪽에서 정리 끝내고 자진 종료하도록.
Node.js 예제
const server = app.listen(3000)
const shutdown = (signal) => async () => {
console.log(`${signal} 수신, 정리 중...`)
server.close(() => process.exit(0))
// 25초 지나도 종료 안 되면 강제
setTimeout(() => process.exit(1), 25_000).unref()
}
process.on('SIGTERM', shutdown('SIGTERM'))
process.on('SIGINT', shutdown('SIGINT'))
Python 예제
import signal, sys, time
def shutdown(signum, frame):
print(f"signal {signum} 수신, 정리 중...")
# cleanup code here
sys.exit(0)
signal.signal(signal.SIGTERM, shutdown)
signal.signal(signal.SIGINT, shutdown)
while True:
time.sleep(1)
SIGKILL — 아껴 써야 하는 이유
kill -9 PID. 개발자들의 오랜 습관. 그런데 이건 프로세스에게 정리할 기회를 주지 않는 시그널이다.
- 커널이 프로세스를 즉시 종료
- 프로세스는 시그널을 캐치하거나 무시하거나 핸들러를 등록할 수 없다 (불가능)
- DB 커넥션이 절반만 닫힌 상태로 방치, 파일이 잠금 해제되지 않은 채 남을 수 있음
- 쓰기 중이던 데이터가 flush 안 된 상태로 증발
쓸 만한 때
- SIGTERM을 보냈는데 충분히 기다린 뒤에도 안 죽음 (핸들러 무한 루프 등)
- Docker grace period 초과 → Docker가 자동으로 SIGKILL 전송
- K8s grace period 초과 → kubelet이 SIGKILL
- OOM killer가 메모리 확보 위해 희생자로 선정
즉 순서는 항상 SIGTERM → 대기 → (안 되면) SIGKILL 이다. 처음부터 kill -9로 가는 건 게으름이거나 데이터 손상 리스크를 감수하는 행위.
SIGHUP — 의미가 뒤집힌 시그널
이름 그대로 “HangUp"에서 왔다. 1970년대 전화 연결이 끊기면 모뎀이 보내주던 신호. 지금은 그 용도가 거의 사라졌지만 두 가지로 남아 있다.
의미 1 — 터미널 세션 종료
SSH로 원격 서버 접속해서 명령을 실행하고 접속을 끊으면, 그 자식 프로세스들에 SIGHUP이 간다. 기본 동작은 종료다.
그래서 다음 도구들이 생겼다.
- nohup — SIGHUP 무시 플래그 설정 후 실행. “no hup”
- disown — 셸의 job 테이블에서 제거해 SIGHUP 전파 차단
- setsid — 새 세션 생성, 터미널과의 연결 자체를 분리
- tmux / screen — 세션을 원격 호스트의 상주 데몬이 잡고 있어 클라이언트 연결이 끊겨도 프로세스 유지
주의: nohup은 SIGHUP만 막는다. SIGTERM, SIGKILL은 그대로 먹힌다. 시스템 재부팅/종료 때도 보호 못 함.
의미 2 — 설정 리로드 관용
현대 데몬들이 SIGHUP을 “설정 다시 읽어라” 신호로 재해석해서 쓴다. 공식 표준은 아니지만 사실상 관례.
- nginx —
kill -HUP또는nginx -s reload. 마스터가 새 설정으로 워커를 띄우고 기존 워커는 현재 연결 끝나면 종료 - rsyslog, syslog-ng — 설정 리로드
- haproxy — 리로드용 (최근 버전은 seamless-reload 따로)
- postgres —
pg_reload_conf()또는kill -HUP postmaster
HUP의 원래 의미와는 전혀 상관없는 용도인데, 이제는 이쪽이 더 유명하다.
쿠버네티스의 Termination 시퀀스
K8s에서 Pod를 지우면 내부적으로 다음 순서로 돈다.
- Pod의 status가
Terminating으로 변경 - 서비스의 endpoint에서 해당 Pod 제거 (신규 트래픽 차단)
preStophook 실행 (정의돼 있다면)- 각 컨테이너의 PID 1에 SIGTERM 전송
- Termination grace period (기본 30초) 대기
- 그래도 살아 있으면 SIGKILL
여기서 3번 preStop과 4번 SIGTERM 사이의 순서가 중요하다. 많은 이가 preStop이 먼저 실행되고 그 끝에 SIGTERM이 간다고 오해하는데, 실제로는 둘이 사실상 동시에 시작된다 (preStop은 먼저 호출되지만 SIGTERM 전송을 막지 않는다는 게 정확한 표현). preStop의 역할은 주로 “LB에서 빠질 시간을 벌기” 같은 용도.
grace period 설정
spec:
terminationGracePeriodSeconds: 60
containers:
- name: app
# ...
lifecycle:
preStop:
exec:
command: ["sleep", "10"] # LB가 endpoint 제거를 반영할 시간
앱 내부의 graceful shutdown 타임아웃은 이 값보다 짧게 잡는다.
흔한 함정들
Dockerfile shell form ENTRYPOINT
# ❌ 나쁜 예 — /bin/sh가 PID 1이 되어 SIGTERM을 흡수
CMD node server.js
# ✅ 좋은 예 — node가 직접 PID 1
CMD ["node", "server.js"]
shell form(문자열)은 sh -c로 감싸져 실행되는데, sh는 기본적으로 시그널을 자식에게 전파하지 않는다. docker stop이 SIGTERM을 PID 1(sh)에 보내지만 실제 앱은 그걸 못 받는다. 결국 10초 기다렸다가 SIGKILL로 박살난다.
긴 preStop을 graceful shutdown 대체용으로 쓰지 말 것
preStop: sleep 60 같은 걸로 “종료 시간 버는” 건 꼼수에 가깝다. 앱이 실제 정리를 해주는 게 아니라 단지 종료를 늦출 뿐이다. 진짜 해법은 앱에 SIGTERM 핸들러.
Python의 KeyboardInterrupt는 SIGINT만
try/except KeyboardInterrupt는 SIGINT만 잡는다. SIGTERM은 안 잡힌다. 컨테이너에서 돌리는 Python이면 반드시 signal.signal(signal.SIGTERM, ...)을 등록해야 한다.
Node.js에서 동기 블로킹 작업
이벤트 루프를 오래 점유하는 동기 작업(큰 파일 sync read, 무거운 crypto 연산 등)은 process.on('SIGTERM', ...) 핸들러조차 못 돌린다. Node 프로세스가 시그널을 처리할 기회를 못 얻기 때문. graceful shutdown이 의미 있으려면 이벤트 루프를 풀어주는 코드여야 한다.
nohup 과신
nohup long_task.sh &로 돌려놨다고 “이제 안전"이라 여기기 쉬운데, SIGHUP만 막는다. reboot 이나 SIGTERM 앞에서는 무력하다. 장기 작업은 systemd 유닛이나 tmux 세션 쪽이 더 안전하다.
한 줄로
“SIGTERM을 잡을 줄 아는 코드"와 “SIGTERM을 쏠 줄 아는 운영자"가 만나면 SIGKILL이 필요 없는 날이 많아진다. 컨테이너 오케스트레이션 시대에 이 오래된 주제가 다시 중요해진 이유는 결국 이것이다.
참고
- SIGKILL vs SIGTERM: Master Process Termination in Linux and Kubernetes — SUSE
- Kubernetes best practices: terminating with grace — Google Cloud
- Graceful Shutdown in Go: Practical Patterns — VictoriaMetrics
- Kubernetes: containers, and the “lost” SIGTERM signals — ITNEXT
- SIGHUP for Configuration Reload — linuxvox
- How to Use the nohup Command in Linux — DigitalOcean