기본 콘텐츠로 건너뛰기

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...

RDS에서 Django 앱 성능을 높이는 데이터베이스 설정 팁

RDS에서 Django 앱 성능을 높이는 데이터베이스 설정 팁 안녕하세요! 오늘은 AWS RDS를 사용하는 Django 애플리케이션의 성능을 최적화하는 방법에 대해 알아보겠습니다. 1. RDS 인스턴스 최적화 1.1 인스턴스 타입 선택 # RDS 인스턴스 크기 조정 import boto3 def resize_rds_instance(): rds = boto3.client('rds') response = rds.modify_db_instance( DBInstanceIdentifier='your-db', DBInstanceClass='db.t3.large', # 워크로드에 맞는 인스턴스 타입 선택 ApplyImmediately=True ) return response['DBInstance'] 1.2 파라미터 그룹 설정 def create_parameter_group(): rds = boto3.client('rds') # PostgreSQL 파라미터 그룹 생성 response = rds.create_db_parameter_group( DBParameterGroupName='django-optimized', DBParameterGroupFamily='postgres13', Description='Optimized parameters for Django applications' ) # 성능 관련 파라미터 설정 parameters = [ { 'ParameterName': 'shared_buffers', 'ParameterValue': '2GB...