IT/CI CD

ArgoCD ApplicationSet, 사실 내부적으로는 이렇게 돈다

gfrog 2026. 6. 22. 12:14
SMALL

ArgoCD ApplicationSet은 보통 "여러 클러스터에 같은 앱을 자동으로 배포해주는 도구" 정도로 설명된다. 우리 팀도 처음엔 그렇게 썼다. List generator로 환경 별 파라미터 넘기고, 템플릿에 변수 박아 넣고. 잘 돌아갔다. 문제가 터지기 전까지는.

작년 가을, 멀티 리전에 깔린 60개 가까운 Application이 사일런트하게 drift나기 시작했다. ApplicationSet 매니페스트는 그대로인데 일부 Application만 sync 상태가 이상했다. 그때 처음으로 ApplicationSet 컨트롤러 코드를 직접 까봤다. 그러고 보니 이 컨트롤러, 생각보다 단순하지 않다.

reconcile 루프의 4단계

ApplicationSet 컨트롤러를 한 줄로 요약하면 "ApplicationSet → 여러 Application 리소스로 펼치는 reconciler"다. 내부 흐름은 대략 4단계로 나뉜다.

먼저 reconcile 트리거가 들어오면 컨트롤러는 등록된 generator들을 모두 호출한다. Git, List, Cluster, Matrix, Merge, SCM, Pull Request, Plugin — 각각 자기 방식대로 파라미터 맵의 슬라이스를 뱉어낸다. 예를 들어 List generator는 그냥 정적인 맵을 돌려주지만, Cluster generator는 ArgoCD가 알고 있는 secret 중 argocd.argoproj.io/secret-type: cluster 라벨이 붙은 것들을 다 긁어와서 각각을 하나의 파라미터 셋으로 변환한다.

두 번째 단계는 템플릿 렌더링이다. 각 파라미터 셋을 가지고 spec.template에 들어 있는 Go template을 평가해서 완성된 Application 리소스를 만든다. 여기서 자주 놓치는 게 있는데, generator가 동일한 metadata.name을 가진 파라미터를 두 번 뱉으면 컨트롤러는 그냥 마지막 것으로 덮어쓴다. 에러도 안 난다. 우리가 겪은 drift의 일부가 여기서 시작됐다. Matrix generator에서 의도치 않게 카테시안 곱이 어떤 환경에선 키 충돌을 만들고 있었던 것.

세 번째는 비교. 컨트롤러는 지금 렌더된 Application 셋과, 클러스터에 이미 존재하는 (이 ApplicationSet의 ownerReference를 가진) Application 셋을 diff 한다. 새로 생기는 건 create, 사라진 건 delete, 양쪽에 있는 건 update 대상으로 분류한다.

네 번째가 실제 적용이다. applyPolicy에 따라 create/update/delete가 선택적으로 게이팅된다. 여기서 create-onlycreate-update로 두면 컨트롤러는 더 이상 Application을 삭제하지 않는다. 사고를 막을 수 있는 안전장치인데, 반대로 ApplicationSet에서 항목을 빼도 클러스터엔 남아 있게 되니까 정리 책임은 운영자에게 넘어온다.

ownerReference라는 끈

여기까지가 기본 골격이다. 그런데 위의 3단계 비교에서 핵심은 "ownerReference로 묶인 Application만 본다"는 점이다. ApplicationSet 컨트롤러는 자기가 만든 자식들만 관리한다. 누군가 같은 이름의 Application을 따로 만들어 두면, 컨트롤러는 그걸 자기 것이라고 인식하지 못한다. 그러면 ApplicationSet의 템플릿에 따라 같은 이름의 Application을 create 시도하다가 충돌 난다. 의외로 이런 케이스가 마이그레이션할 때 자주 생긴다. 기존 수동 Application들을 ApplicationSet으로 흡수하려면 ownerReference를 손으로 박아주거나, 일단 삭제했다가 다시 만들어야 한다.

이 ownerReference 체인은 cascading delete에도 영향을 준다. ApplicationSet을 지우면 기본적으로 자식 Application들도 같이 사라지고, Application의 finalizer가 걸려 있으면 그 안의 리소스까지 정리된다. 즉 ApplicationSet 매니페스트 하나 잘못 지우면 클러스터 절반을 날릴 수 있다. 우리는 그래서 운영 클러스터의 ApplicationSet에는 preserveResourcesOnDeletion: true를 항상 켜둔다.

progressive sync, 베타지만 쓸만하다

올해 들어 우리가 가장 잘 쓰고 있는 기능이 RollingSync 전략이다. 기본 동작은 AllAtOnce — 파라미터가 갱신되면 모든 자식 Application이 동시에 sync 큐에 들어간다. 운영 환경에선 이게 무섭다. 잘못된 헬름 차트 한 번이 동시에 12개 클러스터에 퍼진다.

RollingSync는 이걸 단계로 쪼갠다. 내부적으론 RequiresPruning 같은 헬스 게이트와 label selector 매칭으로 각 step의 대상을 정한다. step 1의 모든 Application이 Healthy 상태로 수렴할 때까지 step 2는 시작되지 않는다. 사실 내부에선 별도의 큐를 두지 않고, reconcile마다 "이 step의 모든 대상이 healthy인가"를 체크해서 다음 step의 sync를 허용하는 식이다. 그래서 step 사이에 약간의 reconcile interval 만큼의 딜레이가 항상 생긴다. canary 처럼 빠른 롤아웃을 기대하면 좀 답답할 수 있다.

베타라고 안내되어 있긴 한데, 컨트롤러 플래그(--enable-progressive-syncs) 또는 argocd-cmd-params-cmapplicationsetcontroller.enable.progressive.syncs: "true"로 켜야 동작한다. 기본값으로 켜진 상태가 아니라는 점은 종종 잊는다. 우리도 한 번 매니페스트만 RollingSync로 바꿔놓고 왜 동시에 다 나가지 했었다.

그래서 결국

ApplicationSet은 매니페스트 수준에선 우아한 추상화처럼 보이지만, 내부적으론 generator → template → diff → apply의 다소 평범한 reconciler다. 그 평범함을 이해하면 디버깅이 빨라진다. 파라미터가 이상하면 generator 로그를 보고, Application이 안 만들어지면 ownerReference를 보고, 동시에 다 나가는 게 걱정이면 progressive sync를 켠다. 매니페스트만 들여다보던 시절보다 컨트롤러 한 번 까보고 나서 운영이 훨씬 편해졌다.

남은 숙제는 Plugin generator 쪽이다. 사내 서비스 카탈로그와 묶으려고 시도 중인데, 인증 토큰 관리가 생각보다 까다롭다. 정리되면 다음에 한번 풀어보겠다.

BIG