Cron 자동화 장치의 출력 상태를 표시하는 주황색 제어 패널과 로그 카드 일러스트

Python subprocess text=True UnicodeDecodeError: Cron agent 출력에 errors 정책 적용하기

자식 프로세스는 0으로 종료했는데 부모 Python에는 CompletedProcess가 남지 않을 수 있다. Python subprocess text=True UnicodeDecodeError는 이 어긋남에서 시작한다. text=True가 파이프의 바이트를 str로 바꾸는 동안 decoder가 먼저 실패하면, check=False여도 호출부는 result.returncode를 확인할 기회를 얻지 못한다.

2026년 8월 13일 Linux 7.0.0-1009-aws x86_64, CPython 3.12.3, 기본 로케일 인코딩 UTF-8 환경에서 고정 바이트를 쓰는 자식 프로세스로 이 흐름을 검증했다. 실제 Cron 스케줄러나 운영 agent를 실행한 결과는 아니다. 이 글에서는 같은 exit 0 입력으로 strict, replace, bytes 모드를 비교하고, 사람용 통합 로그와 무손실 진단 데이터에 서로 다른 정책을 적용하는 기준까지 다룬다.

20초 핵심 요약

  • 무엇: text=True는 stdout이 올바른 텍스트라는 보증이 아니라 캡처한 바이트를 지정 정책으로 디코딩하라는 요청이다.
  • 왜: strict 디코딩은 자식이 exit 0이어도 CompletedProcess 반환을 막아 종료 코드와 통합 로그를 남기지 못하게 할 수 있다.
  • 어떻게: 동일한 invalid 바이트를 strict·replace·bytes 모드로 재현한 뒤, 가독성·손실 가시성·원본 보존 요구에 맞춰 정책을 고른다.

exit 0과 호출 성공을 같은 상태로 기록하면 원인을 놓친다

HuntLab의 scripts/run_daily_pipeline.py:273-280에 있는 run_stage() 호출은 stdout=PIPE, stderr=STDOUT, text=True, check=False를 사용하지만 encodingerrors는 지정하지 않는다. 정상 경로라면 subprocess.run()이 돌려준 result.stdout을 로그로 나누고 result.returncode로 성공 여부를 판정한다.

문제는 판정 순서다. CPython 3.12.3의 _translate_newlines(data, encoding, errors)는 캡처한 datadecode(encoding, errors)를 적용한 다음 줄바꿈을 변환한다. 기본 strict decoder가 바이트를 받아들이지 못하면 CompletedProcess가 만들어져 반환되기 전에 UnicodeDecodeError가 발생한다. 자식의 exit 0은 이미 정해졌지만 부모 호출은 완료되지 않은 상태다.

check=False는 이 실패를 막지 않는다. check는 0이 아닌 종료 코드를 CalledProcessError로 바꿀지를 정할 뿐, stdout과 stderr의 디코딩 오류를 처리하는 인자가 아니다. 따라서 로그에는 “자식 프로세스 실패”가 아니라 “부모가 캡처 바이트를 선택한 문자 인코딩으로 변환하지 못함”으로 두 실패층을 구분해야 한다.

같은 바이트를 네 정책에 넣어 반환 지점을 확인한다

다음 목적 제작 테스트는 자식이 b'ok\xff\n' 또는 b'ok\n'을 stdout에 쓰고 0으로 끝나게 한다. 부모의 파이프 구성과 check=False는 유지한 채 텍스트 정책만 바꾼다. 프로젝트나 외부 시스템을 수정하지 않는 인라인 명령이다.

