하나의 main에 두 릴리스가 있었다
houkago.server에는 애플리케이션뿐 아니라 Compose와 Worker, systemd, Nginx 설정도 함께 있었다. Systemd unit
한 줄이 바뀌어도 새 이미지를 만들고 app을 재시작할 필요는 없었다. 반대로 app과 호스트 설정이 함께
바뀌면 어느 쪽을 먼저 적용할지 경로만 보고 결정할 수 없었다.
같은 저장소 이력을 사용하되 애플리케이션과 호스트 운영 설정의 릴리스 상태를 따로 관리하기로 했다.
CI 뒤에 변경 분류를 추가했다
Classifier는 before..sha push 변경 범위 전체의 경로를 검사한다.
| 분류 | 자동 운영 결과 |
|---|---|
APP_ONLY | 이미지 게시와 app 배포 |
OPS_ONLY | Ops reconcile |
APP_AND_OPS | 차단 |
NO_PRODUCTION_IMPACT | CI만 실행 |
CONTROL_PLANE | 차단과 수동 검토 |
애플리케이션 원본과 Dockerfile, Gradle은 APP이고 Compose와 실제 Worker, systemd, Nginx는 OPS다. 테스트와
README, 예시는 운영 영향이 없다. Workflow와 classifier 자체는 배포 정책을 바꾸므로
CONTROL_PLANE이다. 알 수 없는 경로도 별도 목록에 남기고 이 분류로 실패시킨다. DB 마이그레이션은 APP이면서
migration_changed=true를 가진다.
결과에는 base와 head SHA, 변경 경로와 영역 flag가 들어간다. CI는 JSON 산출물을 남기고 후속 workflow는 실행을 시작한 run ID로 내려받아 SHA와 스키마를 확인한다. 분류와 관계없이 테스트와 빌드는 모두 실행한다.
Classifier는 어떤 영역이 바뀌었는지만 말한다. 혼합 변경의 안전한 적용 순서나 스키마 호환성은 판단하지 않는다.
운영 설정에 별도 릴리스 상태를 뒀다
APP_ONLY -> immutable image -> Deploy Worker -> App state
OPS_ONLY -> protected request -> Ops Worker -> Ops stateOps Worker는 대상이 origin/main 이력에 있고 현재 Ops에서 fast-forward 가능한지 확인한다. 허용 목록의
변경 파일만 준비하고 검증과 root 전용 백업, 원자적 설치, 설치 후 검증 뒤 상태를
회전한다. 운영 파일이 예상 상태와 다르면 덮어쓰지 않는다.
운영 OPS_ONLY E2E에서는 요청 202, Worker 성공과 설치 hash, Ops 상태 전환을 확인했다. 이미지
게시와 app 재생성, MySQL 재시작은 없었다. 저장소 밖 Compose를 사용해도 namespace가 유지되도록
프로젝트 이름을 server로 고정하고 설치 후 검증도 실제 변경 범위로 좁혔다.
혼합 변경은 호환되는 두 commit으로 나눴다
새 app이 새 mount나 환경 변수를 필요로 하면 이전 app과도 호환되는 Ops를 먼저 배포한다. Worker가 새 API나 실행기를 필요로 하면 app 기능을 먼저 추가한다.
Ops first: OPS_ONLY -> APP_ONLY
App first: APP_ONLY -> OPS_ONLY안전한 중간 상태를 만들 수 없다면 수동 조정 배포가 필요하다. 경로만으로 순서를 추측하는 범용 조정기는 만들지 않았다.
Push 하나만 보면 차단된 변경을 우회할 수 있었다
R1의 APP_AND_OPS가 차단된 뒤 R2에 APP 파일만 수정하면 이번 push 분류는 APP_ONLY다. 하지만 R2 tree에는
R1의 미적용 Ops 변경도 들어 있다. 반대의 OPS 후속 변경도 같은 문제가 있다.
따라서 Worker는 자신이 바꿀 상태가 아니라 상대 영역의 현재 리비전부터 대상까지 검사한다.
App deploy: currentOps..targetApp
-> 미적용 Ops 또는 unknown이 있으면 BLOCK
Ops reconcile: currentApp..targetOps
-> 미적용 App, migration 또는 unknown이 있으면 BLOCK리비전 문자열이 아니라 main의 실제 조상 관계를 확인한다. 없거나 손상된 상태, 잘못된 SHA와 non-fast-forward, 갈라진 변경 범위는 상태 변경 전에 차단한다. 이미 상대 lane에 적용된 변화는 변경 범위 밖이므로 다시 차단 사유가 되지 않는다.
첫 누적 gate는 control-plane에서 교착됐다
처음에는 누적 변경 범위에 CONTROL_PLANE 경로가 있으면 양쪽 관문을 모두 막았다.
R0: App = Ops
R1: CONTROL_PLANE -> 자동 배포 차단
R2: APP_ONLYR1에서 두 상태가 전진하지 않으므로 R2의 누적 변경 범위에도 R1이 들어갔다. App과 Ops 어느 쪽도 R1을 지날 수 없는 설계상 교착 상태였다. 운영 장애가 아니라 Worker 구현 전 fixture에서 발견했다.
개별 push 정책과 실행 시 호환성을 분리해 해결했다.
- 대상 push가
CONTROL_PLANE이면 자동 배포를 차단한다. - 과거 control-plane 경로 자체는 실행 시 차단 사유로 보지 않는다.
- 현재 분류 규칙으로도 알 수 없는 경로는 계속 양쪽을 차단한다.
- Ops 변경 범위의 마이그레이션과 잘못된 조상 관계도 차단한다.
회귀 테스트는 control-plane 뒤 APP_ONLY·OPS_ONLY는 허용하고, 혼합 변경 뒤 후속 변경과 unknown, migration은 차단하도록 고정했다.
현재 대상의 분류 규칙으로 다시 분류했다
과거 분류 산출물을 합치지 않는다. 대상 리비전의 classifier를 정확한 Git object에서 꺼내
oppositeCurrent..target 변경 범위를 현재 분류 규칙으로 다시 분류한다. 과거 unknown이 현재 명시적인
NO_PRODUCTION_IMPACT가 됐다면 영구 차단 사유로 남기지 않는다.
Worker는 대상 SHA와 main 조상 관계, classifier 문법, JSON 필드와 base·head 일치를 검증한다. 안정된 인터페이스가 없거나 출력이 잘못되면 상태를 변경하지 않는다.
누적 관문은 실제 App과 Ops 상태를 가진 호스트 Worker 안에 둔다. 영역 잠금과 maintenance lock을 얻은 뒤 상대 상태를 읽고 호환성을 판단한 다음 자신의 상태만 바꾼다. App 차단은 이미지 가져오기와 재생성 전, Ops 차단은 준비와 설치 전에 발생한다.
Ops Worker는 호환성 검사 범위와 설치 범위도 구분한다. currentApp..targetOps는 호환성을 검사하고,
currentOps..targetOps와 허용 목록의 교집합만 호스트에 설치한다.
운영에는 배포 경로 자체부터 순서대로 넣었다
먼저 안정된 classifier 인터페이스를 CONTROL_PLANE commit으로 만들고 자동 배포하지 않았다. 그다음 누적
관문을 OPS_ONLY commit으로 분리해 기존 Ops 배포 경로로 적용했다.
운영 초기화에서는 CI 성공과 OPS_ONLY 분류, 변경된 관리 파일 3개의 설치와 hash 일치, Ops 상태와
checkout 전진을 확인했다. 이미지 게시와 app 배포는 건너뛰었고 app과 MySQL 재시작 증가도 0이었다.
이 결과는 독립 Ops 배포 경로의 운영 동작을 증명한다. 실제 운영에서는 의미 있는 누적 APP_ONLY·OPS_ONLY 전진을 일부러 만들지 않았고, 이 경로는 fixture로만 검증했다. 모든 교차 릴리스 경로를 운영에서 확인했다고 과장하지 않았다.
최종 정책은 단순하다. APP과 OPS는 각 배포 경로로 보내고 영향 없는 변경은 CI에서 끝낸다. 혼합 변경과 control-plane은 자동 운영을 멈춘다. 경로 분류 규칙과 두 릴리스 상태, Worker 관문을 관리하고 개발자가 호환되는 중간 commit을 설계해야 하지만, 차단된 혼합 변경이 후속 변경으로 우회되는 것보다 안전했다.