[개발 깨알 상식_Tips] / 제한 시간 뒤에도 남는 subprocess 자식 프로세스.md

제한 시간 뒤에도 남는 subprocess 자식 프로세스

조회

2026년 7월 30일 | 개발 깨알 상식_Tips


subprocess.run(..., timeout=30)에서 TimeoutExpired가 났고 호출한 명령도 끝났는데, 그 명령이 띄운 worker는 계속 CPU를 쓰고 있었다. 다음 테스트가 같은 포트를 열지 못해 처음에는 종료 확인이 늦게 반영된 줄 알았다. 실제로는 Python이 기다리던 직접 자식과 그 자식이 다시 만든 프로세스를 같은 종료 범위로 봤던 것이 문제였다.

Python 문서대로 run()의 timeout이 만료되면 내부 자식 프로세스는 kill되고 wait까지 끝난 뒤 예외가 다시 올라온다. 하지만 이 설명의 child process는 내가 시작한 Popen 대상이다. 그 대상이 shell, 테스트 러너, 빌드 도구처럼 손자 프로세스를 만들면 직접 자식의 종료와 전체 작업 트리의 종료는 같은 보장이 아니다.

subprocess timeout에서 직접 자식 종료와 POSIX 프로세스 그룹 종료의 차이
Figure 1. 같은 runner-worker 구조도 직접 자식만 끊는 경로와 새 세션의 프로세스 그룹을 정리하는 경로는 종료 범위가 다르다.

TimeoutExpired는 전체 프로세스 트리 종료 증명이 아니다

재현은 단순했다. Python이 runner를 하나 띄우고 runner가 별도 worker를 실행하게 했다. runner에만 timeout을 걸면 예외 처리와 직접 자식의 wait는 끝났지만 worker PID는 살아 있었다. worker가 잡고 있던 포트와 임시 파일도 그대로였다. wrapper의 함수 호출이 반환됐다는 사실을 운영 작업 전체가 정리됐다는 증거로 쓰면 안 됐다.

shell=True일 때는 경계가 더 잘 가려진다. Python이 직접 보는 PID는 shell이고, 실제 명령은 그 아래에서 실행될 수 있다. shell을 끊었다고 실제 작업까지 항상 사라진다고 기대하기보다 가능하면 인자 목록으로 직접 실행하고, 여러 프로세스를 만드는 도구라면 처음부터 종료할 소유 범위를 별도 계약으로 잡는 편이 안전하다.

여기서 무작정 이름이 같은 프로세스를 전부 죽이면 다른 터미널이나 다른 작업의 worker까지 건드릴 수 있다. 종료 범위는 이름 검색이 아니라 내가 시작한 PID, process group ID, 작업 디렉터리, 포트처럼 소유권을 설명할 수 있는 값에서 출발해야 한다.

POSIX에서는 새 세션으로 종료 범위를 만든다

Linux와 macOS에서는 Popen(start_new_session=True)를 쓰면 자식에서 setsid()가 호출된다. 새 프로세스는 session leader이자 process group leader가 되고, 그 아래에서 평범하게 만들어진 자식들은 같은 process group을 물려받는다. 이때 Popen의 PID를 process group ID로 사용해 그룹 전체에 신호를 보낼 수 있다.

proc = subprocess.Popen(
    args,
    start_new_session=True,
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
    text=True,
)

예전 코드에서는 preexec_fn=os.setsid를 자주 봤다. 현재 Python 문서는 세션이나 그룹을 바꾸려는 목적이라면 start_new_sessionprocess_group 매개변수로 대체하라고 안내한다. 특히 thread가 있는 프로그램에서 preexec_fn은 child가 exec 전에 교착될 수 있어 피하는 쪽이 맞다.

TERM 유예 뒤 같은 그룹에 KILL을 보낸다

timeout이 났다고 바로 SIGKILL부터 보내면 worker가 파일을 닫거나 부분 산출물을 정리할 기회가 없다. 나는 먼저 같은 process group에 SIGTERM을 보내고 짧은 유예 시간을 둔다. 유예 안에 끝나지 않을 때만 SIGKILL로 올린다. 중요한 점은 두 신호를 모두 직접 자식 PID 하나가 아니라 처음 만든 그룹에 보낸다는 것이다.

