서브프로세스가 프로그램보다 오래 살아남는 이유: 확실하게 종료하는 방법
요약
Python의 subprocess 모듈 사용 시 자식 프로세스가 제대로 종료되지 않고 남는 원인과 해결 방법을 다룹니다. SIGTERM과 SIGKILL의 차이, 셸 실행 시 발생하는 신호 전달 문제 등 프로세스 관리의 기술적 간극을 분석합니다.
핵심 포인트
- terminate()는 SIGTERM을 보내며 프로세스가 이를 무시하거나 지연시킬 수 있음
- kill()은 SIGKILL을 보내 즉시 종료시키지만 정리(cleanup) 기회를 박탈함
- shell=True 사용 시 신호가 직계 자식인 셸에만 전달되어 실제 워커에 닿지 않을 수 있음
- 안전한 프로세스 종료를 위해 신호 전달 대상과 핸들러 동작을 정확히 이해해야 함
02:00에 배포 스크립트가 성공을 보고했습니다. 로그에는 워커(worker)가 종료되고, 상태 확인(health check)을 통과했으며, 스크립트가 종료 코드 0으로 종료되었다고 기록되었습니다. 하지만 로그가 보여주지 않은 것은 10분 전에 시작된 자식 프로세스(child process)였습니다. 그 프로세스는 여전히 살아있었고, 여전히 포트(port)를 점유하고 있었으며, 이미 로테이션(rotated)되어 사라진 로그 파일에 계속 내용을 추가하고 있었습니다. 다음 배포는 포트가 이미 사용 중이라는 이유로 실패했고, 사후 분석(postmortem) 결과는 당연한 사실을 말해주었습니다: 이전 실행이 정리(cleanup)를 하지 않았다는 것입니다. 아무도 명령어를 잘못 입력하지 않았습니다. 스크립트가 단지 잘못된 것을 종료했을 뿐입니다.
서브프로세스(subprocess)를 시작하는 모든 언어는 종료 버튼을 함께 제공하지만, 모든 언어에서 그 버튼은 절반 정도는 거짓말입니다. Python에서 이 거짓말은 세 부분으로 나뉘며, 정해진 순서대로 실패합니다: terminate()가 실제로 무엇을 보내는지, 신호(signal)가 도착했을 때 누가 듣고 있는지, 그리고 신호가 전달된 후 프로세스에 어떤 일이 일어나는지입니다. 대부분의 정리(cleanup) 버그는 호출과 사체(corpse) 사이의 간극에 존재하며, 대부분의 버그는 코드 리뷰를 통과합니다. 왜냐하면 해피 패스(happy path) — 즉, 스스로 종료되는 자식 프로세스 — 는 이러한 문제를 절대 드러내지 않기 때문입니다. 이 글은 첫 번째 신호부터 수확된(reaped) 프로세스까지 그 간극을 살펴보고, 아무것도 실행되지 않은 채 남는 종료 프로토콜(termination protocol)과 단 한 번의 sleep 없이도 이를 증명하는 테스트로 마무리합니다.
terminate()가 실제로 보내는 것
subprocess.Popen 객체에 대해 process.terminate()를 호출하면, Python은 자식 프로세스에 SIGTERM을 보냅니다. SIGTERM은 '죽이는 것(kill)'이 아닙니다. 그것은 종료 요청이며, 자식 프로세스가 교체했을 수도 있고, 무시하고 있을 수도 있으며, 혹은 아직 처리할 준비가 되지 않았을 수도 있는 시그널 핸들러(signal handler)로 전달됩니다. 잘 동작하는 프로세스는 종료됩니다. 버퍼를 비우거나(flush), 상태를 저장하거나, 네트워크 호출을 완료하는 핸들러를 가진 프로세스는 몇 초 또는 몇 분이 걸릴 수 있습니다. 핸들러가 전혀 없는 프로세스는 즉시 종료됩니다. 그리고 당신이 제어할 수 없는 코드 내에서 종료하지 않기로 결정한 핸들러를 가진 프로세스는 그저 계속 실행될 뿐입니다.
process.kill()은 SIGKILL을 전송하며, 커널은 프로세스에게 아무것도 묻지 않고 이를 전달합니다. 그 무엇도 이를 무시할 수 없고, 아무것도 이를 미룰 수 없으며, 프로세스는 정리(clean up)할 기회를 얻지 못합니다. 이 두 호출은 부드러운 방식과 잔혹한 방식 사이의 토글(toggle)처럼 보입니다. 하지만 실제로는 부모가 관리해야 하는 협상의 두 끝점입니다. 왜냐하면 부모는 자신이 어떤 종류의 자식과 대화하고 있는지 미리 알 수 없기 때문입니다.
import signal
import subprocess
...
실제로 신호를 받는 대상은 누구인가
두 번째 거짓말은 전달(delivery)에 관한 것입니다. 기본 설정에서 신호는 정확히 하나의 프로세스, 즉 직계 자식(direct child)에게 전달됩니다. 만약 그 자식이 셸(shell)이라면 — shell=True를 전달할 때마다 그렇고, 지정한 실행 파일이 래퍼 스크립트(wrapper script), Makefile, 또는 자체 워커(worker)를 생성하는 언어 러너(language runner)인 경우에도 자주 그렇습니다 — 자식은 아무것도 전달하지 않습니다. 당신의 SIGTERM은 3개의 프로세스가 더 있는 트리(tree) 위에 앉아 있는 셸에 도달했을 뿐이며, 셸은 자신의 자식들에게 전달될 신호를 어떻게 처리할지에 대한 자신만의 생각을 가지고 있습니다.
셸이 없는 경우에도, 실제 자식들은 손자 프로세스(grandchildren)를 생성합니다. 빌드 도구는 컴파일러를 시작하고, 컴파일러는 링커(linker)를 시작하며, 미디어 인코더는 워커 프로세스들로 퍼져 나갑니다. 부모는 그 트리의 최상단에 대해서만 핸들(handle)을 보유하고 있으며, 최상단을 종료하면 나머지는 계속 실행된 채로 남습니다. 그 나머지가 바로 포트(port), 잠금 파일(lock file), 또는 절반만 작성된 데이터베이스 행(row)을 점유하고 있는 부분입니다.
# shell=True는 명령어를 /bin/sh -c로 감쌉니다
proc = subprocess.Popen(
"ffmpeg -i in.mp4 out.mp4", # sh가 ffmpeg를 자신의 자식으로 생성합니다
...
가족 전체를 종료하기
해결책은 프로세스에 직접 명령하는 것을 멈추고 그룹(group)에 명령하는 것입니다. POSIX 시스템은 신호가 트리 전체를 겨냥할 수 있도록 프로세스들을 그룹화합니다. start_new_session=True를 사용하여 자식을 자신만의 세션(session)에서 시작하면 해당 자식은 프로세스 그룹 리더(process-group leader)가 됩니다. 그 후 os.killpg를 사용하여 그룹의 모든 구성원에게 신호를 보냅니다. 당신이 시작한 자식, 그 자식이 생성한 손자, 그리고 그 사이의 셸 모두가 단 한 번의 호출로 신호를 받게 됩니다.
proc = subprocess.Popen(
["worker", "--queue", "high"],
start_new_session=True, # 자식이 자신만의 프로세스 그룹 (process group)을 이끎
...
그룹화 (Grouping)는 종료 (kill)의 의미를 "최상위 프로세스에게 중단하라고 말하기"에서 "이 유닛에 속한 모두에게 중단하라고 말하기"로 바꿉니다. 이는 매니저에게 전화를 거는 것과 층 전체를 대피시키는 것의 차이이며, 프로세스 트리 (process tree)의 경우 층이 실제로 비어 있는지 확신할 수 있는 유일한 방법입니다.
자식을 계속 실행하게 만드는 타임아웃 (Timeout)
세 번째 거짓말은 시간에 관한 것입니다. Popen.communicate(timeout=N)은 서브프로세스 (subprocess)의 범위를 제한하는 가장 일반적인 방법이며, 그 실패 모드는 조용히 유명합니다. 해당 메서드는 TimeoutExpired를 발생시키고, 호출자는 이를 잡아내지만(catch), 자식 프로세스는 계속 실행됩니다. 타임아웃은 부모의 인내심을 제한했을 뿐, 자식의 생명을 제한한 것이 아니기 때문입니다. 이 함정은 더욱 까다로운데, 침묵하는 것이 아니기 때문입니다. 코드는 호출이 타임아웃되었음을 명확히 알고 있지만, 로그를 남기고 계속 진행하는 자연스러운 반응이 바로 고아 프로세스 (orphan)를 남겨두는 바로 그 반응이 됩니다.
try:
out, err = proc.communicate(timeout=10)
except subprocess.TimeoutExpired:
...
같은 메서드 내부에는 두 번째 함정이 있습니다. 타임아웃이 발생한 후, communicate()는 파이프 (pipes)를 일관되지 않은 상태로 남겨두며, 표준 라이브러리 (standard library) 문서에서는 복구 방법에 대해 명시적으로 설명하고 있습니다. 먼저 프로세스를 종료(kill)한 다음, communicate()를 다시 호출하여 버퍼 (buffers)에 남아 있는 것을 모두 비워내라는 것입니다. 두 번째 호출은 선택적인 회계 처리가 아닙니다. 종료될 당시 대용량 페이로드 (payload)를 쓰고 있던 자식 프로세스는, 남은 바이트를 읽어주지 않으면 부모 프로세스가 가득 찬 파이프 버퍼에서 블로킹 (blocked) 상태가 될 수 있습니다.
except subprocess.TimeoutExpired:
proc.kill()
out, err = proc.communicate() # 파이프를 비운 뒤, 회수(reap)합니다
끝을 맺는 종료 프로토콜 (Termination Protocol)
이 모든 조각을 합치면, 신뢰할 수 있는 종료(termination)는 네 단계의 고정된 시퀀스로 이루어집니다: 그룹에 정중하게 신호를 보내고, 마감 시간(deadline)을 두고 기다린 뒤, 그룹에 강제적으로 신호를 보내고, 다시 시체가 수거될 때까지 기다리는 것입니다. 두 신호 사이의 마감 시간은 모범적으로 동작하는 프로세스가 정리(cleanup) 작업을 수행할 기회를 제공하며, 두 번째 대기(wait)는 부모 프로세스가 다음 단계로 넘어가기 전에 프로세스 테이블(process table)의 슬롯이 실제로 비워졌음을 보장합니다.
def terminate_tree(proc: subprocess.Popen, grace: float = 5.0) -> int:
os.killpg(proc.pid, signal.SIGTERM)
try:
...
유예 기간(grace period)은 튜닝 노브(tuning knob)가 아니라 정책적 결정입니다. 즉, 파일이 절반만 작성될 위험을 감수하기 전에 당신이 지불할 용의가 있는 정리 시간의 양을 의미합니다. 이 프로토콜이 작동하는 이유는 각 단계가 무조건적(unconditional)이기 때문입니다. 어떤 단계도 자식 프로세스가 협조적인지 묻지 않으며, 어떤 단계도 결정을 내리기 전에 프로세스 상태를 재확인하지 않습니다. 전체 흐름에서 유일한 조건문은 타임아웃(timeout)뿐이며, 타임아웃은 마감 시간(deadline)이지 자식의 기분을 추측하는 것이 아닙니다.
좀비(Zombies), 고아(Orphans), 그리고 누가 그들을 회수하는가
종료된 프로세스는 끝난 것이 아닙니다. 그것은 좀비(zombie)가 됩니다. 즉, 종료 상태(exit status)를 가진 채 프로세스 테이블(process table)의 항목으로 남아, 부모가 wait()를 통해 해당 상태를 읽어주기를 기다리는 상태입니다. 좀비는 CPU나 언급할 만한 메모리도 거의 사용하지 않기에 비용이 거의 들지 않지만, 프로세스 테이블의 슬롯을 점유합니다. 좀비가 충분히 쌓인 시스템은 결국 새로운 프로세스 생성을 거부하게 됩니다. 커널이 시체를 남겨두는 이유는 부모가 자식이 어떻게 죽었는지 알고 싶어 할 수도 있기 때문입니다. 부모의 임무는 그 시체를 수거(collect)하는 것입니다.
Popen.wait()와 communicate()가 대신 수거(reaping)를 수행합니다. 미묘한 부분은 부모가 이 메서드들을 절대 호출하지 않을 때 발생합니다. 일회성 자식 프로세스를 생성하고 각 자식에 대해 대기(wait)하는 것을 잊어버리는 장기 실행 워커(long-lived worker)는 시스템이 인지할 때까지 좀비(zombie) 프로세스를 축적합니다. 부모가 먼저 죽은 자식은 가장 가까운 생존 조상에게 입양되는데, 이는 보통 당신의 코드가 실행을 멈추는 순간 당신의 문제에서도 벗어남을 의미합니다. 하지만 이는 당신의 코드가 실제로 멈출 때만 해당됩니다. 에러를 포착하고 계속 실행되는 프로세스 내에서는, 수거되지 않은 자식은 계속 수거되지 않은 상태로 남습니다.
asyncio 세계도 내부 구조(plumbing)만 다를 뿐 동일한 형태를 가집니다. create_subprocess_exec은 동일한 wait() 및 communicate() 메서드를 가진 Process 객체를 반환하며, 동일한 에스컬레이션(escalation)이 적용됩니다. 추가적인 위험 요소는, await를 하지 않은 서브프로세스를 감싸는 단순한 예외 처리기(exception handler)가 있을 경우, 이벤트 루프(event loop) 자체가 종료될 때까지 자식 프로세스가 계속 실행된다는 점입니다. 그리고 루프는 자식이 계속 작업하는 동안에도 기꺼이 종료될 것입니다. 종료(Termination)는 이벤트 루프가 당신을 위해 관리해 주는 세부 사항이 아닙니다.
Sleep 없이 라이프사이클 테스트하기
라이프사이클 버그는 사람들이 보통 작성하는 테스트에서는 보이지 않습니다. 왜냐하면 일반적인 테스트는 프로세스를 시작하고, 출력을 검증(assert)한 뒤, 자연스럽게 종료되도록 두기 때문입니다. 이 글에서 다루는 모든 실패 모드는 종료를 거부하는 자식 프로세스와 관련이 있으므로, 테스트에서 의도적으로 이를 생성해야 합니다. 요령은 Python 자체를 다루기 힘든 자식 프로세스로 사용하는 것입니다. 즉, SIGTERM을 포착하고 이를 받았다고 출력하며, 더 강력한 신호가 올 때까지 계속 실행되는 짧은 스크립트를 만드는 것입니다.
STUB = """
import signal
import sys
...
중간 상태 — SIGTERM 이후에는 살아있고, SIGKILL 이후에는 죽어있는 상태 — 를 검증(asserting)함으로써 버그가 발생하는 정확한 지점에 테스트를 고정할 수 있습니다. 동일한 스텁(stub)은 쉘(shell) 케이스에서도 작동합니다. shell=True로 시작하고, 최상위 프로세스를 종료한 뒤, 손자 프로세스가 일반적인 terminate()에는 살아남지만 killpg에는 살아남지 못하는지 확인하십시오. 모든 검증(assertion)은 실제 시간(wall-clock timing)이 아닌 프로세스 상태에 관한 것이므로, 테스트는 결정론적(deterministic)이며 절대 sleep을 사용하지 않습니다.
서브프로세스를 종료하는 것은 단순한 호출(call)이 아닙니다. 그것은 네 가지 의무를 수반하는 프로토콜입니다: 당신의 시그널(signal)이 무엇을 요구하는지 알고, 누가 듣고 있는지 알며, 전체 트리(tree)를 다루고, 사체(corpse)를 수습하는 것입니다. Python은 SIGTERM, start_new_session, os.killpg, wait와 같은 모든 요소를 제공하지만, 이 요소들이 잘못된 순서로 실행된다면 그 중 어느 것도 작동하지 않으며, 자식 프로세스가 스스로 종료되도록 허용하는 테스트들 중 그 어느 것도 이를 검증하지 않습니다. 다음에 포트 점유 문제로 배포(deploy)가 실패한다면, 질문은 이전 실행이 자식 프로세스를 죽였느냐가 아닙니다. 질문은 그 자식 프로세스가 실제로 죽었느냐 하는 것입니다.
원문은 Dispatch에 처음 게시되었습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기