오래된 주제, 새로운 무대

유닉스 시그널은 운영체제 수업 첫 단원에서 가볍게 훑고 지나가는 주제다. 그런데 Docker, Kubernetes, systemd, PM2 같은 프로세스 오케스트레이터들이 일상이 된 지금, 이 네 개의 시그널은 오히려 운영 사고의 주요 원인이 되고 있다.

  • “Docker stop을 했는데 왜 10초 뒤에야 죽나요?”
  • “K8s rolling update 중 request가 잘립니다”
  • “Ctrl+C가 안 먹히는데 뭐가 잘못됐죠?”
  • “nohup으로 돌렸는데 SSH 끊기니까 죽었어요”

전부 시그널 이해와 직결된 질문이다. 한 번 제대로 정리해두면 두고두고 보인다.


네 시그널 개요

시그널번호기본 동작핸들 가능대표 발생 경로
SIGINT2프로세스 종료✅ 캐치 가능터미널 Ctrl+C
SIGTERM15프로세스 종료✅ 캐치 가능kill PID, docker stop, K8s pod 종료
SIGHUP1프로세스 종료✅ 캐치 가능터미널 세션 종료, 관용적으로 “config reload”
SIGKILL9즉시 종료❌ 불가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 — 인자 없이 쓰면 기본이 SIGTERM
  • docker stop CONTAINER — 컨테이너 PID 1에 SIGTERM 전송
  • Kubernetes의 Pod 종료 시작 시점 — 각 컨테이너에 SIGTERM
  • systemd가 unit을 멈출 때 — 기본이 SIGTERM
  • PM2, foreman, supervisor 등 프로세스 매니저의 기본 stop 시그널

SIGTERM은 “그만 종료해 주세요” 라는 의사 표현이다. 프로세스가 캐치할 수 있고, 원하는 시간 동안 정리할 수 있다.

Graceful shutdown 패턴 (Go 예제)

프로덕션 서버가 SIGTERM을 받았을 때 해야 할 일은 대체로 이렇다.

  1. 신규 요청 수신을 중단
  2. 진행 중인 요청을 완료할 때까지 기다림
  3. DB 커넥션, 파일, 임시 자원 정리
  4. 종료

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을 “설정 다시 읽어라” 신호로 재해석해서 쓴다. 공식 표준은 아니지만 사실상 관례.

  • nginxkill -HUP 또는 nginx -s reload. 마스터가 새 설정으로 워커를 띄우고 기존 워커는 현재 연결 끝나면 종료
  • rsyslog, syslog-ng — 설정 리로드
  • haproxy — 리로드용 (최근 버전은 seamless-reload 따로)
  • postgrespg_reload_conf() 또는 kill -HUP postmaster

HUP의 원래 의미와는 전혀 상관없는 용도인데, 이제는 이쪽이 더 유명하다.


쿠버네티스의 Termination 시퀀스

K8s에서 Pod를 지우면 내부적으로 다음 순서로 돈다.

  1. Pod의 status가 Terminating으로 변경
  2. 서비스의 endpoint에서 해당 Pod 제거 (신규 트래픽 차단)
  3. preStop hook 실행 (정의돼 있다면)
  4. 각 컨테이너의 PID 1에 SIGTERM 전송
  5. Termination grace period (기본 30초) 대기
  6. 그래도 살아 있으면 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이 필요 없는 날이 많아진다. 컨테이너 오케스트레이션 시대에 이 오래된 주제가 다시 중요해진 이유는 결국 이것이다.


참고