try:
    out, err = proc.communicate(timeout=30)
except subprocess.TimeoutExpired as exc:
    os.killpg(proc.pid, signal.SIGTERM)
    try:
        out, err = proc.communicate(timeout=5)
    except subprocess.TimeoutExpired:
        os.killpg(proc.pid, signal.SIGKILL)
        out, err = proc.communicate()
    raise exc

communicate()를 다시 호출하는 이유도 있다. pipe를 직접 조금씩 읽고 wait만 하면 자식이 가득 찬 stdout 또는 stderr pipe에서 막힐 수 있다. Python 문서는 timeout 뒤 communicate()를 다시 호출해도 이미 받은 출력이 손실되지 않는다고 설명한다. 종료 코드뿐 아니라 timeout 직전 로그가 필요한 배치라면 이 흐름이 디버깅 자료를 덜 잃는다.

다만 대상 프로그램이 스스로 새 session을 만들거나 daemonize하면 처음 만든 그룹 밖으로 나갈 수 있다. process group 종료는 모든 후손을 추적하는 만능 트리 삭제가 아니다. 그런 프로그램은 foreground 모드, pidfile, 컨테이너, systemd scope처럼 그 도구가 제공하는 수명주기 경계를 먼저 쓰는 편이 낫다.

정리 후에는 PID보다 부작용을 확인한다

SIGTERM이나 SIGKILL 호출이 성공했다는 로그만으로 끝내지 않는다. 시작할 때 기록한 PID와 PGID가 사라졌는지, 테스트 포트가 비었는지, 임시 디렉터리에 부분 산출물이 남았는지 확인한다. 프로세스가 종료됐어도 worker가 이미 외부 큐에 작업을 제출했거나 원격 요청을 보냈다면 부작용은 계속 진행될 수 있다.

재시도도 여기서 갈린다. 읽기 전용 변환처럼 다시 실행해도 안전한 작업은 정리 확인 뒤 재시도할 수 있다. 배포, 결제, 큐 제출, 파일 교체처럼 중복 부작용이 있는 작업은 timeout을 곧바로 실패로 단정하지 않고 외부 상태부터 조회한다. timeout은 “아무 일도 일어나지 않았다”가 아니라 호출자가 제한 시간 안에 완료를 관찰하지 못했다는 상태다.

Windows에는 같은 코드를 복사하지 않는다

start_new_session, os.killpg, SIGTERM은 POSIX 쪽 해법이다. Windows의 CREATE_NEW_PROCESS_GROUP는 이름이 비슷하지만 Unix process group과 종료 의미가 같지 않다. console process group에 CTRL_BREAK_EVENT를 보내는 조건, GUI 프로세스와 service, 강제 종료 범위가 따로 갈린다. 하위 프로세스 전체를 확실히 묶어 관리해야 하면 Windows Job Objects 같은 운영체제 수명주기 기능을 검토해야 한다.

그래서 공용 라이브러리에는 “모든 OS에서 자식 트리를 종료한다”는 이름의 함수 하나를 억지로 만들지 않는다. POSIX 그룹 종료와 Windows 쪽 구현을 분리하고, 지원하지 않는 환경에서는 조용히 직접 자식만 끊기보다 기능 제한을 명확히 드러낸다.

내가 회귀 테스트에서 보는 경계

회귀 테스트는 timeout 예외 하나만 assertion하지 않는다. runner가 worker를 띄우고 고유 포트를 열게 한 뒤 제한 시간을 넘긴다. cleanup이 끝나면 runner와 worker PID가 모두 사라졌는지, 포트에 다시 bind할 수 있는지, 부분 파일 처리 정책이 기대와 맞는지 확인한다. 다른 작업을 해치지 않았다는 것을 보려고 고유한 작업 ID와 임시 디렉터리도 함께 쓴다.

이번 문제에서 수정한 것은 timeout 숫자가 아니었다. 직접 자식의 수명과 작업 전체의 수명을 분리하고, 내가 만든 그룹만 종료하고, 부작용까지 확인하는 경계였다. Python의 subprocess 문서os.killpg 문서를 같이 읽어야 timeout 뒤에 무엇이 보장되고 무엇이 남는지 선명해졌다.

댓글

홈으로 돌아가기

검색 결과

"" 검색 결과입니다.