Argo CD 의 내부 구조와 동기화 과정
저번 포스팅에서는, GitOps 와 Argo CD 의 개념을 공부하였습니다. 이번에는 Argo CD 가 실제로 어떻게 만들어져 있는지를 들여다보겠습니다. 구성 요소를 하나씩 보고, 커밋 하나가 클러스터에 반영되기까지의 경로를 따라간 뒤, 매니페스트를 비교하는 과정에서 생기는 까다로운 문제들을 다루겠습니다.
쿠버네티스 시리즈 2편에서 kubectl apply 한 줄의 경로를 따라갔던 것과 같은 방식입니다.
1. 구성 요소
Argo CD 는 보통 argocd 네임스페이스에 설치되고, 세 개의 주요 구성 요소로 이루어집니다.
1.1. Application Controller
핵심입니다. 쿠버네티스 컨트롤러와 똑같은 방식으로 동작합니다.
- Application 리소스를 지켜봅니다.
- Git 에서 생성한 매니페스트(원하는 상태)와 클러스터의 실제 상태(현재 상태)를 비교합니다.
- 차이가 있으면 Sync Status 를 OutOfSync 로 바꾸고 자동 동기화가 켜져 있으면 적용합니다.
- 리소스의 건강 상태를 평가해
status에 기록합니다.
쿠버네티스 시리즈에서 본 컨트롤 루프 그대로입니다. 다른 점은 원하는 상태가 etcd 가 아니라 Git 에 있다는 것뿐입니다. 대상 클러스터의 리소스는 List-Watch 로 지켜보며 메모리에 캐시해둡니다. 그래서 매번 클러스터에 묻지 않고도 빠르게 비교할 수 있습니다. 대신 관리하는 리소스가 많아지면 컨트롤러의 메모리 사용량이 커집니다. Argo CD 운영에서 메모리가 먼저 문제가 되는 이유가 이것입니다.
1.2. Repository Server
Git 저장소를 읽고 최종 매니페스트를 만들어내는 역할입니다. 저장소를 로컬에 받아두고 Helm 이나 Kustomize 를 실행해 YAML 을 뽑아냅니다. 결과는 캐시합니다. 같은 커밋에 대해 매번 다시 렌더링하면 부하가 크기 때문입니다.
이 작업이 CPU 를 많이 씁니다. Helm 차트가 크거나 Application 이 많으면 여기가 병목이 됩니다. 그래서 규모가 커지면 Repository Server 를 여러 개로 늘립니다.
1.3. API Server
웹 UI, CLI, 그리고 외부 연동이 붙는 지점입니다. 인증과 인가를 처리하고 동기화 요청을 받아 컨트롤러에 전달합니다. 여기서 권한 통제가 이루어집니다. Argo CD 는 자체 RBAC 을 갖고 있어서 어떤 사용자가 어떤 프로젝트의 어떤 Application 을 볼 수 있고 동기화할 수 있는지를 정합니다. 쿠버네티스 RBAC 과는 별개의 체계입니다.
Argo CD 자신은 대상 클러스터에 대해 큰 권한을 갖고 있다는 점을 유념해야 합니다. 그래서 Argo CD 의 접근 권한을 느슨하게 두면, 쿠버네티스 RBAC 을 아무리 잘 짜두어도 Argo CD 로 우회할 수 있습니다.
1.4. 함께 쓰는 것들
- Redis : 매니페스트 렌더링 결과와 클러스터 상태를 캐시합니다. 데이터가 날아가도 다시 만들 수 있으므로 영구 저장소가 필요 없습니다.
- Dex : 외부 인증 시스템과 연결하는 중계자입니다. 이미 OIDC 제공자가 있다면 Dex 없이 직접 붙일 수도 있습니다.
- ApplicationSet Controller : Application 을 자동으로 만들어주는 컨트롤러입니다. 3편에서 다루겠습니다.
- Notifications Controller : 동기화 결과나 상태 변화를 Slack 등으로 알립니다.
Argo CD 자체는 상태를 데이터베이스에 두지 않습니다. Application 도 AppProject 도 전부 쿠버네티스 CRD 이고 따라서 etcd 에 저장됩니다. 그래서 Argo CD 를 백업한다는 것은 결국 이 리소스들을 백업한다는 뜻입니다.
2. 커밋 하나가 반영되기까지
2.1. 변화를 알아챈다
Repository Server 가 주기적으로(기본 3분) 저장소를 확인하거나, 웹훅을 받아 즉시 확인합니다. 대상 리비전의 커밋 해시가 바뀌었는지를 봅니다.
2.2. 매니페스트를 만든다
바뀌었으면 Repository Server 가 해당 커밋을 체크아웃하고 Helm 이나 Kustomize 를 돌려 최종 YAML 을 만듭니다. 이때 Application 에 적힌 값들이 주입됩니다. Helm 의 values, Kustomize 의 이미지 태그 같은 것들입니다.
2.3. 비교한다
Application Controller 가 생성된 매니페스트와 클러스터의 실제 리소스를 비교합니다. 이 단계가 생각보다 까다로운데 3장에서 따로 다루겠습니다. 차이가 있으면 Application 의 status.sync.status 가 OutOfSync 가 되고 어떤 리소스가 어떻게 다른지가 기록됩니다.
2.4. 적용한다
자동 동기화가 켜져 있거나 사람이 Sync 를 누르면 적용이 시작됩니다. 기본 동작은 kubectl apply 와 같습니다. 선언형 적용이므로 이미 같은 것은 건드리지 않고 다른 부분만 갱신됩니다. 여기서 쿠버네티스 시리즈 2편의 내용이 이어집니다. Argo CD 가 apiserver 에 요청을 보내면, 인증 → 인가 → 어드미션 → 저장 을 거쳐 etcd 에 기록되고 그다음은 쿠버네티스의 컨트롤러들이 이어받습니다. Argo CD 는 Pod 를 직접 만들지 않습니다. Deployment 를 apiserver 에 제출할 뿐이고 나머지는 쿠버네티스가 합니다.
2.5. 건강 상태를 지켜본다
적용이 끝나도 동기화가 완료된 것은 아닙니다. Argo CD 는 리소스가 Healthy 가 될 때까지 지켜봅니다. Deployment 라면 롤아웃이 끝나 원하는 복제본이 모두 Ready 가 되어야 Healthy 입니다. 그 전까지는 Progressing 입니다.
"Synced 이지만 Progressing" 인 구간이 바로 배포가 진행 중인 시간입니다.
3. 비교는 왜 어려운가
개인적으로 Argo CD 에서 가장 흥미로웠던 부분입니다. "Git 의 YAML 과 클러스터의 리소스를 비교한다" 는 말은 쉬운데 실제로는 그렇게 단순하지 않습니다.
3.1. 클러스터가 값을 채워 넣는다
Git 에는 이렇게 적혀 있습니다.
spec:
replicas: 3
template:
spec:
containers:
- name: web
image: web:1.2.3
그런데 클러스터에서 읽어오면 필드가 수십 개로 불어나 있습니다. creationTimestamp, resourceVersion, uid 같은 메타데이터가 붙고 terminationMessagePath 나 dnsPolicy 처럼 적지 않으면 기본값이 채워지는 필드들이 들어갑니다.
그대로 비교하면 항상 다르다고 나옵니다. 그래서 Argo CD 는 "내가 명시한 것" 만 비교합니다.
3.2. 무엇을 명시했는지 어떻게 아는가
kubectl apply 는 마지막으로 적용한 내용을 kubectl.kubernetes.io/last-applied-configuration 어노테이션에 통째로 저장해둡니다. Argo CD 의 기본 동작도 여기에 기댑니다.
다만 이 방식에는 한계가 있습니다. 어노테이션 크기가 커지고 여러 주체가 같은 리소스를 고칠 때 누가 무엇을 소유하는지 알 수 없습니다. 그래서 나온 것이 서버 사이드 적용(Server-Side Apply) 입니다. 이 방식에서는 apiserver 가 필드마다 소유자를 기록합니다. Argo CD 가 소유한 필드, HPA 가 소유한 필드가 따로 관리됩니다. Argo CD 도 이것을 지원하며 특히 CRD 처럼 스키마가 큰 리소스에서 유리합니다.
3.3. 다른 컨트롤러가 값을 바꾼다
이것이 실무에서 가장 자주 만나는 문제입니다. HPA 를 쓰면 복제본 수를 HPA 가 조정합니다. 그런데 Git 에는 replicas: 3 이 적혀 있습니다. HPA 가 5로 늘리는 순간 OutOfSync 가 되고 selfHeal 이 켜져 있으면 Argo CD 가 3으로 되돌립니다. 그러면 HPA 가 다시 5로 늘리고 둘이 무한히 싸웁니다.
해결책은 두 가지입니다.
- Git 에서
replicas를 빼버립니다. 명시하지 않으면 비교 대상이 아니므로 HPA 가 마음대로 조정할 수 있습니다. 이것이 권장되는 방법입니다. ignoreDifferences로 그 필드를 비교에서 제외합니다.
spec:
ignoreDifferences:
- group: apps
kind: Deployment
jsonPointers:
- /spec/replicas
같은 문제가 여러 곳에서 생깁니다. 어드미션 웹훅이 사이드카를 주입하는 경우, cert-manager 가 인증서를 갱신하는 경우, 클라우드 컨트롤러가 Service 에 주소를 채우는 경우 등입니다. 쿠버네티스에는 리소스를 고치는 주체가 여럿이고, Argo CD 는 그중 하나일 뿐이라는 것을 받아들이는 데서 출발해야 합니다.
3.4. Secret 은 값이 보이지 않는다
Argo CD 는 UI 에서 Secret 의 값을 가립니다. 비교는 하되 화면에는 드러내지 않습니다. 다만 OutOfSync 라고 나오는데 무엇이 다른지 볼 수 없어 원인 파악이 어려운 경우가 생깁니다. 애초에 평문 Secret 을 Git 에 두면 안 됩니다. 4장에서 다루겠습니다.
4. 순서를 제어하기
리소스를 한꺼번에 적용하면 곤란한 경우가 있습니다. 데이터베이스 마이그레이션이 끝난 뒤에 애플리케이션이 떠야 하거나, CRD 가 먼저 등록되어야 그것을 쓰는 리소스를 만들 수 있는 경우입니다.
4.1. 기본 순서
Argo CD 는 아무 어노테이션이 없어도 상식적인 순서를 따릅니다. Namespace 를 먼저 만들고 CRD 를 그다음에 만드는 식입니다. 같은 종류 안에서는 이름순입니다.
4.2. Sync Waves
더 세밀한 제어가 필요하면 동기화 물결(Sync Wave) 을 씁니다.
metadata:
annotations:
argocd.argoproj.io/sync-wave: "-1"
숫자가 작은 것부터 적용됩니다. 기본값은 0 이므로 음수를 주면 먼저, 양수를 주면 나중에 적용됩니다. 중요한 점은 한 물결의 리소스가 전부 Healthy 가 된 뒤에 다음 물결이 시작된다는 것입니다. 단순히 순서대로 제출하는 것이 아니라 기다립니다.
4.3. Sync Hooks
특정 시점에 Job 을 실행할 수도 있습니다.
- PreSync : 적용 전에 실행. 데이터베이스 마이그레이션 등.
- Sync : 적용과 함께 실행.
- PostSync : 전부 Healthy 가 된 뒤 실행. 스모크 테스트 등.
- SyncFail : 동기화가 실패했을 때 실행. 정리 작업 등.
훅으로 만든 리소스는 Git 에 없는 것을 만들어내는 셈이므로, 언제 지울지도 정해야 합니다. hook-delete-policy 로 성공 시 삭제, 실패 시 유지 같은 방식을 지정합니다.
5. Secret 은 어떻게 다루는가
GitOps 의 원칙은 "모든 것을 Git 에" 인데, 비밀번호를 Git 에 올릴 수는 없습니다. Argo CD 자체는 이 문제를 풀어주지 않으므로 별도의 수단이 필요합니다.
- Sealed Secrets : 클러스터 안의 컨트롤러만 풀 수 있는 공개키로 암호화해 Git 에 올립니다. 암호문은 그 클러스터에서만 복호화됩니다.
- SOPS : KMS 나 age 로 값을 암호화한 파일을 Git 에 둡니다. 플러그인으로 복호화해 적용합니다.
- External Secrets Operator : Git 에는 "어느 비밀 저장소의 어느 키를 가져와라" 라는 참조만 두고, 실제 값은 Vault 나 클라우드의 Secrets Manager 에서 가져옵니다.
셋 중에서는 세 번째가 널리 쓰입니다. Git 에 암호문조차 남기지 않고, 비밀 관리는 전용 시스템에 맡기며, 값이 회전되어도 Git 을 건드릴 필요가 없기 때문입니다.
6. 자주 만나는 문제들
6.1. 계속 OutOfSync 로 남는다
3.3 에서 본 경우가 대부분입니다. 다른 컨트롤러가 필드를 바꾸고 있는 것입니다. UI 의 Diff 화면에서 어느 필드가 다른지 확인하고 그 필드를 Git 에서 빼거나 ignoreDifferences 로 제외합니다.
6.2. 동기화가 끝나지 않는다
Sync Wave 를 썼는데 앞선 물결의 리소스가 Healthy 가 되지 못하는 경우입니다. Argo CD 는 계속 기다립니다. 어느 리소스가 Progressing 이나 Degraded 에 머물러 있는지 봐야 합니다.
6.3. prune 이 무언가를 지웠다
prune: true 는 Git 에 없는 것을 지웁니다. 경로를 잘못 바꾸거나 파일을 실수로 지우면 그대로 반영됩니다. 중요한 리소스에는 삭제 방지 어노테이션을 걸 수 있습니다.
metadata:
annotations:
argocd.argoproj.io/sync-options: Prune=false
PVC 처럼 지워지면 데이터가 사라지는 것에는 걸어두는 편이 안전합니다.
6.4. 리소스가 너무 많아 느리다
1.1 에서 본 대로 컨트롤러가 모든 리소스를 캐시합니다. 관리 대상이 수만 개가 되면 메모리와 비교 시간이 문제가 됩니다. 대응 방법은 이렇습니다.
- 컨트롤러를 샤딩해 여러 개로 나눕니다. 클러스터 단위로 분배됩니다.
- 관심 없는 리소스 종류를 추적 대상에서 제외합니다.
- Repository Server 를 늘려 렌더링 부하를 분산합니다.
7. 조금 더 들어가보기 : 누가 어느 필드를 소유하는가
비교가 왜 어려운지를 앞에서 봤는데 여러 주체가 같은 리소스를 나눠 소유한다는 부분을 한 층 더 들어가 보겠습니다. Argo CD 운영에서 겪는 문제의 상당수가 여기서 나옵니다.
7.1. 세 개의 상태를 비교한다
"Git 과 클러스터를 비교한다" 고 말하지만 실제로는 세 가지를 봅니다.
- 원하는 상태 : Git 에서 렌더링한 매니페스트
- 실제 상태 : 클러스터에서 읽어온 리소스
- 마지막으로 적용한 상태 : 제가 지난번에 무엇을 적용했는지
세 번째가 왜 필요할까요. "내가 지운 것" 과 "애초에 내 것이 아닌 것" 을 구분하기 위해서입니다. Git 에서 어떤 필드를 지웠다고 하겠습니다. 실제 상태에는 그 필드가 남아 있습니다. 이때 그것이 제가 전에 넣었다가 이제 뺀 것인지, 아니면 다른 컨트롤러가 넣은 것인지 알아야 합니다. 앞의 경우라면 지워야 하고 뒤라면 건드리면 안 됩니다.
세 번째 상태 없이 둘만 비교하면 이 구분이 불가능합니다. "Git 에 없으니 지운다" 고 하면 다른 컨트롤러가 넣은 것까지 계속 지우게 됩니다.
7.2. 어노테이션에 적어두는 방식의 한계
앞서 정리한 대로 전통적인 방식은 마지막으로 적용한 내용을 어노테이션에 통째로 저장하는 것입니다. 간단하지만 문제가 있습니다.
- 크기가 두 배가 됩니다. 리소스 본문과 거의 같은 내용이 어노테이션에 한 번 더 들어갑니다. CRD 처럼 스키마가 큰 리소스에서는 오브젝트 크기 상한에 걸리기도 합니다.
- 주체가 하나라고 가정합니다. "마지막으로 적용한" 것이 누구인지는 기록하지 않습니다. 여러 도구가 같은 리소스를 다루면 서로의 기록을 덮어씁니다.
7.3. 필드마다 소유자를 기록하는 방식
이것을 푸는 것이 서버 사이드 적용입니다. apiserver 가 필드마다 "누가 이 값을 설정했는지" 를 기록합니다. 그러면 판단이 정확해집니다.
- Argo CD 가 소유한 필드에서 값이 다르면 → 되돌립니다.
- 다른 주체가 소유한 필드는 → 건드리지 않습니다.
- Argo CD 가 소유했던 필드가 Git 에서 사라지면 → 소유를 놓고 지웁니다.
앞서 본 HPA 와 복제본 수 충돌이 여기서 깔끔해집니다. Git 에서 replicas 를 빼면 Argo CD 가 그 필드의 소유를 놓고 HPA 가 소유자가 되어 자유롭게 조정합니다. 싸움이 일어나지 않습니다.
"충돌을 감지하는" 것이 아니라 "소유를 나누는" 방식으로 문제를 바꾼 것이 핵심입니다.
7.4. 그래도 남는 것들
소유권으로 다 풀리지는 않습니다.
- 어드미션 웹훅이 주입한 값은 누구의 소유도 아닌 채로 들어옵니다. 사이드카가 주입된 Pod 템플릿이 Git 과 다르게 보이는 경우입니다.
- 컨트롤러가 상태를 채우는 필드는 원래 비교 대상이 아니어야 하는데 일부 CRD 는 원하는 상태와 현재 상태를 한 필드에 섞어둡니다.
- 기본값이 채워지는 필드는 Git 에 없어도 실제에는 있습니다.
그래서 비교에서 제외하는 설정이 여전히 필요합니다. 이것을 "예외 처리" 로 보기보다, 쿠버네티스에 리소스를 고치는 주체가 여럿이라는 사실을 인정하는 선언으로 보는 편이 맞습니다.
7.5. 대규모에서 무엇이 무거워지는가
- 컨트롤러 메모리 : 관리 대상 리소스를 전부 캐시에 들고 비교하므로, 리소스 수에 비례해 메모리가 늡니다. 클러스터 단위로 샤딩해 나눕니다.
- 렌더링 CPU : Helm 이나 Kustomize 실행이 Application 수만큼 일어납니다. 같은 커밋에 대해서는 캐시가 듣지만 공통 차트를 바꾸면 캐시가 통째로 무효가 됩니다.
- Watch 부하 : Argo CD 도 대상 클러스터의 리소스를 구독하므로, 앞서 정리한 apiserver 부하에 더해집니다.
- 비교 대상 범위 : 관심 없는 리소스 종류를 추적에서 빼는 것만으로 메모리와 부하가 눈에 띄게 줄어듭니다.
특히 두 번째와 관련해, 공통 차트 하나를 고치는 것이 수백 개의 렌더링과 동기화를 동시에 유발한다는 점을 염두에 두어야 합니다. 앞서 여러 번 본 "동시에 몰리는 것이 위험하다" 가 여기서도 나타납니다.
결론
내부 구조를 들여다보면서 가장 크게 느낀 것은, Argo CD 가 쿠버네티스 컨트롤러와 완전히 같은 모양이라는 점이었습니다. Application 이라는 CRD 가 있고, 그것을 지켜보는 컨트롤러가 있고, 원하는 상태와 현재 상태를 비교해 좁힙니다. 쿠버네티스 시리즈 8편에서 본 오퍼레이터 패턴 그 자체였습니다. 다른 점은 원하는 상태를 etcd 가 아니라 Git 에서 읽는다는 것뿐이었습니다.
두 번째로 인상 깊었던 것은 비교가 생각보다 어렵다는 사실이었습니다. 클러스터가 기본값을 채워 넣고, 다른 컨트롤러가 필드를 바꾸고, 어드미션 웹훅이 내용을 주입합니다. "Git 과 클러스터를 비교한다" 는 한 문장 뒤에 이렇게 많은 사정이 있을 줄 몰랐습니다. 특히 HPA 와 replicas 가 서로 싸우는 문제는, 쿠버네티스에 리소스를 고치는 주체가 여럿이라는 사실을 정면으로 보여주는 예였습니다.
세 번째는 Sync Wave 가 단순한 순서가 아니라 기다림이라는 점이었습니다. 앞선 물결이 Healthy 가 될 때까지 멈춘다는 것은, Argo CD 가 "적용했다" 가 아니라 "잘 돌게 되었다" 를 기준으로 삼는다는 뜻입니다. 1편에서 본 두 상태의 구분이 여기서 실제 동작으로 이어지고 있었습니다.
공부 참고 자료
📑 1. Argo CD - Architectural Overview
📑 2. Argo CD - Diffing Customization
📑 3. Argo CD - Sync Waves and Hooks
📑 5. Argo CD - High Availability
📑 6. Kubernetes - Server-Side Apply