1. 개요
1편에서 “배포의 유일한 입구는 infra 저장소 커밋” 이라고 썼다. 그럼 그 커밋은 누가 만드나. 사람이 아니다. 이 글은 개발자가 PR 을 머지한 순간부터 운영 파드가 새 이미지로 뜨기까지, 이미지 태그가 어떤 손을 거치는지 따라간다.
DoEatFit 은 백엔드 빌드가 30~40분 걸린다. 이 숫자 하나가 파이프라인 모양을 거의 다 결정했다. 다른 프로젝트에 이 구조를 그대로 가져가려다 멈춘 이야기는 뒤에 쓴다.
2. 핵심 내용
2-1. 정상 경로
sequenceDiagram participant Dev as 개발자 participant GH as GitHub Actions participant REG as GHCR participant RP as release-please participant IU as image-updater participant INFRA as infra 저장소 participant ARGO as ArgoCD Dev->>GH: feature PR 머지 (main) GH->>REG: 테스트 → 빌드 → prod-<sha7> push GH->>REG: latest 승격 (조상 검사 통과 시) IU->>INFRA: staging: latest digest bump RP->>Dev: Release PR 생성/갱신 (Conventional Commits) Dev->>GH: Release PR 머지 → 태그 vX.Y.Z GH->>REG: 재빌드 없이 prod-<sha7> 를 vX.Y.Z 로 재태깅 IU->>INFRA: prod: semver 감시 → newTag write-back ARGO->>ARGO: 폴링 → 롤아웃 → Flyway → validate
- main 머지: 테스트 티어를 돈 뒤 이미지를
prod-<sha7>로 GHCR 에 올린다. PR 에서는 이미지를 push 하지 않는다. - staging: image-updater 가
:latest의 digest 를 따라간다. main 의 머리가 곧 staging 이다. - release-please: main 에 push 가 쌓일 때마다 Conventional Commits 를 모아 Release PR 을 만든다.
feat·fix·perf·refactor·docs는 릴리스가 나가고,style·chore·test·ci넷만 숨겨진다. - Release PR 머지: 태그
vX.Y.Z가 만들어지고, 별도 워크플로가 재빌드 없이 그 릴리스 커밋의prod-<sha7>을buildx imagetools로 재태깅한다. - prod: image-updater 가
^v\d+\.\d+\.\d+$태그를 semver 로 감시해overlays/prod/kustomization.yaml의newTag를 고쳐 main 에 직접 커밋한다. - ArgoCD: 3분 폴링으로 동기화. 파드가 뜨면서 Flyway 가 마이그레이션을 돌리고 Hibernate
validate가 스키마를 대조한다.
“재빌드 없이 재태깅” 이 핵심이다. 빌드가 40분이면 릴리스마다 다시 빌드할 수 없다. 대신 테스트를 통과한 그 바이트 에 버전을 붙인다. 그리고 부작용으로, 빌드가 없으면 릴리스가 멈춘다. 이건 버그가 아니라 안전장치로 쓰고 있다.
2-2. latest 승격은 조건부다
main 빌드 두 개가 병렬로 돌면 늦게 끝난 옛 빌드가 latest 를 덮을 수 있다. 그래서 latest 승격 전에 “지금 latest 가 가리키는 커밋이 이 빌드 커밋의 조상인가” 를 확인한다. 조상이 아니면 승격하지 않는다. staging 이 뒤로 가는 일을 막는 한 줄이다.
2-3. 실제로 밟은 함정
① 릴리스는 나갔는데 이미지가 없다. release-please 와 이미지 빌드는 같은 커밋에서 따로 돈다. release-please 가 success 라고 이미지 빌드도 success 인 건 아니다. gh run list --limit 1 로 “최근 run” 을 보면 둘 중 아무거나 잡힌다. 실제로 이미지 없이 Release PR 을 머지해 태그만 나가고 prod 는 그대로였던 적이 있다. 복구는 빌드가 끝난 뒤 재태깅 워크플로를 태그 인자로 수동 재실행.
② 커밋 본문의 괄호가 줄을 넘으면 릴리스 PR 이 안 생긴다. release-please 파서가 그 커밋을 통째로 건너뛴다. 워크플로는 success. 릴리스 PR 이 안 생기는데 초록불이라 한참 헤맸다.
③ 연달아 머지하면 가운데 빌드가 사라진다. 같은 concurrency 그룹의 queued 실행이 새 실행에 밀려 취소된다. 그 커밋의 prod- 이미지는 영영 없고, PR 은 초록 그대로다. feat 커밋이면 그 버전 이미지 없이 릴리스가 진행된다. main push 의 concurrency 그룹을 커밋마다 나눠서 해결했다.
④ 프론트 릴리스 이미지는 첫 부모의 빌드다. 프론트는 Release PR 을 머지 커밋으로 합친다. 머지 커밋의 diff 가 비어 “빌드 무관” 으로 건너뛰어지고, 재태깅은 첫 부모의 prod- 를 찾아 붙인다. 결과: 이미지 안 package.json 버전이 태그보다 한 단계 낮고, style 같은 숨김 타입 변경이 CHANGELOG 없이 배포된다. 백엔드도 재태깅 대상이 버전 bump 직전 커밋이라 앱 로그의 버전이 태그보다 한 릴리스 뒤다. 배포 버전 판정은 파드 이미지 태그나 flyway_schema_history 로 한다. 앱 로그의 버전 문자열은 믿지 않는다.
⑤ 문서만 고친 커밋엔 이미지가 없다. paths-ignore 로 빌드가 스킵된다. 재태깅 워크플로는 최대 30커밋 뒤로 걸어가 이미지를 찾는다. 이 “걷기” 의 무시 규칙과 paths-ignore 가 같은 뜻이어야 하는데, 두 곳에 따로 있어 어긋나기 쉽다. 테스트로 둘을 묶어 놨다.
⑥ image-updater 의 CRD 레이스. ImageUpdater CR 은 helm 이 만드는 CRD 가 있어야 유효하다. ArgoCD sync 는 원자적이라 CRD 와 CR 을 한 커밋에 넣으면 CR 이 먼저 거부된다. 두 단계로 나눠 적용해야 한다.
2-4. 이 구조를 다른 프로젝트에 가져가면 안 되는 이유
같은 홈랩의 다른 프로젝트 둘은 빌드가 8분이다. 처음엔 이 파이프라인을 그대로 옮기려 했다. 실측하고 나서 멈췄다.
위 함정 ①·④·⑤ 는 전부 “재빌드 없이 재태깅” 에서 파생됐다. 재태깅을 택한 이유는 40분 빌드다. 빌드가 8분이면 릴리스 태그에서 직접 빌드하는 게 맞다. 그러면 이미지 준비 게이트, workflow_run 재시도, 30커밋 걷기 스크립트가 전부 필요 없어진다. 남는 트레이드오프는 하나다. 재빌드된 이미지는 테스트를 통과한 그 바이트와 동일하지 않다.
참고 구현이 있으면 그대로 가져오고 싶어진다. 하지만 그 구현의 복잡함이 어떤 전제에서 나왔는지 먼저 봐야 한다. 전제가 다르면 없는 문제의 해법과 그 해법 고유의 버그를 함께 수입하게 된다.
3. 마무리
요약
- main 머지 →
prod-<sha7>→ (staging 은 latest digest) → Release PR → 재태깅 → image-updater → ArgoCD.- 재태깅은 40분 빌드의 산물이다. 빌드가 짧으면 태그에서 직접 빌드가 맞다.
- 워크플로 초록불과 이미지 존재는 별개다. 릴리스 전에
git log v<직전>..origin/main과 빌드 상태를 같이 본다.- 배포 버전은 앱 로그가 아니라 파드 이미지 태그로 판정한다.
다음 편은 스키마 관리다. 상수 하나 추가했다가 400 이 났던 날부터.