1. 개요
8월까지는 master 에 머지하면 CI 가 infra 저장소의 이미지 태그를 고쳐 push 하고, ArgoCD 가 바로 배포했다. “머지 = 배포” 다. 편했다. 9월 26일에 이걸 수동 으로 되돌렸다. 자동화를 늘리는 글은 많은데 줄이는 글은 적어서, 왜 줄였는지 적어 둔다. 그리고 같은 시기에 백업이 사실 외부로 한 벌도 안 나가고 있었다는 것을 발견한 이야기도.
2. 핵심 내용
2-1. 머지 = 배포가 문제가 된 날
PR 16개를 하루에 정리한 날이 있었다. 각각 초록이었고, 각각 머지했다. 머지마다 빌드가 돌고, 빌드마다 배포가 나갔다. 16번의 롤아웃이 몇 시간 안에 이어졌다.
두 가지가 터졌다. CI 러너와 운영 파드가 같은 노드에 있어 노드가 포화됐고, 게이트웨이가 504 를 냈다. 그리고 16개 PR 이 각각은 맞는데 합치면 어긋나는 회귀를 하나 만들었다. 한 PR 의 CTA 색 통일이 다른 PR 의 새 404 화면을 놓쳤다. 각 PR 은 자기 시점의 master 기준으로 초록이었다.
“머지 = 배포” 는 머지 단위가 곧 릴리스 단위라는 뜻이다. 혼자 개발할 때는 그게 맞았다. PR 을 여러 개 병렬로 열고 한 번에 닫는 방식으로 일하기 시작하자 맞지 않게 됐다.
2-2. 수동 deploy.yml
CI 에서 infra 를 건드리는 잡을 떼어냈다. 이제 master 빌드는 GHCR 에 prod-<sha7> 이미지를 올리는 것까지만 한다. 배포는 사람이 workflow_dispatch 로 태그 하나를 넣어 실행한다.
gh workflow run deploy.yml --ref master -f tag=prod-abc1234이 워크플로는 fail-closed 로 짰다. 어느 단계든 의심스러우면 멈춘다.
- 실행 ref 가 master 인지, 태그가
^prod-[0-9a-f]{7}$형식인지 확인 - GHCR 에 그 태그의 manifest 가 실제로 있는지 HEAD 로 확인
- infra 저장소를 임시 디렉터리에 clone
yq로 kustomization 의 대상 이미지 항목이 정확히 1개 인지 확인한 뒤 그 항목의newTag만 치환kustomize build로 렌더한 결과에 새 태그가 나오는지 grep- 봇 계정으로 infra main 에 push, 충돌 시 rebase 재시도
롤백은 같은 명령에 옛 태그다. 배포 경로와 롤백 경로가 같으면 롤백을 연습할 필요가 없다. 매번 배포가 연습이다.
2-3. 이 결정을 도운 실측
다른 프로젝트(DoEatFit 2편)의 release-please 파이프라인을 그대로 가져올까 했다. 실측하고 접었다.
- 이 프로젝트의 master 빌드는 중앙값 8분. 재빌드 없이 재태깅할 이유가 없다.
- Conventional Commits 준수율 38/40. 도입은 가능하지만 태그·릴리스 이력이 0건이라 당장 필요하진 않다.
- 브랜치가
master라 릴리스 커밋 제목이chore(master): release다. 다른 프로젝트의chore(main)문자열을 복사하면 조용히 안 맞는다.
1단계로 수동 deploy.yml 만 넣었다. “사람이 읽는 릴리스 경계” 는 필요해지면 그때.
부수 발견: paths-ignore: **.md 로 빌드가 건너뛰어진 커밋은 prod-<sha7> 태그가 없다. “커밋 해시 = 이미지” 가 아니다. 배포할 태그를 고를 때 git log 가 아니라 GHCR 을 봐야 한다.
2-4. 백업은 되읽어야 백업이다
PostgreSQL 은 매일 CronJob 이 pg_dump -Fc 로 덤프해 Longhorn 볼륨에 둔다. 덤프 파일은 크기가 1KB 미만이거나 pg_restore --list 가 실패하면 오류로 처리하고, 임시 파일에 쓴 뒤 원자적으로 rename 한다. 보존 14일, 최소 3벌. 여기까지는 잘 돼 있었다.
문제는 두 번째 사본 이었다. Longhorn 의 RecurringJob 이 이 볼륨을 매일 외부 NFS 로 백업한다고 주석에 적혀 있었다. 실측하니 0벌 이었다. Longhorn RecurringJob 은 detached 볼륨을 건너뛴다. 덤프 볼륨은 CronJob 이 도는 몇 초만 attach 되고 나머지 시간은 detached 다. RecurringJob 이 돌 때마다 “attach 된 볼륨이 없네” 하고 지나갔다.
해법은 pgdump-holder 다. pause 컨테이너 하나가 그 볼륨을 읽기 전용으로 상시 마운트해 attached 상태로 붙잡는다. CronJob 은 podAffinity 로 같은 노드에 묶는다. 이제 RecurringJob 이 볼륨을 본다.
그리고 복원 리허설을 했다. 덤프를 빈 DB 에 복원해 테이블 8개의 행 수가 운영과 일치하는지 셌다. 되읽어 보기 전까지 백업은 백업이 아니다. 주석은 더더욱 아니다.
2-5. 매니페스트 검증은 게이트가 아니라 감지기
infra 저장소에는 PR 마다 도는 검증이 있다. kustomize 렌더 → kubeconform strict → Gateway 정책 의미 검사 → 평문 Secret 금지 → :latest 금지.
Gateway 정책 의미 검사는 따로 만들었다. targetRefs 의 오타나 principal 없는 Allow 규칙은 kubeconform 도 ArgoCD 도 exit 0 으로 통과시킨다. 스키마상 유효하니까. 하지만 오타 난 targetRef 는 아무것도 보호하지 않고, principal 없는 Allow 는 전부 허용이다. 이런 “조용한 실패” 는 스크립트로 잡아야 한다.
다만 이 저장소는 GitHub 무료 플랜의 private 저장소라 브랜치 보호를 걸 수 없다. 그래서 이 CI 는 게이트가 아니라 감지기 다. 빨간불이 떠도 머지는 된다. 이걸 알고 있는 것과 모르는 것은 다르다. 강제가 필요한 검증은 배포 워크플로(2-2 의 5단계)처럼 봇이 push 하기 전에 스스로 한다.
3. 마무리
요약
- 머지 = 배포는 머지 단위가 릴리스 단위일 때만 맞다. 병렬 PR 을 시작하면 갈라진다.
- 수동 deploy.yml 은 fail-closed 로. 형식·존재·항목 개수·렌더 결과를 전부 확인한 뒤 push.
- 롤백 경로 = 배포 경로. 매 배포가 롤백 연습이다.
- Longhorn RecurringJob 은 detached 볼륨을 건너뛴다. holder 파드로 붙잡아라.
- 되읽어 보기 전까지 백업은 백업이 아니다.
시리즈는 여기까지다. 혼자 만들고 운영하는 사이트의 결정들은 대부분 “인스턴스 수” 와 “되돌릴 수 있는가” 두 축에서 나왔다.