# 백업 검증이 조용히 통과할 때 - PIPESTATUS, set -e, mongorestore --dryRun

백업 Job에 무결성 검사를 넣는 것은 조용한 실패를 막기 위해서입니다. 그런데 검사 자체가 같은 방식으로 조용히 실패하는 경우가 있습니다. 로그에는 rc=1이 찍혀 있는데 게이트가 통과하고 업로드까지 끝나는 식입니다. 백업은 복원이 필요할 때까지 아무도 열어 보지 않으니, 이 실패는 가장 나쁜 시점에 드러납니다.

이 글에서는 셸 스크립트의 검증 게이트가 무효가 되는 세 가지 경로를 다룹니다. 종료 코드를 읽는 시점, set -e가 꺼지는 문맥, 검사 명령이 실제로 확인하는 범위입니다. 마지막에는 각 실패 경로를 실제로 돌려 본 fail-closed 스크립트를 정리합니다.

# 1. 게이트가 무효가 되는 세 가지 경로

경로 겉보기 실제
종료 코드를 늦게 읽음 검사 결과로 분기 다른 명령의 성공(0)으로 분기
set -e가 무시되는 문맥 실패하면 스크립트가 멈춤 if·|| 안의 실패, local 대입이 가린 실패는 계속 진행
검사 범위 오해 아카이브 무결성 확인 헤더만 읽거나, 아카이브와 무관한 이유로 실패

세 경로 모두 실패 신호는 어딘가에 남는데 분기에는 반영되지 않는다는 공통점이 있습니다. 그래서 로그를 사람이 읽어야만 드러납니다.

# 2. PIPESTATUS는 다음 명령에서 덮인다

# 2-1. 정의가 곧 함정이다

파이프라인에서는 마지막 명령의 종료 코드만 $?에 남습니다. 앞 명령의 결과가 필요하면 PIPESTATUS를 읽습니다. bash 매뉴얼의 정의에 함정이 그대로 들어 있습니다.

An array variable containing a list of exit status values from the commands in the most-recently-executed foreground pipeline, which may consist of only a simple command.

"가장 최근 파이프라인"에는 명령 하나짜리도 포함됩니다. 그래서 echo를 한 번 실행하면 값이 바뀝니다.

mongorestore --dryRun --archive=dump.gz 2>&1 | tail -n 5
echo "dryRun rc=${PIPESTATUS[0]}"          # 로그에는 rc=1이 찍힘
if [ "${PIPESTATUS[0]}" -ne 0 ]; then       # echo의 결과 0을 읽음
  exit 1
fi

로그에는 실패가 기록되고 게이트는 통과합니다. bash 3.2와 5.2에서 모두 같은 결과였습니다.

$ false | true; echo "${PIPESTATUS[0]}"; echo "${PIPESTATUS[0]}"
1
0

