LabHub
배우기 러닝패스 코스

Shell Scripting

set -euo pipefail, and Why That Alone Is Not Enough

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

한 줄 요약

set -euo pipefail 은 스크립트가 조용히 잘못된 결과를 내는 것을 막는 최소 장치다. 하지만 그것만으로는 임시 파일 정리도, 중복 실행 방지도 되지 않는다.

Concept map: 조용히 잘못된 결과를 내는 것 · 문제를 발견 지점에서 멈춰 세우는 것 · -e · -u

왜 이게 필요했나

백업 스크립트가 매일 새벽에 돌았고 매일 성공으로 보고했다. 복구가 필요한 날, 백업 파일이 0바이트라는 걸 알았다.

tar czf "$DEST" "$SRC_DIR"

SRC_DIR 이 오타로 비어 있었고, 셸은 빈 문자열을 그냥 넘겼고, tar 는 빈 아카이브를 만들었고, 종료 코드는 0이었다. 세 군데서 각각 "조용히" 넘어간 결과다.

set -u 하나면 첫 단추에서 멈췄을 것이다. 이 옵션들의 가치는 문제를 발견 지점에서 멈춰 세우는 것에 있다.

어떻게 동작하나

네 줄짜리 표준 헤더는 이렇다.

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

그런데 set -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 으로 임시 파일을 정리하고, 잠금으로 중복 실행을 막고, 공백이 든 경로를 안전하게 처리하고, 종료 코드 규약을 지키는 백업 스크립트와 재시도 헬퍼를 만든 뒤, 마지막에는 다른 스크립트를 검사하는 감사 도구를 작성한다.