쿠버네티스 배포는 대개 kubectl apply나 CI 파이프라인에서 매니페스트를 클러스터로 직접 밀어넣는 푸시(push) 방식에서 출발한다. 처음엔 잘 돌아가지만 서비스와 환경이 늘면 문제가 드러난다. 클러스터에 실제로 무엇이 떠 있는지 아무도 확신하지 못하고, kubectl edit으로 급히 손댄 변경은 어디에도 기록되지 않는다. 롤백은 “이전 태그가 뭐였더라”를 뒤지는 일이 된다.

GitOps는 이 혼란을 Git 저장소를 유일한 진실의 원천(single source of truth)으로 삼아 정리한다. 원하는 상태를 전부 Git에 선언해두면 에이전트가 클러스터를 계속 관찰하며 Git과 실제 상태의 차이를 좁힌다. ArgoCD는 이를 구현하는 대표적인 도구다. 이 글에서는 ArgoCD의 선언형 동기화(declarative sync)를 중심으로 Application 정의, 자동 동기화와 셀프힐, 싱크·헬스 상태, 드리프트 감지, 실무 함정을 실제 매니페스트와 함께 정리한다.

푸시 배포가 아니라 풀 배포다

기존 CI 배포는 파이프라인이 빌드 마지막 단계에서 클러스터로 kubectl apply밀어넣는다. 반면 ArgoCD는 클러스터 안에 상주하며 Git 저장소를 주기적으로 당겨온다(pull). 방향이 정반대다.

이 차이는 단순한 취향이 아니다. 풀 방식에서는 클러스터 외부(CI 서버)에 강력한 관리자 자격증명을 뿌릴 필요가 없다. CI는 이미지 빌드와 매니페스트 커밋까지만 책임지고, 클러스터에 손대는 주체는 내부의 ArgoCD 하나로 좁혀진다.

  • 푸시 배포: CI가 클러스터 자격증명 보유, 배포 이력이 CI 로그에 흩어짐, 수동 변경을 막지 못함
  • 풀 배포(GitOps): 에이전트가 내부에서 Git을 당겨옴, Git 커밋이 곧 배포 이력, 실제 상태를 지속 수렴

Application: 동기화의 최소 단위

ArgoCD의 모든 것은 Application 커스텀 리소스로 표현된다. Application은 “어느 Git 경로의 매니페스트를 어느 클러스터의 어느 네임스페이스에 동기화할지”를 선언한다.

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: payments-api
  namespace: argocd            # Application 리소스는 argocd 에 산다
spec:
  project: default
  source:
    repoURL: https://github.com/acme/k8s-manifests.git
    targetRevision: main        # 추적할 브랜치/태그
    path: apps/payments/overlays/prod   # kustomize overlay 경로
  destination:
    server: https://kubernetes.default.svc   # 동일 클러스터
    namespace: payments
  syncPolicy:
    syncOptions: [CreateNamespace=true]      # 없으면 생성

핵심은 sourcedestination의 분리다. 소스는 “원하는 상태가 어디 선언돼 있는가”, 목적지는 “그것을 어디에 반영할 것인가”다. ArgoCD는 소스를 렌더링(kustomize build/helm template)한 결과와 목적지의 실제 리소스를 비교한다. GitOps 철학을 지키려면 Application 자체도 Git으로 관리해야 하며, 이것이 App of Apps 패턴의 출발점이다.

선언형 동기화: 원하는 상태와 실제 상태의 차이 좁히기

ArgoCD의 동작은 세 단계 루프다. Git에서 매니페스트를 렌더링해 원하는 상태(desired state)를 만들고, 클러스터에서 실제 상태(live state)를 조회한 뒤, diff를 계산해 정책에 따라 실제 상태를 원하는 상태로 맞춘다.

이 diff 결과가 곧 Sync 상태다. 일치하면 Synced, 다르면 OutOfSync다. 흔한 오해로, Synced는 “리소스가 잘 떠서 트래픽을 받는다”는 뜻이 아니다. 그건 별개의 Health 상태가 담당한다.

argocd app get payments-api
# Sync Status:    Synced         <- Git 과 클러스터가 일치
# Health Status:  Progressing    <- 하지만 파드는 아직 준비 안 됨(롤아웃 중)

argocd app diff payments-api     # 배포 전 diff 를 눈으로 검토

두 상태는 함께 봐야 한다. Synced이면서 Healthy여야 “Git에 선언한 대로 정상 동작 중”이다. Synced인데 Degraded라면 Git의 선언 자체가 잘못됐을 가능성이 높다.

자동 동기화와 셀프힐, 프룬

