DEVELOPMENT NOTE3

운영 배포가 systemd 권한에서 두 번 멈춘 이유

실제 배포와 rollback을 검증하며 로컬 테스트가 놓친 filesystem 경계를 찾은 기록

#continuous-delivery#production-e2e#systemd#docker-compose#rollback#fail-closed
아카이브로 돌아가기

개별 테스트 통과만으로는 부족했다

백엔드 CI와 ARM64 image publish, Deploy Worker와 rollback은 각각 테스트를 통과했다. 하지만 실제 main push부터 운영 배포와 복구까지 이어진다는 보장은 없었다.

완료 조건을 다음처럼 정했다.

  1. Main push의 CI가 성공한다.
  2. 검증한 exact SHA로 ARM64 image와 digest를 만든다.
  3. Spring이 배포 작업을 보존하고 HTTP 202를 완료한다.
  4. Worker가 app만 교체하고 MySQL과 Spring, 공개 API를 확인한다.
  5. Current와 previous 상태를 확정한다.
  6. Previous digest로 rollback하고 같은 candidate를 공식 경로로 복원한다.
  7. 전체 전환에서 MySQL ID와 restart count가 유지된다.

GitHub Actions 성공과 HTTP 202는 중간 상태일 뿐이다. 최종 기준은 Worker 결과와 실행 환경 점검, 릴리스 상태다.

실제 main revision으로 전체 경로를 실행했다

두 이미지를 릴리스 A와 B로 구분했다. 실제 리비전과 digest는 확인 자료에 남아 있지 않아 추측하지 않았다.

text
Main push
  -> Backend CI
  -> ARM64 image publish
  -> Deploy request
  -> Release B: current=B, previous=A
  -> Rollback: current=A, previous=B
  -> 공식 restore: current=B, previous=A

시작 전에는 app 이미지와 리비전, app·MySQL 컨테이너 ID와 재시작 횟수, 상태와 공개 API, 릴리스 env를 기록했다. 새 컨테이너가 생긴 것만이 아니라 의도한 구성 요소만 바뀌었는지 비교하기 위해서다.

CI 실행 31769101818은 서버 리비전 48a8904에서 2분 57초 걸렸다. 이어진 ARM64 게시 실행 31769258098은 28초였고 정확한 SHA와 digest, linux/arm64, 리비전·원본 label을 확인했다. CI 시작부터 게시 완료까지는 약 3분 27초였다. 더 세분화된 시각은 확인 자료에 남아 있지 않아 복원하지 않았다.

Production에서는 HTTP 202가 완전히 전달된 뒤 notBefore가 지나 app 교체가 시작되는 순서도 확인했다. 작업의 안전한 인계는 File Spool이 담당하고 시간 지연은 자체 교체 경쟁 상태를 줄이는 추가 방어다.

첫 번째 중단은 Git metadata 쓰기 권한이었다

첫 배포는 마이그레이션 관문의 git fetch에서 .git/FETCH_HEAD를 열지 못해 멈췄다. Systemd unit의 ProtectSystem=strict와 allowlist에서 working tree를 읽게 했지만 Git metadata 쓰기는 허용하지 않았다. 로컬 fixture는 실제 systemd mount namespace의 제한을 재현하지 못했다.

이 단계는 image pull과 app mutation보다 앞이었다.

text
.git 쓰기 실패
  -> migration 검사 실패
  -> app과 release state 유지

검사를 생략하거나 저장소 전체를 쓰기 가능하게 열지 않았다. 작업 트리는 읽기 전용으로 유지하고 .git에 필요한 최소 쓰기 경계만 추가했다. 회귀 테스트에도 이 계약을 넣었고 수정은 서버 commit cf22db6에 남겼다.

두 번째 중단은 상태 파일의 atomic rename이었다

첫 문제를 고친 뒤 candidate B는 app 교체와 health, 공개 API 확인까지 통과했다. 그러나 current release를 게시할 때 다시 실패했다.

상태 파일은 같은 디렉터리에 임시 파일을 만들고 권한을 적용한 뒤 이름을 바꾼다. Unit에는 대상 파일 쓰기만 허용돼 있었고, 임시 파일과 rename에 필요한 상위 디렉터리 쓰기 권한이 없었다.

