ArgoCD ApplicationSet Progressive Sync가 내부적으로 어떻게 동작하는가
멀티 클러스터 20개를 한꺼번에 sync 걸어놓고 잠들었던 적이 있다. 다음 날 아침 슬랙이 폭발해 있었다. 카나리 배포한다고 flag까지 걸어뒀는데도 20개 클러스터가 동시에 다 뒤집혀버린 상황. 그때 알았다. ApplicationSet 자체는 "언제 sync할지"에 대해 아무 개념이 없다는 걸.
ArgoCD 2.9에서 Progressive Sync가 stable로 승격된 지도 꽤 됐고, 2026년 들어서는 pause/resume 애노테이션 같은 기능도 얹혀서 실전 쓸만해졌다. 그런데 이게 정확히 뭘 하는 놈인지, RollingSync strategy가 내부적으로 어떤 순서로 Application을 다루는지 이해 못한 채 쓰는 사람이 꽤 있는 것 같아서 정리해본다. 사실 나도 최근에 다시 파봐서야 명확해진 부분이 있다.
ApplicationSet Controller의 원래 동작 방식
Progressive Sync를 이해하려면 ApplicationSet controller가 원래 어떻게 작동하는지부터 짚어야 한다.
ApplicationSet CR을 apply하면 controller가 generator(list, cluster, git, matrix 등)를 실행해서 파라미터 집합을 만든다. 각 파라미터 집합에 template을 렌더링해서 Application CR을 생성한다. 예를 들어 cluster generator가 등록된 클러스터 20개를 뽑아주면, template이 20번 렌더링돼서 Application 20개가 만들어진다.
여기까지가 controller의 job이다. 생성된 Application의 sync는 각자 자기 sync policy에 따라 독립적으로 진행된다. syncPolicy.automated가 걸려있으면 Git 변경이 감지되는 순간 Application controller가 그걸 클러스터에 밀어넣는다. ApplicationSet controller는 이 sync 타이밍에 아무 개입도 안 한다.
그래서 Git에 커밋 하나 밀면 20개 Application의 sync가 거의 동시에 트리거된다. 이게 문제였다.
RollingSync가 붙으면 뭐가 달라지는가
spec.strategy.type: RollingSync를 붙이는 순간, ApplicationSet controller의 관심사가 하나 늘어난다. template이 렌더링돼서 최종적으로 Application에 반영될 spec을, 조건이 맞을 때까지 유예한다.
핵심 메커니즘은 이렇다. Application의 spec을 controller가 관리하는 대신, argocd.argoproj.io/application-set-refresh 같은 애노테이션과 함께 generator가 만든 최신 값이 아니라 이전 값을 유지한다. Application 입장에서는 자기 spec이 안 바뀌었으니 sync를 트리거할 이유가 없다.
controller는 그 다음 step 정의를 훑는다. 각 step은 matchExpressions로 대상 Application을 골라내고, maxUpdate로 동시에 업데이트할 개수를 제한한다.
strategy:
type: RollingSync
rollingSync:
steps:
- matchExpressions:
- key: env
operator: In
values: [canary]
maxUpdate: 1
- matchExpressions:
- key: env
operator: In
values: [staging]
maxUpdate: 100%
- matchExpressions:
- key: env
operator: In
values: [prod]
maxUpdate: 20%
step 1 대상 Application들만 spec을 최신으로 밀어넣고, 나머지는 여전히 이전 spec으로 유지한다. step 1의 Application들이 sync + Healthy에 도달하면, 그때서야 step 2 대상들의 spec을 최신으로 반영한다. 이걸 마지막 step까지 반복한다.
이 관점이 중요하다. Progressive Sync는 sync를 지연시키는 게 아니라, Git과 Application 사이의 값 반영을 지연시키는 방식으로 rolling을 구현한다. Application controller는 자기 spec이 바뀐 순간 자동 sync를 돌릴 뿐, ApplicationSet의 step 개념을 알지 못한다. 계층을 확실히 분리한 설계다.
Healthy 판정과 phase 전환
step이 넘어가는 조건은 "해당 step의 대상 Application들이 전부 Healthy이면서 Synced" 상태에 도달하는 것이다.
여기서 함정이 있다. Application의 Health는 그 Application이 배포한 리소스의 Health status에 의해 결정된다. Deployment는 자체 health check가 있지만, ConfigMap 같은 건 존재만 하면 Healthy다. 그래서 Deployment가 배포되고 파드가 진짜로 뜨기 전까지 잠깐 Progressing 상태를 거친다.
maxUpdate가 1인데 배포한 Deployment가 실패해서 Progressing → Degraded로 빠지면 어떻게 될까. controller는 이 step에서 멈춘다. 다음 step으로 안 넘어간다. 그러나 이 step 안에서 다른 Application을 더 업데이트하지도 않는다 — 이미 하나 시도했고 실패했으니까.
이게 blast radius 제어의 핵심이다. canary 클러스터 하나에 배포했고, 거기가 죽었으면, 나머지 19개는 안 건드린다. 자동으로 롤백해주는 건 아니다. 그건 사용자가 Git을 되돌리거나 수동 개입해야 한다.
Pause 애노테이션의 동작
2026년 초에 추가된 기능 중 실제로 유용한 게 pause annotation이다. argocd.argoproj.io/applicationset-refresh: pause를 붙이면 controller가 진행 중인 step의 다음 대상으로 넘어가지 않는다.
내부적으로는 reconcile loop 안에서 애노테이션 체크가 들어간다. step 진행 로직에 들어가기 전에 이 애노테이션을 확인하고, 있으면 early return한다. 이미 sync가 시작된 Application은 그대로 진행되지만, 아직 spec 갱신이 안 된 Application은 계속 이전 상태로 유지된다.
pause를 걸어놓고 배포된 클러스터에서 메트릭을 확인한 다음, 문제없으면 애노테이션을 지운다. 그러면 다음 reconcile에서 controller가 원래 흐름을 재개한다. 우리 팀에서는 prod step에 도달하기 전에 무조건 pause 걸어두는 걸 규칙으로 만들었다. 실수 방지용이다.
실전에서 부딪히는 이슈들
몇 가지 함정이 있다.
첫째, matchExpressions가 label 기준인데, 이 label은 Application의 label이 아니라 generator가 만든 파라미터에서 온다. cluster generator를 쓸 때는 ArgoCD에 등록된 cluster secret의 label을 참조한다. 처음에 이걸 몰라서 Application에 label 붙였다가 하나도 매칭이 안 됐다. 로그도 친절하지 않다.
둘째, step 사이의 대기 시간이 정해져 있지 않다. Healthy 판정만 만족되면 즉시 다음 step으로 넘어간다. 그러나 Deployment가 Healthy가 됐다고 해서 서비스가 실제로 잘 도는 걸 뜻하진 않는다. 몇 분 정도의 baking time을 강제하려면 별도 방법이 필요하다. Argo Rollouts를 조합하거나, step 사이에 dummy 리소스를 넣어서 sync wave를 활용하는 식이다.
셋째, ApplicationSet spec의 template을 바꾼 경우 vs Git의 helm chart를 바꾼 경우, controller 반응이 미묘하게 다르다. template 변경은 controller가 즉시 감지해서 Application spec을 다시 계산한다. Git의 chart 변경은 Application controller가 감지해서 sync 대상으로 인식한다. Progressive Sync 관점에서는 둘 다 "sync가 필요한 상태"로 취급되지만, 후자의 경우 spec 지연 로직이 관여할 여지가 상대적으로 적다. 이 부분은 아직 나도 완벽히 이해 못한 영역이다.
정리하며
Progressive Sync를 처음 봤을 때는 "그냥 sync를 지연시키는 스케줄러"라고 오해했다. 파고들어 보니 아키텍처적으로 훨씬 깔끔한 설계였다. Application controller는 자기가 하던 대로 spec 변화에 반응하기만 하면 되고, 지연은 ApplicationSet controller가 spec 반영 자체를 늦추는 방식으로 처리한다. 두 controller의 책임이 명확히 나뉘어 있다.
프로덕션에서 멀티 클러스터로 운영한다면 이거 없이는 무섭다. 우리 팀은 이걸 도입한 뒤로 배포 사고가 확실히 줄었다. 이건 좋은 신호였다.
다음에는 Argo Rollouts의 canary analysis와 이걸 조합했을 때의 트레이드오프에 대해 써보려고 한다. 두 개를 동시에 쓰면 blast radius가 이중으로 제어되는데, 이게 항상 좋은 건 아니더라.