Python subprocess timeout 자식 프로세스 종료: Cron에서 고아 프로세스 막기
예약 작업이 제한 시간을 넘겼다는 알림은 떴는데도 CPU(Central Processing Unit, 중앙 처리 장치) 사용이나 로그 출력은 멈추지 않고, 다음 실행까지 겹친다. CPU는 프로그램 명령을 실제로 처리하며, 이 장애에서는 끝났어야 할 계산이 아직 실행 중인지 확인하는 값이다. 이때는 Python subprocess timeout 자식 프로세스 종료 여부를 예외 한 줄이 아니라 실제 실행 중인 프로세스와 점유 자원으로 확인해야 한다. Cron은 정해 둔 시각에 명령을 실행하는 Unix 계열 예약 실행기이며, 여기서는 남은 작업과 새 작업이 만나는 시점을 만든다. 제목에서 말하는 고아 프로세스는 부모가 먼저 끝나 다른 부모에게 맡겨진 프로세스를 뜻한다. 이미 끝났지만 부모가 종료 상태를 회수하지 않은 좀비 프로세스와는 다르며, 이 글의 직접 문제는 timeout 뒤에도 계속 실행 중인 자손이다.
아래 절차는 2026년 8월 16일 Ubuntu 계열 Linux 커널 7.0.0-1009-aws와 Python 3.12.3에서 만든 격리 실험을 바탕으로 한다. POSIX(Portable Operating System Interface, 이식 가능 운영체제 인터페이스)는 Unix 계열 운영체제가 공통으로 따르는 프로세스 규칙이며, 이 글에서는 새 세션과 그룹 신호를 쓰는 해법의 적용 범위를 Linux·Unix 계열로 한정하는 기준이다. 실제 Cron 데몬이나 운영 작업은 변경하지 않았다. 운영자는 남은 부모와 자손을 확인하고, 새 세션과 프로세스 그룹 단위 종료를 적용한 뒤, 종료 직후와 다음 예약 시점에 무엇을 봐야 하는지까지 이어서 확인할 수 있다.
20초 핵심 요약
- 무엇: Python이 직접 시작한 프로세스의 timeout과 그 프로세스가 다시 만든 자손의 종료 범위를 구분한다.
- 왜: 직접 부모만 끝나고 손자가 남으면 CPU·포트·파일 잠금·로그 쓰기가 계속돼 다음 Cron 실행과 충돌할 수 있다.
- 어떻게: 프로세스 번호와 그룹 번호로 잔존 범위를 확인하고, 새 세션의 그룹에 종료 요청→유예→강제 종료를 적용한 뒤 자손 0개와 다음 예약의 단일 완료를 검사한다.
1. timeout 알림보다 계속 남는 사용자 결과를 먼저 본다
timeout은 정해 둔 대기 시간을 넘겼다는 상태이며, 이 흐름에서는 작업 전체가 멈췄는지 조사하라는 경보다. Cron 알림이나 로그에서 TimeoutExpired를 발견했다면 다음 예약 실행이 이미 시작됐는지, CPU·포트·파일 잠금·로그 쓰기가 계속되는지 함께 본다. TimeoutExpired는 Python이 제한 시간 만료를 알리는 예외이며, 종료 정리가 필요한 분기로 들어가기 위해 필요하다.
Python의 subprocess는 외부 명령을 시작하고 입출력과 종료 코드를 받는 표준 모듈이다. subprocess.run(timeout=...)은 제한 시간이 지나면 자신이 직접 시작해 추적하는 자식 프로세스를 kill하고 wait한 뒤 예외를 다시 던진다. kill은 프로세스에 종료 신호를 보내는 동작이고, wait는 그 프로세스의 종료 상태를 회수하는 동작이다. 문서와 CPython 구현은 이 직접 자식 처리까지 보여 주지만, 그 아래 자손을 찾아 재귀적으로 끝낸다고 약속하지 않는다.
따라서 예외가 발생한 뒤 직접 부모가 사라졌다면 API(Application Programming Interface, 응용 프로그램 프로그래밍 인터페이스)는 문서대로 작동한 것이다. API는 코드가 기능을 요청하는 공개 호출 규칙이며, 여기서는 Python이 보장하는 종료 범위와 운영자가 기대한 종료 범위를 구분하기 위해 확인한다. 문제는 “직접 자식 종료가 작업 트리 전체 종료와 같다”는 운영 가정이다.
2. 프로세스 번호로 부모와 자손을 함께 찾는다
먼저 남은 실행의 관계와 종료 범위를 기록한다.
PID(process ID)는 운영체제가 각 프로세스에 붙이는 번호다. 어떤 실행을 확인하고 신호를 보낼지 식별하는 데 필요하다.PPID(parent process ID)는 해당 프로세스를 시작한 부모의 PID다. 부모와 자손의 연결을 따라가는 데 필요하다.PGID(process group ID)는 같은 신호를 받을 프로세스 묶음의 번호다. 하나의 작업을 그룹 단위로 끝낼 수 있는지 판단하는 데 필요하다.SID(session ID)는 하나 이상의 프로세스 그룹을 담는 세션의 번호다. Cron 래퍼나 다른 작업과 이번 실행의 종료 경계를 분리했는지 확인하는 데 필요하다.
Linux에서 직접 부모 PID를 알고 있다면 다음처럼 첫 관계를 볼 수 있다.
ps -o pid,ppid,pgid,sid,stat,cmd --forest -p <PID> --ppid <PID>
기대하는 결과는 부모와 직접 자식의 PID·PPID·PGID·SID가 한 화면에 나타나는 것이다. 자손이 더 깊다면 이 명령만으로 전부 보이지 않을 수 있으므로 환경의 프로세스 트리 도구로 범위를 확장한다. 명령 문자열을 grep해서 결과가 없다는 사실만으로 완료를 판정하지 않는다. 문자열이 달라질 수 있고 PID도 다시 사용될 수 있기 때문이다.
작업이 점유하던 포트, 잠금 파일, 출력 로그도 이때 함께 확인한다. 프로세스 목록에서 부모가 사라졌어도 이 자원이 계속 사용 중이라면 종료는 아직 끝나지 않았다.
3. 기본 timeout에서 손자가 남는지 같은 입력으로 재현한다
운영 작업을 바로 바꾸기 전에 부모가 손자 하나를 시작하는 작은 fixture를 만든다. fixture는 특정 상태를 재현하기 위한 고정된 시험 프로그램이며, 여기서는 직접 자식과 자손의 종료 범위를 따로 보기 위해 필요하다. 손자 PID를 반드시 기록하고, 시험 뒤 남아 있으면 즉시 정리한다.
이번 격리 실험은 부모 Python이 SIGTERM을 무시하며 30초 기다리는 손자 Python을 시작하도록 했다. SIGTERM은 프로그램에 정리할 기회를 주는 종료 요청 신호다. 기본군에는 subprocess.run(..., timeout=0.4)을 적용하고, timeout 뒤 /proc/<pid>/stat 상태와 os.kill(pid, 0)을 함께 확인했다. /proc은 Linux가 프로세스 상태를 파일처럼 보여 주는 인터페이스이며 이 판정은 Linux 전용이다.
같은 운영체제, Python, 부모 코드, 손자 코드와 0.4초 timeout에서 얻은 핵심 출력은 다음과 같다.
BEFORE timeout=True parent_alive=False grandchild_alive=True
AFTER timeout=True pid_eq_pgid=True kill_escalated=True parent_rc=-15 grandchild_alive=False
ASSERT before_orphan=True after_tree_cleaned=True
probe_exit=0

