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
  1. main 머지: 테스트 티어를 돈 뒤 이미지를 prod-<sha7> 로 GHCR 에 올린다. PR 에서는 이미지를 push 하지 않는다.
  2. staging: image-updater 가 :latest 의 digest 를 따라간다. main 의 머리가 곧 staging 이다.
  3. release-please: main 에 push 가 쌓일 때마다 Conventional Commits 를 모아 Release PR 을 만든다. feat·fix·perf·refactor·docs 는 릴리스가 나가고, style·chore·test·ci 넷만 숨겨진다.
  4. Release PR 머지: 태그 vX.Y.Z 가 만들어지고, 별도 워크플로가 재빌드 없이 그 릴리스 커밋의 prod-<sha7> 을 buildx imagetools 로 재태깅한다.
  5. prod: image-updater 가 ^v\d+\.\d+\.\d+$ 태그를 semver 로 감시해 overlays/prod/kustomization.yaml 의 newTag 를 고쳐 main 에 직접 커밋한다.
  6. 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 이 났던 날부터.