Python os.pidfd_open()으로 PID 재사용 레이스 없이 프로세스 관리하기

PID 기반 프로세스 관리(os.kill(pid, sig), os.waitpid(pid, ...))에는 근본적인 문제가 있다. PID는 재사용된다. 어떤 프로세스가 죽고 그 PID가 회수되면, 커널은 그 번호를 완전히 다른 새 프로세스에 다시 배정할 수 있다. 자식 프로세스가 죽었다고 착각한 채 들고 있던 옛 PID로 시그널을 보내면, 그 사이 같은 번호를 받은 전혀 다른 프로세스를 건드리는 TOCTOU(Time-Of-Check-Time-Of-Use) 레이스가 생긴다. Python 3.9부터 os.pidfd_open()으로 pidfd(프로세스를 가리키는 파일 디스크립터)를 열 수 있게 되면서, 그 fd가 살아있는 한 정확히 그 프로세스 하나만 가리키는 안전한 핸들을 쓸 수 있게 됐다. 이 글에서는 pidfd의 세 가지 실전 활용 — 이벤트 기반 종료 대기, waitid 연동, 안전한 시그널 전달 — 을 실제로 확인한다.

os.pidfd_open()으로 pidfd 얻기

사용법 자체는 단순하다. 대상 프로세스의 PID를 넘기면 그 프로세스를 가리키는 파일 디스크립터를 돌려준다.

>>> import os, subprocess
>>> proc = subprocess.Popen(["sleep", "5"])
>>> pidfd = os.pidfd_open(proc.pid)
>>> pidfd
3

이 fd는 일반 파일 디스크립터처럼 select/poll/epoll에 등록할 수 있고, 대상 프로세스가 종료되면 읽기 가능(POLLIN) 상태가 된다. 자식이 아닌 임의의 PID도 열 수 있지만, 다른 사용자 소유 프로세스라면 권한 체크에 걸려 PermissionError가 난다.

select/poll로 이벤트 기반 종료 대기

proc.wait()는 내부적으로 폴링(busy-wait에 가까운 재시도)이거나 SIGCHLD 핸들러에 의존하는데, pidfd는 진짜 이벤트 기반 대기가 가능하다. 다른 fd(소켓, 파이프 등)와 함께 하나의 이벤트 루프에서 감시할 수 있다는 뜻이다.

import os
import select
import subprocess
import time

proc = subprocess.Popen(["sleep", "2"])
pidfd = os.pidfd_open(proc.pid)
print(f"pid={proc.pid}, pidfd={pidfd}")

poller = select.poll()
poller.register(pidfd, select.POLLIN)

start = time.time()
events = poller.poll()
elapsed = time.time() - start
print(f"pidfd readable after {elapsed:.2f}s, events={events}")

proc.wait()
print(f"exit code: {proc.returncode}")
os.close(pidfd)

실제 실행 결과다.

$ python3 wait_exit.py
pid=50852, pidfd=3
pidfd readable after 3.58s, events=[(3, 1)]
exit code: 0

sleep 2인데 3.58초가 걸린 건 VM 환경의 스케줄링 오버헤드 때문이고, 중요한 건 poll()이 프로세스 종료 시점에 정확히 깨어났다는 점이다. 이벤트 값 1select.POLLIN에 해당한다.

os.waitid(os.P_PIDFD, …)로 네이티브 reap

poll로 종료를 감지한 뒤에도 결국 proc.wait()로 좀비를 reap해야 한다. Python은 os.waitid()os.P_PIDFD ID 타입을 지원해서, pidfd를 직접 넘겨 기다리고 reap까지 한 번에 처리할 수 있다.

import os
import subprocess
import time

proc = subprocess.Popen(["sleep", "1"])
pidfd = os.pidfd_open(proc.pid)
print(f"pid={proc.pid}, pidfd={pidfd}")

start = time.time()
info = os.waitid(os.P_PIDFD, pidfd, os.WEXITED)
elapsed = time.time() - start
print(f"waitid 반환까지 {elapsed:.2f}s, info={info}")

os.close(pidfd)
$ python3 waitid_pidfd.py
pid=51301, pidfd=3
waitid 반환까지 2.18s, info=posix.waitid_result(si_pid=51301, si_uid=1000, si_signo=17, si_status=0, si_code=1)

waitid_result에 담긴 si_pid가 우리가 연 pidfd와 정확히 일치하는 프로세스라는 게 커널이 보장하는 부분이다. 일반 os.waitpid(pid, ...)였다면 그사이 PID가 재사용됐더라도 넘어온 번호만 보고 그대로 믿어야 했다.