text
Candidate health 성공 != Release promotion 성공

Worker는 B를 current로 기록하지 않고 기존 app과 상태 복구를 시도했다. 복구까지 불가능하면 manual recovery required로 남겼다. 수정에서는 env 디렉터리의 제한된 쓰기만 열고 server.env와 Worker secret은 개별 읽기 전용으로 유지했다. 이 변경은 commit 48a8904에 남겼다.

첫 실패는 상태 변경 전에 멈췄고, 두 번째는 후보 실행 뒤 상태 기록에서 멈췄다. Fail-closed는 항상 아무것도 바뀌지 않는다는 뜻이 아니라 불완전한 결과를 성공으로 기록하지 않는다는 뜻이었다. Durable 근거가 부족한 공유 잠금 권한 문제는 운영 장애로 포함하지 않았다.

수정 뒤 배포와 rollback을 왕복했다

두 경계를 고친 뒤 official endpoint로 A에서 B를 배포했다. MySQL과 Spring health, 공개 list·featured·detail API를 확인한 뒤 current=B, previous=A가 됐다.

Worker CLI로 A의 digest와 platform, label, Compose resolution을 다시 검증해 rollback했고 상태는 current=A, previous=B로 바뀌었다. 이어서 임시 명령이 아니라 새 endpoint delivery로 같은 B를 복원해 current=B, previous=A를 확인했다.

세 전환 모두 정확한 digest와 같은 실행 환경 검증을 사용했다. 후보 상태 확인 실패의 자동 롤백은 fixture로 확인했지만, 운영에 고장 난 image를 일부러 배포하는 실험은 하지 않았다.

Backend CD 변경 뒤 기존 콘텐츠 동기화도 회귀 확인했다. Current B digest의 일회성 sync는 TOUCHED=265, DELETED=0으로 끝났고 app과 MySQL은 재시작되지 않았다.

MySQL은 유지됐지만 짧은 중단은 있었다

배포와 롤백, 복원은 모두 app만 강제로 다시 만들었다. 전후 MySQL 컨테이너 ID와 재시작 횟수, 상태는 같았다. Flyway 변경은 이미지 롤백과 스키마 롤백이 다르므로 계속 수동 검토 대상으로 뒀다.

App이 하나라 기존 app 종료와 candidate 시작 사이에는 응답할 instance가 없다. 최초 registry cutover에서 약 12초, rollback과 restore에서 각각 약 11~13초의 외부 HTTP 502가 관찰됐다. Graceful shutdown은 진행 중 요청의 강제 종료를 줄였지만 무중단을 만들지는 않았다.

검증 환경마다 찾을 수 있는 문제가 달랐다

검증 층확인한 범위
단위 테스트·shell fixtureWorker 상태와 실패 분기, lock, 릴리스 상태
GitHub Actions원본과 이미지, Compose, Docker build
Synthetic 운영 요청HTTPS 인증, spool, 중복과 same-release 처리
운영 E2ESystemd namespace, filesystem permission, Docker와 network timing

Synthetic 요청에서는 잘못된 Bearer가 작업을 만들지 않는지, current release 요청이 already_current로 끝나는지, 중복 delivery와 잘못된 리비전이 상태 변경 전에 거부되는지도 확인했다. 하지만 .git과 상위 디렉터리 권한 문제는 실제 운영 namespace에서만 드러났다.

측정값은 한 차례의 관찰이다.

항목관찰값
Backend CI2분 57초
ARM64 image publish28초
CI 시작부터 publish 완료약 3분 27초
최초 cutover의 외부 502약 12초
Rollback·restore의 외부 502각각 약 11~13초

실행기 캐시와 네트워크, 이미지 가져오기와 확인 시점에 따라 달라지므로 SLA나 최대 중단 시간은 아니다.

남은 과제는 blue/green 배포와 migration 승인 절차, spool 정리, release history, alert와 observability다. 이번 검증은 높은 가용성을 완성한 것이 아니라 실제 운영 경계에서 잘못된 상태를 성공으로 표시하지 않고 정확한 이미지로 배포와 복구를 왕복할 수 있음을 확인한 과정이었다.