프로덕션의 모든 DB API가 500을 반환하는 장애를 조사하다가, 코드 변경이 수개월째 반영되지 않았는데도 CI/CD와 배포 워크플로가 줄곧 성공으로 표시됐다는 사실을 발견했어요. 서비스가 응답한다는 사실과 요청한 배포가 완료됐다는 사실을 구분하지 않고 성공으로 판정한 게 문제의 핵심이었어요.
프로덕션은 멈췄지만 파이프라인은 성공하고 있었다
장애의 직접적인 원인은 VM 디스크 사용률이 100%에 도달하면서 PostgreSQL 컨테이너가 크래시 루프에 빠진 것이었어요. 장애를 조사하면서 프로덕션 VM의 컨테이너가 언제 생성됐는지도 확인했어요.
application-container Created=<creation-timestamp> RestartCount=0
애플리케이션 컨테이너는 수개월 동안 한 번도 재생성되지 않았어요. 그동안 코드 변경이 프로덕션에 반영되지 않았는데도 CI/CD와 배포 워크플로는 계속 성공으로 표시됐어요.
단순히 배포가 실패한 문제가 아니었어요. 파이프라인이 실패를 성공으로 판정하는 바람에 운영자는 프로덕션 상태를 잘못 파악하고 있었어요.
디스크 부족은 방아쇠였고, 근본 원인은 실패 전파 부재였다
프로덕션 환경 설정 파일과 클라우드 인증 키 파일은 모두 0바이트였고, 수정 시각도 똑같은 특정 시점으로 남아 있었어요. 배포 스크립트와 대조해 보니 인과관계가 다음과 같이 이어졌어요.
원격 배포 워크플로의 SSH 스크립트에는 set -e가 없었어요. 디스크가 가득 찬 상태에서 다음과 같은 환경 파일 쓰기가 ENOSPC로 실패해도 스크립트는 멈추지 않았어요.
printf ... > production.env
쓰기 전에 파일이 truncate되면서 프로덕션 환경 설정 파일은 0바이트가 됐어요. ${DEPLOY_IMAGE}도 비어 있어서 컨테이너 구성은 기본값인 ${DEPLOY_IMAGE:-application:local}을 선택했어요. 해당 이미지가 VM에 없어 docker compose up -d가 실패했지만 이 실패도 전체 배포 실패로 이어지지 않았어요.
마지막 검증은 일반 헬스체크 엔드포인트가 HTTP 200을 반환하는지만 확인했어요. 새 컨테이너는 뜨지 않았지만 기존 컨테이너가 계속 실행되고 있었거든요. 결국 갱신되지 않은 옛 컨테이너가 200을 반환했고, SSH 스크립트와 CI/CD 플랫폼은 배포를 성공으로 처리했어요.
디스크 부족은 특정 배포 실패를 촉발한 방아쇠였어요. 이 문제를 수개월 동안 감춘 근본 결함은 중간 명령의 실패를 무시하고, 실행 중인 코드가 배포 대상과 같은지 확인하지 않은 채 성공으로 판정한 데 있었어요.
서비스 생존과 배포 완료를 분리했다
기존 파이프라인은 헬스체크가 성공하면 배포도 성공했다고 간주했어요. 하지만 헬스체크로 증명할 수 있는 건 응답 가능한 컨테이너가 있다는 사실뿐이에요. 그 컨테이너가 이번 배포에서 생성됐는지는 증명하지 못해요.
그래서 성공 조건을 다음과 같이 바꿨어요.
배포를 요청한 커밋 SHA == 현재 실행 중인 컨테이너의 커밋 SHA
먼저 Dockerfile을 수정해 빌드 커밋을 이미지에 각인했어요.
ARG GIT_SHA=unknown
ENV BUILD_REVISION=$GIT_SHA
LABEL org.opencontainers.image.revision=$GIT_SHA
애플리케이션에는 DB에 의존하지 않는 GET /health/version 엔드포인트를 추가했어요. 이 API는 이미지의 BUILD_REVISION을 반환하므로 DB에 장애가 나도 실행 중인 애플리케이션 버전을 확인할 수 있어요.
배포 워크플로는 일반 헬스체크를 통과한 다음 이 엔드포인트를 호출해요. 응답한 SHA가 배포 대상 SHA와 다르거나 비어 있으면 배포를 실패로 처리해요. 기존 컨테이너가 200을 반환해도 버전이 다르므로 이제는 성공으로 판정하지 않아요.
BUILD_REVISION은 프로덕션 환경 설정 파일에 기록하지 않았어요. env_file이 이미지의 ENV를 덮어쓰기 때문에 배포 스크립트가 기대하는 SHA를 환경 파일에 직접 넣으면 실제 이미지와 상관없이 검증을 통과할 수 있거든요. 환경 파일에 빌드 리비전 값이 들어오면 배포를 중단하는 가드도 추가했어요.
배포 대상을 불변 커밋으로 고정했다
이미지 태그도 latest에서 커밋 SHA로 바꿨어요. latest는 시간이 지나면 가리키는 대상이 달라져 현재 실행 중인 코드를 식별하기 어렵고, 정확한 버전으로 롤백하기도 어려워요.
CD 워크플로의 이미지 태그에는 현재 워크플로 컨텍스트의 SHA가 아니라 선행 워크플로를 실제로 촉발한 커밋 SHA를 사용했어요. 연쇄 실행되는 워크플로에서는 두 값이 달라질 수 있기 때문이에요. 그렇지 않으면 커밋 X의 코드를 빌드하면서 커밋 Y의 태그를 붙일 가능성이 있었어요.
VM 파일을 변경하기 전에 docker manifest inspect를 실행해 해당 SHA 태그가 컨테이너 레지스트리에 있는지 확인해요. 이미지를 pull한 뒤에는 org.opencontainers.image.revision 라벨을 배포 대상 SHA와 비교하고, 태그와 이미지 내용이 일치하지 않으면 컨테이너를 실행하기 전에 중단해요.
배포 대상을 정하는 단계부터 레지스트리 이미지, 이미지 라벨, 런타임 버전을 확인하는 단계까지 하나의 SHA로 연결한 셈이에요.
조용한 실패를 fail-closed로 바꿨다
원격 배포 스크립트에는 set -euo pipefail을 추가하고 로그인 셸이 Bash인지도 확인해요. pipefail을 지원하지 않는 셸에서는 설정 자체가 실패해 엄격 모드가 적용되지 않은 채 과거 동작으로 돌아갈 수 있거든요.
엄격 모드만으로 모든 문제를 잡을 수 있는 건 아니에요. set -e는 명령 실패를 감지하지만 값이 비어 있는 상황까지 판단하지는 않아요. 그래서 선택 값에는 기본값을 주고, 배포에 필요한 필수 값은 모두 명시적으로 검사해 하나라도 비어 있으면 일찍 실패하도록 했어요.
설정 파일은 완성된 임시 파일을 만든 다음 교체하도록 바꿨어요. 프로덕션 환경 설정 파일의 마지막에는 센티널 라인을 기록하고, 이를 확인해 파일이 끝까지 생성됐는지 검사해요. 설정 항목이 늘어나면 불완전한 파일도 정상으로 오인할 수 있어서 줄 수 임계값은 사용하지 않았어요.
클라우드 인증 키 파일도 먼저 임시 파일에 쓴 다음 크기와 개인 키 필드를 검증해요. 검증을 마친 내용만 기존 파일에 반영하되, 실행 중인 컨테이너의 단일 파일 바인드 마운트를 유지하려고 inode는 보존해요. 빈 문자열을 기록할 때도 개행 1바이트 때문에 비어 있지 않은 파일로 오인할 수 있는 echo 대신 printf를 사용했어요.
디스크 부족이 다시 파이프라인을 무력화하지 않게 했다
이번 장애를 직접 촉발한 디스크 축적 문제에도 방어선을 추가했어요. 컨테이너 구성의 애플리케이션, PostgreSQL, Caddy 컨테이너에는 json-file 로그 로테이션을 적용해 파일 크기와 보관 개수에 상한을 뒀어요. 장애 당시에는 PostgreSQL 크래시 루프 로그만 수백 MB까지 늘어나 있었어요.
배포 전에는 루트 디스크에 미리 정한 최소 여유 공간이 남아 있는지 확인해요. 공간이 부족하면 오래된 빌더 캐시와 롤백 보존 기간이 지난 애플리케이션 이미지만 정리한 뒤 다시 측정해요. 그래도 기준에 미치지 못하면 이미지 pull이 DB까지 위협하지 않도록 배포를 중단해요.
이미지 정리는 배포 검증이 끝난 뒤로 옮겼어요. 실패했을 때 되돌아갈 이미지를 먼저 지우지 않기 위해서예요. 정리 대상도 org.opencontainers.image.revision 라벨이 있는 애플리케이션 이미지로 한정해 PostgreSQL과 Caddy 이미지까지 함께 제거할 위험을 줄였어요.
초록불의 의미를 다시 정의했다
이제 CI/CD 플랫폼의 성공은 단순히 “어떤 컨테이너가 200을 반환했다”는 뜻에 그치지 않아요. 배포 대상 SHA가 확정돼야 하고, 해당 이미지가 컨테이너 레지스트리에 있으며 이미지 라벨도 대상 SHA와 일치해야 해요. 설정 파일을 완전하게 생성하고 새 컨테이너를 정상적으로 기동한 뒤에는 런타임이 반환한 버전까지 요청한 SHA와 같아야 해요.
중간 명령 실패, 빈 필수 값, 잘린 설정 파일, 잘못된 이미지 태그, 이전 컨테이너의 헬스체크 응답이 성공으로 포장되던 경로를 각각 차단한 셈이에요.
변경 후 수십 개의 단위 테스트 suite와 수백 개의 test가 통과했어요. 환경 설정 검증과 헬스체크 컨트롤러의 회귀 테스트를 추가했고, yarn lint:check, yarn build, 배포 스크립트의 bash -n 검사도 통과했어요. SHA 형식, 센티널, 빌드 리비전 값 유입, 버전 비교, 클라우드 인증 키 검증을 포함한 여러 가드도 격리 실행으로 확인했어요.
다만 통합 테스트는 아직 수행하지 않았어요. 검증 실패 시 자동 롤백과 배포 실패 알림도 구현 범위 밖에 남아 있어요. 파이프라인이 거짓으로 성공하던 경로는 바로잡았지만, 통합 릴리즈 검증과 실패 통지는 후속으로 보강해야 해요.
이번 사고에서 얻은 중요한 교훈은 헬스체크를 더 많이 호출해야 한다는 게 아니에요. CI/CD의 성공 조건은 “서비스가 응답하는가”가 아니라 “요청한 변경이 실제 프로덕션에서 실행되고 있는가”를 증명해야 해요. 배포 대상과 런타임이 같은지 확인하지 않은 초록불은 운영 상태가 아니라 파이프라인의 착각일 수 있어요.
근거: 내부 변경 검토 기록