website: 배포 스크립트 git pull 실패 흡수 방지 + 서버·git 파일 불일치 해소
멋사 경희대 사이트 프로젝트에서 3연속으로 실패하던 스테이지 CD의 진짜 원인을 추적해서, 배포 스크립트의 에러 흡수 문제와 서버-git 파일 불일치를 해소한 PR입니다.
요약
최근 스테이지 CD가 3번 연속 헬스체크 실패로 롤백됐고, 원인을 추적한 끝에 배포 스크립트 자체의 결함을 찾아 고친 PR입니다. .github/workflows/cd.yml, infra/backup-db.sh, infra/push-backup-metric.py, infra/.gitattributes 4개 파일에 86줄을 추가했고, 2026-07-12에 dev 브랜치로 바로 병합됐습니다. 생성부터 병합까지 2분밖에 안 걸린 걸 보면 이미 원인 분석과 로컬 검증이 끝난 상태에서 PR만 올린 것으로 보입니다.
배경 및 목적
문제는 겉으로는 "헬스체크 타임아웃"으로만 보였습니다. 하지만 실제 원인은 두 겹으로 꼬여 있었습니다.
첫 번째는 배포 스크립트 내부의 git pull이 매번 실패하고 있었다는 점입니다. OCI 서버 워킹트리에 infra/backup-db.sh 등 untracked 파일들이 남아있어서, git pull이 error: The following untracked working tree files would be overwritten by merge 에러를 내며 죽고 있었습니다.
두 번째, 더 심각한 문제는 배포 스크립트에 set -e가 없어서 이 실패가 조용히 무시되고 스크립트가 계속 진행됐다는 점입니다. 결과적으로 서버의 infra 체크아웃은 몇 주째 옛날 커밋에 고정돼 있었는데, 정작 CD 로그에는 엉뚱하게 2분 뒤 헬스체크 타임아웃만 실패로 찍혔습니다. 진짜 원인이 로그에 드러나지도 않은 채 계속 반복됐던 셈입니다.
더 파고들어 보니, 서버에 untracked로 남아있던 backup-db.sh는 단순 잔재가 아니라 미머지 상태인 infra/#83-observability-alerts 브랜치 작업분이 서버에 직접 반영되며 실제로 운영 중이던 코드였습니다. 백업 성공 시 메트릭을 전송하는 3줄이 이미 살아 움직이고 있었고, 이게 없으면 OCI Monitoring의 백업 부재(Absence) 알람 자체가 동작하지 않는 상황이었습니다. 반면 backup_upload.py, dbclient-sqlite-guard.sh는 git 버전과 완전히 동일해서 그냥 정리해도 되는 파일들이었습니다.
구현 내용
1. 배포 스크립트 에러 흡수 방지
.github/workflows/cd.yml의 SSH 배포 스크립트 맨 앞에 set -euo pipefail을 추가했습니다.
script: |
set -euo pipefail
# GHCR 인증 — 서버에서 private 이미지 pull 권한 획득
echo "GHCR_USER" --password-stdin
이제 git pull이든 뭐든 스크립트 중간에 실패하면 그 자리에서 바로 드러납니다. 지금까지처럼 진짜 원인은 숨고 애먼 헬스체크 실패만 보고되는 일은 없을 것입니다.
2. 서버 실제 상태와 git 상태 일치시키기
#83 브랜치 전체를 머지하는 대신, git pull 충돌의 원인이 됐던 변경분만 최소로 cherry-pick 했습니다.
infra/push-backup-metric.py (신규, 78줄) — 백업 성공을 OCI Monitoring custom metric으로 전송하는 dead man's switch 스크립트입니다. backup-db.sh가 백업+업로드를 마칠 때마다 값 1을 찍고, OCI Monitoring의 Absence Alarm이 "N시간 동안 이 메트릭이 안 들어오면" 트리거하는 방식입니다. cron 미실행, 서버 다운, 스크립트 중도 실패까지 이 하나로 다 잡을 수 있습니다.
여기서 눈에 띄는 부분은 인스턴스 OCID를 하드코딩하지 않고 IMDS로 런타임 조회한다는 점입니다.
def instance_metadata():
"""인스턴스가 자기 자신의 OCID/compartment를 IMDS에서 런타임에 조회.
하드코딩하지 않는 이유: 이 값들을 소스에 박아두면 gitleaks가 OCI
OCID로 탐지해 CI를 막고(#83 PR에서 실제로 걸림), 인스턴스가 교체되면
코드도 같이 고쳐야 함 - 둘 다 IMDS 조회로 피할 수 있음.
"""
서버에서 지금 돌고 있는 실제 버전은 OCID가 하드코딩돼 있는데, 이 PR이 머지되면서 IMDS 버전으로 교체될 예정입니다.
infra/backup-db.sh (4줄 추가) — 백업 업로드 완료 직후 메트릭 전송 호출을 추가했습니다.
# 성공 신호 - OCI Monitoring Absence Alarm이 이게 26시간 이상 안 들어오면 알림
# (cron 미실행·서버 다운·스크립트 중도실패 전부 이걸로 잡힘, #83)
"SCRIPT_DIR/push-backup-metric.py" "$db_name"
이 3줄은 서버에 이미 존재하던 것과 동일한 내용이라, 사실상 git과 서버 상태를 맞추는 작업입니다.
infra/.gitattributes (신규) — *.sh, *.py 파일에 LF 개행을 강제했습니다.
*.sh text eol=lf
*.py text eol=lf
이건 재발 방지 성격이 큽니다. 원본 커밋(f34ac44)에서 CRLF 때문에 backup-db.sh가 이틀간 백업 실패를 일으켰던 전례가 있어서, 같은 문제가 다시 생기지 않도록 함께 가져왔습니다.
변경된 파일
.github/workflows/cd.ymlinfra/.gitattributes(신규)infra/backup-db.shinfra/push-backup-metric.py(신규)
기술적 의사결정
가장 눈에 띄는 판단은 "미머지 브랜치 전체를 당기지 않고, 충돌 해소에 필요한 최소 변경만 cherry-pick 한다"는 것입니다. #83 관측 미션 브랜치가 아직 리뷰/머지 전인 상태였는데도 그 안의 일부 코드가 서버에 이미 운영 중이었던 애매한 상황에서, 브랜치를 통째로 머지해버리면 아직 검증되지 않은 다른 변경사항까지 함께 들어올 위험이 있었습니다. 대신 git pull 충돌을 일으키는 원인이 되는 파일들만 정확히 골라서 dev에 반영하는 방식을 택했습니다. 배포 안정성 문제를 급하게 고치면서도 범위를 최소한으로 유지하려는 판단으로 보입니다.
또 하나는 OCID 하드코딩을 피하고 IMDS 조회로 바꾼 부분인데, 이건 단순히 스타일의 문제가 아니라 실제로 gitleaks가 하드코딩된 OCID를 탐지해서 #83 PR의 CI를 막았던 경험에서 나온 결정입니다. 인스턴스 교체 시 코드 수정이 필요 없다는 실용적 이유도 있고요.
배운 점 및 개선점
이번 케이스는 "로그에 찍히는 에러"와 "진짜 원인"이 다를 수 있다는 걸 보여주는 사례였습니다. set -e 없는 셸 스크립트에서 중간 실패가 조용히 넘어가면, 그 뒤에 벌어지는 아무 상관 없는 실패(여기서는 헬스체크 타임아웃)만 눈에 보이고 정작 원인은 몇 주씩 숨어있을 수 있습니다. CI/CD 스크립트에는 set -euo pipefail을 기본값으로 깔아두는 게 왜 중요한지 체감한 케이스입니다.
또한 서버 상태와 git 상태가 벌어지기 시작하면 그 간극이 조용히 계속 벌어진다는 점도 확인했습니다. 운영 편의상 서버에 직접 변경을 반영하는 경우, 그게 git에 트래킹되지 않으면 다음 배포에서 뜻밖의 충돌로 튀어나올 수 있습니다.
PR 설명의 "머지 후 필요한 작업" 항목을 보면 아직 남은 일이 있습니다. 서버의 stray 파일(backup_upload.py, dbclient-sqlite-guard.sh) 정리, push-backup-metric.py를 IMDS 버전으로 실제 교체, 그리고 git pull 재확인과 다음 CD 정상 통과 확인까지 — 이 PR은 문제의 원인 규명과 재발 방지 장치 마련까지고, 서버 쪽 실물 정리는 별도로 진행해야 하는 상태로 마무리됐습니다.