Published on

bash set -euo pipefail로 배포 스크립트 실패 잡기

Authors

배포 스크립트는 “성공한 것처럼 보이는데 실제로는 실패”하는 순간이 가장 위험합니다. 예를 들어 curl이 실패했는데 다음 단계가 계속 진행되거나, 변수 오타로 빈 값이 들어가도 스크립트가 끝까지 돌아가 버리면, 결과는 장애로 이어집니다. 이때 Bash의 set -euo pipefail은 실패를 조기에 드러내는 가장 비용 대비 효과가 큰 안전장치입니다.

이 글에서는 옵션 각각의 의미, 실제 배포 스크립트에서 자주 부딪히는 함정, 그리고 “엄격 모드”를 유지하면서도 필요한 예외를 깔끔하게 처리하는 패턴을 정리합니다.

왜 배포 스크립트는 조용히 실패하는가

Bash는 기본적으로 “에러에 관대”합니다.

  • 어떤 명령이 실패해도(종료 코드가 0이 아님) 다음 줄을 계속 실행합니다.
  • 정의되지 않은 변수를 사용하면 빈 문자열로 취급되는 경우가 많습니다.
  • 파이프라인(cmd1 | cmd2)은 중간 단계가 실패해도 마지막 명령이 성공하면 전체가 성공으로 간주됩니다.

배포 자동화에서 이런 동작은 치명적입니다. 예를 들어 이미지를 받지 못했는데 쿠버네티스 롤아웃을 진행해 CrashLoopBackOff로 이어질 수 있습니다. 이런 경우에는 배포 파이프라인이 “실패를 빨리 감지”해야 합니다. 장애가 터진 뒤 원인 찾는 과정은 더 비쌉니다. (쿠버네티스 장애 진단 관점은 K8s CrashLoopBackOff 원인 10가지·즉시 진단법도 함께 참고하면 좋습니다.)

set -euo pipefail 한 줄 요약

보통 스크립트 상단에 아래를 둡니다.

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

각 옵션은 다음을 의미합니다.

  • -e: 어떤 “단순 명령”이 실패하면 즉시 종료
  • -u: 정의되지 않은 변수 사용 시 즉시 종료
  • -o pipefail: 파이프라인에서 중간 명령이 실패해도 실패로 간주

여기에 실무에서는 디버깅을 위해 -x를 상황에 따라 추가합니다.

set -euo pipefail
# 필요할 때만
# set -euxo pipefail

set -e의 실제 동작과 함정

set -e는 “실패하면 무조건 종료”처럼 보이지만, 예외가 많습니다. 특히 아래 문맥에서는 실패해도 종료하지 않을 수 있습니다.

  • if 조건식 내부
  • while이나 until 조건식 내부
  • &&, ||로 연결된 일부 구문
  • ! cmd처럼 부정 실행

예시를 보면 감이 옵니다.

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

if grep -q "READY" ./status.txt; then
  echo "ready"
else
  echo "not ready"
fi

echo "script continues"

여기서 grep이 매칭 실패로 종료 코드 1을 반환해도, if 조건식 안이므로 스크립트는 정상적으로 계속 진행합니다. 즉, set -e만 믿고 “실패하면 무조건 멈출 것”이라 가정하면 위험합니다.

실전 팁: 실패를 정말로 실패로 만들기

조건식에서 실패를 “진짜 에러”로 취급하고 싶다면, 명시적으로 처리하세요.

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

if ! grep -q "READY" ./status.txt; then
  echo "status.txt에 READY가 없습니다" >&2
  exit 1
fi

이렇게 하면 의도가 분명해지고, 배포 파이프라인에서 실패를 확실히 감지합니다.

set -u로 변수 오타와 빈 값 배포 막기

배포 스크립트에서 가장 흔한 사고 중 하나는 환경 변수가 비어 있는데도 그대로 진행되는 것입니다.

#!/usr/bin/env bash
# set -u가 없으면, 오타 변수는 빈 문자열이 되어 위험
echo "deploy to $ENVIROMENT"  # ENVIRONMENT 오타

set -u를 켜면 이런 실수를 즉시 잡습니다.

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

echo "deploy to $ENVIROMENT"
# bash: ENVIROMENT: unbound variable

필수 변수는 기본값 대신 “명시적 검증”이 안전

배포 대상, 이미지 태그, 클러스터 컨텍스트 같은 값은 기본값을 주는 것보다, 없으면 실패시키는 편이 더 안전합니다.

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

: "${ENVIRONMENT:?ENVIRONMENT is required}"
: "${IMAGE_TAG:?IMAGE_TAG is required}"

echo "deploy $IMAGE_TAG to $ENVIRONMENT"

