🚀 요약
SUMMARY
PR을 머지하면 배포 봇이 ArgoCD를 폴링해 “배포 완료”를 슬랙에 알린다. 그런데 봇이 직전 sync에서 남아있던 Healthy를 그대로 읽고, 새 리비전이 반영되기도 전에 완료로 오판했다. 머지가 돌려준 커밋 SHA를 기준으로
status·operationState의 리비전 필드를 모아 매칭시키고, “그 리비전에서 Healthy 도달”을 엄격히 요구하는 게이트로 오탐을 없앴다.
⚙️ 환경
- ArgoCD: ApplicationSet 기반 GitOps (앱이 라벨로 스탬핑되어 stage별로 조회됨)
- 배포 봇: Go로 재작성 (구버전은 Python), ArgoCD REST API를 폴링
- 트리거: GitHub PR squash merge → 봇이 슬랙 메시지 한 개를
chat_update로 갱신하며 진행 상황 표시
💬 이슈
배포 봇의 역할은 단순하다. 개발자가 슬랙에서 PR을 squash merge하면, 봇이 그 커밋이 실제로 클러스터에 반영되고 앱이 정상인지를 대신 지켜봐 주는 것이다. GitOps라 머지가 곧 배포 트리거고, 봇은 ArgoCD Application의 상태를 폴링하다가 다 올라오면 “배포 완료”를 같은 슬랙 메시지에 갱신한다.
문제는 봇이 너무 빨리 완료를 알렸다는 거다. 머지 버튼을 누르자마자 몇 초 만에 ”✅ 배포 완료”가 떴는데, 정작 새 코드는 아직 파드에 반영되기 전이었다. 봇 말만 믿고 확인하러 들어가면 옛날 버전이 돌고 있었다.
원인은 최종 일관성(eventual consistency)이었다. ArgoCD는 자기 리컨실 루프에 따라 주기적으로 git을 폴링하고 sync한다. 머지 직후에는 ArgoCD가 아직 새 커밋을 감지하지도, sync를 시작하지도 않은 상태다. 그런데 이때 앱의 status.health.status를 읽으면 여전히 Healthy다. 직전 sync가 성공적으로 끝났으니 당연히 Healthy인 것이다.
즉 헬스 상태는 “어느 리비전이 Healthy인지”를 말해주지 않는다. 봇은 “Healthy면 됐지”라고 판단했지만, 그 Healthy는 방금 머지한 커밋과 아무 상관 없는 직전 배포의 잔상이었다. 머지와 ArgoCD 리컨실 사이의 경쟁 조건(race condition)이고, 나는 이걸 stale-Healthy 오탐이라고 부른다.
고칠 지점은 분명하다. Healthy만 보지 말고, “머지한 그 커밋에서 Healthy인가”를 봐야 한다.
🧗 해결
1. 어떤 커밋을 기준으로 삼나
리비전을 매칭하려면 먼저 “정답 커밋”이 있어야 한다. 여기서 실수하기 쉬운 게 머지 전 PR 객체의 merge_commit_sha를 쓰는 건데, 이건 GitHub가 미리 계산해둔 test-merge SHA일 수 있어서 실제 머지 결과와 다를 수 있다.
그래서 기준 SHA는 머지 API가 200으로 돌려준 응답의 sha만 쓴다. 그리고 머지 요청(PUT)에는 현재 head SHA를 optimistic concurrency로 실어 보낸다. 그 사이 PR에 새 커밋이 붙었으면 GitHub가 409를 주고, 봇은 엉뚱한 커밋을 머지하는 대신 “다시 시도”를 띄우고 빨리 실패한다. 애매한 기준으로 ArgoCD를 게이팅하느니 여기서 끊는 게 낫다.
2. 리비전 후보 모으기
ArgoCD 앱 상태에서 “지금 이 앱이 가리키는 리비전”은 한 군데 박혀 있지 않다. 특히 멀티소스(multiple sources) 앱이면 status.sync.revisions[]에 여러 개가 들어온다. 단일 필드만 보면 놓친다. 그래서 관련 필드를 전부 긁어 후보 목록을 만든다.
// 한 앱의 status에서 "지금 가리키는 리비전" 후보를 전부 모은다.
// sync 결과와 operationState의 sync 결과 양쪽을 봐야 누락이 없다.
add := func(r string) {
if r != "" {
st.Revisions = append(st.Revisions, r)
}
}
add(s.Status.Sync.Revision)
for _, r := range s.Status.Sync.Revisions { // 멀티소스 앱 대비
add(r)
}
add(s.Status.OperationState.SyncResult.Revision)
for _, r := range s.Status.OperationState.SyncResult.Revisions {
add(r)
}status.sync는 ArgoCD가 관측한 현재 sync 상태고, operationState.syncResult는 마지막으로 수행한 sync 작업의 결과다. 둘이 항상 같지는 않아서(작업 중이거나 방금 끝난 순간엔 갈릴 수 있다) 양쪽을 모두 후보에 넣는다. 빈 값은 버리고, 중복은 순서를 유지한 채 걸러낸다.
3. 짧은 SHA와 긴 SHA 매칭
후보를 모았으면 기준 SHA와 비교하는데, 그냥 문자열 ==로는 안 된다. ArgoCD가 돌려주는 리비전이 풀 40자 SHA일 때도 있고 짧게 잘린 형태일 때도 있다. 한쪽이 다른 쪽의 접두사이고, 짧은 쪽이 최소 7자 이상이면 같은 커밋으로 인정했다. (7자 미만은 충돌 가능성이 있어 신뢰하지 않는다.)
// 짧은 SHA와 긴 SHA를 같은 커밋으로 인정한다.
func RevisionMatch(a, b string) bool {
a, b = strings.TrimSpace(a), strings.TrimSpace(b)
if a == "" || b == "" {
return false
}
if a == b {
return true
}
lo, hi := a, b
if len(lo) > len(hi) {
lo, hi = hi, lo
}
return len(lo) >= 7 && strings.HasPrefix(hi, lo)
}4. 게이트 판정
이제 게이트다. Healthy만으로는 부족하고, “기준 SHA에 도달한 상태에서” Healthy여야 한다.
// Healthy AND (기준 SHA 매칭) 을 함께 요구한다.
func ReadyAtRevision(s AppState, targetSHA string) bool {
if s.Health != "Healthy" {
return false
}
matched := false
for _, r := range s.Revisions {
if RevisionMatch(r, targetSHA) {
matched = true
break
}
}
if !matched {
return false // ← 직전 sync에서 남은 stale-Healthy가 여기서 걸린다
}
if s.Sync == "Synced" {
return true
}
// Synced로 안정되기 전, OutOfSync인데 마지막 sync는 성공한 과도기도 인정
return s.Sync == "OutOfSync" && s.OperationPhase == "Succeeded"
}리비전 매칭에서 걸러지는 지점(matched == false)이 바로 오탐을 잡는 곳이다. 머지 직후의 stale-Healthy는 후보 리비전에 새 SHA가 없으니 여기서 false가 되고, 봇은 계속 폴링한다.
마지막 두 줄은 겪어보고 추가한 예외다. sync 상태가 Synced면 당연히 통과인데, ArgoCD는 리비전에서 Healthy하게 sync가 끝난 직후에도 리소스 드리프트 등으로 잠깐 OutOfSync로 다시 튈 때가 있다. 이 경우까지 실패로 보면 멀쩡히 배포된 걸 타임아웃으로 처리해버린다. 그래서 OutOfSync이더라도 마지막 sync 작업이 Succeeded면 성공으로 인정했다.
NOTE
이 게이트는 모니터링 전용이다. 봇은 ArgoCD에 sync·rollback 같은 side-effect 요청을 보내지 않는다. 상태를 읽어 판정만 하고, 실제 배포는 ArgoCD 리컨실 루프에 맡긴다. 봇이 성급하게
refresh=hard를 남발하면 그것대로 ArgoCD에 부하를 준다.
✅ 확인
판정 로직이 순수 함수라 네트워크 없이 테이블 테스트로 검증했다. 특히 오탐 케이스(stale healthy no rev)를 false로 잡는 게 이 작업의 전부라, 그 케이스를 테스트에 먼저 넣었다.
sha := "deadbeefcafef00d"
cases := []struct {
name string
s AppState
want bool
}{
{"healthy synced at rev", AppState{Health: "Healthy", Sync: "Synced", Revisions: []string{sha}}, true},
{"stale healthy no rev", AppState{Health: "Healthy", Sync: "Synced"}, false}, // 오탐 재현
{"healthy synced wrong rev",AppState{Health: "Healthy", Sync: "Synced", Revisions: []string{"0000000000"}}, false},
{"progressing", AppState{Health: "Progressing", Sync: "Synced", Revisions: []string{sha}}, false},
{"outofsync op succeeded", AppState{Health: "Healthy", Sync: "OutOfSync", OperationPhase: "Succeeded", Revisions: []string{sha}}, true},
{"outofsync op running", AppState{Health: "Healthy", Sync: "OutOfSync", OperationPhase: "Running", Revisions: []string{sha}}, false},
}stale healthy no rev는 Healthy·Synced인데 후보 리비전이 비어 있는 상태 — 딱 머지 직후의 그 순간이다. 이게 false로 나오면 오탐은 잡힌 것이다.
실제 폴링 루프는 앱별로 한 줄씩 상태를 그려 슬랙에 갱신한다. 아직 도달 안 한 앱은 ⏳, 게이트를 통과하면 ✅로 바뀐다.
⏳ `stg-tenant-a-core` sync=OutOfSync health=Progressing
✅ `stg-tenant-a-data` sync=Synced health=Healthy모든 대상 앱이 ✅가 되면 봇이 최종 “배포 완료”를 확정한다. 이제 이 완료는 “머지한 그 커밋이 올라와서 Healthy”라는 뜻이 됐다. 고치기 전엔 그냥 “뭔가 Healthy”였던 것과는 다르다.