기본값에서 ArgoCD는 OutOfSync를 감지만 하고 반영하지는 않는다. 자동 수렴하려면 syncPolicy.automated를 켠다.

syncPolicy:
  automated:
    prune: true       # Git 에서 삭제된 리소스를 클러스터에서도 제거
    selfHeal: true    # 클러스터에서 수동 변경 시 Git 상태로 되돌림
    allowEmpty: false # 렌더 결과가 0개면 전량 삭제 방지 (안전장치)
  syncOptions:
    - CreateNamespace=true
    - PruneLast=true          # 다른 리소스 적용 후 마지막에 삭제
    - ServerSideApply=true    # 큰 CRD 충돌 회피
  retry:
    limit: 5
    backoff: { duration: 5s, factor: 2, maxDuration: 3m }

prune는 위험하면서도 중요하다. Git에서 Deployment를 지웠을 때 prune이 꺼져 있으면 유령 리소스로 남고 켜져 있으면 제거된다. 경로를 잘못 바꿔 렌더 결과가 통째로 비면 전체가 삭제될 수 있어 allowEmpty: false가 안전판이 된다.

selfHeal은 자기 치유를 담당한다. kubectl scale로 레플리카를 5로 바꿔도 Git이 3이면 곧바로 3으로 되돌린다. 강력하지만 HPA(오토스케일러)가 관리하는 replicas 필드와 충돌하며, 이 함정은 뒤에서 다시 다룬다.

동기화 순서 제어: 훅과 웨이브

리소스를 한꺼번에 적용하면 순서 문제가 생긴다. DB 마이그레이션이 새 코드보다 먼저 끝나야 하거나 ConfigMap이 Deployment보다 앞서 존재해야 하는 경우다. ArgoCD는 싱크 웨이브 어노테이션(argocd.argoproj.io/sync-wave)으로 순서를 제어한다. 값이 작을수록 먼저 적용되며(기본 0, 음수 가능), ConfigMap에 "-1"을 주면 Deployment보다 앞서 생성된다.

일회성 작업은 싱크 훅으로 처리한다. PreSync는 동기화 전, PostSync는 완료 뒤 실행되며, DB 마이그레이션이 대표적인 PreSync 대상이다.

apiVersion: batch/v1
kind: Job
metadata:
  name: db-migrate
  annotations:
    argocd.argoproj.io/hook: PreSync           # 본 동기화 전에 실행
    argocd.argoproj.io/hook-delete-policy: HookSucceeded  # 성공 시 정리
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
        - { name: migrate, image: "registry.acme.io/payments:{{ .Values.image.tag }}",
            command: ["python", "manage.py", "migrate", "--noinput"] }

훅 Job이 실패하면 본 동기화가 진행되지 않아, 마이그레이션이 깨진 채 새 코드가 나가는 것을 막는다.

드리프트 감지와 이미지 태그 전략

가장 흔한 오해는 “이미지가 바뀌면 ArgoCD가 알아서 배포한다”는 것이다. ArgoCD가 감시하는 것은 Git의 매니페스트이지 컨테이너 레지스트리가 아니다. image: payments:latest처럼 태그를 고정하면 레지스트리의 latest가 바뀌어도 Git 매니페스트는 그대로라 아무 변화도 감지하지 못한다.

정석은 CI가 이미지를 불변 태그(커밋 SHA 등)로 빌드하고 그 태그를 매니페스트 저장소에 커밋하는 것이다. 그 커밋이 곧 OutOfSync를 유발해 배포로 이어진다. 이 구조에서는 롤백이 곧 git revert다. 방금 커밋을 되돌리는 것만으로 이전 태그로 정확히 복귀한다.

# CI: 이미지 빌드 후 매니페스트 저장소의 태그를 갱신해 커밋
IMAGE_TAG="$(git rev-parse --short HEAD)"      # 불변 태그 = 커밋 SHA
docker build -t registry.acme.io/payments:"$IMAGE_TAG" .
docker push registry.acme.io/payments:"$IMAGE_TAG"

# 매니페스트 저장소에서 kustomize 로 태그 갱신 후 커밋
kustomize edit set image registry.acme.io/payments="registry.acme.io/payments:$IMAGE_TAG"
git commit -am "deploy payments $IMAGE_TAG" && git push   # ArgoCD 가 감지

App of Apps: 애플리케이션도 선언형으로

서비스가 수십 개가 되면 Application을 손으로 만드는 것 자체가 다시 드리프트의 원천이 된다. 해결책은 App of Apps 패턴으로, 여러 Application을 자식으로 거느리는 부모 하나만 부트스트랩하면 나머지가 Git에서 자동으로 펼쳐진다.

