자신을 교체할 서버에 Docker 권한을 줄 수 없었다
GHCR에 ARM64 이미지를 게시해도 그 digest를 운영에 적용할 주체가 필요했다. GitHub Actions가 OCI에 SSH로 접속하면 CI에 운영 자격 증명을 둬야 하고, Spring 엔드포인트가 직접 Docker를 사용하면 인터넷 요청을 받는 애플리케이션에 호스트 권한을 주게 된다. 요청을 처리하던 app이 즉시 교체되면 HTTP 응답도 끝나기 전에 연결이 끊길 수 있다.
그래서 Spring은 요청 검증과 작업 발행까지만 맡고, root 소유의 systemd Worker가 실제 배포를 수행하게 했다.
GitHub image publish
-> Spring: 배포 요청 검증
-> Atomic File Spool
-> systemd.path
-> Backend Deploy Worker
-> image와 운영 계약 검증
-> app만 교체
-> health와 공개 API 확인
-> current·previous 상태 갱신HTTP 요청과 배포 작업을 파일로 나눴다
엔드포인트가 받는 값은 세 개다.
deliveryId: 중복 요청을 식별하는 UUIDrevision: 0이 아닌 40자리 full Git SHAimage: 허용된 GHCR 저장소의 SHA-256 digest 참조
Bearer 비밀값은 SHA-256 digest로 만든 뒤 constant-time 방식으로 비교한다. 본문 크기와 정확한 필드도 검사하고 mutable tag, 다른 저장소와 짧은 리비전은 거부한다.
검증한 작업에는 receivedAt과 약 5초 뒤의 notBefore를 추가한다. JSON을 임시 파일에 완전히 쓴 뒤 같은
파일 시스템의 incoming으로 원자적으로 이동한다. 같은 delivery ID가 .tmp, incoming, processing,
succeeded, failed 어디에든 있으면 새 작업을 만들지 않는다.
새 작업은 HTTP 202, 중복은 200으로 응답한다. 202는 배포 완료가 아니라 작업을 안전하게 넘겼다는
뜻이다. 이후 app이 교체돼도 파일로 남은 작업은 사라지지 않는다.
notBefore는 응답이 클라이언트에 전달될 시간을 확보하는 추가 방어다. 단순한 대기가 응답 완료를 증명하지는
않으며, 실제 생명주기를 나누는 경계는 File Spool이다. 운영 검증에서도 202 완료가 app 교체 시작보다
앞서는지 따로 확인했다.
Worker는 변경 전에 모든 계약을 확인했다
Worker의 실행 순서는 다음과 같다.
- UUID와 revision, digest, timestamp를 다시 검증한다.
notBefore까지 기다린다.- Deploy lock과 공용 maintenance lock을 얻는다.
current와 대상 사이의 Flyway 마이그레이션 변경을 확인한다.- 이미지를 가져오고 digest, platform, 리비전·원본 label을 검사한다.
- Candidate 환경에서
app.image와sync.image가 같은 digest인지 확인한다. - MySQL 컨테이너 ID를 기록하고
app만 강제로 다시 만든다. - MySQL과 Spring 상태, 이미지 식별자와 공개 API를 확인한다.
- 성공한 후보만
current로 만들고 이전current를previous에 보존한다. - 작업을
succeeded또는failed로 옮긴다.
마이그레이션과 이미지, Compose 검증이 끝나기 전에는 app을 변경하지 않는다.
배포와 콘텐츠 작업이 겹치지 않게 했다
Deploy Worker는 먼저 deploy domain lock을 non-blocking으로 얻고, 그다음 공용 backend-maintenance lock을
제한된 시간 동안 기다린다. Content Worker도 content domain lock을 먼저 얻은 뒤 같은 maintenance lock을
사용한다.
| Lock | 막는 충돌 |
|---|---|
| Content domain | 콘텐츠 동기화끼리의 중복 |
| Deploy domain | 백엔드 배포끼리의 중복 |
| Maintenance | 콘텐츠 동기화와 app 교체의 교차 실행 |
모든 Worker가 domain, maintenance 순서를 지킨다. Maintenance lock을 얻지 못하면 작업을 processing으로
옮기지 않고 incoming에 남긴다. 판단 기준은 lock 파일 존재가 아니라 실제 flock 획득 결과다.
요청한 리비전과 이미지를 다시 연결했다
Worker는 정확한 digest로 이미지를 가져온 뒤 다음을 검사한다.
- OS와 architecture가
linux/arm64인가 - Revision label이 요청한 full SHA와 같은가
- Source label이 허용한 저장소인가
- 요청한 digest가 실제
RepoDigests에 있는가
Ambient shell의 image 변수를 제거하고 canonical server env와 candidate release env로 Compose를 해석한다.
상시 app과 일회성 sync가 같은 digest를 보는지도 확인한다. 요청 리비전과 이미지 label이 다르면
원본과 이미지의 연결이 끊긴 것으로 보고 배포하지 않는다.
Migration이 있으면 자동 배포를 멈췄다
current와 대상 리비전 사이에서 src/main/resources/db/migration이 하나라도 바뀌면
MANUAL_REVIEW_REQUIRED로 중단한다. SQL keyword로 안전성을 추측하지 않았다. 일부 안전한 migration까지
수동 검토하게 되더라도 잘못된 호환성 판단보다 보수적인 실패를 택했다.
이유는 이미지 롤백이 스키마 롤백을 뜻하지 않기 때문이다. 이전 app을 실행해도 이미 적용된 데이터베이스 스키마는 돌아가지 않는다. 스키마 변경 가능성이 있으면 자동 이미지 복구로 해결된다고 가정하지 않고 app 상태 변경 전에 멈춘다.
데이터베이스는 배포 대상에서 제외했다
Compose의 app, sync, mysql 중 code deploy가 다시 만드는 것은 app뿐이다.
docker compose up -d --no-deps --force-recreate appWorker는 전후 MySQL 컨테이너 ID가 같은지 확인하고 상태만 검사한다. One-shot sync도 상시로 다시 만들지
않으며 다음 실행부터 current app과 같은 릴리스 digest를 사용한다.
Spring에는 graceful shutdown과 lifecycle timeout 25초, Compose에는 stop_grace_period: 30s를 설정했다.
하지만 app이 하나뿐이므로 무중단 배포는 아니다. 운영에서 기존 app 종료와 candidate 시작 사이 약
11~13초의 HTTP 502가 관찰됐다. 진행 중 요청의 강제 종료를 줄였을 뿐, 더 높은 가용성에는 blue/green이나
복수 replica와 Nginx 전환이 필요하다.
성공한 뒤에만 release 상태를 바꿨다
Release identity는 current와 previous 파일로 관리한다. Candidate 실행만으로 current를 바꾸지 않는다.
MySQL과 Spring 상태, 후보 이미지 식별자, 공개 list·featured·detail API가 모두 성공한 뒤 원자적으로
회전한다.
Candidate health가 실패하고 schema가 바뀌지 않았다면 기존 current image로 app만 복구한다. Candidate는 성공 release로 기록하지 않는다. 상태 파일 갱신까지 실패하면 성공으로 숨기지 않고 이전 상태를 복원하거나 수동 복구가 필요한 상태로 남긴다.
Worker에는 자동 run 외에도 status, retry, dry-run, rollback 명령을 뒀다. 대신 endpoint와 secret,
File Spool, systemd unit, lock, release 환경 파일과 filesystem 권한이라는 운영 대상이 늘었다. 특히 systemd
sandbox의 Git metadata 쓰기 범위와 atomic rename에 필요한 상위 디렉터리 권한은 로컬 fixture만으로 완전히
재현하기 어려웠다.
핵심은 Spring Boot가 자신을 직접 교체하지 않는다는 점이었다. 외부 요청은 배포 의도를 검증해 보존하고, 호스트 Worker가 상태 변경 전에 이미지와 스키마, 실행 계약을 다시 확인한 뒤 app 하나만 교체한다.