이코에코(Eco²) GitOps #05 Sync Wave
🛎️ 본 포스팅은 이미 구현이 완료된 사안만 다룹니다. 현재 이코에코 14-nodes cluster는 ap-northeast-2 리전에 배포돼 있습니다.

드디어 기다리던 Sync-wave 포스팅이다.
가장 시간을 많이 썼으면서도 그만큼 유용했기에 이번 구축에서 가장 애정하는 기능이다.
왜 굳이 Sync Wave였나?
Ansible bootstrap부터 App-of-Apps로 가는 동안 가장 큰 병목지점은 Reconcile Loop가 동시에 돌면서 서로 엉키는 일이었다.
- CRD가 아직 없는데 Operator가 먼저 올라와서 CrashLoop
- Secret이 없는데 Ingress가 먼저 뜨면서 BadRequest
- 데이터 Operator/Instance가 한꺼번에 올라오다가 FailedCreate
ArgoCD 자체는 Sync Wave 없이도 이런 걸 알아서 처리하진 않는다.
App-of-Apps 구조에서도 기본은 “모든 App을 동시에 Sync”라서, 의존성을 명시해 주지 않으면 결국 운에 맡기는 셈이었다. 그래서 Wave를 도입했다. 이유만 정리하면 아래와 같다.
- 동시 Reconcile을 제어하기 위해
- App-of-Apps 안쪽에서 여러 Application이 동시에 배포될 때
의존성 있는 리소스가 꼬이지 않도록 순서를 강제로 부여.
- App-of-Apps 안쪽에서 여러 Application이 동시에 배포될 때
- GitOps와 Runbook을 일치시키기 위해
- Wave 번호 자체가 “운영 순서”와 동일하니까
장애 대응할 때도 “Wave 10이 멈췄으면 Secrets부터 본다”처럼 바로 체크리스트로 전환할 수 있었다.
- Wave 번호 자체가 “운영 순서”와 동일하니까
Wave를 도입한 이후로 인프라 레이어가 조립 순서대로 차곡차곡 올라가기 시작했고,
Git에 남는 숫자(예: 15-alb-controller.yaml)가 운영 순서랑 일치시키면서 가시성을 높였다.
이번 편은 오직 ArgoCD Sync Wave 순서와 의존성만 다룬다.
RBAC/네임스페이스, API 얘기는 다음 편으로 넘기고,
“왜 이 Wave 순서가 나왔는지”만 한 번에 정리해 보려고 한다.
기준 문서는 여기다:ARGOCD_SYNC_WAVE_PLAN.md
Sync Wave는 정확히 뭐고, 왜 쓰게 됐나?
1. 개념부터 짚고 가자
ArgoCD는 기본적으로 “root-app을 기반으로 클러스터 내 모든 Application을 동시에 Sync”하는 구조다.
App-of-Apps 패턴을 쓰면 root-app 아래에 있는 수십 개의 Application이
동시에 Reconcile을 시도한다. 의존성이 얽혀 있으면 이때 문제가 터진다.
그래서 ArgoCD는 argocd.argoproj.io/sync-wave라는 Annotation을 제공한다.
- 숫자가 낮을수록 먼저 Sync 된다.
- 같은 Wave 안에서는 리소스 이름 순으로 진행된다.
- Wave 번호는 음수/정수 모두 가능하고, 보통
-1,0,5,10… 식으로 띄워서 쓴다.
먼저 이코에코에선 이 개념을 정책으로 정리했다:
→ docs/gitops/ARGOCD_SYNC_WAVE_PLAN.md
Sync Wave를 이용해 인프라 → 데이터 → 애플리케이션 순서로 배포한다. 그 뒤에 Wave별 리소스, 의존성, 실제 ArgoCD Application 파일명을 명시했다.
2. Sync hook
Sync Wave만으로 순서를 제어하기 어렵거나, 특정 작업을 따로 끼워 넣고 싶을 때는 Sync Hook을 같이 쓴다.
ArgoCD 는 Presync, Sync, PostSync, Skip, SyncFail Hook 타입을 제공한다.
PreSync
- Wave가 진행되기 전에 가장 먼저 실행된다.
- DB 마이그레이션, ConfigMap 생성, 사전 점검 같은 걸 넣을 때 유용하다.
Sync
- 일반 리소스가 적용되는 기본 단계. 별도 Hook을 지정하지 않으면 모두 Sync 단계로 취급된다.
PostSync
- Sync가 성공적으로 끝난 뒤 실행된다.
- Smoke 테스트, Slack 알림, Health 체크 스크립트 등으로 자주 활용된다.
SyncFail
- Sync 과정에서 실패가 발생했을 때 동작한다.
- 롤백 스크립트, 에러 리포트, 리소스 정리 작업을 넣을 수 있다.
이코에코에서는 일반 Sync를 제외하면 PostSync의 중요도가 높다.
주로 도메인 앱이 배포될 때 DB(PostgreSQL)에 스키마와 데이터를 주입할 때 활용된다.
상세한 동작 방식은 차후 location API 개발 과정에 기록해 두겠다.
3. 러프한 Wave → 문서화된 Wave
처음 루트 앱을 짰을 때는 Ansible Bootstrap의 절차를 그대로 옮기려 했다.
그러다 특정 Helm 혹은 Operator에서 Sync 시 충돌이 날 때,
그때그때 argocd.argoproj.io/sync-wave 숫자를 아무거나 붙여 넘기는 식이었다.
- Calico보다 NetworkPolicy가 먼저 올라가서 파드가 다 죽는다든가
- External Secrets보다 ALB Controller가 먼저 떠서 Secret 못 찾고 CrashLoop 돌고…
그렇지만 이런 러프한 wave로는 전체 앱을 안정적으로 배포하긴 힘들었다.
OutOfSync와 Missing, Sync Error를 헤매며 시간을 꽤 많이 소모한 듯 싶다.
v0.7.4 즈음에 Wave 표를 만들기 시작했다. 그게 지금 ARGOCD_SYNC_WAVE_PLAN.md에 있는 표다.
표의 맨 윗줄은 이렇게 생겼다.
| Wave | 계층 | 대표 리소스 | 선행 의존성 | 예시 파일 |
|---|---|---|---|---|
| 0 | CRD Seed | ALB, Prometheus, Postgres, ESO CRDs | - | 00-crds.yaml |
| 2 | Namespaces | 13개 Namespace | Wave 0 | 02-namespaces.yaml |
| 3 | RBAC · Storage | ServiceAccount, ClusterRole, StorageClass | Wave 2 | 03-rbac-storage.yaml |
| 5 | CNI | Calico (VXLAN) | Wave 3 | 05-calico.yaml |
| 6 | NetworkPolicy | default-deny, tier 격리 | Wave 5 | 06-network-policies.yaml |
| … | … | … | … | … |
한 줄씩 보면서 “이게 왜 이 순서였는지”를 실제 경험으로 연결해 본다.
4. Wave 배치 예시 (네임스페이스 ~ API까지)
문서에 있는 표를 그대로 가져오면 이런 구조다.
| Wave | 계층 | 대표 리소스 | 선행 의존성 | 파일 예시 |
|---|---|---|---|---|
| 0 | CRD Seed | ALB, Prometheus, Postgres, ESO CRDs | 없음 | 00-crds.yaml |
| 2 | Namespaces | 13개 Namespace (tier, domain) | Wave 0 | 02-namespaces.yaml |
| 3 | RBAC · Storage | ServiceAccount, ClusterRole, StorageClass | Wave 2 | 03-rbac-storage.yaml |
| 5 | CNI | Calico (VXLAN) | Wave 3 | 05-calico.yaml |
| 6 | NetworkPolicy | default-deny, tier 기반 격리 | Wave 5 | 06-network-policies.yaml |
| 10 | Secrets Operator | ExternalSecrets Operator | Wave 6 | 10-secrets-operator.yaml |
| 11 | Secrets CR | ExternalSecret CR (SSM → K8s Secret) | Wave 10 | 11-secrets-cr.yaml |
| 15 | Ingress Controller | AWS Load Balancer Controller | Wave 11 | 15-alb-controller.yaml |
| 16 | DNS Automation | ExternalDNS | Wave 11, 15 | 16-external-dns.yaml |
| 20 | Monitoring Operator | kube-prometheus-stack | Wave 15 | 20-monitoring-operator.yaml |
| 21 | Observability UI | Grafana | Wave 20 | 21-grafana.yaml |
| 25 | Data Operators | Postgres/Redis/RabbitMQ Operators | Wave 20 | 25-data-operators.yaml |
| 35 | Data Instances | PostgresCluster, RedisFailover CR | Wave 25 | 35-data-cr.yaml |
| 60 | Applications | 7개 도메인 API | Wave 35 | 60-apis-appset.yaml |
| 70 | Application Ingress | ALB Path routing | Wave 60 | 70-ingress.yaml |
각 Wave의 이유는 앞선 글에서 사례 중심으로 설명했지만, 간략히 요약하면:
- Wave 0~6: 네트워크(네임스페이스, RBAC, CNI, NetworkPolicy)
- Wave 10~16: Secrets와 인프라 출입구(ExternalSecrets Operator → Secret → ALB → ExternalDNS)
- Wave 20~35: 모니터링과 데이터 계층
- Wave 60~70: 애플리케이션과 Ingress
5. 규칙을 몇 가지로 정리해뒀다
문서에는 Wave 설계 규칙도 6개로 정리되어 있다.
큰 흐름은 리소스 -> 네트워크 + ALB 컨트롤러 -> DB + 모니터링 -> App + Instance 순이다.
먼저 클러스터에서 사용할 K8s 리소스들을 깔고, 네트워크 Policy와 ALB 설정을 끝낸다.
이 절차에서 부트스트래핑이 연상된다면 정확하다. 여기까진 그 구조다.
그다음 App을 실행하기 전 연결할 인프라(DB, Monitoring)를 깔아둔다.
각 절차 내 세부 의존성은 아래와 같다.
- CRD → Operator → Instance
- 커스텀 리소스가 필요한 건 “언어 정의 → 컨트롤러 → 인스턴스” 순서를 지킨다.
- Network와 TLS 선행
- ExternalSecrets를 올리기 전에 Calico/NetworkPolicy가 준비돼 있어야 하고,
ALB Controller는 ACM 인증서(이미 Terraform에서 만든)를 필요로 한다.
- ExternalSecrets를 올리기 전에 Calico/NetworkPolicy가 준비돼 있어야 하고,
- 모니터링은 Ingress보다 앞
- Prometheus Operator가 늦게 올라오면 Admission Webhook이 꼬인다.
그래서 Ingress Wave(15)보다 Monitoring Wave(20)를 항상 높게 잡았다.
- Prometheus Operator가 늦게 올라오면 Admission Webhook이 꼬인다.
- 데이터 계층 보호
- Operator와 Instance 사이에 Wave를 비워 둬서 Drift나 실패 지점을 빠르게 찾는다.
- Exporter는 데이터 이후
- Exporter/ServiceMonitor는 DB/Queue Endpoint가 준비된 뒤에만 배포한다.
- 애플리케이션은 진짜 마지막
- API/Worker는 Wave 60 이후, 인프라 전체가 Healthy 상태일 때만 올라오도록 강제한다.
6. ArgoCD 설정은 이렇게 쓴다
Wave를 붙이는 방식은 yaml에 Annotation 하나 추가하는 걸로 끝이다.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: data-operators
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "25"
spec:
project: infrastructure
source:
repoURL: https://github.com/SeSACTHON/backend.git
path: k8s/operators
targetRevision: main
destination:
server: https://kubernetes.default.svc
namespace: data-operators
syncPolicy:
automated:
prune: true
selfHeal: trueWave
번호만 맞춰줘도, Root App에서 하위 앱을 Sync할 때 이 순서를 그대로 따라간다. 이제 파트별로 그 순서와 절차를 알아보자.
CRD → 네임스페이스 → RBAC → CNI → NetworkPolicy
1. CRD Seed (Wave 0)
- ALB Controller, Prometheus Operator, Postgres Operator, External Secrets Operator 등
컨트롤러가 쓰는 CRD를 묶어서 Wave 0에 배치했다.
clusters/dev/apps/00-crds.yaml 같은 파일이 이 단계에 해당한다.
2. Namespaces (Wave 2)
CRD가 깔리면 바로 네임스페이스를 만든다.
- auth, my, scan, character, location 같은 도메인 네임스페이스
- data, databases, monitoring 같은 인프라 네임스페이스도 같이.
- 레이블을
tier,domain으로 미리 붙여서,
나중에 NetworkPolicy / NodeAffinity / RBAC이 다 이 레이블을 기준으로 움직이게 했다.
clusters/dev/apps/02-namespaces.yaml 파일이 올라오는 단계가 바로 여기다.
3. RBAC · Storage (Wave 3)
ServiceAccount,ClusterRole,RoleBinding,StorageClass,EBS CSI Driver같은 것들이 여기.- namesapce가 먼저 있어야 SA/Role이 붙을 수 있으니 Wave 3으로 뒀다.
03-rbac-storage.yaml 파일에서 정의.
4. CNI → NetworkPolicy (Wave 5 ~ 6)
초기엔 네트워크 정책을 너무 빨리 올렸다가,
Calico DaemonSet이 올라오기도 전에 default-deny가 적용돼서 트래픽이 막히는 일이 있었다.
그래서 CNI → NetworkPolicy 순서를 딱 붙였다.
- Wave 5: Calico (VXLAN, BGP Off)
- Wave 6: NetworkPolicy (단계별 격리, default-deny)
Calico 설치 애노테이션만 맞춰두면 네트워크 레벨이 안정되고,
그 다음에 Pod 간 통신 제한을 실제로 적용한다.
Secrets → Ingress → DNS
1. External Secrets Operator → ExternalSecret (Wave 10, 11)
여기서 실제로 겪었던 문제는 이거다.
- ALB Controller가 먼저 뜨는데,
AWS 자격증명/리전/클러스터 이름 같은 값을 담은 Secret이 아직 준비 안 돼 있으면 컨트롤러가 CrashLoopBackOff에 빠진다.
그래서 두 Wave로 쪼갰다.
- Wave 10: External Secrets Operator (컨트롤러)
- Wave 11: ExternalSecret CR (SSM Path 매핑)
이 순서로 바꾸고 나서야 ALB Controller가 안정적으로 뜨기 시작했다.
clusters/dev/apps/10-secrets-operator.yaml과 11-secrets-cr.yaml에 해당.
2. ALB Controller → ExternalDNS (Wave 15, 16)
그 다음에 트래픽 출입구를 세팅한다.
- Wave 15: ALB Controller (Helm)
- Wave 16: ExternalDNS
ExternalDNS를 ALB보다 먼저 올리면,
Route53에서는 레코드를 만들려고 하는데 Ingress가 아직 없어서 대기하거나 실패한다.
반대로 ALB Controller가 먼저 뜨면, Ingress 생성 → ALB/TG 생성 → ExternalDNS가 Route53 반영까지 이어진다.
이 순서 하나 바꿨을 뿐인데,
Route53 레코드가 늦게 생성돼서 프런트엔드에서 404가 난다거나 하는 일이 확 줄었다.
모니터링과 데이터 계층
1. Prometheus Operator → Grafana (Wave 20, 21)
Prometheus Operator랑 Grafana는 한 Wave에 같이 넣어도 될 줄 알았다가,
한 번은 이 순서가 꼬여서 Prometheus 웹훅이 제대로 등록되지 못한 적이 있었다. 그후론 완전히 떼어냈다.
- Wave 20:
kube-prometheus-stack(Operator) - Wave 21: Grafana
이제 Wave 20이 끝나면 /metrics를 긁어오는 상태가 이미 확보되고,
Wave 21에서 Grafana가 올라와서 대시보드를 붙인다.
2. Data Operators → Data Instances (Wave 25, 35)
데이터 계층은 Operator와 Instance를 완전히 나눴다.
- Wave 25: Postgres/Redis/RabbitMQ Operators (혹은 Helm 기반 컨트롤러)
- Wave 35: PostgresCluster, RedisFailover 같은 Instance CR
이 사이에 Wave를 하나라도 띄워두면,
Operator 실수를 Instance Wave가 잡아내기도 훨씬 쉽다.
나중에 Operator를 부분적으로만 쓰고 Helm으로 되돌아오긴 했지만 Wave 구조는 그대로 유지했다.
애플리케이션 → Ingress
1 Applications (Wave 60)
몇 없는 ApplicationSet이다.
이 Wave에서 auth, location, character, my, scan, info, chat 같은 API들이 한꺼번에 올라온다.
루트앱 쪽에서는 60-apis-appset.yaml이 이 Wave에 해당한다.
중요한 건 “이 Wave 이전에 모든 인프라 Wave가 Healthy가 돼 있어야 한다”는 거다.
- 네트워크 (Calico + NetworkPolicy)
- Secret (ExternalSecret → K8s Secret)
- ALB / ExternalDNS
- 모니터링
- 데이터베이스
이게 하나라도 빠진 상태에서 API를 올리면
“Pod는 떴는데 DB 접속을 못 해서 CrashLoop” 같은 일이 그대로 터진다.
2. Application Ingress (Wave 70)
그리고 마지막 Wave가 Ingress다.
- API 라우팅 (
/api/,/auth/,/scan/등) - ArgoCD / Grafana / Prometheus UI용 Ingress
- Dev/Prod 도메인 연결, ACM 인증서 적용
Ingress는 마지막이다. 앞선 Wave에서 API Pods가 Healthy 되었다는 것까지 확인한 뒤 ingress를 연결해 외부에 엔드포인트를 노출한다.
변화 사례
- Secrets → ALB 순서
- 초기엔 ALB Controller가 Secret 없다고 튕기고, Route53 레코드는 Pending 상태였다.
- Wave 10/11로 쪼갠 뒤부터는 secrets operator → secret CR → ALB → 외부 DNS 순으로 정리돼서
ALB 관련 장애가 눈에 띄게 줄었다.
- CNI → NetworkPolicy 순서
- Calico보다 네트워크 정책이 일찍 올라가면 pod가 전부
CrashLoop로 넘어간다. - Wave 5/6로 나눠서 “Calico→Default deny” 순서를 강제하고 나서,
네트워크 관련 장애를 훨씬 쉽게 추적할 수 있게 됐다.
- Calico보다 네트워크 정책이 일찍 올라가면 pod가 전부
- Data Operator → Instance 분리
- Postgres Operator를 도입하려다 놓친 부분이 많아서 Operator와 Instance Wave 사이에 간격을 두고 나니 Operator 업그레이드 → Instance 재사용 흐름을 명확히 살릴 수 있었다.
- 나중에 Helm으로 회귀해도, “데이터 컨트롤러 → 데이터 클러스터” 순서는 그대로 유지하고 있다.