pidfd로 안전하게 시그널 보내기 — os엔 없어서 ctypes로

PID 재사용 레이스가 진짜 위험해지는 지점은 시그널을 보낼 때다. 커널은 pidfd_send_signal(2) 시스템 콜로 이 문제를 해결하는데, Python os 모듈에는 이 syscall의 래퍼가 없다(os.pidfd_open()만 있고 os.pidfd_send_signal()은 존재하지 않는다). 최근 다룬 ctypes 글처럼, 없는 syscall은 직접 호출하면 된다.

import ctypes
import os
import signal
import subprocess

libc = ctypes.CDLL(None, use_errno=True)
NR_pidfd_send_signal = 424  # x86_64 전용 syscall 번호


def pidfd_send_signal(pidfd, sig):
    ret = libc.syscall(NR_pidfd_send_signal, pidfd, sig, None, 0)
    if ret == -1:
        errno = ctypes.get_errno()
        raise OSError(errno, os.strerror(errno))
    return ret


proc = subprocess.Popen(["sleep", "1"])
pidfd = os.pidfd_open(proc.pid)
print(f"pid={proc.pid}, pidfd={pidfd}")

pidfd_send_signal(pidfd, signal.SIGCONT)
print("살아있는 동안 SIGCONT 전달 성공")

proc.wait()
print(f"프로세스 종료 및 reap 완료 (exit code={proc.returncode})")

try:
    pidfd_send_signal(pidfd, signal.SIGTERM)
    print("경고: 종료된 프로세스에 시그널이 전달됨(있어서는 안 되는 상황)")
except OSError as e:
    print(f"안전하게 실패: {e}")

os.close(pidfd)
$ python3 safe_signal.py
pid=51712, pidfd=3
살아있는 동안 SIGCONT 전달 성공
프로세스 종료 및 reap 완료 (exit code=0)
안전하게 실패: [Errno 3] No such process

살아있을 때는 시그널이 정상 전달되고, 프로세스가 종료·reap된 뒤 같은 pidfd로 다시 시그널을 보내면 ESRCH(No such process)로 깔끔하게 실패한다. 이게 핵심이다 — PID 기반이었다면 그 사이 번호가 재사용된 엉뚱한 프로세스에 시그널이 갈 수도 있었지만, pidfd는 fd가 정확히 그 프로세스(더 정확히는 그 task_struct)에 묶여 있어서 재사용될 여지가 없다. 죽은 프로세스에 보내면 그냥 실패할 뿐, 절대 다른 프로세스를 잘못 건드리지 않는다.

주의사항

  • os.pidfd_open()은 Python 3.9+, 리눅스 커널 5.3+가 필요하다. pidfd_send_signal(2) syscall 자체는 커널 5.1부터 있었지만 Python os 모듈에 대응 래퍼가 아직 없으므로, 이 글처럼 ctypes로 직접 호출하거나 python-pidfd 같은 서드파티 패키지를 써야 한다.
  • syscall 번호(424)는 x86_64 전용이다. ARM64 등 다른 아키텍처에서는 번호가 다르므로, 아키텍처를 가리지 않는 코드가 필요하면 platform.machine()으로 분기하거나 서드파티 바인딩을 쓰는 게 안전하다.
  • 자신이 만들지 않은 임의 PID를 pidfd_open()으로 열 때는 대상 프로세스에 대한 ptrace 권한이 필요하다. 다른 사용자 소유 프로세스는 보통 열리지 않는다.
  • select.poll()은 블로킹 호출이다. asyncio 이벤트 루프 안에서 쓰려면 loop.add_reader(pidfd, callback)으로 등록해 논블로킹 방식으로 통합해야 한다.

마무리

pidfd는 결국 “숫자 PID 대신 파일 디스크립터로 프로세스를 가리키자”는 아이디어다. fd 하나가 정확히 하나의 프로세스에 묶여 있다는 보장 덕분에 PID 재사용 레이스가 구조적으로 사라지고, 기존 이벤트 루프(select/poll/epoll)에 프로세스 종료 감지를 자연스럽게 통합할 수 있다. 다만 Python 표준 라이브러리의 지원이 아직 pidfd_open()waitid() 연동까지만 되어 있고 시그널 전달은 빠져 있다는 점은 실제로 코드를 짜보기 전까지는 잘 드러나지 않는 함정이다.

참고

답글 남기기