Published on

bash set -euo pipefail로 스크립트 터질 때 대처법

Authors

서버 운영이나 CI에서 Bash 스크립트를 단단하게 만들겠다고 set -euo pipefail을 켜면, 어느 날부터 스크립트가 “갑자기” 터지기 시작합니다. 문제는 대개 Bash의 에러 전파 규칙이 직관과 다르거나, -e/-u/pipefail이 각각 다른 방식으로 실패를 “성공적으로” 감지하기 때문입니다.

이 글은 set -euo pipefail 환경에서 자주 터지는 패턴을 실제로 재현하고, 안전한 예외 처리와 디버깅 루틴(트랩, 에러 메시지 강화, 파이프라인 처리)을 정리합니다. Kubernetes에서 컨테이너가 즉시 종료되어 CrashLoopBackOff로 보이는 상황도 결국 이런 스크립트 종료가 원인인 경우가 많습니다(관련 디버깅 흐름은 Kubernetes CrashLoopBackOff 원인별 로그·Probe·리소스 디버깅도 참고).

set -euo pipefail이 각각 하는 일

set -e (errexit)

  • “단순 명령(simple command)”이 0이 아닌 종료 코드를 반환하면 스크립트를 종료합니다.
  • 하지만 예외가 많습니다. if, while, until의 조건식, &&/|| 체인, ! cmd 같은 문맥에서는 동작이 달라질 수 있습니다.

set -u (nounset)

  • 정의되지 않은 변수를 참조하면 즉시 종료합니다.
  • CI에서 환경변수 누락을 빨리 잡아주지만, 선택적 변수를 다룰 때는 방어 코드가 필수입니다.

set -o pipefail

  • cmd1 | cmd2 | cmd3 파이프라인에서 마지막 명령이 아니라 중간 명령이 실패해도 파이프라인 전체를 실패로 만듭니다.
  • 로그 필터링(grep)이나 스트리밍 처리에서 특히 많이 터집니다.

“터질 만한” 기본 골격: 디버깅 친화 템플릿

아래 템플릿은 실패 지점을 식별하기 쉽게 만들고, -e 환경에서 흔히 놓치는 라인/명령 정보를 출력합니다.

#!/usr/bin/env bash
set -Eeuo pipefail

# 에러 발생 시점의 라인/명령을 최대한 출력
trap 'rc=$?; echo "[ERROR] rc=$rc line=$LINENO cmd=$BASH_COMMAND" >&2' ERR

# 선택: 실행 추적(필요할 때만 켜기)
# set -x

main() {
  echo "hello"
}

main "$@"

포인트:

  • -E를 추가하면 함수/서브셸에서도 ERR 트랩이 전파됩니다.
  • BASH_COMMAND, LINENO는 “왜 터졌는지”를 찾는 데 매우 유용합니다.

케이스 1) grep이 못 찾으면 실패(종료 코드 1)

pipefail을 켠 상태에서 가장 흔한 폭탄입니다.

set -euo pipefail

echo "foo" | grep "bar" | wc -l

echo "never reached"

grep는 매칭이 없으면 종료 코드 1을 반환합니다. pipefail 때문에 파이프라인 전체가 실패로 간주되고, -e가 스크립트를 종료시킵니다.

해결 1: “없을 수도 있음”을 명시적으로 허용

set -euo pipefail

count=$(echo "foo" | grep "bar" | wc -l || true)
echo "count=$count"
  • || true는 실패를 “의도된 실패”로 바꿉니다.
  • 단, 남발하면 진짜 실패도 묻힐 수 있으니 의미 있는 곳에만 사용하세요.

해결 2: grep -q + 조건 분기

set -euo pipefail

if echo "foo" | grep -q "bar"; then
  echo "found"
else
  echo "not found"
fi

조건식 문맥에서는 -e가 즉시 종료하지 않도록 동작하는 경우가 많아(예외 규칙) 의도를 표현하기 좋습니다.

케이스 2) read가 EOF에서 실패하면서 터짐

파일/파이프에서 read는 EOF에 도달하면 1을 반환합니다. -e가 켜져 있으면 반복문이 깨끗하게 끝나지 못하고 죽는 상황이 생깁니다.

set -euo pipefail

echo -e "a\nb" | while read -r line; do
  echo "line=$line"
done

echo "done"  # 환경에 따라 도달 못할 수 있음

해결: while IFS= read -r ... 패턴 + 파이프 대신 리다이렉션

파이프를 쓰면 while이 서브셸에서 돌면서 변수 스코프도 꼬이기 쉽습니다.

set -euo pipefail

tmp=$(mktemp)
printf "a\nb\n" > "$tmp"

while IFS= read -r line; do
  echo "line=$line"
done < "$tmp"

echo "done"

또는 EOF 종료를 정상 흐름으로 취급하려면:

set -euo pipefail

while IFS= read -r line || [[ -n "$line" ]]; do
  echo "line=$line"
done < <(printf "a\nb")

케이스 3) set -u로 선택적 환경변수 참조하다 터짐