:는 no-op 명령이고, ${VAR:?message}는 변수가 비었거나 unset이면 메시지와 함께 종료합니다. 배포 스크립트에서 매우 자주 쓰는 패턴입니다.

선택 변수는 기본값을 주되, 의도를 드러내기

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

NAMESPACE="${NAMESPACE:-default}"
TIMEOUT_SECONDS="${TIMEOUT_SECONDS:-300}"

필수와 선택을 구분해두면 운영 중 실수의 폭이 크게 줄어듭니다.

pipefail이 없으면 로그는 성공처럼 보인다

파이프라인은 배포 스크립트에서 흔합니다. 예를 들어 원격에서 매니페스트를 받아 kubectl apply에 넘기는 패턴이 그렇습니다.

curl -fsSL "$MANIFEST_URL" | kubectl apply -f -

문제는 pipefail이 없으면 curl이 실패해도 kubectl이 빈 입력을 받아 “성공처럼 보이는” 상황이 생길 수 있다는 점입니다(상황에 따라 다르지만, 핵심은 실패 감지가 불완전해진다는 것). set -o pipefail을 켜면 파이프라인 중간에서 실패가 발생했을 때 전체 파이프라인의 종료 코드가 실패로 올라옵니다.

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

curl -fsSL "$MANIFEST_URL" | kubectl apply -f -

파이프라인 디버깅: PIPESTATUS로 원인 좁히기

실패했는데 어느 단계가 실패했는지 빠르게 보려면 PIPESTATUS를 활용할 수 있습니다.

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

set +e
curl -fsSL "$MANIFEST_URL" | kubectl apply -f -
rc=$?
statuses=("${PIPESTATUS[@]}")
set -e

if [ "$rc" -ne 0 ]; then
  echo "pipeline failed: rc=$rc statuses=${statuses[*]}" >&2
  exit "$rc"
fi

엄격 모드에서는 보통 자동 종료되므로 자주 쓰진 않지만, 장애 상황에서 원인 파악에 도움이 됩니다.

배포 스크립트에 바로 쓰는 템플릿

아래는 set -euo pipefail을 기본으로 깔고, 로깅과 트랩으로 실패 지점을 남기는 실전형 골격입니다.

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

log() {
  printf '%s %s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$*" >&2
}

die() {
  log "ERROR: $*"
  exit 1
}

on_err() {
  local exit_code=$?
  # BASH_LINENO는 호출 스택 라인 정보를 담습니다
  log "failed (exit=$exit_code) at line=${BASH_LINENO[0]} cmd=${BASH_COMMAND}"
  exit "$exit_code"
}
trap on_err ERR

: "${ENVIRONMENT:?ENVIRONMENT is required}"
: "${IMAGE_TAG:?IMAGE_TAG is required}"
NAMESPACE="${NAMESPACE:-default}"

log "deploy start env=$ENVIRONMENT ns=$NAMESPACE tag=$IMAGE_TAG"

log "build image"
docker build -t "myapp:${IMAGE_TAG}" .

log "push image"
docker push "myapp:${IMAGE_TAG}"

log "apply manifests"
kubectl -n "$NAMESPACE" set image deploy/myapp myapp="myapp:${IMAGE_TAG}"

log "rollout status"
kubectl -n "$NAMESPACE" rollout status deploy/myapp --timeout=300s

log "deploy done"

이 템플릿의 핵심은 다음입니다.

  • 실패 시점(라인, 명령)을 남겨 재현 시간을 줄임
  • 필수 변수는 반드시 선언적으로 검증
  • kubectl rollout status 같은 “완료 확인”을 포함해 성공 조건을 명확화

엄격 모드에서도 예외는 필요하다: 안전한 우회 패턴

배포 스크립트에서는 “실패해도 괜찮은 작업”이 있습니다. 예를 들어 캐시 삭제, 임시 리소스 정리, 존재하지 않을 수도 있는 리소스 삭제 등이 그렇습니다.

패턴 1: || true는 최소화하고, 이유를 남기기

kubectl -n "$NAMESPACE" delete job/my-migration || true

이 방식은 간단하지만, 진짜 중요한 실패도 묻힐 수 있습니다. 꼭 써야 한다면 주석이나 로그로 의도를 남기세요.

log "cleanup: job may not exist"
kubectl -n "$NAMESPACE" delete job/my-migration || true

패턴 2: 특정 종료 코드만 허용하기

예를 들어 grep은 매칭 없음이 1이고, 그 외는 진짜 에러일 수 있습니다.

set +e
grep -q "migrated" ./state.txt
rc=$?
set -e

