컴퓨터공학 300 주제 시리즈의 123번째 글이다. 전체 지도는 여기.

한 줄 요약

시그널은 커널이 프로세스에게 “이런 일이 생겼다” 고 비동기로 알리는 작은 번호다. 데이터는 거의 싣지 않지만, 프로세스의 실행 흐름을 아무 지점에서나 끊고 들어올 수 있다는 점이 강력하면서 위험하다.

왜 필요한가

Ctrl+C 를 누르면 프로그램이 멈춘다. kill 명령으로 프로세스를 끈다. 자식 프로세스가 끝나면 부모가 알게 된다. 잘못된 메모리 주소를 읽으면 “Segmentation fault” 가 뜬다. 이 모든 장면 뒤에 시그널이 있다.

현업에서 더 중요한 장면은 종료다. 쿠버네티스가 파드를 내릴 때, systemd 가 서비스를 재시작할 때, 모두 먼저 SIGTERM 을 보낸다. 이 신호를 제대로 처리하지 않으면 처리 중이던 요청이 잘리고, 열린 트랜잭션이 끊기고, 배포할 때마다 오류율이 튄다. 시그널은 “우아한 종료(graceful shutdown)” 의 출발점이다.

핵심 개념

자주 만나는 시그널

이름 번호(x86 Linux) 기본 동작 주로 언제
SIGHUP 1 종료 터미널 끊김. 데몬은 관례적으로 설정 재적재
SIGINT 2 종료 Ctrl+C
SIGQUIT 3 코어 덤프 Ctrl+\
SIGKILL 9 종료 강제 종료. 잡거나 무시할 수 없음
SIGSEGV 11 코어 덤프 잘못된 메모리 접근
SIGPIPE 13 종료 읽는 쪽이 없는 파이프·소켓에 쓰기
SIGTERM 15 종료 정중한 종료 요청. kill 의 기본값
SIGCHLD 17 무시 자식 프로세스 상태 변화
SIGSTOP 19 정지 강제 일시정지. 잡을 수 없음

번호는 아키텍처마다 다를 수 있으니 코드에서는 항상 이름을 쓴다.

시그널의 생애

  1. 생성(generate): 커널이나 다른 프로세스(kill())가 시그널을 만든다.
  2. 대기(pending): 대상 프로세스에 표시만 해 둔다. 같은 표준 시그널이 여러 번 와도 대기 중에는 하나로 합쳐진다. 시그널은 큐가 아니다.
  3. 차단(blocked): 프로세스는 시그널 마스크로 특정 시그널의 전달을 미룰 수 있다.
  4. 전달(deliver): 프로세스가 커널에서 사용자 모드로 돌아가는 시점에 처리된다. 기본 동작, 무시, 혹은 등록한 핸들러 실행 중 하나다.

SIGKILL 과 SIGSTOP 은 잡을 수도, 무시할 수도, 차단할 수도 없다. 관리자가 언제나 프로세스를 멈출 수 있게 하는 안전장치다.

핸들러는 어디서든 끼어든다

핸들러는 메인 코드가 어느 줄을 실행하든 그 사이에 끼어든다. 메인 코드가 malloc 내부에서 락을 잡은 상태일 때 핸들러가 또 malloc 을 부르면 교착이 생길 수 있다. 그래서 POSIX 는 핸들러 안에서 불러도 안전한 함수 목록(async-signal-safe)을 정해 두었다. write, _exit 등은 안전하고, printf, malloc 은 안전하지 않다.

실무의 정석은 이렇다. 핸들러에서는 플래그만 세우거나 self-pipe(또는 signalfd)에 한 바이트를 쓰고 바로 돌아온다. 실제 정리 작업은 메인 루프가 그 플래그를 보고 한다.

 메인 루프:  while not stop: 일한다   ──►  stop 이면 정리 후 종료
                    ▲
 SIGTERM ─► 핸들러: stop = True (그 외 아무것도 하지 않음)

signal() 보다 sigaction()

C 에서 오래된 signal() 은 시스템마다 의미가 달랐다. 이식성 있는 코드는 sigaction() 을 쓴다. 마스크, SA_RESTART(시스템 콜 자동 재시작) 같은 플래그를 명시할 수 있다.

시스템 콜과 EINTR

블록된 read 중에 시그널이 오면 시스템 콜이 EINTR 로 실패할 수 있다. SA_RESTART 를 주거나 재시도해야 한다. 파이썬은 3.5 부터 PEP 475 에 따라 대부분의 시스템 콜을 자동으로 재시도한다.

파이썬의 시그널