# 부모 Application: 자식 정의들이 담긴 경로를 가리킨다
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata: { name: root, namespace: argocd }
spec:
  project: default
  source:
    repoURL: https://github.com/acme/k8s-manifests.git
    targetRevision: main
    path: bootstrap/apps      # 각 서비스의 Application yaml 경로
  destination: { server: https://kubernetes.default.svc, namespace: argocd }
  syncPolicy:
    automated:
      prune: true             # Git 에서 앱을 지우면 해당 Application 도 제거
      selfHeal: true

클러스터·리전별로 같은 앱을 반복 배포해야 한다면 ApplicationSet으로 Application을 템플릿으로 찍어낼 수 있다. 다만 App of Apps로 시작해 반복이 명확히 보일 때 넘어가는 편이 관리 비용이 낮다.

실무에서 밟는 함정

선언형 동기화는 강력하지만 몇 지점에서 예상치 못한 동작을 만난다. 미리 알아두면 대부분 피한다.

  • HPA와 selfHeal의 replicas 충돌: HPA가 replicas를 조정하는데 Git에도 값이 박혀 있으면 ArgoCD가 계속 되돌려 오토스케일이 무력화된다. 매니페스트에서 해당 필드를 빼거나 아래처럼 diff에서 제외한다.
  • 어드미션 웹훅이 주입하는 필드: 사이드카 인젝터나 정책 웹훅이 런타임에 추가한 필드가 매번 OutOfSync로 잡힌다. 이 역시 ignoreDifferences로 거른다.
  • Helm의 무작위 값: randAlphaNum처럼 매번 다른 값을 렌더링하면 영원히 OutOfSync다. 시크릿은 외부 오퍼레이터에서 관리한다.
# replicas 를 HPA 에 위임하고 ArgoCD diff 에서 제외
spec:
  ignoreDifferences:
    - group: apps
      kind: Deployment
      jsonPointers: [/spec/replicas]   # 이 필드 차이는 무시 (HPA 가 관리)
  syncPolicy:
    automated: { selfHeal: true, prune: true }
    syncOptions: [RespectIgnoreDifferences=true]  # 동기화 때도 무시 규칙 존중

놓치기 쉬운 점으로 동기화 주기가 있다. ArgoCD는 기본 3분마다 Git을 폴링하므로 커밋 후 배포까지 최대 3분 지연된다. 즉시 반영이 필요하면 Git 저장소 웹훅을 ArgoCD에 연결한다.

마무리

ArgoCD의 본질은 “배포를 명령형 동작에서 선언형 상태로 바꾸는 것”이다. 무엇을 배포할지 kubectl로 지시하는 대신 원하는 최종 상태를 Git에 선언하면 에이전트가 그 상태로 클러스터를 지속 수렴시킨다. 그 대가로 Git 히스토리가 곧 감사 로그이자 롤백 수단이 되고, 수동 변경은 셀프힐로 교정되며, 배포 상태의 불확실성이 사라진다.

다만 선언형이 만능은 아니다. HPA가 관리하는 replicas, 웹훅이 주입하는 필드, 매번 달라지는 렌더 값처럼 “Git 밖에서 정당하게 바뀌는 것들”과의 경계를 ignoreDifferences로 정확히 그어줘야 하고, 이 경계 설정이 운영 난이도의 대부분을 차지한다. 자동 동기화와 셀프힐을 켜기 전에 “Git이 통제해야 하는 것과 아닌 것”을 먼저 구분하는 것이 안정적인 GitOps의 출발점이다.

자주 묻는 질문

Q. selfHeal을 켜면 긴급 상황에서 kubectl로 손도 못 대나요?
A. 손대는 순간 ArgoCD가 곧 Git 상태로 되돌립니다. 진짜 긴급 상황이라면 해당 Application의 자동 동기화를 잠시 끄고 조치한 뒤 그 내용을 Git에 반영하고 다시 켜는 것이 정석입니다. 임시 변경을 Git에 남기지 않으면 결국 사라진다는 점을 팀이 공유해야 합니다.

Q. 애플리케이션 매니페스트와 소스 코드를 같은 저장소에 둬야 하나요?
A. 분리하는 편이 대체로 안정적입니다. 매니페스트 저장소를 별도로 두면 CI가 이미지 태그를 갱신하려 커밋할 때 소스 저장소에 무한 커밋 루프가 생기는 것을 피할 수 있고, 배포 이력과 코드 이력이 섞이지 않습니다. 팀이 작다면 단일 저장소로 시작해도 무방합니다.