set -euo pipefail

echo "DEPLOY_ENV=$DEPLOY_ENV"  # DEPLOY_ENV 미설정이면 즉시 종료

해결: 기본값/필수값을 명시

기본값이 있는 경우:

set -euo pipefail

DEPLOY_ENV=${DEPLOY_ENV:-dev}
echo "DEPLOY_ENV=$DEPLOY_ENV"

필수값인 경우(에러 메시지 개선):

set -euo pipefail

: "${DEPLOY_ENV:?DEPLOY_ENV is required}"

이 패턴은 CI에서 누락된 시크릿/환경변수를 빠르게 잡아주며, 메시지도 명확합니다.

케이스 4) command substitution 내부 실패가 바깥을 죽임

$(...) 안에서 실패가 나면, -e/pipefail 조합에서 전체가 종료되는 경우가 많습니다.

set -euo pipefail

val=$(curl -fsS https://example.invalid/api | jq -r .id)
echo "$val"

여기서 curl -f는 HTTP 에러면 실패, jq는 JSON이 아니면 실패합니다. 둘 다 좋은데, “일시적 실패”를 재시도하거나 오류를 분기하려면 구조를 바꿔야 합니다.

해결: 명령을 분리하고 실패를 다룰 지점을 만든다

set -Eeuo pipefail
trap 'rc=$?; echo "[ERROR] rc=$rc line=$LINENO cmd=$BASH_COMMAND" >&2' ERR

resp=""
if resp=$(curl -fsS --max-time 5 "${API_URL}"); then
  id=$(jq -r '.id' <<<"$resp")
  echo "id=$id"
else
  echo "API call failed; continue with fallback" >&2
  id="unknown"
fi

핵심은 “실패할 수 있는 구간”을 if로 감싸 의도적으로 제어하는 것입니다.

케이스 5) &&/|| 체인에서 의도치 않은 전파

다음 코드는 얼핏 안전해 보이지만, 체인 중간 실패가 -e와 섞이면 읽기 어려워집니다.

set -euo pipefail

mkdir -p build && cd build && make

cd build가 실패하면 make는 실행되지 않지만, 어디서 실패했는지 로그가 빈약합니다.

해결: 단계별로 쓰고, 실패 메시지를 남긴다

set -Eeuo pipefail
trap 'rc=$?; echo "[ERROR] rc=$rc line=$LINENO cmd=$BASH_COMMAND" >&2' ERR

mkdir -p build
cd build
make

운영 스크립트는 “짧음”보다 “디버깅 가능함”이 가치가 큰 경우가 많습니다.

케이스 6) trap ERR가 있는데도 어디서 터졌는지 모를 때

ERR 트랩만으로 부족하면 콜스택을 같이 찍어야 합니다.

#!/usr/bin/env bash
set -Eeuo pipefail

print_stack() {
  echo "[STACK]" >&2
  local i
  for ((i=${#FUNCNAME[@]}-1; i>=1; i--)); do
    echo "  at ${FUNCNAME[$i]} (${BASH_SOURCE[$i]}:${BASH_LINENO[$((i-1))]})" >&2
  done
}

trap 'rc=$?; echo "[ERROR] rc=$rc line=$LINENO cmd=$BASH_COMMAND" >&2; print_stack' ERR

foo() { bar; }
bar() { false; }

foo

이렇게 하면 함수형으로 스크립트를 구성했을 때 원인 추적이 훨씬 쉬워집니다.

운영/CI에서의 실전 팁: “강하게” + “예외는 명시적으로”

  1. 기본은 set -Eeuo pipefail로 두고, 예외는 if ...; then 또는 || true의도를 코드에 남깁니다.
  2. 파이프라인은 특히 조심합니다. grep/head/awk 같은 필터는 정상 흐름에서도 1을 반환할 수 있습니다.
  3. 선택적 변수는 ${VAR:-default}로, 필수 변수는 ${VAR:?message}로 초기에 검증합니다.
  4. 컨테이너 엔트리포인트에서 스크립트가 터지면 애플리케이션 문제가 아니라 “셸 종료”로 CrashLoopBackOff가 보일 수 있습니다. 이런 경우 스크립트에 트랩/스택 출력만 추가해도 원인이 즉시 드러납니다(Kubernetes CrashLoopBackOff 원인별 로그·Probe·리소스 디버깅).

결론: set -euo pipefail은 만능이 아니라 “규율”이다

set -euo pipefail은 스크립트를 더 안전하게 만들지만, 그 안전성은 “실패할 수 있는 지점”을 코드로 명확히 표현할 때 완성됩니다. 갑자기 터질 때는 대개 (1) 파이프라인 중간 실패, (2) grep/read 같은 정상 실패 코드, (3) -u로 인한 변수 누락, (4) $(...) 내부 실패 전파가 원인입니다.

위의 템플릿(trap ERR, 스택 출력, 단계별 명령 분리)을 적용하면, 스크립트가 터지더라도 “왜 터졌는지”를 로그로 남기고 빠르게 복구할 수 있습니다.