python3 - <<'PY'
import locale, os, platform, subprocess, sys
print(f"python={platform.python_version()} platform={platform.system()} locale_encoding={locale.getencoding()}")
invalid = [sys.executable, "-c", "import os; os.write(1, b'ok\\xff\\n')"]
valid = [sys.executable, "-c", "import os; os.write(1, b'ok\\n')"]
for label, command, kwargs in [
    ("current_text_true", invalid, {"text": True}),
    ("invalid_replace", invalid, {"text": True, "encoding": "utf-8", "errors": "replace"}),
    ("valid_strict", valid, {"text": True, "encoding": "utf-8"}),
    ("invalid_bytes", invalid, {"text": False}),
]:
    try:
        result = subprocess.run(command, stdout=subprocess.PIPE,
                                stderr=subprocess.STDOUT, check=False, **kwargs)
        print(f"{label}: status=completed returncode={result.returncode} stdout={result.stdout!r}")
    except UnicodeDecodeError as exc:
        print(f"{label}: status={type(exc).__name__} codec={exc.encoding} "
              f"start={exc.start} end={exc.end} reason={exc.reason} "
              "completed_process=unavailable")
PY
status=$?
printf 'shell_exit_status=%d\n' "$status"
python=3.12.3 platform=Linux locale_encoding=UTF-8
current_text_true: status=UnicodeDecodeError codec=utf-8 start=2 end=3 reason=invalid start byte completed_process=unavailable
invalid_replace: status=completed returncode=0 stdout='ok�\n'
valid_strict: status=completed returncode=0 stdout='ok\n'
invalid_bytes: status=completed returncode=0 stdout=b'ok\xff\n'
shell_exit_status=0

같은 exit 0 자식에서 strict 디코딩 실패와 replace·bytes 반환을 확인한 실제 PTY 연속 실행

2026년 8월 13일에 위 명령을 한 번 실행해 얻은 전체 연속 출력이며 마지막 줄은 같은 셸 세션의 종료 상태다. 현재 run_stage()와 같은 text=True 기본 정책만 바이트 위치 2의 0xff에서 실패해 CompletedProcess를 반환하지 못했다. 같은 입력에서 replace는 그 위치를 U+FFFD인 로 표시하면서 returncode=0을 돌려줬고, bytes 모드는 원래 0xff를 그대로 보존했다. 공식 문서가 설명하는 text·encoding·errors의 역할과 CPython 3.12.3 구현 순서는 이 관측과 일치했다.

이번 환경의 기본 인코딩이 UTF-8이어서 첫 줄처럼 나타났을 뿐, 인코딩을 생략한 text=True가 언제나 UTF-8을 뜻하지는 않는다. TextIOWrapper의 기본값은 플랫폼과 로케일의 영향을 받는다. Cron과 대화형 셸의 로케일이 다르면 같은 코드가 다른 decoder를 선택할 수 있으므로, 실행 정책을 고정하려면 인코딩도 함께 명시해야 한다.

사람용 통합 로그라면 UTF-8과 replace를 함께 명시한다

Cron agent가 사람이 읽는 통합 로그를 남기고 자식 종료 코드를 우선 판정해야 한다면 최소 수정 후보는 다음과 같다.

result = subprocess.run(
    command,
    text=True,
    encoding="utf-8",
    errors="replace",
    stdout=subprocess.PIPE,
    stderr=subprocess.STDOUT,
    check=False,
)

encoding="utf-8"은 Cron 로케일에 따라 decoder가 달라지는 변수를 없앤다. errors="replace"는 디코딩할 수 없는 바이트의 자리를 로 남기고 호출을 계속한다. 이는 복구가 아니다. 원래 0xff는 반환된 문자열만으로 되살릴 수 없다.

ignore는 운영 통합 로그의 기본 선택에서 제외하는 편이 낫다. 잘못된 바이트를 알림 없이 버려 손실 위치도 감추기 때문이다. backslashreplace는 문제 바이트를 \xff처럼 드러내 진단에는 유용하지만, 원래 로그에 있던 역슬래시 표기와 눈으로 혼동할 수 있고 일반 로그의 가독성은 낮아진다.

원본이 증거라면 text=False에서 디코딩 책임을 분리한다

stdout이 JSON이나 프로토콜 메시지처럼 한 글자 손상도 허용하지 않는 입력이면 replace로 계속 진행해서는 안 된다. strict를 유지하고 디코딩 실패를 별도 상태로 처리해야 치환된 문자열이 정상 데이터로 흘러가지 않는다.