파이썬의 핸들러는 C 수준 핸들러가 아니다. C 핸들러는 플래그만 세우고, 실제 파이썬 핸들러는 메인 스레드가 다음 바이트코드를 실행할 때 돈다. 그래서 핸들러는 항상 메인 스레드에서 실행되고, 오래 걸리는 C 함수 안에 있으면 늦게 실행될 수 있다.

PID 1 문제

컨테이너 안에서 앱이 PID 1 이 되면 특별 취급을 받는다. Linux 커널은 PID 1 에게 핸들러를 등록하지 않은 시그널의 기본 동작을 적용하지 않는다. 그래서 SIGTERM 핸들러가 없는 앱은 PID 1 로 돌면 SIGTERM 에 반응하지 않고, 결국 유예 시간 뒤 SIGKILL 로 죽는다. 셸 래퍼 스크립트가 PID 1 이 되어 시그널을 자식에게 넘겨주지 않는 경우도 흔하다. exec 로 앱을 실행하거나 작은 init(tini 등)을 쓰는 이유다.

직접 해 보기

SIGTERM 을 받으면 하던 일을 마무리하고 끝나는 워커다.

import os, signal, time

stop = False
def on_term(signum, frame):
    global stop
    stop = True                       # 플래그만 세운다

signal.signal(signal.SIGTERM, on_term)

pid = os.fork()
if pid == 0:                          # 자식: 0.5초 뒤 부모에게 SIGTERM
    time.sleep(0.5)
    os.kill(os.getppid(), signal.SIGTERM)
    os._exit(0)

done = 0
while not stop:
    time.sleep(0.1)                   # 작업 한 단위
    done += 1
print(f"SIGTERM 수신, 작업 {done}개 마치고 정리 후 종료")
os.waitpid(pid, 0)

실행하면 대략 “작업 5개 마치고” 근처의 숫자가 찍힌다(타이밍에 따라 4~6). 핸들러를 등록하는 줄을 지우면 기본 동작대로 즉시 종료되어 마지막 print 가 실행되지 않는다. 셸에서 echo $? 를 보면 143(128+15)이 나온다.

현업에서는

  • 쿠버네티스 파드 종료. 파드가 삭제되면 kubelet 은 preStop 훅을 실행하고, 컨테이너의 주 프로세스에 SIGTERM 을 보낸 뒤 terminationGracePeriodSeconds(기본 30초)를 기다린다. 그래도 살아 있으면 SIGKILL 이다. 동시에 엔드포인트에서 파드가 빠지는데, 이 전파가 SIGTERM 보다 늦을 수 있다. 그래서 preStop 에 짧은 sleep 을 두어 새 요청 유입이 멈춘 뒤 종료를 시작하는 패턴을 흔히 쓴다.
  • 설정 재적재. nginx 는 SIGHUP 을 받으면 설정을 다시 읽고 워커를 교체한다. 프로세스 재시작 없이 설정을 바꾸는 오래된 관례다.
  • OOM 과 SIGKILL. 메모리 한도를 넘은 컨테이너는 커널 OOM killer 에 의해 SIGKILL 로 죽는다. 종료 코드 137(128+9)을 보면 먼저 메모리를 의심한다.
  • 좀비 프로세스. 부모가 SIGCHLD 를 받고도 wait 하지 않으면 자식이 좀비로 남는다. 자식을 많이 띄우는 PID 1 앱에서 좀비가 쌓이는 문제가 여기서 나온다.

확인 문제

  1. SIGKILL 과 SIGTERM 의 결정적 차이는?
  2. 같은 표준 시그널이 차단된 상태에서 세 번 도착했다. 차단을 풀면 핸들러는 몇 번 실행되는가?
  3. 시그널 핸들러 안에서 printf 나 로깅을 하면 안 되는 이유는?
  4. 종료 코드 137 과 143 은 각각 무엇을 뜻하는가?
  5. 컨테이너에서 앱이 SIGTERM 에 반응하지 않는 흔한 원인 두 가지는?

풀이

  1. SIGTERM 은 잡아서 정리 작업을 할 수 있는 요청이고, SIGKILL 은 잡거나 무시할 수 없는 강제 종료다.
  2. 한 번이다. 표준 시그널은 대기 중에 합쳐진다(실시간 시그널은 큐잉된다).
  3. async-signal-safe 하지 않은 함수라서, 메인 코드가 같은 함수 내부의 락을 잡은 상태에서 끼어들면 교착이나 손상이 생길 수 있다.
  4. 137 = 128+9(SIGKILL), 143 = 128+15(SIGTERM).
  5. 앱이 PID 1 이라 핸들러 없는 시그널의 기본 동작이 적용되지 않는 경우, 셸 래퍼가 PID 1 이 되어 시그널을 자식에게 전달하지 않는 경우.

더 읽을거리 (References)