![]()
`shell=True`인데 returncode가 음수가 아니라서 당황했다면: PR #146255가 정리한 “셸의 책임”
meta_description: POSIX에서 subprocess의 returncode는 보통 신호 종료면 -N이라고 배웠지만, shell=True나 asyncio.create_subprocess_shell()에서는 그 규칙이 깨질 수 있다. CPython PR #146255는 “returncode는 셸의 종료 상태를 반영하며, 신호를 128+N 같은 코드로 매핑할 수 있다”는 점을 문서로 명확히 했다. 운영 코드에서 재현/로깅/알람을 어떻게 설계해야 덜 흔들리는지 정리한다.
meta_keywords: subprocess, asyncio, returncode, shell=True, create_subprocess_shell, create_subprocess_exec, POSIX, signal, 128+N, Bash, exit status, SIGTERM, SIGKILL, 프로세스, 종료코드, 운영, 재현, 로깅, 알람, 파이썬
meta_robots: index,follow
운영하다 보면 이 상황을 한 번은 만난다.
- 프로세스가 SIGTERM으로 죽었으니
returncode == -15일 거라고 생각했는데 - 실제 로그에는
143이 찍혀 있다(= 128 + 15) - 어떤 경우는 또
-15로 찍힌다
그래서 대시보드가 갈라지고, “이번 장애는 신호 종료냐? 정상 종료냐?” 분류가 흔들린다.
CPython PR #146255는 이 혼란의 원인을 문서로 깔끔하게 정리한다. 핵심은 한 줄이다.
shell=True면 returncode는 ‘자식 프로세스’가 아니라 ‘셸(/bin/sh)의 종료 상태’를 반영한다.
셸은 신호를 그대로 “음수”로 내보내지 않을 수 있고, 대신 128+N 같은 규칙으로 매핑할 수 있다(참고자료).
![]()
1) PR #146255가 실제로 추가한 문장(요약)
diff를 보면 문서 두 군데에 같은 요지를 박는다.
asyncio.create_subprocess_exec()로 만든 프로세스는 POSIX에서 “신호 종료면 -N”이 성립한다.- 하지만
asyncio.create_subprocess_shell()은 returncode가 셸의 exit status를 반영하므로, 신호 종료가128+N같은 값으로 나올 수 있다. subprocess문서에도shell=True일 때 동일한 주의 문구를 추가한다.
이게 중요한 이유는 “내가 만든 프로세스”가 아니라 “셸이 만든 프로세스”라는 레이어가 하나 더 생기기 때문이다.
2) 왜 shell=True가 returncode 해석을 망가뜨리나(감각 잡기)
shell=True는 결국 이런 걸 의미한다.
- 내가 실행하고 싶은 커맨드가 있고
- 파이썬은 그 커맨드를 직접 실행하지 않고
- 셸을 띄운 뒤, 셸에게 그 커맨드 문자열을 넘긴다
즉, 파이썬 입장에선 “셸 프로세스” 하나만 직접 자식으로 관리한다.
- 셸이 죽으면 파이썬의 Popen/asyncio 프로세스 객체도 끝난다
- 셸이 내부에서 만든 실제 작업 프로세스가 어떤 신호로 죽었는지는, 셸의 정책에 따라 “종료 코드”로 변환돼서 올라올 수 있다
Bash를 예로 들면, 신호로 종료된 경우 128 + signal 형태가 자주 보인다(PR이 예시로 든 방식도 이거다). 그래서 SIGTERM(15)은 143으로, SIGKILL(9)은 137로 보인다.
문제는 여기서 생긴다.
returncode == -15만 보고 신호 종료를 탐지하면 놓친다returncode == 143만 보고 신호 종료를 탐지하면, exec 경로(-15)에서 놓친다
그래서 “같은 장애”가 환경/코드 경로에 따라 다른 숫자로 찍힌다.
3) 실무에서 흔히 터지는 케이스: asyncio + shell 조합
이 이슈가 asyncio 문서에 들어간 건 이유가 있다.
비동기 코드에서 create_subprocess_shell()은 편하다.
- 문자열 한 줄로 커맨드를 구성하기 쉽고
- 파이프/리다이렉션 같은 셸 기능을 그대로 쓸 수 있고
- 여러 도구를 이어붙이는 배치 작업을 만들기 쉽다
그런데 그 편의는 “종료 코드 계약이 셸로 넘어간다”는 비용을 동반한다.
그래서 장애 대응 관점에선, 가능하면 exec를 기본으로 가져가고, shell은 정말 필요할 때만 쓰는 쪽이 더 안전하다.
create_subprocess_exec(cmd, *args)→ returncode 해석이 단순해짐(-N 규칙 유지)create_subprocess_shell("cmd | other")→ returncode가 셸 정책에 종속됨
4) 운영 코드에서 바로 쓸 수 있는 “returncode 해석” 헬퍼
운영에서 중요한 건 “정확한 셸 규칙을 모두 외우는 것”이 아니라,
- 신호 종료가 의심되는지
- 정상 종료인지
- 재시도/알람 분류를 어떻게 할지
를 일관되게 결정하는 것이다.
나는 보통 아래처럼 해석 레이어를 하나 둔다.
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class ExitInfo:
raw: int
kind: str # "ok" | "exit" | "signal" | "unknown"
code: int | None = None
signal: int | None = None
def interpret_returncode(rc: int, *, shell: bool) -> ExitInfo:
# rc is None handled outside; this function expects int
if rc == 0:
return ExitInfo(raw=rc, kind="ok", code=0)
# POSIX convention for exec-created processes: -N means signal N
if rc < 0:
return ExitInfo(raw=rc, kind="signal", signal=-rc)
# shell=True: many shells map signal to 128+N
if shell and rc >= 128:
n = rc - 128
# not perfect, but practical: treat as signal-ish
return ExitInfo(raw=rc, kind="signal", signal=n)
return ExitInfo(raw=rc, kind="exit", code=rc)
이렇게 해두면, exec 경로(-N)와 shell 경로(128+N)를 같은 분류로 묶을 수 있다.
중요한 건 “n이 진짜 신호 번호가 맞는지”를 100% 확정하는 게 아니라, 운영에서 같은 원인군으로 보고 싶을 때 흔들리지 않게 만드는 것이다.
5) subprocess.run(..., shell=True)에서도 같은 일이 생긴다
이 이슈가 asyncio 문서에 먼저 박힌 것처럼 보이지만, subprocess에서도 똑같이 체감된다. PR diff가 subprocess 문서에 같은 주의 문구를 추가한 이유다.
예를 들어 이런 코드를 운영에서 많이 쓴다.
import subprocess
p = subprocess.run("kill -TERM $$", shell=True)
print(p.returncode)
여기서 “내가 죽인 건 신호니까 -15겠지”라고 기대하면 삐끗한다. 파이썬이 직접 실행한 건 /bin/sh -c ...이고, 그 셸이 신호를 어떻게 exit status로 바꾸는지가 returncode에 남는다.
그래서 shell=True를 쓰는 코드에서는 최소한 이 원칙만 지키는 게 좋다.
- returncode를 그대로 의미 해석하지 말고(특히 음수/양수만으로), shell 여부를 함께 고려한다.
- 신호 종료 분류가 필요하면, exec 경로와 shell 경로를 모두 커버하는 해석 함수를 둔다.
- 알람은 “signal=15” 같은 원인군으로 묶고, raw 코드는 부가 정보로 둔다.
6) 로그/알람 설계 팁: 숫자 하나로 분류하지 말자
PR #146255가 문서에서 하려는 말도 결국 이거다. returncode는 상황에 따라 다른 의미를 가진다.
그래서 나는 로그를 이렇게 남긴다.
shell=True/False를 반드시 같이 기록- 해석 결과(kind=signal/exit)를 같이 기록
- raw returncode도 함께 기록
예:
{
"cmd": "...",
"shell": true,
"returncode_raw": 143,
"returncode_kind": "signal",
"signal": 15
}
이렇게 하면 대시보드가 “-15 vs 143”으로 찢어지지 않는다.
마무리: shell=True는 편의 기능이 아니라 계약 변경이다
PR #146255는 코드를 바꾼 게 아니라 문서를 바꿨다. 그런데 이런 문서 PR이 실무에선 오히려 더 중요하다.
shell=True는 단순한 옵션이 아니라, returncode 계약을 바꾼다create_subprocess_shell()의 returncode는 셸의 exit status를 반영한다- 신호 종료 분류를 하고 싶다면
-N만 믿지 말고, shell일 때128+N도 함께 고려하라
그리고 가장 현실적인 결론은 이거다.
가능하면 exec를 기본으로, shell은 정말 필요할 때만.
덧붙이면, “정말 필요할 때”의 기준도 명확히 잡는 게 좋다.
- 파이프/리다이렉션 같은 셸 문법이 필요하면 shell을 쓰되, returncode 계약이 바뀐다는 걸 받아들인다.
- 단순히 문자열로 커맨드를 만들기 편해서 shell을 쓰는 경우라면, 리스트 인자(exec)로 옮기는 게 대부분 가능하다.
예:
# shell=True 없이도 되는 케이스
# "git"을 실행하고, 인자는 따로 넘긴다
await asyncio.create_subprocess_exec("git", "fetch", "--all")
이렇게 해두면 운영에서 “returncode 해석” 문제를 덜 겪는다. (그리고 보안적으로도 이 편이 대개 낫다.)
References
- CPython PR #146255 — clarify returncode behavior for subprocesses created with
shell=True- https://github.com/python/cpython/pull/146255
- Diff
- https://github.com/python/cpython/pull/146255.diff
이미지 크레딧/라이선스
- Hands on the keyboard (Unsplash).jpg — Puk Khantho / CC0
- https://commons.wikimedia.org/wiki/File:Hands_on_the_keyboard_(Unsplash).jpg
- http://creativecommons.org/publicdomain/zero/1.0/deed.en
댓글
댓글 쓰기