BEFORE의 정상 재현 결과는 TimeoutExpired가 발생하고 직접 부모는 끝났지만 손자는 남는 것이다. 시험 자체가 실패해서 부모까지 남거나 손자 PID를 찾지 못했다면, 종료 코드를 적용하기 전에 fixture와 판정 코드부터 고친다. 의도적으로 남긴 손자는 판정 직후 PID를 사용해 SIGKILL로 정리했다.
SIGKILL은 프로그램이 잡거나 막거나 무시할 수 없는 강제 종료 신호다. 정리 코드를 실행할 기회가 없으므로 시험 잔여물을 치우는 데는 쓸 수 있어도, 운영 데이터 쓰기 중에는 마지막 수단으로만 사용해야 한다.
4. 새 세션을 만들고 이번 실행만의 PGID를 기록한다
프로세스 그룹은 관련 프로세스에 같은 신호를 보낼 수 있게 운영체제가 붙이는 묶음이다. 세션은 그 그룹들을 담는 더 큰 작업 경계다. start_new_session=True는 POSIX 환경에서 자식 실행 직전에 setsid()를 호출해 시작 프로세스를 새 세션과 새 프로세스 그룹의 리더로 만든다. Cron 래퍼나 무관한 작업을 함께 죽이지 않고 이번 실행만 가리키기 위해 이 분리가 먼저 필요하다.
아래 코드의 PIPE는 부모와 자식 사이에서 표준 출력과 표준 오류를 전달하는 통로다. 종료 뒤 남은 출력을 회수하고 파이프가 닫혔는지 확인하려고 사용한다.
import os
import signal
import subprocess
import time
from collections.abc import Sequence
def run_grouped(
command: Sequence[str],
timeout_seconds: float,
grace_seconds: float,
) -> tuple[int, str, str, bool, bool]:
proc = subprocess.Popen(
command,
start_new_session=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
pgid = os.getpgid(proc.pid)
try:
stdout, stderr = proc.communicate(timeout=timeout_seconds)
return proc.returncode, stdout, stderr, False, False
except subprocess.TimeoutExpired:
try:
os.killpg(pgid, signal.SIGTERM)
except ProcessLookupError:
pass
deadline = time.monotonic() + grace_seconds
while time.monotonic() < deadline:
try:
os.killpg(pgid, 0)
except ProcessLookupError:
break
time.sleep(min(0.05, deadline - time.monotonic()))
kill_escalated = False
try:
os.killpg(pgid, 0)
except ProcessLookupError:
pass
else:
kill_escalated = True
os.killpg(pgid, signal.SIGKILL)
stdout, stderr = proc.communicate()
return proc.returncode, stdout, stderr, True, kill_escalated
명령은 가능하면 shell=True인 문자열 한 덩어리보다 인자 목록으로 넘긴다. 시작 직후 기록한 PID와 PGID가 같은지 확인한다. POSIX의 새 세션 리더라면 둘이 같아야 한다. 다르면 신호를 보내지 말고 실제 그룹과 코드 경계를 다시 확인한다. 현재 Cron 래퍼와 같은 그룹을 잘못 대상으로 삼으면 래퍼나 무관한 프로세스까지 종료될 수 있다.
스레드가 있는 프로그램에서는 오래된 preexec_fn=os.setsid 예제를 기본 해법으로 쓰지 않는다. Python은 preexec_fn이 자식의 exec 전 교착 상태를 만들 수 있다고 경고하며, 이 용도에는 start_new_session 같은 전용 매개변수를 제공한다.
5. 그룹에 TERM을 보낸 뒤 측정한 유예 시간만 기다린다
os.killpg(pgid, signal.SIGTERM)은 기록한 PGID의 그룹에 종료 요청을 보낸다. 유예 시간(grace period)은 TERM을 받은 프로그램이 로그를 내보내고 임시 파일이나 트랜잭션을 정리하도록 기다리는 제한된 시간이다. 정리할 기회를 주면서도 다음 예약 실행을 침범하지 않게 하려면 이 시간이 필요하다.
유예 시간은 고정된 모범 숫자가 아니다. 정상 TERM 종료에 실제로 걸리는 시간의 상한보다 길고, 다음 Cron 시작까지 남은 안전 여유보다 짧게 정한다. 이번 실험의 0.2초는 TERM을 무시하는 분기를 빨리 확인하기 위한 시험값일 뿐 운영 권장값이 아니다. 두 조건을 동시에 만족하지 못하면 예약 간격, 작업 크기나 중복 정책부터 바꿔야 한다.
코드의 os.killpg(pgid, 0)은 실제 종료 신호를 보내지 않고 그룹이 존재하는지 묻는다. 기다리는 동안 그룹이 먼저 사라지면 정상 종료이므로 KILL로 넘어가지 않는다. ProcessLookupError는 신호를 보내기 전에 대상이 사라진 경우라 정상적인 선행 종료일 수 있다. 다만 이 검사는 PID·PGID 재사용 경쟁을 완전히 제거하는 보안 장치가 아니며, 자손이 스스로 다른 그룹이나 세션으로 이탈하면 찾지 못한다.
6. 남은 그룹만 KILL하고 파이프와 직접 부모를 회수한다
유예가 끝난 뒤 그룹 존재를 다시 확인하고, 남은 경우에만 SIGKILL로 승격한다. KILL은 임시 파일 삭제나 데이터베이스 작업 취소 같은 정리 코드를 실행하지 못하게 한다. 그러므로 작업의 실패 방식에 맞는 복구 수단을 하나씩 확인해야 한다.
멱등성은 같은 입력을 다시 처리해도 결과가 한 번 처리한 것처럼 유지되는 성질이다. Cron 재실행이 중복 등록이나 중복 결제 같은 부작용을 만들 수 있을 때 필요하다.
원자적 쓰기는 결과가 전부 반영되거나 전혀 반영되지 않게 저장하는 방식이다. 임시 파일을 완성한 뒤 한 번에 이름을 바꾸는 방법처럼, KILL 직전에 작성하던 불완전한 결과가 최종 파일로 노출되지 않게 할 때 필요하다.
체크포인트는 긴 작업이 어디까지 끝났는지 남기는 재개 지점이다. 처음부터 다시 실행하는 비용이 크거나 이미 끝난 구간을 안전하게 건너뛰어야 할 때 필요하다.
롤백은 새 종료 래퍼나 작업 변경을 이전 코드·데이터 상태로 되돌리는 복구 절차다. 그룹 종료가 데이터의 일관성을 깨뜨리거나 다음 예약 실행을 실패시킬 때 적용을 취소하려면 필요하다. 이 가운데 실제 실패에 필요한 수단이 준비되지 않았다면 그룹 강제 종료를 운영에 넣지 않는다.
마지막 proc.communicate()는 표준 출력과 표준 오류의 끝을 읽고 직접 부모의 종료 상태를 회수한다. EOF(end-of-file)는 더 읽을 데이터가 없다는 끝 표식이다. 손자가 출력 파이프의 쓰기 끝을 상속하면 직접 부모가 끝나도 EOF가 오지 않아 운영자가 느끼는 대기 시간이 길어질 수 있다.
여기서 Popen.communicate(timeout=...)과 subprocess.run(timeout=...)의 책임을 구분해야 한다. 전자는 timeout 때 자식을 자동으로 끝내지 않으므로 호출자가 kill한 뒤 다시 communicate()해야 한다. 후자는 내부에서 직접 자식을 kill하고 wait한 뒤 예외를 다시 던진다. 어느 쪽도 자손 트리를 재귀적으로 종료한다고 약속하지 않는다.
또한 communicate()는 읽은 출력을 메모리에 모은다. 출력이 크거나 끝없이 이어질 수 있다면 이 골격의 파이프 수집을 그대로 쓰지 말고 파일, 스트리밍 소비나 회전 로그를 별도로 설계한다.
왼쪽에서 오른쪽으로 첫 실행의 timeout, 직접 부모 종료, 손자 생존, 그룹 TERM/KILL, 다음 예약 실행 순서로 읽는다.

완료 기준은 TimeoutExpired가 아니라 종료 직후 자손 0개와 다음 예약 실행의 단일 정상 완료다.
7. 종료 직후에는 return code가 아니라 자손과 자원을 확인한다
수정군의 AFTER는 시작 PID와 PGID가 같았고, TERM을 무시한 손자 때문에 KILL 승격이 발생했으며, 마지막에는 손자가 실행 상태에서 사라졌음을 보여 준다. parent_rc=-15는 POSIX에서 직접 부모가 신호 15, 즉 SIGTERM으로 끝났다는 뜻일 뿐 그룹 전체가 사라졌다는 증거는 아니다.
종료 루틴 직후 다음 값을 한 기록에 남긴다.
- timeout 발생 여부와 경과 시간
- 시작 PID와 기록한 PGID
- TERM 전송 여부와 KILL 승격 여부
- 직접 부모의 return code
- 최종 실행 중인 자손 수
- 작업이 소유한 포트·파일 잠금·출력 경로의 해제 여부
관련 실행 프로세스가 0개이고 소유 자원까지 풀려야 종료 직후 확인이 끝난다. 자손이 남았다면 스스로 setsid()나 setpgid()를 호출해 그룹을 이탈했는지 먼저 본다. 이 경우 같은 killpg 호출을 반복하는 것은 해결이 아니다.
cgroup(control group, 제어 그룹)은 Linux가 프로세스를 계층으로 묶어 소속과 자원을 관리하는 경계다. 프로세스 그룹을 벗어난 자손까지 상위에서 추적해야 할 때 검토할 수 있어 필요하지만, 이번 실험에서는 검증하지 않았다. systemd는 Linux 서비스의 시작·종료와 cgroup 배치를 관리하는 서비스 관리자다. Cron 코드보다 강한 작업 수명 경계가 필요할 때 대안이 될 수 있지만 이 역시 이번 실험의 검증 대상은 아니다.
8. 다음 Cron 시각에 단일 실행과 완료 표식을 확인한다
프로세스 그룹 정리와 중복 실행 방지는 서로 다른 책임이다. 그룹 종료는 timeout 난 한 실행의 자손을 정리한다. flock 같은 잠금이나 애플리케이션 멱등성은 다음 실행의 동시 진입과 중복 부작용을 막는다. Cron은 예약 조건이 맞으면 명령을 실행하며 항목을 매분 검사하지만, 이전 실행이 남았을 때 새 실행을 자동으로 막는 일반 보장은 crontab 문서에 없다.
실제 반영 뒤 다음 예약 시각까지 기다려 다음을 확인한다.
- 새 실행 시작 기록이 정확히 1개다.
- 이전 PID·PGID의 실행이 남지 않았다.
- 잠금을 획득했거나, 중복이면 명시적으로 거절했다.
- 포트·파일 잠금 충돌 없이 완료 표식과 기대 산출물이 남았다.
- timeout 뒤 재실행해도 부분 데이터가 중복되거나 깨지지 않았다.
이번 조사에서는 실제 Cron 실행과 다음 분 재진입을 수행하지 않았다. 따라서 위 목록은 운영 반영 후 남은 필수 검증이다. 실패하면 새 래퍼를 단일 호출 지점이나 기능 플래그에서 되돌리고, 남아 있는 자손은 기록한 PID·PGID와 작업 자원을 기준으로 운영 절차에 따라 정리한다. 기존 run(timeout) 경로로 돌아가면 손자 생존 위험도 함께 돌아온다는 사실을 장애 기록에 남긴다.
적용하지 말아야 할 조건도 먼저 정한다
이 구현은 POSIX/Linux에서 호출자가 같은 프로세스 그룹에 남는 자손 트리를 함께 소유해야 할 때의 채택 후보다. 다음 조건에는 그대로 적용하지 않는다.
- Windows:
os.killpg()는 Unix 전용이다.CREATE_NEW_PROCESS_GROUP도 POSIXkillpg와 같은 강제 트리 종료 API가 아니다.Windows Job Object는 Windows가 여러 프로세스를 한 단위로 관리하고 종료하게 하는 운영체제 객체이며, Windows에서 작업 트리 전체의 수명을 소유하려면 이런 별도 경계가 필요하다. 이번 글에서는 구현하거나 검증하지 않았다. - 그룹을 이탈하는 자손: 자손이 새 세션이나 그룹을 만들면 원래 PGID의 신호가 닿지 않는다.
- 부분 쓰기를 복구할 수 없는 작업: KILL이 중간 파일이나 외부 시스템 변경을 남길 수 있는데 앞에서 설명한 복구 수단이 없다면 적용을 보류한다.
- 크거나 무제한인 출력: 파이프 출력을 메모리에 모으는 현재 골격 대신 출력 경로부터 바꾼다.
- 컨테이너, macOS·BSD 등 미검증 환경: 이번 Linux/Python 3.12.3 결과를 그대로 일반화하지 않고 해당 환경에서 같은 fixture로 확인한다.
배포 전 같은 부모·손자 fixture를 다시 실행하고, 실제 다음 예약 1회에서 자손 0개, 자원 해제와 단일 정상 완료를 확인한다. 이 세 판정을 모두 통과하지 못했다면 timeout 처리가 끝난 게 아니라 다음 충돌을 잠시 미룬 상태다.