포렌식·감사·공급자 인코딩 분석에 원본이 필요할 때는 text=False로 바이트를 받은 뒤, 원본과 표시용 텍스트의 수명을 나눈다. 표시용 사본에만 decode("utf-8", errors="replace")를 적용할 수 있다. 다만 원본 바이트의 저장 위치, 보존 기간, 민감정보 제거 정책은 먼저 정해야 하며 이 조사에서는 그 저장 구현을 검증하지 않았다.

요구사항 선택 얻는 것 감수할 점
사람용 로그 지속성과 가독성 UTF-8 + replace 종료 코드와 손상 위치가 보이는 문자열 원시 바이트 복원 불가
문제 바이트 식별 UTF-8 + backslashreplace \xff 형태의 진단 단서 가독성 저하와 표기 혼동
엄격한 기계 판독 strict + 예외 분리 손상 데이터의 정상 처리 차단 호출 실패 경로를 별도 설계
무손실 재처리 text=False 원시 바이트 보존 저장·디코딩 책임이 호출부로 이동

surrogateescape는 재인코딩으로 바이트를 왕복시킬 수 있지만, surrogate가 JSON·로거·다른 텍스트 소비자에서 추가 인코딩 오류를 만들 수 있다. 전체 소비 경로를 검증하지 않았다면 기본 운영 정책으로 두지 않는다.

수정 후에는 치환과 nonzero 종료가 함께 남는지 본다

replace를 추가한 뒤 invalid 바이트 + exit 0 한 건만 통과시키면 검증이 부족하다. 다음 세 조건을 같은 파이프 구성으로 확인해야 한다.

  1. valid UTF-8 + exit 0은 원문과 returncode=0을 남긴다.
  2. invalid UTF-8 + exit 0은 returncode=0을 함께 남긴다.
  3. invalid UTF-8 + nonzero exit는 치환된 로그와 실제 nonzero returncode를 함께 남긴다.

세 번째 조건이 중요하다. 디코딩 때문에 종료 코드 판정이 사라지는 문제를 고친 뒤, 이번에는 치환 정책이 자식 실패까지 성공으로 덮어서는 안 된다. 다운스트림 parser가 를 정상 텍스트로 받아들이는지도 함께 검사해야 한다.

오류가 계속되면 예외의 encoding, start, end, reason을 먼저 기록한다. 그다음 호출부의 encoding 생략 여부와 Cron의 locale.getencoding()을 비밀값 없이 확인한다. 자식이 실제로 약속한 인코딩이 UTF-8이 아니라면 replace보다 올바른 codec 지정이 먼저다. stderr=STDOUT으로 이미 합친 결과에서는 손상 바이트가 어느 스트림에서 왔는지 복원할 수 없으므로 최소 재현에서는 두 스트림을 분리해 좁힌다.

적용과 롤백은 출력의 용도를 기준으로 결정한다

HuntLab의 사람용 agent 통합 로그처럼 한 비정상 바이트 때문에 파이프라인 진단 전체를 잃지 않는 것이 우선이면 encoding="utf-8", errors="replace"가 합리적인 채택 후보다. 반면 기계 판독, 감사 원본, UTF-8이 아닌 명시적 출력 계약에는 이 수정안을 그대로 적용하지 않는다. 실제 Codex agent 출력, Windows 코드 페이지, 대용량 스트림, stdout·stderr 분리 수집은 이번 검증 범위 밖이다.

되돌릴 때는 encodingerrors를 제거해 기존 로케일 기반 strict 동작으로 복귀할 수 있다. 다만 롤백 후 invalid-byte fixture에서 UnicodeDecodeError가 다시 발생하고 CompletedProcess가 없다는 예상까지 확인해야 한다. 정책 변경은 예외가 사라졌는지가 아니라, 필요한 종료 코드와 로그를 남기면서 허용한 손실이 독자의 데이터 용도와 맞는지로 판정한다.

출력 디코딩을 고친 뒤 프로세스 수명까지 이어서 점검해야 한다면 Python subprocess timeout과 프로세스 그룹 글에서 timeout 뒤 자식 정리 범위를 확인할 수 있다.

참고 링크

비슷한 글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다