systemd나 Docker, Kubernetes는 프로세스를 종료할 때 먼저 SIGTERM을 보내고, 일정 시간(기본 10초 안팎) 안에 종료하지 않으면 SIGKILL로 강제 종료한다. 이 시간 안에 진행 중인 요청을 마무리하거나 파일 핸들을 정리하지 못하면 데이터가 깨지거나 커넥션이 걸린 채로 남는다. Python signal 모듈은 이 SIGTERM을 코드 레벨에서 가로채 정리 작업을 실행할 시간을 벌어준다. 이 글에서는 signal.signal()로 SIGTERM/SIGINT를 잡아 graceful shutdown을 구현하고, SIGALRM으로 타임아웃을 거는 패턴을 실제 실행 결과와 함께 정리한다.
핵심 개념
자주 쓰는 시그널과 기본 동작이다.
| 시그널 | 기본 동작 | 주 용도 |
|---|---|---|
| SIGTERM | 프로세스 종료 | 정상 종료 요청 (systemd/k8s/docker stop이 보내는 신호) |
| SIGINT | 프로세스 종료 | Ctrl+C |
| SIGHUP | 프로세스 종료 | 터미널 연결 종료, 데몬은 흔히 “설정 재로드”로 재정의 |
| SIGALRM | 프로세스 종료 | signal.alarm(n)으로 n초 뒤 발생시키는 타이머 |
| SIGCHLD | 무시 | 자식 프로세스 종료 알림 |
| SIGKILL | 즉시 종료 (핸들러 등록 불가) | 강제 종료 — 코드로 가로챌 수 없음 |
signal.signal(sig, handler)로 특정 시그널에 대한 핸들러 함수를 등록한다. 핸들러는 (signum, frame) 두 인자를 받고, 인터프리터가 다음 바이트코드 명령 사이의 안전한 지점에서 호출한다 — 그래서 핸들러 안에서는 무거운 로직 대신 플래그만 세팅하고 실제 처리는 메인 루프에서 하는 패턴이 권장된다.
실전: SIGTERM으로 graceful shutdown
SIGTERM/SIGINT 핸들러가 플래그만 세팅하고, 메인 루프가 그 플래그를 확인해 종료하는 코드다.
import signal
import time
import sys
shutdown_requested = False
def handle_sigterm(signum, frame):
global shutdown_requested
print(f"[handler] signal {signum} ({signal.Signals(signum).name}) 수신, graceful shutdown 시작", flush=True)
shutdown_requested = True
signal.signal(signal.SIGTERM, handle_sigterm)
signal.signal(signal.SIGINT, handle_sigterm)
tick = 0
while not shutdown_requested:
tick += 1
print(f"[main] tick {tick}", flush=True)
time.sleep(1)
print("[main] 정리 작업 수행 후 종료", flush=True)백그라운드로 띄운 뒤 2.5초 후 kill -TERM으로 시그널을 보냈다.
python3 graceful.py &
pid=$!
sleep 2.5
kill -TERM $pid
wait $pid[main] tick 1
[main] tick 2
[main] tick 3
[handler] signal 15 (SIGTERM) 수신, graceful shutdown 시작
[main] 정리 작업 수행 후 종료
SIGTERM(15)이 들어온 시점에 핸들러가 플래그만 세팅했고, while 루프가 다음 반복에서 이를 확인해 정리 문구를 출력하고 정상 종료했다.
실전: SIGALRM으로 타임아웃 구현
signal.alarm(n)으로 n초 뒤 SIGALRM을 예약하고, 핸들러에서 예외를 던져 try/except로 잡는 방식이다.
import signal
import time
class TimeoutError(Exception):
pass
def handle_alarm(signum, frame):
raise TimeoutError("작업이 제한 시간을 초과함")
def slow_task(seconds):
signal.signal(signal.SIGALRM, handle_alarm)
signal.alarm(2) # 2초 후 SIGALRM 발생
try:
time.sleep(seconds)
print(f"{seconds}초 작업 완료")
except TimeoutError as e:
print(f"타임아웃: {e}")
finally:
signal.alarm(0) # 알람 취소
slow_task(1)
slow_task(5)1초 작업 완료
타임아웃: 작업이 제한 시간을 초과함
1초짜리 작업은 2초 알람이 울리기 전에 끝나 정상 완료됐고, 5초짜리 작업은 2초 시점에 SIGALRM이 발생해 TimeoutError로 중단됐다. finally에서 signal.alarm(0)으로 알람을 취소해야 다음 호출에 이전 알람이 남아있지 않는다.
주의사항
- 시그널 핸들러는 메인 스레드에서만 등록할 수 있다. 워커 스레드에서
signal.signal()을 호출하면ValueError: signal only works in main thread가 난다. - SIGKILL(9)과 SIGSTOP은 핸들러를 등록할 수 없다 — 커널이 무조건 처리한다. “정리 시간을 벌기 위한” 대상은 항상 SIGTERM이지 SIGKILL이 아니다.
- 핸들러 안에서 락을 잡거나 I/O를 수행하면 재진입성 문제로 데드락이 날 수 있다. 플래그 세팅이나
os.write()처럼 async-signal-safe한 최소한의 작업만 하고, 실제 정리 로직은 메인 루프에서 처리한다. signal.alarm()은 유닉스 전용이라 Windows에서는AttributeError가 난다. 크로스플랫폼 타임아웃이 필요하면concurrent.futures나 별도 스레드 기반 타이머를 쓴다.- 여러 SIGALRM/SIGTERM이 짧은 시간에 몰리면 파이썬은 마지막으로 등록된 시그널만 처리하고 나머지는 병합될 수 있다(시그널은 큐잉되지 않는다). 여러 종료 요청을 구분해야 한다면 별도 카운터나 플래그를 둬야 한다.
마무리
SIGTERM 핸들러에서 플래그만 세팅하고 메인 루프가 그 플래그를 보고 정리 후 종료하는 패턴 하나만 익혀두면 systemd/Docker/k8s 환경에서 강제 종료(SIGKILL)로 넘어가기 전에 안전하게 정리할 시간을 확보할 수 있다. SIGALRM은 별도 스레드나 외부 라이브러리 없이 표준 라이브러리만으로 타임아웃을 구현할 수 있는 실용적인 도구다.