Table of contents
- 설정을 넘어 운영으로
- App of Apps 패턴
- 모노레포 vs 멀티레포 전략
- CI와 ArgoCD 연동 — Image Updater
- 트러블슈팅 — 자주 마주치는 문제들
- 운영 팁 모음
설정을 넘어 운영으로
여기까지 시리즈를 따라왔다면 ArgoCD의 핵심 기능은 대부분 익혔을 것이다. 설치, Application 생성, 싱크 전략, 멀티 클러스터, 접근 제어까지. 하지만 도구를 아는 것과 잘 쓰는 것은 다른 이야기다.
이번 편에서는 실제 운영 환경에서 반복적으로 등장하는 패턴과 문제들을 다룬다. 특별히 새로운 기능을 소개하기보다는, 지금까지 배운 것들을 어떻게 조합하고 어떤 상황에서 어떤 선택을 하는지에 초점을 맞춘다.
App of Apps 패턴
클러스터에 배포해야 할 Application이 수십 개라면, 이 Application 매니페스트들도 관리가 필요하다. Application 하나를 추가할 때마다 수동으로 kubectl apply를 하는 건 GitOps 정신에 맞지 않는다.
App of Apps 패턴은 “Application을 관리하는 Application”을 만드는 것이다. 루트 Application 하나가 다른 Application 매니페스트들이 담긴 디렉토리를 바라보고, ArgoCD가 알아서 하위 Application들을 생성하고 관리한다.
루트 Application이 하위 Application들을 관리하고, 각 하위 Application이 실제 Kubernetes 리소스를 관리하는 구조다.
flowchart TB
Root["Root Application\n(apps/ 디렉토리 감시)"]
ChildA["Child App: monitoring\n(Prometheus + Grafana)"]
ChildB["Child App: ingress-nginx\n(Ingress Controller)"]
ChildC["Child App: backend-api\n(백엔드 서비스)"]
ResA1["Deployment: prometheus"]
ResA2["Service: grafana"]
ResB1["Deployment: ingress-nginx"]
ResB2["ConfigMap: nginx-config"]
ResC1["Deployment: api-server"]
ResC2["Service: api-server"]
ResC3["HPA: api-server"]
Root -->|"자동 생성 & 관리"| ChildA
Root -->|"자동 생성 & 관리"| ChildB
Root -->|"자동 생성 & 관리"| ChildC
ChildA --> ResA1
ChildA --> ResA2
ChildB --> ResB1
ChildB --> ResB2
ChildC --> ResC1
ChildC --> ResC2
ChildC --> ResC3
디렉토리 구조를 먼저 보자.
infra-repo/
├── root-app.yaml # 이것만 수동으로 apply
└── apps/
├── monitoring.yaml # Prometheus + Grafana
├── cert-manager.yaml # 인증서 관리
├── ingress-nginx.yaml # Ingress Controller
├── backend-api.yaml # 백엔드 서비스
└── frontend-web.yaml # 프론트엔드 서비스
루트 Application은 apps/ 디렉토리를 소스로 가리킨다.
# root-app.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: root-app
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/my-org/infra-repo.git
targetRevision: main
path: apps
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated:
prune: true
selfHeal: true
apps/ 디렉토리 안의 각 파일은 하나의 Application 정의다.
# apps/monitoring.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: monitoring
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "1"
spec:
project: infra
source:
repoURL: https://github.com/my-org/infra-repo.git
targetRevision: main
path: k8s/monitoring
destination:
server: https://kubernetes.default.svc
namespace: monitoring
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
이 구조의 장점은 명확하다. 수동으로 apply해야 하는 건 root-app.yaml 딱 하나뿐이다. 새 서비스를 추가하려면 apps/ 디렉토리에 YAML 파일 하나를 커밋하면 된다. 루트 Application이 변경을 감지하고 새 Application을 자동으로 만들어준다.
주의할 점도 있다. 루트 Application에 prune: true를 설정하면, apps/ 디렉토리에서 파일을 삭제했을 때 해당 Application과 그 Application이 관리하는 모든 리소스가 연쇄적으로 삭제된다. 의도한 동작이지만, 실수로 파일을 지웠을 때의 파급 범위가 크니 프로덕션에서는 prune: false로 두는 것도 고려해볼 만하다.
App of Apps와 앞에서 다뤘던 ApplicationSet 중 어떤 걸 써야 할까? Application 간 구조가 비슷하고 파라미터만 다르면 ApplicationSet이 적합하고, 각 Application의 설정이 서로 다르거나 세밀한 제어가 필요하면 App of Apps가 더 유연하다. 둘을 함께 쓰는 것도 가능하다. 루트 Application 아래에 ApplicationSet을 두는 식이다.
모노레포 vs 멀티레포 전략
GitOps에서 “어떤 레포 구조를 쓸 것인가”는 생각보다 영향이 큰 결정이다. 크게 세 가지 전략이 있다.
전략 1: 앱 코드와 매니페스트를 같은 레포에 (모노레포)
my-app/
├── src/ # 애플리케이션 코드
├── Dockerfile
└── k8s/ # Kubernetes 매니페스트
├── base/
└── overlays/
├── dev/
├── staging/
└── prod/
개발자 입장에서 직관적이다. 코드를 고치고 매니페스트도 같은 PR에서 수정할 수 있다. 하지만 CI 파이프라인에서 코드 변경과 매니페스트 변경을 구분하기 어렵고, ArgoCD가 코드 변경에도 반응할 수 있어서 불필요한 싱크가 발생할 수 있다. path 설정으로 k8s/ 디렉토리만 바라보게 할 수 있지만, Git 이력은 함께 섞인다.
전략 2: 매니페스트 전용 레포 (멀티레포)
# 레포 1: my-app (소스 코드)
my-app/
├── src/
├── Dockerfile
└── ci/
# 레포 2: my-app-deploy (매니페스트)
my-app-deploy/
├── base/
└── overlays/
├── dev/
├── staging/
└── prod/
관심사가 분리된다. CI는 소스 레포에서 이미지를 빌드하고, CD는 배포 레포의 변경에만 반응한다. 접근 권한도 분리할 수 있어서 운영자만 배포 레포에 쓸 수 있게 제한하기 쉽다. 단점은 코드 변경과 매니페스트 변경이 별도 커밋이 되어서 추적이 번거로울 수 있다는 것이다.
전략 3: 중앙 인프라 레포
infra-repo/
├── apps/ # App of Apps 매니페스트
├── k8s/
│ ├── api-server/
│ ├── web-frontend/
│ └── worker/
├── base-infra/
│ ├── monitoring/
│ ├── cert-manager/
│ └── ingress/
└── applicationsets/
모든 매니페스트를 하나의 인프라 레포에 모으는 방식이다. 전체 인프라 상태를 한눈에 볼 수 있고, App of Apps 패턴과 잘 맞는다. 다만 레포가 커지면 팀 간 충돌이 생길 수 있고, 세밀한 접근 제어가 필요해진다.
정답은 없다. 소규모 팀이라면 모노레포의 단순함이 장점이고, 팀이 크고 서비스가 많다면 멀티레포 + 중앙 인프라 레포 조합이 관리하기 쉬울 수 있다.
CI와 ArgoCD 연동 — Image Updater
GitOps에서 CI와 CD의 경계는 이렇다. CI가 이미지를 빌드하고 레지스트리에 푸시하면, CD는 새 이미지 태그를 Git에 반영하고 클러스터에 적용한다. 문제는 이 “새 이미지 태그를 Git에 반영하는” 단계다.
CI 파이프라인에서 직접 Git 레포의 매니페스트를 수정하고 커밋하는 방식이 흔한데, 이러면 CI에 Git 쓰기 권한을 줘야 하고, 자동 커밋이 쌓이면서 이력이 지저분해진다.
ArgoCD Image Updater는 이 문제를 해결한다. 컨테이너 레지스트리를 주기적으로 확인해서 새 이미지 태그가 있으면 자동으로 ArgoCD Application을 업데이트한다.
Image Updater를 설치한다.
kubectl apply -n argocd \
-f https://raw.githubusercontent.com/argoproj-labs/argocd-image-updater/stable/manifests/install.yaml
Application에 어노테이션을 추가해서 Image Updater가 관리할 이미지를 지정한다.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: my-app
namespace: argocd
annotations:
argocd-image-updater.argoproj.io/image-list: myapp=my-org/my-app
argocd-image-updater.argoproj.io/myapp.update-strategy: semver
argocd-image-updater.argoproj.io/myapp.allow-tags: "regexp:^[0-9]+\\.[0-9]+\\.[0-9]+$"
argocd-image-updater.argoproj.io/write-back-method: git
argocd-image-updater.argoproj.io/git-branch: main
spec:
project: default
source:
repoURL: https://github.com/my-org/my-app-deploy.git
targetRevision: main
path: overlays/prod
destination:
server: https://kubernetes.default.svc
namespace: my-app
update-strategy는 새 이미지 태그를 어떻게 선택할지 결정한다.
| 전략 | 동작 |
|---|---|
semver | Semantic Versioning 규칙으로 가장 높은 버전 선택 |
latest | 가장 최근에 빌드된 이미지 선택 |
digest | 같은 태그라도 다이제스트가 바뀌면 업데이트 |
name | 태그 이름을 알파벳 순으로 비교해 가장 큰 값 선택 |
allow-tags로 태그 형식을 제한할 수 있다. 위 예시는 1.2.3 형식의 태그만 허용하므로, latest나 dev-abc123 같은 태그는 무시된다.
write-back-method: git을 설정하면 Image Updater가 새 이미지 태그를 Git 레포에 직접 커밋한다. 이 방식이 GitOps 원칙에 가장 부합하지만, 레포에 대한 쓰기 권한이 필요하다. 대안으로 argocd 방식을 쓰면 Git을 건드리지 않고 ArgoCD의 파라미터 오버라이드로만 처리하는데, 이 경우 Git에 기록이 남지 않는다는 단점이 있다.
트러블슈팅 — 자주 마주치는 문제들
ArgoCD를 운영하다 보면 반복적으로 만나는 에러들이 있다. 당황하지 않으려면 원인과 해결법을 미리 알아두는 게 좋다.
Sync Failed
싱크가 실패하는 가장 흔한 원인부터 살펴보자.
매니페스트 문법 오류가 가장 기본적인 경우다. YAML 인덴트 실수나 잘못된 필드명이 원인이다.
argocd app get my-app
이 명령으로 상세 에러 메시지를 확인할 수 있다. 대부분 Kubernetes API 서버가 반환하는 에러 메시지에 원인이 담겨 있다.
권한 부족도 흔하다. ArgoCD의 ServiceAccount에 해당 리소스를 생성할 권한이 없거나, AppProject에서 해당 리소스 종류나 네임스페이스를 허용하지 않은 경우다.
argocd app sync my-app --dry-run
dry-run으로 먼저 테스트하면 실제 적용 전에 문제를 발견할 수 있다.
CRD 의존성 문제도 자주 발생한다. Custom Resource를 배포하려는데 해당 CRD가 아직 설치되지 않은 경우다. Sync Wave로 CRD를 먼저 배포하게 순서를 잡거나, SkipDryRunOnMissingResource=true 옵션을 쓰면 해결된다.
syncOptions:
- SkipDryRunOnMissingResource=true
OutOfSync 상태가 풀리지 않을 때
싱크를 해도 계속 OutOfSync로 표시되는 경우가 있다. 대부분 Kubernetes가 리소스를 생성하면서 기본값을 자동으로 추가하거나, 컨트롤러가 필드를 수정하기 때문이다.
대표적인 예가 Deployment의 spec.replicas다. HPA가 값을 계속 바꾸니까 ArgoCD 입장에서는 항상 다른 상태로 보인다.
spec:
ignoreDifferences:
- group: apps
kind: Deployment
jsonPointers:
- /spec/replicas
- group: admissionregistration.k8s.io
kind: MutatingWebhookConfiguration
jqPathExpressions:
- ".webhooks[]?.clientConfig.caBundle"
ignoreDifferences로 특정 필드를 비교 대상에서 제외할 수 있다. jsonPointers로 정확한 경로를 지정하거나, jqPathExpressions로 더 복잡한 패턴을 처리할 수 있다.
전체 Application 수준이 아니라 시스템 전역에서 무시할 필드가 있다면 argocd-cm에 설정할 수도 있다.
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cm
namespace: argocd
data:
resource.customizations.ignoreDifferences.admissionregistration.k8s.io_MutatingWebhookConfiguration: |
jqPathExpressions:
- '.webhooks[]?.clientConfig.caBundle'
Degraded 상태
Application이 Degraded로 표시되면 하위 리소스 중 비정상인 것이 있다는 뜻이다.
argocd app get my-app --show-operation
어떤 리소스가 문제인지 확인한 뒤, 해당 리소스의 이벤트와 로그를 살펴봐야 한다.
kubectl describe deployment my-app -n my-app
kubectl logs -l app=my-app -n my-app --tail=50
흔한 원인으로는 이미지 풀 실패(ImagePullBackOff), 리소스 제한 초과(OOMKilled), 헬스체크 실패(CrashLoopBackOff) 등이 있다.
ArgoCD는 리소스별로 Health Check 로직을 내장하고 있지만, CRD의 경우 커스텀 헬스체크를 정의할 수도 있다.
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cm
namespace: argocd
data:
resource.customizations.health.mycrd.example.com_MyResource: |
hs = {}
if obj.status ~= nil then
if obj.status.phase == "Ready" then
hs.status = "Healthy"
hs.message = "Resource is ready"
elseif obj.status.phase == "Provisioning" then
hs.status = "Progressing"
hs.message = "Resource is being provisioned"
else
hs.status = "Degraded"
hs.message = obj.status.message or "Unknown status"
end
end
return hs
Lua 스크립트로 작성하며, CRD의 status 필드를 기준으로 ArgoCD에 Healthy/Progressing/Degraded 상태를 매핑해준다.
운영 팁 모음
마지막으로 운영하면서 알아두면 좋은 팁들을 모았다.
Notification 설정하기. ArgoCD Notifications 컨트롤러를 설치하면 싱크 성공/실패, 헬스 상태 변경 등의 이벤트를 Slack, Teams, 이메일로 보낼 수 있다. PostSync Hook으로 알림을 보내는 것보다 체계적이고 유지보수가 쉽다.
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-notifications-cm
namespace: argocd
data:
service.slack: |
token: $slack-token
trigger.on-sync-succeeded: |
- when: app.status.operationState.phase in ['Succeeded']
send: [app-sync-succeeded]
template.app-sync-succeeded: |
message: |
Application {{.app.metadata.name}} has been successfully synced.
Revision: {{.app.status.sync.revision}}
리소스 한도 설정하기. ArgoCD 서버와 repo-server에 리소스 요청/제한을 적절히 설정해야 한다. 특히 repo-server는 Git 레포를 클론하고 매니페스트를 렌더링하는 역할이라, 레포가 크거나 Helm 차트가 복잡하면 메모리를 많이 쓴다.
Diff 커스터마이징. 특정 어노테이션이나 레이블의 변경을 무시하고 싶을 때가 있다. 예를 들어 kubectl.kubernetes.io/last-applied-configuration 같은 어노테이션은 차이가 나도 의미 없는 경우가 많다.
spec:
ignoreDifferences:
- group: "*"
kind: "*"
managedFieldsManagers:
- kube-controller-manager
정기적 정리. 오래된 Application의 히스토리가 쌓이면 ArgoCD의 Redis 메모리 사용량이 늘어난다. argocd-cmd-params-cm에서 히스토리 보관 개수를 설정할 수 있다.
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cmd-params-cm
namespace: argocd
data:
controller.status.processors: "20"
controller.operation.processors: "10"
controller.repo.server.timeout.seconds: "120"
reposerver.parallelism.limit: "0"
여기까지가 ArgoCD 시리즈의 마지막이다. 설치와 기본 개념에서 출발해 싱크 전략, 멀티 클러스터, 접근 제어, 그리고 실전 운영 패턴까지 한 바퀴를 돌았다. GitOps는 도구가 아니라 문화이고, ArgoCD는 그 문화를 실현하기 위한 도구일 뿐이다. 중요한 건 “Git이 유일한 진실의 원천”이라는 원칙을 팀 전체가 받아들이고, 이 원칙에 맞는 워크플로우를 꾸준히 다듬어 나가는 것이다.
Loading comments...