빌드노트
명령은 실패했는데 tee를 붙이면 성공으로 끝난다
Bash 배치에 tee를 붙였을 때 생산자 7·tee 0인데 전체 상태가 0이 되는 차이를 확인한다. 짧은 pipefail 오류 처리부, 즉시 저장하는 PIPESTATUS와 소비자 조기 종료의 SIGPIPE 한계를 설명한다.
Bash 배치에 로그 저장용 tee를 붙이면 앞 명령의 실패를 놓칠 수 있다. 한 줄을 출력하고 7로 끝나는 합성 명령을 실행했을 때, 단계별 상태는 생산자=7, tee=0인데 파이프 전체 상태는 0이었다. set -o pipefail을 켜자 같은 출력과 단계 상태에서 전체 상태만 7로 바뀌었다. 기본 설정은 마지막 명령인 tee의 성공을 파이프의 성공으로 삼기 때문이다.
어느 단계든 실패하면 배치를 멈춰야 한다면, Bash 배치 스크립트에서 다음처럼 처리할 수 있다. job.sh는 독자가 이미 가진 작업 스크립트를 뜻한다. 해당 작업 폴더에서 실행하고, bash ./job.sh를 자기 작업 명령으로 바꾼다. Bash와 tee, 로그를 쓸 권한이 필요하며 batch.log는 덮어쓴다.
set -o pipefail
if bash ./job.sh 2>&1 | tee batch.log; then
:
else
rc=$?
printf 'batch pipeline failed: %s\n' "$rc" >&2
exit "$rc"
fi
:는 성공 분기에서 아무 일도 하지 않는 명령이다. 그 뒤에는 원래 배치의 다음 단계를 둘 수 있다. 실패 분기에서는 다른 명령보다 먼저 $?를 저장하고 그 값으로 종료한다. pipefail만 켜고 실패 뒤에도 성공하는 명령을 실행해 끝내면, 배치의 최종 상태가 다시 0이 될 수 있다. 2>&1은 표준 오류도 로그에 담으며 종료 상태를 고치는 부분은 아니다.
이 처리부에 7을 반환하는 합성 job.sh를 연결한 별도 실행에서는 오류 메시지가 batch pipeline failed: 7이었고, 호출자가 받은 스크립트 상태도 7이었다. 정상 종료하는 대조군은 0을 반환했다. Bash의 -e를 켜고 호출해도 두 결과가 같았다. 작업 자체가 내부 오류를 0으로 바꿔 반환한다면 이 처리부로는 알아낼 수 없다.
전체 상태와 단계별 상태를 함께 확인한다
다음은 실패를 숨기는 차이를 직접 확인할 짧은 입력이다. Linux나 WSL에서 bash --noprofile --norc로 새 Bash를 열고, 쓰기 가능한 빈 폴더에서 실행한다. 기존 로그와 섞이지 않도록 off.log, on.log가 없는 폴더를 쓴다. 비교 중에는 실패 직후 상태를 읽어야 하므로 set +e로 자동 종료를 끈다.
set +e
producer() { printf 'synthetic output\n'; return 7; }
set +o pipefail
producer 2>&1 | tee off.log
snapshot=( "$?" "${PIPESTATUS[@]}" )
printf 'off: pipeline=%s producer=%s tee=%s\n' "${snapshot[@]}"
set -o pipefail
producer 2>&1 | tee on.log
snapshot=( "$?" "${PIPESTATUS[@]}" )
printf 'on: pipeline=%s producer=%s tee=%s\n' "${snapshot[@]}"
Ubuntu 24.04.4의 Bash 5.2.21, GNU coreutils 9.4에서 관찰한 출력이다. 실제 배포 작업을 실행한 결과는 아니다.
synthetic output
off: pipeline=0 producer=7 tee=0
synthetic output
on: pipeline=7 producer=7 tee=0
두 로그의 내용도 같았다. 생산자와 tee 중 어느 쪽이 실패했는지부터 보면 된다. 생산자도 정상 종료한 대조군은 pipeline=0 producer=0 tee=0이었다. 반대로 로그 경로의 상위 폴더가 없는 조건에서는 pipeline=1 producer=0 tee=1이었다. 이 경우에는 작업 명령보다 로그 저장 경로를 확인한다.
Debian의 GNU Bash 5.2 매뉴얼 (새 탭에서 열림)에 설명된 pipefail은 오른쪽에 있는 0이 아닌 상태를 전체 상태로 고른다. 단계가 7 9 0이면 9, 9 7 0이면 7이었다. 가장 큰 숫자나 먼저 실패한 시간을 고르는 규칙은 아니다. 전부 0이면 전체도 0이다.
snapshot의 첫 값은 $?, 나머지는 왼쪽부터 각 단계의 PIPESTATUS다. 파이프 바로 다음 한 번의 대입에서 함께 저장해야 뒤의 출력 명령에 덮이지 않는다. rc=$? 다음 줄에서 stages=( "${PIPESTATUS[@]}" )를 실행했을 때는 rc=7이 남았지만 stages에는 7 0 대신 0만 남았다. 대입도 명령 하나로 취급되기 때문이다. 단계별 상태만 필요하다면 첫 명령으로 stages=( "${PIPESTATUS[@]}" )만 복사해도 된다.
배치의 성공·실패 분기만 필요하면 앞의 pipefail 처리부를 쓰고, 실패한 단계까지 진단해야 하면 PIPESTATUS를 함께 보관한다. 앞 처리부의 실패 분기에 적용하려면 rc=$?를 같은 snapshot 대입으로 바꾸고, 이후의 출력과 exit에는 ${snapshot[0]}을 사용한다. 새 Bash에서 비교를 마치면 exit로 나온다. 배열과 PIPESTATUS는 Bash 문법이므로 sh로 실행한다고 가정하지 않는다. 이번 환경의 /bin/sh인 dash 0.5.12는 set -o pipefail에서 오류와 상태 2를 반환했다. 다른 sh 구현·버전까지 같은 결과라는 뜻은 아니다.
소비자가 먼저 끝나는 경우에는 의도를 본다
pipefail을 적용할 때는 head처럼 일부만 읽고 끝나는 단계도 확인한다. 별도 실험에서 GNU yes payload | head -n 1은 한 줄을 출력했지만, 즉시 저장한 상태는 pipeline=141 yes=141 head=0이었다. head는 정상 종료했고, 계속 쓰던 yes는 SIGPIPE로 끝났다.
Linux pipe(7) (새 탭에서 열림)은 읽기 끝이 모두 닫힌 파이프에 쓰면 SIGPIPE가 발생한다고 설명한다. Bash는 치명적 신호 번호에 128을 더해 종료 상태로 표현한다. 이번 Linux의 SIGPIPE 번호는 13이어서 141이었다. 임의의 프로그램에서 숫자 141만 봤다고 신호 종료를 확정할 수는 없다.
전체 작업 완료와 로그가 필요한 배치라면 생산자가 끝까지 실행돼야 한다. 첫 줄만 얻는 것이 목적이라면 소비자의 조기 종료가 의도한 것인지 단계별 상태와 함께 판단한다. 141을 모든 파이프에서 무시하는 처리는 이 구분을 없앤다. 출력이 적어 생산자가 먼저 쓰기를 끝내는 경우까지 항상 SIGPIPE가 발생하는 것도 아니다.
여기서 실행한 범위는 합성 전경 파이프와 위 GNU 도구다. 실제 배치, 디스크 용량 부족, SIGPIPE를 무시하는 프로그램, 다른 셸·운영체제의 동작은 검증하지 않았다.