Argo CD
GitOps 동기화 엔진의 내부 구조부터 컨트롤러 샤딩, 멀티테넌시까지 운영자 관점으로 파고든다.
Argo CD는 Git에 선언된 상태를 쿠버네티스 클러스터에 계속 맞추는 GitOps 컨트롤러다.
Intro
GitOps의 전제는 단순하다. Git 저장소가 유일한 진실의 원천이고, 컨트롤러가 클러스터의 실제 상태를 그 선언에 계속 수렴시킨다.
사람이 kubectl apply로 클러스터를 직접 만지는 대신, Git에 커밋하면 컨트롤러가 그 차이를 감지해 반영하고, 손으로 만든 drift는 도로 지운다.
운영자에게 Argo CD는 “설치하고 끝”이 아니다.
애플리케이션이 수백 개로 늘고 클러스터가 여러 개 붙으면, 어떤 컴포넌트가 병목인지, 컨트롤러를 어떻게 쪼개는지, 팀 간 경계를 어떻게 긋는지가 곧바로 문제가 된다.
Architecture
Argo CD는 단일 바이너리가 아니라 역할이 분리된 여러 프로세스의 묶음이다.
flowchart LR
GIT[(Git repo)] -->|clone + render| RS
UI["UI / CLI / CI"] --> API
subgraph CP[Argo CD control plane]
API["argocd-server<br/>API / RBAC"]
RS["repo-server<br/>manifest 생성"]
AC["application-controller<br/>reconcile"]
RD[(Redis<br/>cache)]
end
RS -->|desired manifests| AC
API -.- RD
RS -.- RD
AC -.- RD
AC -->|diff + sync| K8S[(target cluster)]
K8S -->|live state| AC
argocd-server는 gRPC/REST API 서버다. UI, CLI, CI/CD가 여기로 붙는다.
애플리케이션 상태 조회, sync/rollback 같은 작업 호출, 저장소와 클러스터 자격증명 관리, 외부 IdP 인증 위임, RBAC 강제, Git webhook 수신을 담당한다. 상태를 갖지 않아(stateless) replica를 늘리는 것만으로 수평 확장된다. 공식 문서도 “argocd-server는 stateless하며 문제를 일으킬 가능성이 가장 낮다”고 표현한다.
repo-server는 Git 저장소를 로컬에 클론해두고, “저장소 URL + revision + path + 파라미터”를 받으면 최종 쿠버네티스 매니페스트를 렌더링해 돌려주는 내부 서비스다. Helm, Kustomize 같은 도구를 실제로 실행하는 곳이 여기다. 이것도 stateless라 replica로 확장한다.
application-controller는 이름 그대로 컨트롤러다. 실행 중인 애플리케이션을 계속 감시하면서 live state와 desired state를 비교하고, OutOfSync를 감지하면 (설정에 따라) 교정 작업을 한다. PreSync, Sync, PostSync 같은 라이프사이클 훅도 이 컨트롤러가 호출한다. 앞의 둘과 달리 StatefulSet으로 뜨며, 확장 방식이 replica 추가가 아니라 클러스터 샤딩이다. 이 차이가 스케일링 설계를 가른다.
Redis는 순수한 캐시일 뿐이다. 문서 표현 그대로 “언제든 버리고 다시 만들어도 서비스 중단이 없는 일회용 캐시”다. 모든 영속 데이터는 쿠버네티스 오브젝트(Application, AppProject 등)로 etcd에 저장되므로, Argo CD 자체는 대체로 stateless하다.
Application, AppProject, Root
배포 단위는 세 층으로 나눠 보면 깔끔하다.
Application은 sync의 최소 단위다. “이 Git source를, 이 클러스터의 이 namespace(destination)에 배포한다”를 선언하는 CRD다.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: guestbook
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/org/manifests
targetRevision: main
path: apps/guestbook
destination:
server: https://kubernetes.default.svc
namespace: guestbook
syncPolicy:
automated:
prune: true
selfHeal: true
AppProject는 여러 Application을 묶는 논리적 그룹이자 테넌시 경계다. 어떤 저장소에서, 어떤 클러스터/namespace로, 어떤 종류의 리소스를 배포할 수 있는지를 제한한다.
Root는 별도 CRD가 아니라 패턴이다. Application 하나가 다시 “여러 Application을 담은 디렉터리”를 source로 가리키면, 그 Root Application이 자식 Application들을 만들고 관리한다. 이것이 App-of-Apps다. 클러스터 부트스트랩 전체를 Git 커밋 하나로 재현할 수 있게 해주는 구조다. 운영에서 중요한 건 이 Root를 몇 개 둘 것인가인데, 그 답이 멀티테넌시 설계와 직결된다.
Generating Apps with ApplicationSet
App-of-Apps는 자식 Application을 사람이 하나씩 Git에 적어 만든다.
클러스터가 열 개면 거의 똑같은 Application 매니페스트를 열 벌 복사해 두는 식이다.
ApplicationSet은 이 반복을 템플릿과 generator로 걷어낸다.
CRD 하나가 “이런 모양의 Application을 이 목록만큼 찍어내라”를 선언하면, 컨트롤러가 목록 항목마다 Application을 만들고, 목록이 바뀌면 따라서 만들고 지운다.
v2.3부터 Argo CD에 기본 포함되어 별도 설치가 필요 없다.
동작은 generator, parameter, template 세 단계로 흐른다.
generator가 Key-Value 파라미터 묶음을 뽑고, 그 파라미터를 template에 한 벌씩 대입해 렌더하고, 렌더된 결과 하나하나가 진짜 Application이 된다.
template의 필드는 Application의 spec과 그대로 대응하므로, project나 source.repoURL, destination.server 자리를 파라미터로 채우는 셈이다.
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: guestbook
namespace: argocd
spec:
goTemplate: true
goTemplateOptions: ["missingkey=error"]
generators:
- list:
elements:
- cluster: dev
url: https://1.2.3.4
- cluster: prod
url: https://2.4.6.8
template:
metadata:
name: '{{.cluster}}-guestbook'
spec:
project: default
source:
repoURL: https://github.com/org/manifests
targetRevision: main
path: guestbook/{{.cluster}}
destination:
server: '{{.url}}'
namespace: guestbook
위 List generator는 dev, prod 두 항목을 파라미터로 내놓고, ApplicationSet은 dev-guestbook과 prod-guestbook Application을 각각 만든다.
generator는 파라미터를 어디서 얻느냐로 갈린다. 지금은 아홉 종류다.
- List: 매니페스트에 박아 둔 고정 목록을 그대로 파라미터로 쓴다.
- Cluster: Argo CD에 등록된 클러스터 secret에서 클러스터 목록을 자동으로 읽는다.
- Git: Git 저장소의 디렉터리 구조나 JSON/YAML 파일 내용에서 파라미터를 뽑는다. 디렉터리 이름을 값으로 쓰거나, 파일을 파싱해 키-값으로 쓴다.
- SCM Provider: GitHub, GitLab 같은 SCM API로 조직 안의 저장소를 자동 발견한다. 마이크로서비스가 저장소별로 흩어진 구조에 맞는다.
- Pull Request: 열려 있는 PR을 발견해 PR마다 프리뷰 환경을 띄운다.
- Cluster Decision Resource: 외부 컨트롤러가 고른 클러스터 목록을 받아 온다.
- Plugin: 컨트롤러가 외부 HTTP 엔드포인트를 호출해, 원하는 커스텀 로직으로 파라미터를 받아온다.
- Matrix: 두 generator의 출력을 곱집합으로 조합한다. Git(앱 목록)과 Cluster(클러스터 목록)를 묶으면 모든 앱을 모든 클러스터에 찍는 식이다. 자식은 정확히 둘까지고, 조합형 generator는 한 번만 중첩할 수 있다.
- Merge: 여러 generator의 파라미터를 merge key로 맞춰 합치고, 뒤쪽 generator 값이 앞쪽을 덮어쓰게 한다.
Reconciliation Loop
reconcile 루프가 Argo CD 동작의 중심이다.
desired state와 live state를 비교해 일치시키는 반복 과정을 말한다.
repo-server가 Git에서 desired 매니페스트를 렌더링하고, controller가 그것을 대상 클러스터의 live 리소스와 비교해 diff를 계산한 뒤, OutOfSync면 sync를 수행한다.
기본적으로 controller는 Git을 3분마다 폴링한다(timeout.reconciliation, 기본 180초).
애플리케이션이 많으면 이 주기가 repo-server에 스파이크를 만들기 때문에, timeout.reconciliation.jitter로 refresh 시점을 흩뿌린다.
폴링을 기다리지 않으려면 Git webhook을 걸어 커밋 즉시 refresh를 트리거하면 된다.
sync를 자동화하려면 syncPolicy.automated를 켠다. 여기 세 옵션의 의미를 정확히 알아야 사고를 막는다.
- prune(기본 false): Git에서 리소스를 지우면 클러스터에서도 지운다. 안전장치로 기본은 꺼져 있고, 켜야 삭제가 전파된다.
- selfHeal(기본 false): 클러스터에서 손으로 바꾼 drift를 감지해 Git 상태로 되돌린다. 기본은 꺼져 있어, live 변경만으로는 sync가 트리거되지 않는다. 켜면 self-heal timeout(기본 5초) 뒤 다시 sync한다.
- allowEmpty(기본 false): prune가 켜진 상태에서 desired 리소스가 0개가 되면, 실수로 전부 지우는 걸 막기 위해 기본적으로 거부한다. 정말 빈 상태를 허용하려면 명시적으로 켜야 한다.
리소스 적용 순서가 필요할 때는 sync wave를 쓴다. argocd.argoproj.io/sync-wave 어노테이션에 정수(음수 가능, 기본 0)를 주면 낮은 값부터 배포된다. CRD를 먼저 깔고 그걸 쓰는 리소스를 나중에 까는 식이다. wave 사이에는 기본 2초 지연(ARGOCD_SYNC_WAVE_DELAY)이 있어, 다른 컨트롤러가 반응하고 health를 재평가할 여유를 준다.
절차적 작업이 필요하면 resource hook을 건다. argocd.argoproj.io/hook 어노테이션으로 PreSync(적용 직전), Sync(적용과 동시), PostSync(적용 후 전부 Healthy가 된 뒤), SyncFail(실패 시)을 지정한다. DB 마이그레이션을 PreSync Job으로 돌리는 게 전형적인 예다. 훅 리소스 정리는 hook-delete-policy(HookSucceeded, HookFailed, BeforeHookCreation)로 제어한다.
마지막으로, Git에는 없지만 컨트롤러나 웹훅이 채우는 필드 때문에 영원히 OutOfSync로 남는 경우가 있다. 이때는 spec.ignoreDifferences로 특정 JSON 경로를 diff에서 제외한다. jsonPointers, jqPathExpressions, 그리고 특정 field manager가 소유한 필드를 무시하는 managedFieldsManagers 세 방식을 지원한다.
Drift and Health
Argo CD가 화면에 띄우는 두 축은 sync 상태(Git과 일치하는가)와 health 상태(리소스가 실제로 정상인가)다. 둘은 별개다. Synced인데 Degraded일 수 있다. Git대로 배포는 됐지만 Pod가 뜨지 못하는 상황이다.
diff는 desired(Git 렌더 결과)와 live(클러스터 실제 오브젝트)를 구조적으로 비교해 계산한다. 여기서 흔한 함정이 앞서 말한 “관리 주체가 다른 필드”인데, 라이브에만 존재하는 필드가 diff에 잡혀 계속 OutOfSync로 보이면 ignoreDifferences나 시스템 레벨의 resource.compareoptions로 잡는다.
health는 리소스 종류별로 판정 로직이 다르다. Deployment, StatefulSet, DaemonSet은 “observed generation이 desired와 같고, updated replica 수가 desired와 같은가”로 본다. LoadBalancer 타입 Service와 Ingress는 status.loadBalancer.ingress가 채워졌는지 본다. 전체 상태는 Healthy, Progressing, Degraded, Suspended, Missing, Unknown으로 수렴한다.
내장 판정으로 부족한 CRD(예: cert-manager Certificate)는 Lua로 커스텀 health check를 짤 수 있다. argocd-cm의 resource.customizations.health.<group>_<kind>에 Lua 스크립트를 넣으면, 전역 변수 obj로 해당 리소스를 받아 { status, message }를 반환한다. 보안상 표준 Lua 라이브러리는 기본 비활성이며 useOpenLibs로 켠다.
# argocd-cm ConfigMap
data:
resource.customizations.health.cert-manager.io_Certificate: |
hs = {}
if obj.status ~= nil and obj.status.conditions ~= nil then
for _, c in ipairs(obj.status.conditions) do
if c.type == "Ready" and c.status == "True" then
hs.status = "Healthy"
hs.message = c.message
return hs
end
end
end
hs.status = "Progressing"
return hs
Multi-Tenancy with AppProject
여러 팀이 하나의 Argo CD를 공유하면, 격리 경계는 AppProject가 진다. default 프로젝트는 의도적으로 가장 느슨하게 열려 있어(모든 repo, 모든 클러스터, 모든 종류 허용) 실서비스에서는 그대로 쓰면 안 된다. 팀별로 프로젝트를 만들어 범위를 좁힌다.
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: team-alpha
namespace: argocd
spec:
sourceRepos:
- https://github.com/org/team-alpha-* # 허용 저장소(glob)
destinations:
- server: https://kubernetes.default.svc # 허용 클러스터
namespace: alpha-* # 허용 namespace
clusterResourceWhitelist: # 허용 cluster-scoped 종류
- group: ''
kind: Namespace
namespaceResourceBlacklist: # 금지 namespaced 종류
- group: ''
kind: ResourceQuota
roles:
- name: deployer
policies:
- p, proj:team-alpha:deployer, applications, sync, team-alpha/*, allow
groups:
- org:team-alpha
핵심 필드는 아래와 같다.
sourceRepos(허용 저장소)destinations(허용 클러스터와 namespace)clusterResourceWhitelist/BlacklistnamespaceResourceWhitelist/Blacklist(허용/금지 리소스 종류), 그리고roles(프로젝트 범위 RBAC)
특히 주의할 점 하나. destination이 Argo CD가 설치된 namespace로의 배포를 허용하면, 그 프로젝트의 Application은 사실상 admin 권한을 얻는다. 테넌트 프로젝트에서는 반드시 막아야 한다.
전역 RBAC은 argocd-rbac-cm의 policy.csv로 관리한다.
문법은 p, <subject>, <resource>, <action>, <object>, <effect>이고, 그룹에 역할을 붙일 때는 g, <group>, <role>을 쓴다.
내장 역할은 role:readonly(전체 읽기)와 role:admin(전체 무제한)이며, OIDC 그룹을 프로젝트 역할에 매핑해 팀별로 자기 프로젝트만 다루게 만든다.
여러 Root(App-of-Apps)를 팀별로 두고 각각을 팀 프로젝트에 묶으면, Git 저장소, 배포 대상, 조작 권한이 프로젝트 경계 안에서 모두 닫힌다.
References
- Argo CD — Architecture
https://argo-cd.readthedocs.io/en/stable/operator-manual/architecture/ - Argo CD — High Availability
https://argo-cd.readthedocs.io/en/stable/operator-manual/high_availability/ - Argo CD — Dynamic Cluster Distribution
https://argo-cd.readthedocs.io/en/stable/operator-manual/dynamic-cluster-distribution/ - Argo CD — Automated Sync, Sync Waves, Resource Hooks
https://argo-cd.readthedocs.io/en/stable/user-guide/auto_sync/ - Argo CD — Resource Health & Custom Lua Checks
https://argo-cd.readthedocs.io/en/stable/operator-manual/health/ - Argo CD — Projects & RBAC
https://argo-cd.readthedocs.io/en/stable/operator-manual/rbac/ - Argo CD — ApplicationSet & Generators
https://argo-cd.readthedocs.io/en/stable/operator-manual/applicationset/ - Argo CD — ApplicationSet Template & Go Template
https://argo-cd.readthedocs.io/en/stable/operator-manual/applicationset/GoTemplate/ - Argo CD — ApplicationSet Controlling Resource Modification
https://argo-cd.readthedocs.io/en/stable/operator-manual/applicationset/Controlling-Resource-Modification/