[[ ]]로 비교해도 같은 문제가 생깁니다. 비교 자체는 올바른 값을 읽지만, [[PIPESTATUS를 자신의 결과로 덮어씁니다. 두 번째 조건에서 같은 변수를 다시 읽으면 틀린 값이 나옵니다.

# 2-2. 파이프라인 직후에 복사한다

cmd1 | cmd2 | cmd3
rc=("${PIPESTATUS[@]}")     # 다른 명령보다 먼저
echo "rc=${rc[*]}"
(( rc[0] == 0 )) || exit 1
$ false | true | (exit 3); rc=("${PIPESTATUS[@]}"); echo "${rc[*]}"; echo "$PIPESTATUS"
1 0 3
0

셸마다 이름도 다릅니다. zsh에서는 PIPESTATUS가 빈 값이고 소문자 pipestatus를 써야 합니다. 인덱스도 1부터 시작합니다. 스크립트 첫 줄에 #!/usr/bin/env bash를 명시해야 하는 이유가 하나 더 생깁니다.

# 3. pipefail과 set -e는 어디까지 믿을 수 있나

# 3-1. pipefail로 대부분의 경우를 없앤다

PIPESTATUS를 직접 다루지 않는 방법이 pipefail입니다.

If pipefail is enabled, the pipeline's return status is the value of the last (rightmost) command to exit with a non-zero status, or zero if all commands exit successfully.

mongodump ... | tail에서 mongodump가 실패하면 파이프라인 전체가 실패합니다. set -e와 함께 쓰면 그 자리에서 멈춥니다. pipefail은 POSIX.1-2024(Issue 8)에 들어갔지만, 모든 sh 구현이 따라온 것은 아닙니다. macOS에 들어 있는 dash는 set: Illegal option -o pipefail로 거부했습니다. 이미지의 /bin/sh가 무엇이든 bash로 실행되게 고정해야 합니다.

# 3-2. set -e가 꺼지는 문맥

set -e는 모든 실패에서 멈추지 않습니다. 매뉴얼이 예외를 나열합니다.

The shell does not exit if the command that fails is part of the command list immediately following a while or until reserved word, part of the test in an if statement, part of any command executed in a && or || list except the command following the final && or ||, any command in a pipeline but the last (subject to the state of the pipefail shell option), or if the command's return status is being inverted with !.

백업 스크립트에서 특히 문제가 되는 것은 두 가지입니다. 아래 결과는 모두 set -e 상태에서 실행한 것입니다.

함수를 조건문 안에서 호출하면 함수 전체에서 -e가 꺼집니다.

If a compound command or shell function executes in a context where -e is being ignored, none of the commands executed within the compound command or function body will be affected by the -e setting

backup() { mongodump ...; gzip -t "$ARCHIVE"; aws s3 cp ...; }
if backup; then notify "backup ok"; fi    # gzip -t가 실패해도 업로드까지 진행

알림을 붙이려고 if로 감싸는 순간 안의 검사가 무력해집니다. 알림은 trap ... ERR이나 스크립트 종료 코드를 보는 바깥 계층에서 처리합니다.

local은 명령 치환의 실패를 가립니다.

x=$(false)          → 스크립트 종료 (exit 1)
local x=$(false)    → 계속 진행 (local의 종료 코드 0)

일반 대입은 "마지막 명령 치환의 종료 코드"를 결과로 삼습니다. 반면 local은 자신의 결과(0)를 돌려줍니다. 함수 안에서는 local x; x=$(cmd)로 선언과 대입을 나눕니다.

# 3-3. 명령 치환 안의 set -e

bash는 기본적으로 명령 치환 서브셸에서 -e를 끕니다. out=$(false; echo continued)continued를 담고 진행합니다. bash 4.4부터는 shopt -s inherit_errexit로 이 동작을 바꿀 수 있습니다. bash 5.2에서는 이 옵션을 켜면 스크립트가 종료됐습니다. bash 3.2에는 옵션 자체가 없습니다.

# 4. mongorestore --dryRun이 실제로 확인하는 것

# 4-1. 서버가 필요하다

문서의 설명만 보면 오프라인 검사처럼 읽힙니다.

Runs mongorestore without actually importing any data, returning the mongorestore summary information.

mongo-tools 소스(100.18.0)를 보면 dry run 분기에 들어가기 전에 세션을 만들고 서버에 Ping을 보냅니다. 연결 대상을 주지 않으면 기본값으로 붙습니다.

By default, mongorestore attempts to connect to a MongoDB instance running on the localhost on port number 27017.

백업 Job 파드에는 보통 mongod가 없습니다. 그래서 --uri 없이 실행한 --dryRun은 아카이브와 무관하게 연결 실패로 rc=1을 냅니다. 2장의 버그와 겹치면 두 겹으로 무효가 됩니다. 검사는 애초에 실패했고, 그 실패마저 분기에 반영되지 않습니다.

# 4-2. 헤더만 읽는다

소스를 따라가면 dry run은 아카이브의 prelude(매직 넘버, 헤더, 네임스페이스 목록)를 읽습니다. 그리고 컬렉션 데이터를 읽는 Demux.Run()보다 앞에서 "dry run completed"를 찍고 반환합니다.

if restore.OutputOptions.DryRun {
    log.Logvf(log.Always, "dry run completed")
    return Result{}
}
// 이 아래에서 demuxErr = restore.archive.Demux.Run() - 데이터 블록 전체 읽기

--gzip 입력은 gzip 리더를 거치는데, gzip의 CRC와 길이 검사는 스트림 끝에서 일어납니다. dry run은 끝까지 읽지 않습니다. 소스를 근거로 보면 데이터 구간이 잘리거나 손상된 아카이브도 dry run을 통과할 수 있습니다.

# 4-3. 검사마다 잡는 범위가 다르다

검사 잡는 것 못 잡는 것 서버
mongorestore --dryRun 아카이브 형식, 헤더, 네임스페이스 데이터 구간 손상, gzip 끝부분 필요
gzip -t 압축 스트림 잘림, CRC 불일치 논리적으로 잘못된 덤프 불필요
S3 업로드 체크섬 전송 중 손상 업로드 전에 이미 손상된 파일 불필요
테스트 배포에 실제 복원 위 전부 + 복원 가능 여부 - 필요

200KB 무작위 데이터를 압축해 gzip -t를 돌려 보면 잘린 파일과 중간이 손상된 파일을 모두 잡습니다.

intact: 0
gzip: trunc.gz: unexpected end of file
truncated: 1
gzip: invalid compressed data--crc error
corrupted middle: 1

작고 압축률이 높은 파일로 시험하면 head -c로 잘라도 실제로 잘리지 않아 통과할 수 있습니다. 검사를 시험할 때는 충분히 큰 데이터를 씁니다.

결국 확실한 검증은 복원입니다. MongoDB 문서의 백업 가이드라인도 같은 말을 합니다.

Verify your backups by restoring them to a test deployment.

매 백업마다 복원하기 어렵다면 백업 Job은 gzip -t와 dry run으로 빠르게 거르고, 주기적인 복원 리허설을 따로 둡니다.

덤프의 일관성도 함께 챙겨야 합니다. 레플리카 셋에서 쓰기가 진행 중일 때는 --oplog로 덤프 중의 쓰기를 함께 담습니다. 복원할 때는 --oplogReplay로 재생해야 한 시점의 상태가 됩니다.

# 5. fail-closed 백업 스크립트

#!/usr/bin/env bash
set -Eeuo pipefail
shopt -s inherit_errexit   # bash 4.4+

: "${MONGO_URI:?MONGO_URI is required}"
: "${S3_DEST:?S3_DEST is required}"
WORK_DIR="${WORK_DIR:-/work}"
ARCHIVE="$WORK_DIR/dump-$(date -u +%Y%m%dT%H%M%SZ).archive.gz"

log() { printf '%s %s\n' "$(date -u +%FT%TZ)" "$*" >&2; }
trap 'log "FAILED at line $LINENO (exit $?)"' ERR

log "dump"
mongodump --uri="$MONGO_URI" --oplog --gzip --archive="$ARCHIVE" 2>&1 | tail -n 5

log "gzip integrity"
gzip -t "$ARCHIVE"

log "archive header"
mongorestore --uri="$MONGO_URI" --gzip --archive="$ARCHIVE" --dryRun

log "upload"
aws s3 cp "$ARCHIVE" "$S3_DEST/" --checksum-algorithm SHA256
log "done"

설계 원칙은 세 가지입니다.

  • 종료 코드를 사람이 옮기지 않습니다. PIPESTATUS를 읽는 대신 pipefailset -e에 맡기고, 검사를 if로 감싸지 않습니다.
  • 검사 명령에 필요한 입력을 명시합니다. --dryRun에도 --uri를 줍니다. dry run은 쓰기를 하지 않습니다.
  • 실패하면 업로드 전에 멈춥니다. trap ERR은 어느 줄에서 멈췄는지만 남기고 흐름에는 끼어들지 않습니다.

이 스크립트를 실제 도구 대신 동작을 흉내 내는 스텁으로 바꿔 실패 경로마다 실행했습니다.

시나리오 멈춘 곳 종료 코드 업로드
정상 - 0
mongodump 실패 (파이프 앞쪽) dump 줄 1 안 됨
아카이브 잘림 gzip -t 1 안 됨
dry run 실패 mongorestore 1 안 됨

게이트는 통과하는 경우가 아니라 실패하는 경우를 돌려 봐야 검증됩니다. 새 검사를 추가할 때마다 일부러 망가뜨린 입력으로 한 번씩 실행합니다.

# 6. 트러블슈팅

증상 원인 해결
로그에 rc=1이 찍혔는데 다음 단계로 진행 PIPESTATUSecho 뒤에 읽음 파이프라인 직후 배열로 복사, 또는 pipefail
set -e인데 중간 실패 후 업로드됨 함수를 if|| 안에서 호출 조건문 밖에서 호출, 알림은 trap ERR
함수 안 명령 치환 실패가 무시됨 local x=$(cmd) local x; x=$(cmd)
dry run이 항상 실패 --uri 없이 localhost:27017에 연결 시도 --uri 지정
dry run은 통과했는데 복원이 중간에 실패 dry run은 헤더만 읽음 gzip -t 추가, 주기적 복원 리허설
set: Illegal option -o pipefail /bin/sh가 pipefail 미지원 구현 bash로 실행 고정

# 7. 마무리

  • PIPESTATUS다음 명령이 실행되는 순간 덮입니다. 읽어야 한다면 파이프라인 바로 다음에 배열로 복사하고, 가능하면 pipefail에 맡깁니다.
  • set -eif, ||, local 문맥에서 조용히 꺼집니다. 검사 단계를 조건문으로 감싸지 않습니다.
  • mongorestore --dryRun은 서버에 연결하고 헤더만 읽습니다. 아카이브 손상은 gzip -t로 잡고, 복원 가능 여부는 실제 복원으로만 확인됩니다.

# 참고