들어가며
졸업 프로젝트 인프라를 잡으면서 계속 들었던 의문이 있었다. 배포는 결국 누가 kubectl apply를 치느냐의 문제인데 그걸 사람이 안 한다는 게 가능한가? 처음엔 ArgoCD가 그냥 자동 배포 도구 하나려니 싶었는데 직접 구성하고 굴려보면서 생각이 바뀌었다.
결론부터 말하면, GitOps는 도구가 아니라 "클러스터의 모든 상태를 Git으로 설명할 수 있게 만드는 규율" 이었다. 이전 CJ 프로젝트는 Helm으로 했는데, 이번엔 Kustomize + ArgoCD App of Apps + External Secrets 조합으로 가봤다. 그 과정을 정리한다.
레포: github.com/DGU-CAP/infra
흐름
개발자 git push
│
▼
GitHub (DGU-CAP/infra) ← 인프라 형상의 단일 진실원천
│ ArgoCD가 3분마다 폴링 (또는 수동 Sync)
▼
ArgoCD ── root app (App of Apps)
│ k8s/apps/ 디렉토리 감시
├─ secretstore (sync-wave -1, 먼저 생성)
├─ backend ── overlays/eks/backend
├─ ai ── overlays/eks/ai
├─ postgres
└─ redis
│ 각 앱이 Kustomize overlay를 렌더링
▼
EKS 클러스터 (자동 동기화: prune + selfHeal)
사람이 클러스터에 직접 apply를 치지 않는다.*모든 변경은 Git 커밋으로 일어나고, 그 차이를 ArgoCD가 클러스터에 밀어넣는다.
왜 Helm을 두고 Kustomize로 갔나
CJ 때 Helm을 써봤더니 강력하긴 한데, 팀원이 K8s 입문 단계면 {{ .Values.something }} 같은 템플릿 문법부터가 벽이었다. "지금 실제로 뜨는 YAML이 뭔데?"를 한눈에 못 본다는 게 협업에선 은근히 컸다.
| Helm | Kustomize | |
|---|---|---|
| 방식 | 템플릿 + values 치환 | 순수 YAML + 패치(overlay) |
| 진입장벽 | 템플릿 문법 학습 필요 | YAML 그대로라 바로 읽힘 |
| 환경 분기 | values 파일 | base + overlays |
| kubectl 내장 | ❌ | ✅ (kubectl -k) |
그래서 이번엔 렌더링 결과가 눈에 바로 보이는Kustomize로 갔다. 공통은 base에 두고 환경 차이는 overlay 패치로만 표현한다.
k8s/manifests/
├── base/ # 공통 (이미지, 포트, env 등)
│ └── backend/{deployment,service,configmap,rbac}.yaml
└── overlays/
├── kind/backend/ # 로컬: imagePullPolicy: Never
└── eks/backend/ # 운영: ExternalSecret + 이미지 SHA 태그
같은 base, 다른 overlay. 그래서 kind에서 개발하다 EKS로 넘어갈 때 ArgoCD Application의 path 한 줄만 바꾸면 끝난다.
# overlays/eks/backend/kustomization.yaml
resources:
- ../../../base/backend
- externalsecret.yaml # EKS에서만 추가되는 리소스
images:
- name: <ECR>/dgu-cap-backend
newTag: e15dd4bbb39a... # ← CI가 커밋 SHA로 갈아끼움
patches:
- path: patch.yaml
target: { kind: Deployment, name: backend }
처음엔 base/overlay 두 군데 나뉘는 게 중복처럼 느껴졌는데, 환경이 두 개 이상 되는 순간 "공통은 한 곳, 차이만 따로"가 얼마나 편한지 바로 체감했다.
App of Apps — 앱을 일일이 등록하지 않는다
앱이 5개인데 ArgoCD에 하나씩 등록한다고 생각하니 벌써 귀찮았다. 그래서 루트 앱 하나가 k8s/apps/ 폴더를 통째로 감시하게 했다. 폴더에 Application YAML을 떨궈두면 자동으로 새 앱이 등록된다.
# k8s/apps/root.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: root
namespace: argocd
spec:
source:
repoURL: https://github.com/DGU-CAP/infra.git
targetRevision: HEAD
path: k8s/apps # ← 이 폴더의 Application들을 자동 등록
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated:
prune: true # Git에서 지운 리소스는 클러스터에서도 삭제
selfHeal: true # 누가 손으로 바꿔도 Git 상태로 되돌림
시작은 딱 한 줄이다.
kubectl apply -f k8s/apps/root.yaml
이 한 줄 이후로는 더 이상 손으로 apply를 칠 일이 없다. 확인해보면:
NAME SYNC STATUS HEALTH STATUS
root Synced Healthy
secretstore Synced Healthy
backend Synced Healthy
ai Synced Healthy
postgres Synced Healthy
redis Synced Healthy
prune / selfHeal, 이 두 개가 핵심이다
kubectl edit로 몰래 손대도 ArgoCD가 다시 Git 기준으로 덮어쓴다.이 두 개를 켜는 순간 "클러스터의 실제 상태 = Git" 이 강제된다. 누군가 급하다고 손으로 고쳐놓고 까먹는 그 드리프트(drift)가 원천 차단되는 거다.
순서가 꼬이는 지점 — sync-wave
여기서 한 번 막혔다. ExternalSecret이 동작하려면 그 전에 SecretStore가 먼저 있어야 하는데 ArgoCD는 기본적으로 모든 앱을 동시에(wave 0) 싱크한다. 그러니 가끔 ExternalSecret이 "SecretStore 없는데?" 하고 먼저 떠서 실패했다.
해결은 간단했다. secretstore에 더 빠른 wave를 주면 된다.
# k8s/apps/secretstore.yaml
metadata:
name: secretstore
annotations:
argocd.argoproj.io/sync-wave: "-1" # backend/ai(wave 0)보다 먼저
CRD → CR, SecretStore → ExternalSecret처럼 의존 관계가 있는 리소스는 sync-wave로 순서를 강제해야 한다는 걸 이때 배웠다.
"그럼 DB 비번도 Git에 넣나?" — External Secrets
GitOps를 처음 보면 누구나 한 번은 걸리는 질문이다. "모든 걸 Git에 두라면서 그럼 DB 비밀번호도 Git에 박나?" 당연히 아니다. External Secrets Operator(ESO) 로 AWS Secrets Manager에서 런타임에 가져온다.
# overlays/eks/secretstore/clustersecretstore.yaml
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: aws-secretsmanager
spec:
provider:
aws:
service: SecretsManager
region: ap-northeast-2
auth:
jwt:
serviceAccountRef: # ESO 컨트롤러 SA의 IRSA로 인증
name: external-secrets
namespace: external-secrets
# overlays/eks/backend/externalsecret.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: backend-secret
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secretsmanager
kind: ClusterSecretStore
target:
name: backend-secret # ← Deployment의 secretRef와 이름 일치
dataFrom:
- extract:
key: dgu-cap/backend # Secrets Manager의 JSON 시크릿 통째 추출
그러면 Deployment는 그냥 그 Secret을 평범하게 참조만 한다.
envFrom:
- configMapRef: { name: backend-config }
- secretRef: { name: backend-secret } # ESO가 만들어준 Secret
backend-secret의 생명주기를 따라가보면 이렇게 된다. Git에는 "어떤 시크릿을 가져올지"라는 참조만 박혀 있고, 실제 값은 어디에도 없다. ESO가 IRSA(ServiceAccount ↔ IAM Role)로 인증해서 Secrets Manager에서 값을 꺼내 K8s Secret으로 만들어주고, Deployment는 그걸 받아 쓴다. 키를 코드에 박지 않아도 되는 구조다.
그럼 CI는 뭘 하나 — 이미지 태그만 갈아끼운다
GitOps를 하면 CI/CD 경계가 깔끔하게 갈린다. 한참 헷갈렸던 부분인데 정리하면 이렇다.
[CI] 앱 코드 push → 이미지 빌드 → ECR push → kustomization의 newTag를 커밋 SHA로 교체 → infra 레포에 커밋
│
[CD] ArgoCD가 그 커밋을 감지 ───────────────────────────────────────────┘ → 클러스터에 새 이미지 롤아웃
여기서 latest 태그를 안 쓰고 굳이 커밋 SHA를 태그로 박는 이유가 있다. latest는 내용이 바뀌어도 태그 문자열이 그대로라 ArgoCD가 "변경"을 인식하지 못한다. 그러면 동기화가 안 일어난다. 반면 SHA로 박으면 커밋마다 태그가 달라지니 ArgoCD가 바로 알아챈다. 덤으로 "지금 어떤 코드가 떠 있나"도 추적된다.
# kustomization.yaml — CI가 이 줄을 갱신
images:
- name: <ECR>/dgu-cap-backend
newTag: e15dd4bbb39a271af5388d78e9f691d1f3716b4e # = git SHA
마무리
| 구성요소 | 역할 |
|---|---|
| Kustomize | base + overlay로 kind/EKS 환경 분기 |
| ArgoCD App of Apps | 루트 앱 하나로 전체 앱 자동 등록 |
| prune + selfHeal | Git = 클러스터 상태(드리프트 차단) |
| sync-wave | 의존 리소스 생성 순서 보장 |
| External Secrets | 시크릿은 Secrets Manager에서, Git엔 참조만 |
작업 방식 자체도 좀 바뀌었다. 매니페스트랑 Application YAML은 에이전트한테 초안을 뽑게 하고, 나는 그걸 다른 에이전트로 한 번 더 리뷰시키는 식으로 굴렸다. "이 overlay 구조 더 단순하게 못 짜?", "prune/selfHeal 이렇게 켜면 위험한 시나리오 없어?" 하고 서로 다른 관점으로 교차 검증을 돌리니, 혼자 머리 싸매는 것보다 훨씬 빨리 구멍이 보였다. 내 역할은 코드를 한 줄씩 치는 게 아니라 Helm 대신 Kustomize를 고른 이유, base/overlay를 어떻게 쪼갤지, sync-wave를 왜 secretstore에 줘야 하는지 같은 방향을 잡고, 에이전트들이 뱉은 결과 중 뭘 채택할지 결정하는 쪽으로 옮겨갔다.
이번에 GitOps를 직접 정리하면서 가장 크게 느낀 건, 결국 핵심은 ArgoCD라는 도구가 아니라 "클러스터의 상태를 전부 Git으로 설명할 수 있는가" 였다는 점이다. 다음 글에서는 이 클러스터 자체를 만든 Terraform 구성을 따라가본다.