if [ "$rc" -eq 0 ]; then
  log "already migrated"
elif [ "$rc" -eq 1 ]; then
  log "not migrated yet"
else
  die "grep failed rc=$rc"
fi

패턴 3: 일시적 네트워크 실패는 재시도 래퍼로 감싸기

배포 중 네트워크/레지스트리/클러스터 API는 일시적으로 흔들릴 수 있습니다. 이때는 “실패를 숨기는 것”이 아니라 “재시도 후에도 실패하면 확실히 종료”가 정답입니다.

retry() {
  local max=$1; shift
  local delay=$1; shift
  local attempt=1

  while true; do
    if "$@"; then
      return 0
    fi

    if [ "$attempt" -ge "$max" ]; then
      return 1
    fi

    sleep "$delay"
    attempt=$((attempt + 1))
  done
}

retry 5 2 curl -fsSL "$MANIFEST_URL" -o /tmp/manifest.yaml
kubectl apply -f /tmp/manifest.yaml

이 패턴은 Docker 빌드나 레지스트리 통신처럼 외부 요인에 영향을 받는 단계에서 특히 유용합니다. 빌드/캐시 이슈로 배포가 흔들릴 때는 Docker BuildKit 캐시 무효화 원인·해결 8가지도 같이 보면 원인 분리가 빨라집니다.

set -euo pipefail을 켰는데도 놓치는 것들

엄격 모드는 만능이 아닙니다. 아래는 배포 스크립트에서 자주 놓치는 포인트입니다.

서브셸과 커맨드 치환 내부 실패

커맨드 치환 $(...) 안의 실패가 기대대로 전파되지 않는다고 느끼는 경우가 있습니다. Bash 버전과 구문에 따라 차이가 있고, 무엇보다 “치환 결과를 바로 사용”하면 실패가 숨겨진 채 빈 문자열이 들어갈 수 있습니다. 이런 경우는 치환을 변수에 담고 검증하는 방식이 안전합니다.

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

commit_sha="$(git rev-parse --short HEAD)"
: "${commit_sha:?failed to get git sha}"

read와 파이프 조합

echo ... | while read ...; do ...; done 형태는 while이 서브셸에서 돌 수 있어, 루프 안에서 만든 변수가 밖으로 안 나오는 문제가 생깁니다. 배포 스크립트에서 상태를 누적하는 로직이 있다면 특히 주의하세요.

안전한 대안은 프로세스 치환을 쓰는 것입니다.

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

count=0
while IFS= read -r line; do
  count=$((count + 1))
done < <(kubectl get pods -n default -o name)

echo "pods=$count"

여기서 <>가 포함된 프로세스 치환 구문은 MDX에서 오해될 수 있으니, 반드시 코드 블록 안에서만 사용해야 합니다. (이 글의 모든 예시는 코드 블록으로 감싸 안전합니다.)

CI와 운영에서의 권장 체크리스트

  • 스크립트 첫 줄에 #!/usr/bin/env bash를 명시하고, Bash로 실행되는지 보장
  • 최상단에 set -euo pipefail 적용
  • 필수 환경 변수는 : "${VAR:?}"로 선언적 검증
  • 네트워크/외부 의존 단계는 retry로 감싸되, 최종 실패는 반드시 실패로 처리
  • 성공 조건을 명시: kubectl rollout status, 헬스 체크, smoke test 등
  • 실패 시점 로깅: trap ERR로 라인과 명령을 남기기

운영 장애는 대개 “원인 자체”보다 “원인 파악 시간”이 더 큰 비용을 만듭니다. 배포 스크립트에 엄격 모드를 적용하고 실패를 선명하게 만드는 것만으로도, 장애 대응 시간이 크게 줄어듭니다. 장애가 시스템 레벨(예: 노드 네트워킹)까지 번질 때의 진단 흐름은 EKS iptables-legacy/nft 충돌로 네트워크 먹통 해결 같은 케이스 글과 함께 보면, “배포 실패”와 “인프라 장애”를 분리하는 감각을 키우는 데 도움이 됩니다.

마무리

set -euo pipefail은 배포 스크립트를 더 엄격하게 만들어 실패를 숨기지 않게 합니다. 다만 -e의 예외 동작, 파이프라인 실패 전파, 의도적인 예외 처리 같은 디테일을 함께 챙겨야 “안전하면서도 운영 가능한” 스크립트가 됩니다.

이미 운영 중인 스크립트가 있다면, 먼저 상단에 set -euo pipefail과 필수 변수 검증부터 넣고, 실패가 새로 드러나는 구간을 하나씩 의도에 맞게 고쳐 나가는 방식이 가장 현실적인 적용 전략입니다.