기본 콘텐츠로 건너뛰기

Shell Returncodes: -N vs 128+N

thumbnail

`shell=True`인데 returncode가 음수가 아니라서 당황했다면: PR #146255가 정리한 “셸의 책임”

meta_description: POSIX에서 subprocess의 returncode는 보통 신호 종료면 -N이라고 배웠지만, shell=Trueasyncio.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

댓글

이 블로그의 인기 게시물

Django에서 트랜잭션 관리하기

Django에서 트랜잭션 관리하기 안녕하세요! 오늘은 Django에서 데이터베이스 트랜잭션을 효과적으로 관리하는 방법에 대해 알아보겠습니다. 1. 트랜잭션의 중요성 트랜잭션은 데이터베이스의 일관성과 무결성을 보장하는 중요한 개념입니다. Django에서는 여러 가지 방법으로 트랜잭션을 관리할 수 있습니다. 1.1 기본 개념 원자성(Atomicity) : 트랜잭션은 모두 실행되거나 모두 실행되지 않아야 합니다. 일관성(Consistency) : 트랜잭션 전후로 데이터베이스의 일관성이 유지되어야 합니다. 격리성(Isolation) : 동시에 실행되는 트랜잭션들이 서로 영향을 주지 않아야 합니다. 지속성(Durability) : 완료된 트랜잭션의 결과는 영구적으로 저장되어야 합니다. 2. Django의 트랜잭션 관리 2.1 기본 설정 # settings.py DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', 'NAME': 'mydatabase', 'USER': 'myuser', 'PASSWORD': 'mypassword', 'HOST': 'localhost', 'PORT': '5432', 'ATOMIC_REQUESTS': True, # 모든 뷰를 트랜잭션으로 래핑 } } 2.2 데코레이터 사용 from django.db import transaction @transaction.atomic def create_order(user, items): order = Order.objects.create(user=...

AWS S3 + CloudFront로 정적 파일 서빙 완전 가이드

AWS S3 + CloudFront로 정적 파일 서빙 완전 가이드 안녕하세요! 오늘은 AWS S3와 CloudFront를 사용하여 정적 파일을 효율적으로 서빙하는 방법에 대해 알아보겠습니다. 왜 S3와 CloudFront를 사용할까요? 높은 가용성 : AWS의 글로벌 인프라를 활용 빠른 전송 속도 : CloudFront의 CDN 기능으로 전 세계 사용자에게 빠른 전송 비용 효율성 : 사용한 만큼만 지불 보안 : AWS의 보안 기능 활용 확장성 : 트래픽 증가에 자동 대응 1. S3 버킷 설정 1.1 버킷 생성 및 설정 import boto3 def create_s3_bucket(): s3 = boto3.client('s3') # 버킷 생성 bucket_name = 'your-static-files-bucket' s3.create_bucket( Bucket=bucket_name, CreateBucketConfiguration={ 'LocationConstraint': 'ap-northeast-2' } ) # 버킷 정책 설정 bucket_policy = { "Version": "2012-10-17", "Statement": [ { "Sid": "PublicReadGetObject", "Effect": "Allow", "Principal": "*", "Action": "s3:GetObje...

Python에서 asyncio 완전 정복 (await, async, gather 등)

어휴, 요즘 파이썬으로 비동기 프로그래밍 하는 재미에 푹 빠졌어요! 특히 asyncio 는 정말 마법 같더라고요. 처음엔 좀 낯설었는데, 익숙해지니까 속도 향상이 눈에 띄게 느껴져서 완전 반해버렸습니다. 이 글에선 제가 asyncio 를 배우면서 깨달은 점들을 풀어놓을게요. 혹시 비동기 프로그래밍이 뭔지 잘 모르시겠다면, 간단히 말해 여러 작업을 동시에 처리해서 프로그램 속도를 엄청나게 높이는 기술이라고 생각하시면 돼요. 마치 여러 요리사가 동시에 음식을 만들어서 손님에게 빨리 제공하는 것과 비슷하죠! 일단 async 와 await 라는 녀석들이 핵심인데요, async 는 함수 앞에 붙여서 "얘는 비동기 함수야!"라고 선언하는 거예요. 그리고 await 는 다른 비동기 함수가 끝날 때까지 기다리라고 지시하는 역할을 하죠. 예를 들어, 네트워크에서 데이터를 가져오는 함수가 있다면, await 를 사용해서 데이터가 다 가져올 때까지 기다렸다가 다음 작업을 진행할 수 있어요. 그 동안 다른 작업을 처리할 수 있으니, 마치 멀티태스킹을 하는 것처럼 느껴져요. 신기하지 않나요? 그리고 asyncio.gather 는 여러 비동기 함수를 동시에 실행하고 결과를 모아주는 아주 유용한 친구입니다. 제가 웹사이트 여러 개에서 데이터를 동시에 가져와야 할 때 정말 요긴하게 썼어요. 하나씩 순서대로 가져오는 것보다 훨씬 빠르더라고요! 마치 여러 개의 탭을 동시에 열어놓고 작업하는 것과 같다고 생각하시면 될 것 같아요. 실제로 제가 썼던 코드를 보여드릴게요. 세 개의 웹사이트에서 데이터를 가져오는 예제인데요. (아래 코드 삽입) 이 코드를 보시면, fetch_data 함수가 각 웹사이트에서 데이터를 가져오는 역할을 하고, asyncio.gather 가 이 함수들을 동시에 실행하도록 도와주는 것을 볼 수 있을 거예요. asyncio.sleep(2) 는 네트워크 지연을 시뮬레이션하기 위해 넣...