ApplicationSet Progressive Sync 도입하다가 반쯤 실패한 이야기
지난달 우리 팀에서 ArgoCD ApplicationSet의 Progressive Sync를 처음 붙여봤다. 클러스터 6대에 걸쳐 있는 인프라 성격의 앱들을 한 번에 롤아웃할 때 폭발 반경을 줄여보자는 목적이었는데, 결론부터 말하면 절반은 살렸고 절반은 원래 하던 대로 되돌렸다. 그 과정에서 배운 걸 좀 정리해둔다.
왜 갑자기 Progressive Sync였나
우리는 ApplicationSet의 cluster generator로 6개 EKS 클러스터에 fluent-bit, node-exporter, cert-manager, ExternalDNS 같은 인프라 워크로드를 뿌리고 있다. 문제는 이게 진짜로 6대에 동시에 나가버린다는 거였다. 지난 분기에 fluent-bit config를 잘못 바꿔서 6대 전부에서 로그가 30분 넘게 안 올라온 사고가 있었고, 그 회고에서 “스테이지 → 카나리 → 프로덕션 순서로 흘리자”가 액션 아이템으로 잡혔다.
원래는 spec.strategy.type: RollingSync가 딱 그 용도로 있다. 클러스터를 라벨로 그룹핑해서 순서대로 sync 시키는 것. 문서만 보면 30분이면 붙일 것 같았다. 실제로는 3일 걸렸다.
첫 삽 - `matchExpressions` 이해 부족
처음 짠 매니페스트는 대충 이랬다.
spec:
strategy:
type: RollingSync
rollingSync:
steps:
- matchExpressions:
- key: env
operator: In
values: [stage]
- matchExpressions:
- key: env
operator: In
values: [canary]
- matchExpressions:
- key: env
operator: In
values: [prod]
여기서 헷갈렸던 게, 이 matchExpressions가 필터링하는 대상이 클러스터의 라벨이 아니라 생성된 Application 리소스의 라벨이라는 점이다. 우리 cluster generator에는 클러스터에 env: stage 같은 라벨을 붙여놨는데, 그게 자동으로 Application 리소스로 전파되는 게 아니었다. 첫 dry-run에서 “매칭되는 앱이 하나도 없다”면서 스텝을 그냥 스킵해버리길래 한참을 헤맸다.
해결은 template 쪽에서 명시적으로 label을 박아주는 거였다.
template:
metadata:
name: '{{.name}}-fluent-bit'
labels:
env: '{{.metadata.labels.env}}'
{{.metadata.labels.env}}가 cluster generator에서 노출되는 걸 알기까지 또 시간이 갔다. Go template generator를 쓰고 있어서 그나마 낫지, 예전 문법이었으면 이 값 꺼내는 것도 골치였을 거다.
두 번째 삽 - `maxUpdate`의 함정
두 스텝 안에 클러스터가 여러 개 있을 때 그 안에서 병렬로 나가는지 순차로 나가는지가 처음엔 감이 안 왔다. maxUpdate를 안 넣으면 그 스텝 안의 모든 앱이 동시에 sync된다. 즉 “canary 스텝”이라고 이름 붙였어도 canary 클러스터가 2대면 두 대가 동시에 나간다. 우리가 원했던 건 canary는 무조건 1대씩이었다.
- matchExpressions:
- key: env
operator: In
values: [canary]
maxUpdate: 1
이거 넣고 나서야 원하는 순차 동작이 나왔다. maxUpdate는 절대값도 되고 "25%" 같은 퍼센트 문자열도 된다. 근데 여기서 또 하나 걸린 게 있었는데…
세 번째 삽 - “sync 완료”의 정의
Progressive sync가 다음 스텝으로 넘어가는 조건은 이전 스텝의 모든 앱이 Healthy + Synced 상태가 되는 것이다. 문제는 우리 앱 중에 hook을 쓰는 게 있었다는 것. PostSync hook이 도는 동안 앱 상태가 잠깐 Progressing으로 남는데, 그 사이에 컨트롤러가 다음 스텝으로 못 넘어간다. 이건 뭐 예상 가능한 동작이긴 하다.
근데 진짜 골치 아팠던 건 auto-sync가 안 걸린 앱이 섞여 있을 때였다. Progressive sync는 Application이 sync를 시도해야 진행되는데, syncPolicy.automated가 없는 앱은 그냥 OutOfSync 상태로 멈춰있는다. 그럼 그 스텝 자체가 무한히 안 끝난다. ApplicationSet의 goTemplate: true와 함께 template 쪽에 아래를 강제로 박아뒀다.
template:
spec:
syncPolicy:
automated:
prune: true
selfHeal: true
retry:
limit: 3
여기서 팀 내부에 논쟁이 있었는데, “인프라 워크로드에 selfHeal을 켜는 게 맞나”였다. 누가 노드에 직접 kubectl edit 쳐서 임시 조치 해놓은 걸 ArgoCD가 30초 만에 되돌려버리면 상황이 더 나빠질 수 있다. 결국 fluent-bit, node-exporter처럼 상태가 없는 것들만 selfHeal을 켜고, cert-manager는 뺐다.
어디서 접었나
3일째 저녁에 stage → canary → prod 순서로 롤아웃이 잘 되는 걸 확인하고 나서 실전 배포에 태웠는데, prod 스텝에서 6대 중 3대만 진행되고 3대가 Progressing에서 안 넘어가는 상황이 생겼다. 원인은 웃기게도 그 3대 클러스터의 노드 pool이 그 시각에 스케일 아웃 중이어서 DaemonSet이 아직 다 안 뜬 상태였다는 것. Progressive sync는 이런 실환경 노이즈를 딱히 예쁘게 다뤄주지 않는다. 스텝 안에서 재시도를 하지도, 타임아웃을 두지도 않는다. 그냥 계속 기다린다.
이 시점에 팀 내부에서 “인프라 워크로드에 Progressive Sync가 맞긴 한가?”라는 회의가 열렸다. 결론은 이랬다.
- stage → canary 스텝은 유지. 여긴 실수 예방 효과가 크다.
- prod 스텝은 그냥 다시 병렬 sync로 되돌렸다. 대신 fluent-bit 같이 위험한 앱만 별도 ApplicationSet으로 분리해서 그건 Progressive Sync를 유지.
3.5의 UI가 이걸 좀 낫게 만들까
이번 달 초에 ArgoCD 3.5가 GA 됐다. 릴리즈 노트를 보면 ApplicationSet에 UI가 붙었고, template이 어떤 앱을 만들 예정인지 미리 보는 “Preview Apps” 탭이 생겼다. 그리고 각 Application 리소스 트리에 부모 ApplicationSet 이름이 owner badge로 뜬다. 이게 있었으면 우리 삽질의 절반은 안 했을 것 같다. “왜 이 스텝이 매칭이 안 되지”를 CLI로 계속 확인하는 게 진짜 짜증났으니까.
Progressive Sync 자체에 대한 근본적인 개선은 아직 없어 보인다. 스텝 안에서 타임아웃/실패 처리 정책을 두는 얘기가 있긴 한데 아직 3.5에는 없다. 알파 UI라도 얼른 stable로 가서, 최소한 "지금 어느 스텝에서 뭘 기다리고 있는지" 정도는 눈으로 볼 수 있으면 좋겠다.
남기는 팁
matchExpressions는 클러스터가 아니라 Application 라벨을 본다. template에서 명시적으로 label을 박아라.- 스텝마다
maxUpdate를 반드시 지정해라. 안 넣으면 그 스텝 안 전부가 동시에 나간다. syncPolicy.automated가 없는 앱이 스텝에 섞이면 그 스텝은 영원히 안 끝난다.- Progressive Sync는 스텝 안에서 재시도/타임아웃을 안 한다. 노이즈 많은 환경에서는 이 점을 감안해라.
혹시 Progressive Sync를 프로덕션 인프라 워크로드에 잘 붙여서 쓰고 계신 분 있으면 어떻게 운영하시는지 궁금하다. 우리는 아직 조금 겁이 나서 반쯤만 쓰고 있다.