set -euo pipefail, and Why That Alone Is Not Enough
한국어 원문으로 표시합니다.
한 줄 요약
set -euo pipefail 은 스크립트가 조용히 잘못된 결과를 내는 것을 막는 최소 장치다. 하지만 그것만으로는 임시 파일 정리도, 중복 실행 방지도 되지 않는다.
왜 이게 필요했나
백업 스크립트가 매일 새벽에 돌았고 매일 성공으로 보고했다. 복구가 필요한 날, 백업 파일이 0바이트라는 걸 알았다.
tar czf "$DEST" "$SRC_DIR"
SRC_DIR 이 오타로 비어 있었고, 셸은 빈 문자열을 그냥 넘겼고, tar 는 빈 아카이브를 만들었고, 종료 코드는 0이었다. 세 군데서 각각 "조용히" 넘어간 결과다.
set -u 하나면 첫 단추에서 멈췄을 것이다. 이 옵션들의 가치는 문제를 발견 지점에서 멈춰 세우는 것에 있다.
어떻게 동작하나
네 줄짜리 표준 헤더는 이렇다.
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
-e: 명령이 실패하면 즉시 종료한다. 단,if조건이나||뒤처럼 "실패가 예상되는 문맥"에서는 발동하지 않는다.-u: 정의되지 않은 변수를 읽으면 오류로 종료한다. 오타를 즉시 잡는다.-o pipefail: 파이프라인 안 어느 하나라도 실패하면 전체를 실패로 만든다. 이게 없으면curl ... | jq ... | wc -l에서 curl 이 죽어도 성공으로 보인다.IFS: 단어 분리 기준에서 공백을 빼서 공백이 든 파일 이름 사고를 줄인다.
그런데 set -e 로 모든 게 해결되지 않는다. 오히려 함정이 있다.
- 함수 안에서 실패했는데 그 함수를
||나if문맥에서 호출하면-e가 발동하지 않는다 -e로 종료하는 경로에서도 임시 파일은 남는다-e는 "무엇이 실패했는지"를 알려 주지 않는다
그래서 trap 이 짝으로 필요하다.
cleanup() {
local rc=$?
rm -f "$TMPFILE"
exit "$rc"
}
trap cleanup EXIT
여기서 두 가지가 핵심이다. 첫째, EXIT 트랩은 정상 종료·오류 종료·시그널 종료 모두에서 돈다. 둘째, 맨 앞에서 $? 를 붙잡아 두고 마지막에 되돌려주지 않으면 트랩이 종료 코드를 삼켜 버린다.
임시 파일은 반드시 mktemp 로 만든다. 고정된 이름은 동시 실행 시 충돌하고, /tmp 에서는 심볼릭 링크 공격의 표적이 된다.
현장에서 만나는 모습
중복 실행 방지. 크론이 5분마다 도는 작업이 6분 걸리기 시작하면 두 개가 겹쳐 돈다. 잠금이 없으면 같은 파일을 동시에 쓰고 데이터가 깨진다. flock 이나 mkdir 기반 잠금으로 두 번째 실행은 즉시 종료시키되, 종료 코드로 "왜 안 돌았는지"를 구분해 주는 게 중요하다. 그래야 모니터링이 "실패"와 "이미 돌고 있어서 건너뜀"을 구분한다.
종료 코드 규약. 0 성공, 1 일반 실패까지는 모두가 안다. 그 이상은 프로젝트가 정한다. sysexits 관례를 빌리면 64 는 사용법 오류, 66 은 입력 파일 없음이다. 값 자체보다 일관되게 쓰고 문서에 적는 것이 중요하다.
재시도는 조건부로. 네트워크 호출은 실패해도 다시 하면 성공할 수 있다. 다만 재시도에는 세 가지 조건이 붙는다. 최대 횟수가 있어야 하고, 시도 사이에 쉬어야 하고(안 쉬면 상대를 더 밀어붙인다), 성공하면 즉시 멈춰야 한다. 그리고 재시도해도 되는 작업인지(멱등한지) 먼저 확인해야 한다 - 결제 요청을 재시도하면 두 번 결제된다.
shellcheck 는 무료 리뷰어다. 따옴표 누락, 사용하지 않는 변수, 위험한 패턴을 잡아 준다. CI 에 넣어 두면 리뷰 시간이 눈에 띄게 줄어든다.
set -e 가 봐 주지 않는 자리
set -euo pipefail 을 맨 위에 적어 두면 안전하다고 생각하기 쉽다. 그런데
set -e 에는 명세로 정해진 예외가 여럿 있고, 사고는 늘 그 자리에서 난다.
조건으로 쓰인 명령은 실패해도 멈추지 않는다. if, while, &&, ||,
! 의 왼쪽에 있는 명령은 실패가 곧 판단 재료이므로 종료 조건이 아니다.
여기까지는 의도된 동작이다. 문제는 함수를 그 자리에 두었을 때다.
setup() {
mkdir -p /srv/data # 여기서 실패해도
cp config.yml /srv/data # 이 줄이 실행된다
}
if setup; then echo "준비 완료"; fi
함수가 if 의 조건으로 불리는 순간, 그 함수 안쪽 전체에서 set -e 가
꺼진다. 함수는 마지막 명령의 종료 코드를 돌려주므로, 앞에서 무엇이 실패했든
cp 만 성공하면 "준비 완료" 가 찍힌다.
명령 치환의 실패는 대입이 삼킨다.
VERSION=$(cat /etc/app/version) # 파일이 없어도 스크립트는 계속 간다
VERSION=... 이라는 대입문이 성공했기 때문이다. 종료 코드는 대입의 것이지
cat 의 것이 아니다. 앞에 local 이나 export 를 붙이면 더 확실하게 가려진다.
값을 반드시 얻어야 한다면 나눠 쓴다.
VERSION=$(cat /etc/app/version) || exit 1
: "${VERSION:?버전 파일이 비어 있습니다}"
pipefail 이 없으면 파이프의 앞쪽은 없는 셈이다. set -e 만으로는
curl ... | jq . 에서 curl 이 죽어도 jq 가 성공하면 통과한다. 다만
pipefail 을 켜면 head 로 끊는 파이프가 SIGPIPE 로 실패하게 되므로,
그런 자리는 || true 로 의도를 적어 둔다.
정리는 trap 에 맡긴다. 중간에서 멈추는 스크립트일수록 임시 파일이 남는다.
tmp=$(mktemp -d) || exit 1
trap 'rm -rf "$tmp"' EXIT
EXIT 는 정상 종료·set -e 로 인한 종료·exit 호출 모두에서 실행된다.
INT/TERM 까지 잡고 싶다면 함께 적되, 그 안에서 다시 실패할 수 있는 명령을
쓰지 않는다. 정리 함수가 실패하면 남는 것은 임시 파일이 아니라 무한 고리다.
다음 실습에서 할 것
엄격 모드가 실제로 동작하는지 행동으로 증명하고, pipefail 로 파이프 실패를 전파시키고, trap 으로 임시 파일을 정리하고, 잠금으로 중복 실행을 막고, 공백이 든 경로를 안전하게 처리하고, 종료 코드 규약을 지키는 백업 스크립트와 재시도 헬퍼를 만든 뒤, 마지막에는 다른 스크립트를 검사하는 감사 도구를 작성한다.