# 백업 검증이 조용히 통과할 때 - 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를 읽는 대신pipefail과set -e에 맡기고, 검사를if로 감싸지 않습니다. - 검사 명령에 필요한 입력을 명시합니다.
--dryRun에도--uri를 줍니다. dry run은 쓰기를 하지 않습니다. - 실패하면 업로드 전에 멈춥니다.
trap ERR은 어느 줄에서 멈췄는지만 남기고 흐름에는 끼어들지 않습니다.
이 스크립트를 실제 도구 대신 동작을 흉내 내는 스텁으로 바꿔 실패 경로마다 실행했습니다.
| 시나리오 | 멈춘 곳 | 종료 코드 | 업로드 |
|---|---|---|---|
| 정상 | - | 0 | 됨 |
mongodump 실패 (파이프 앞쪽) | dump 줄 | 1 | 안 됨 |
| 아카이브 잘림 | gzip -t | 1 | 안 됨 |
| dry run 실패 | mongorestore | 1 | 안 됨 |
게이트는 통과하는 경우가 아니라 실패하는 경우를 돌려 봐야 검증됩니다. 새 검사를 추가할 때마다 일부러 망가뜨린 입력으로 한 번씩 실행합니다.
# 6. 트러블슈팅
| 증상 | 원인 | 해결 |
|---|---|---|
로그에 rc=1이 찍혔는데 다음 단계로 진행 | PIPESTATUS를 echo 뒤에 읽음 | 파이프라인 직후 배열로 복사, 또는 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 -e는if,||,local문맥에서 조용히 꺼집니다. 검사 단계를 조건문으로 감싸지 않습니다.mongorestore --dryRun은 서버에 연결하고 헤더만 읽습니다. 아카이브 손상은gzip -t로 잡고, 복원 가능 여부는 실제 복원으로만 확인됩니다.
# 참고
- GNU Bash Reference Manual - Bash Variables (PIPESTATUS), The Set Builtin (opens new window)
- Bash NEWS - bash-4.4 inherit_errexit (opens new window)
- POSIX.1-2024 - Shell Command Language (set -o pipefail) (opens new window)
- zsh Manual - Parameters Set By The Shell (pipestatus) (opens new window)
- MongoDB Database Tools - mongorestore (opens new window)
- MongoDB Database Tools - mongodump (opens new window)
- MongoDB - Back Up and Restore with MongoDB Tools (opens new window)
- mongo-tools -
mongorestore/mongorestore.go(100.18.0) (opens new window) - mongo-tools -
common/db/db.go(100.18.0) (opens new window) - GNU gzip Manual (opens new window)
- Amazon S3 - Checking object integrity (opens new window)