- Published on
bash set -euo pipefail로 배포 스크립트 실패 잡기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
배포 스크립트는 “성공한 것처럼 보이는데 실제로는 실패”하는 순간이 가장 위험합니다. 예를 들어 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과 필수 변수 검증부터 넣고, 실패가 새로 드러나는 구간을 하나씩 의도에 맞게 고쳐 나가는 방식이 가장 현실적인 적용 전략입니다.