2026년 8월 15일 · 9분 읽기
ArgoCD app-of-apps 패턴: sync-wave와 selfHeal이 못 보는 것
ArgoCD 정적 app-of-apps로 클러스터 전체를 Git에서 관리하는 구조와 그 사각지대입니다. 새 노드를 붙였는데 exporter가 정상인데도 메트릭이 없던 이유를 찾습니다.
2026년 8월 23일 업데이트
클러스터에서 값 하나를 손으로 고치면 저장소에 적힌 선언과 실제 상태가 갈립니다. 갈린 것을 아무도 모릅니다.
그래서 저장소를 정본으로 삼았습니다. 그런데 저장소가 정본이어도 목록에서 한 줄이 빠지면 아무 일도 일어나지 않습니다. 새 노드를 붙였는데 메트릭이 하나도 오지 않은 사건이 그것이었습니다. 노드는 Ready였고 exporter 파드도 13/13이었습니다.
한눈에 보기
| 문제 | 클러스터에서 값 하나를 손으로 고치면 저장소에 적힌 선언과 실제 상태가 갈립니다. |
| 결정 | root라는 Application 하나를 만들었습니다. |
| 결과 | selfHeal이 지키는 정본은 원격 저장소의 main입니다. |
| 제약 | 그래서 목록을 셀 때는 세 곳을 같이 봅니다. |
root Application 하나를 손으로 올렸습니다
Argo CD는 Git 저장소를 읽어 Kubernetes 리소스를 적용합니다. 저장소의 선언이 클러스터의 상태가 되는 이 방식을 깃옵스(GitOps)라고 부릅니다.
그 첫 장은 손으로 올려야 합니다. 저장소를 읽는 주체가 아직 없기 때문입니다.
root라는 Application 하나를 만들었습니다. 서비스가 아닙니다. argocd/applications 디렉터리를 읽어 그 안의 Application을 자식으로 만드는 부모입니다.
# clab-cluster/argocd/root-app.yaml
spec:
source:
path: argocd/applications # 이 디렉터리를 목록으로 읽는다
directory:
recurse: false # 바로 아래 파일만 본다
syncPolicy:
automated:
prune: true # Git에서 사라진 것은 클러스터에서도 지운다
selfHeal: true # 손으로 바꾼 값은 Git 쪽으로 되돌린다recurse: false가 헷갈릴 수 있습니다. 하위 디렉터리를 훑지 않는다는 뜻입니다. root가 보는 것은 그 디렉터리의 파일뿐이고, 각 파일이 자기 경로를 따로 지정합니다.
prune은 저장소에서 지운 Application을 클러스터에서도 지우고, selfHeal은 손으로 바꾼 값을 Git 선언으로 되돌립니다.
이 구조를 app-of-apps라고 부릅니다. 파일을 하나 추가하면 서비스가 하나 늘어납니다. 부모를 고칠 일은 없습니다.
깃옵스 밖에 남은 것을 세어 보니 다섯이었습니다
깃옵스로 관리되지 않는 수동 작업이 README에 적혀 있습니다. 이번에 하나씩 다시 세었고 라이브에서도 확인했습니다.
| 무엇 | 왜 밖에 있나 | 오늘 라이브 |
|---|---|---|
root-app.yaml 첫 apply |
저장소를 읽는 주체가 아직 없다 | Application root 있음 |
argocd-cmd-params-cm의 server.insecure |
Traefik이 TLS를 끝낸다 | "true" 확인 |
Traefik HelmChartConfig |
k3s 기본 Traefik이 ExternalName을 거부한다 | 프로바이더 둘에 allowExternalNameServices: true 확인 |
| kube-prometheus-stack CRD | 어노테이션 256KiB 한계에 걸린다 | 확인하지 않음 |
| OAuth 클라이언트 시크릿 | Git에 커밋하지 않는다 | 확인하지 않음 |
세 번째 줄이 흥미롭습니다. 그 patch의 내용이 저장소에 파일로 있습니다. bootstrap/traefik-helmchartconfig.yaml이고 README에 "참고용, k3s에 이미 적용됨"이라고 적혀 있습니다.
선언이 저장소에 있는 것과 root가 그것을 적용하는 것은 다릅니다. bootstrap/은 root가 읽는 argocd/applications가 아닙니다. 파일은 있고 적용하는 주체는 사람입니다. 저장소를 보고 "이건 깃옵스로 관리되네"라고 읽으면 틀립니다.
네 번째 줄은 한 번으로 끝나지 않습니다. helm.skipCrds: true로 차트에서 CRD를 빼고 kubectl apply --server-side로 따로 넣었습니다. 차트 버전을 올릴 때마다 같은 명령을 다시 실행해야 합니다. 부트스트랩이 아니라 업그레이드마다 돌아오는 수동 작업입니다.
클러스터를 처음부터 다시 세우면 이 다섯을 기억해야 합니다. README에 적어 두는 것 말고는 한 것이 없습니다.
하위 Application 열한 개를 sync-wave 세 층으로 나눴습니다
argocd/applications/에는 YAML이 열한 개 있습니다. ApplicationSet은 없습니다. 목록을 생성기가 만들지 않고 사람이 커밋합니다.
sync-wave는 적용 순서를 정하는 숫자입니다. 낮은 층이 다 올라간 뒤 다음 층이 갑니다.
| wave | Application | 출처 | 버전 | 네임스페이스 |
|---|---|---|---|---|
| 0 | cert-manager | Helm charts.jetstack.io | v1.16.2 |
cert-manager |
| 0 | kube-prometheus-stack | Helm prometheus-community | 66.3.1 |
monitoring |
| 0 | oauth2-proxy | Helm oauth2-proxy | 7.9.2 |
auth |
| 1 | cluster-issuer | Git infra/cluster-issuer |
main |
cert-manager |
| 1 | oauth-gateway | Git infra/oauth-gateway |
main |
monitoring |
| 1 | observability | Git infra/observability |
main |
monitoring |
| 2 | baguette | Git workloads/baguette |
main |
baguette |
| 2 | fp-router | Git workloads/fp-router |
main |
fp-router |
| 2 | hello-clab | Git workloads/hello-clab |
main |
hello-clab |
| 2 | postal | Git workloads/postal |
main |
postal |
| 2 | stalwart | Git workloads/stalwart |
main |
층이 세 개인 이유는 참조 방향입니다. wave 0은 차트를 올립니다. wave 1은 그 차트가 만든 CRD 위에 리소스를 얹습니다. observability.yaml 첫 줄에 그 이유가 적혀 있습니다. kube-prometheus-stack이 설치한 Operator CRD 위에 얹는다고요. CRD가 없는 클러스터에 그 리소스를 먼저 적용하면 실패합니다.
wave 2는 워크로드입니다. 인증서와 인증 게이트가 이미 서 있어야 붙습니다.
열한 개 전부 prune: true, selfHeal: true, CreateNamespace=true를 씁니다. kube-prometheus-stack과 observability에는 ServerSideApply=true가 하나 더 있습니다. observability 쪽에는 이유가 주석에 적혀 있습니다. 대시보드 JSON을 담은 ConfigMap이 커서 클라이언트 쪽 적용이 last-applied-configuration 어노테이션의 256KiB 한계에 걸릴 수 있습니다.
push 한 번이 클러스터에 닿는 경로는 세 단계입니다
README가 적은 배포 흐름은 짧습니다.
# clab-cluster/README.md:80-82
1. workloads/ 또는 argocd/applications/ 변경 → main 브랜치 push
2. Argo CD가 저장소를 polling(기본 3분) 또는 webhook으로 감지, 자동 sync
3. git revert로 되돌리면 클러스터 상태도 자동으로 되돌아온다세 번째 줄이 이 구조를 쓰는 이유입니다. git revert는 파일을 지우는 것이 아니라 이전 선언을 새 커밋으로 만드는 것입니다. Argo CD가 그 커밋을 sync하면 클러스터도 같은 상태로 돌아옵니다.
되돌리는 방법이 배포하는 방법과 같습니다. 롤백 절차를 따로 외우지 않습니다.
reconcile은 3분마다 돌고 sync는 바뀔 때만 돕니다
오늘 root의 상태를 열어 보니 두 시각이 달랐습니다.
| 값 | 시각 |
|---|---|
reconciledAt |
2026-08-15 04:48 |
| 마지막 sync가 끝난 시각 | 2026-08-07 14:33 |
3분마다 도는 것은 앞의 값입니다. Argo CD가 저장소를 다시 읽고 클러스터와 비교합니다. 같으면 아무 일도 하지 않습니다. sync는 다를 때만 일어나고, 마지막으로 그런 일이 있었던 것이 8일 전입니다.
argocd-cm에 timeout.reconciliation 키가 없습니다. 그래서 3분은 Argo CD의 기본값입니다. README가 "기본 3분"이라고 적은 것이 설정을 넣었다는 뜻이 아니라 넣지 않았다는 뜻입니다.
같은 ConfigMap에 resource.exclusions가 있고, 그 목록에 Endpoints와 EndpointSlice가 있습니다. Argo CD는 이 둘을 아예 보지 않습니다. 파드가 엔드포인트에서 빠지는 것은 Argo CD의 관심사가 아니고, 그것을 보는 것은 사람입니다. 23편에서 API가 트래픽에서 빠졌을 때 Application이 계속 Healthy였던 이유가 여기 한 줄에 있습니다.
exporter는 정상이었고 목록에서 한 줄이 빠져 있었습니다
새 워커 노드 aks-node-02를 붙였습니다. Grafana에 그 노드의 메트릭이 뜨지 않았습니다. aks-node-01은 뜹니다.
이 사건은 clab 클러스터가 아니라 다른 클러스터에서 났습니다. 그쪽은 ApplicationSet을 씁니다. 구조가 다르지만 목록을 어떻게 관리하느냐는 같은 문제입니다.
읽기만 했습니다. 아무것도 바꾸지 않았습니다. 본 순서가 이렇습니다.
| 무엇을 봤나 | aks-node-01 | aks-node-02 |
|---|---|---|
| 노드 상태 | Ready | Ready (26시간 전 조인) |
| exporter DaemonSet | 13/13 Ready | 파드 ovh-node-exporter-sm8dp 2/2 Running |
| PodMonitor | vg-aks-node-01-node-exporter 있음 |
없음 |
| ArgoCD Application | vg-aks-node-01 있음 |
없음 |
| generator 입력 파일 | values/nodes/aks-node-01.yaml 있음 |
없음 |
| Prometheus 시리즈 | 있음 | 없음 |
위에서 두 줄이 정상입니다. 노드도 파드도 살아 있습니다. 세 번째 줄부터 없습니다.
파드가 준비된 것과 Prometheus가 그 파드를 긁는 것은 다른 사실입니다. 긁으려면 스크레이프 대상이 선언돼 있어야 하고, 그 선언이 PodMonitor입니다.
generator가 glob이 아니라 손으로 적은 목록이었습니다
PodMonitor가 없는 이유를 위로 따라갔습니다. 그것을 만드는 Application이 없었고, Application을 만드는 ApplicationSet의 입력에 그 노드가 없었습니다.
ApplicationSet의 git-files generator는 패턴이 아니었습니다. 경로 열세 개를 그대로 적은 목록이었습니다.
# aks-cluster/k8s/argocd/apps/10-observability.yaml:351-377
values/nodes/aks-node-01.yaml
values/nodes/ovh-node-1.yaml … ovh-node-9.yaml
values/nodes/ws-node-1.yaml
values/nodes/la-node-1.yaml
values/nodes/cherry-node-1.yamlaks-node-02.yaml이 없습니다. 저장소에 새 파일이 생겨도 이 목록에 경로를 추가하지 않으면 Application이 만들어지지 않습니다.
그리고 그 파일 자체도 없었습니다. values/nodes/에 aks-node-01.yaml만 있고, catalog.yaml에도 새 노드 항목이 없었습니다. 목록만 고쳐도 끝나지 않는 상태였습니다.
중앙 Prometheus에 직접 물어 마무리했습니다. up{node=~"aks-node-01|aks-node-02"}에서 01의 시리즈만 나왔습니다. Grafana에서 본 것과 같습니다.
원인은 exporter도 네트워크도 아니었습니다. 온보딩 절차에서 두 파일을 빠뜨린 것입니다. 그때는 사용자 승인 전이라 조사만 하고 고치지 않았습니다.
selfHeal은 drift를 되돌리지만 누락은 못 찾습니다
두 구조가 잡아 주는 것이 다릅니다.
| 선언과 다른 상태 | 선언 자체가 없는 상태 | |
|---|---|---|
| 무엇이 잡나 | selfHeal |
아무것도 |
| 어떻게 드러나나 | Argo CD가 되돌린다 | 조용하다 |
| 얼마나 걸리나 | 3분 polling 안 | 사람이 알아챌 때까지 |
selfHeal은 자기가 아는 것만 지킵니다. 선언되지 않은 리소스는 지켜 줄 대상이 아닙니다. 없는 것은 drift가 아닙니다.
aks-node-02 사건에서 모든 계층이 초록색이었습니다. Application은 Synced이고 Healthy였습니다. 그 목록에 없는 노드 하나만 없었습니다.
자동화가 목록을 손으로 들고 있으면 그 목록이 곧 사람의 기억입니다. 기억은 빠뜨립니다.
clab 클러스터의 root는 디렉터리를 읽습니다. 파일을 추가하면 목록이 따라옵니다. 새 노드를 붙이는 저쪽은 경로를 손으로 적습니다. 같은 깃옵스인데 한쪽은 파일을 만들면 끝이고 한쪽은 두 곳을 고쳐야 합니다.
root가 둘이고 한쪽 목록에는 순서가 없습니다
오늘 클러스터에 Application을 물어보니 23개였습니다. 앞에서 센 열한 개보다 많습니다.
root 말고 부모가 하나 더 있었습니다. clab-app-root입니다. 두 부모의 모양이 같습니다. 읽는 저장소만 다릅니다.
root |
clab-app-root |
|
|---|---|---|
| 저장소 | clab-cluster | clab-app |
| 읽는 경로 | argocd/applications |
argocd/applications |
recurse |
false | false |
| 자식 수 | 11 | 10 |
prune·selfHeal |
둘 다 true | 둘 다 true |
| sync-wave | 자식마다 0·1·2 | 없음 |
11과 10을 더하면 21이고, 부모 둘을 더해 23입니다. 두 부모 자신도 Application이라 목록에 나옵니다. 오늘 23개 전부 Synced이고 Healthy였습니다.
마지막 줄이 두 목록의 차이입니다. clab-cluster의 자식 열한 개에는 sync-wave가 붙어 있고 clab-app의 자식 열 개에는 하나도 없습니다.
없어도 되는 이유가 있습니다. clab-cluster의 목록은 인증서와 인증 게이트와 관측 도구입니다. 서로를 전제합니다. clab-app의 목록은 제품 서비스들이고 서로를 전제하지 않습니다. 백엔드가 콘솔보다 먼저 올라갈 필요가 없습니다.
대신 clab-app에는 다른 특징이 있습니다. 목록 열 개가 일곱 개 저장소를 가리킵니다.
| Application | 저장소 | 경로 |
|---|---|---|
| discourse, plane | clab-app | workloads/… |
| justsend-backend, justsend-console, justsend-ops | justsend-platform | backend, console, ops |
| justsend-render | justsend-render | . |
| justsend-share-web | justsend-share-web | . |
| justsend-web | justsend-web | . |
| posy-weather-web | clab-weather-web | deploy/k8s |
| test-agents | test-agents | . |
path: .가 넷입니다. 저장소 루트에 매니페스트를 두고 그 저장소 하나가 서비스 하나에 대응하는 배치입니다. 서비스를 추가할 때 새 저장소를 만들지 저장소 안에 디렉터리를 만들지가 갈리는 자리이고, 여기서는 둘이 섞여 있습니다.
어느 쪽이든 목록에 파일 한 장을 추가하는 일은 같습니다. 다른 것은 그 다음입니다. 저장소가 일곱 개면 git revert로 되돌릴 곳도 일곱 군데입니다. 어느 서비스가 어느 저장소에 있는지 기억해야 합니다.
내 디스크의 사본이 정본이 아니었습니다
이 원고를 검증하면서 목록이 두 번 다르게 나왔습니다.
디스크에 clab-app 사본이 둘 있었습니다. 한쪽은 justsend-ocr.yaml, justsend-ocr-normalizer.yaml, justsend-web-app.yaml을 갖고 있고, 다른 쪽은 discourse.yaml, justsend-ops.yaml, justsend-render.yaml을 갖고 있습니다. 셋씩 다릅니다.
# 같은 저장소의 두 사본
clab/clab-app HEAD e810581 (2026-08-04)
justsend-platform/clab-app HEAD 1fbd5d2 (2026-08-14)
origin/main 1fbd5d2오래된 쪽이 열흘 뒤처져 있었습니다. 클러스터에 물어보니 discourse와 justsend-ops와 justsend-render가 있고 justsend-ocr는 없습니다. 새 쪽이 맞습니다.
옛 사본만 보고 이 원고를 썼다면 서비스 세 개를 없는 것으로 적고, 없는 서비스 세 개를 있는 것으로 적었을 것입니다. 파일 목록이 곧 서비스 목록이니 사본이 틀리면 목록이 통째로 틀립니다.
selfHeal이 지키는 정본은 원격 저장소의 main입니다. 내 노트북의 체크아웃이 아닙니다. Argo CD는 자기가 fetch한 것을 보고, 사람은 자기가 열어 둔 디렉터리를 봅니다. 둘이 갈렸다는 신호는 어디에도 뜨지 않습니다.
그래서 목록을 셀 때는 세 곳을 같이 봅니다. 원격의 main, 클러스터의 Application, 그리고 내가 읽고 있는 디렉터리입니다. 이 원고의 숫자는 셋이 맞은 뒤에 적었습니다.