kind에서 EKS까지 (GitOps)

엄경문·2026년 6월 5일

project

목록 보기
2/4

들어가며

졸업 프로젝트 인프라를 잡으면서 계속 들었던 의문이 있었다. 배포는 결국 누가 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이 뭔데?"를 한눈에 못 본다는 게 협업에선 은근히 컸다.

HelmKustomize
방식템플릿 + 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, 이 두 개가 핵심이다

  • prune: Git에서 리소스를 지우면 클러스터에서도 사라진다. 즉 Git에 없는 건 클러스터에도 없다.
  • 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

마무리

구성요소역할
Kustomizebase + overlay로 kind/EKS 환경 분기
ArgoCD App of Apps루트 앱 하나로 전체 앱 자동 등록
prune + selfHealGit = 클러스터 상태(드리프트 차단)
sync-wave의존 리소스 생성 순서 보장
External Secrets시크릿은 Secrets Manager에서, Git엔 참조만

작업 방식 자체도 좀 바뀌었다. 매니페스트랑 Application YAML은 에이전트한테 초안을 뽑게 하고, 나는 그걸 다른 에이전트로 한 번 더 리뷰시키는 식으로 굴렸다. "이 overlay 구조 더 단순하게 못 짜?", "prune/selfHeal 이렇게 켜면 위험한 시나리오 없어?" 하고 서로 다른 관점으로 교차 검증을 돌리니, 혼자 머리 싸매는 것보다 훨씬 빨리 구멍이 보였다. 내 역할은 코드를 한 줄씩 치는 게 아니라 Helm 대신 Kustomize를 고른 이유, base/overlay를 어떻게 쪼갤지, sync-wave를 왜 secretstore에 줘야 하는지 같은 방향을 잡고, 에이전트들이 뱉은 결과 중 뭘 채택할지 결정하는 쪽으로 옮겨갔다.

이번에 GitOps를 직접 정리하면서 가장 크게 느낀 건, 결국 핵심은 ArgoCD라는 도구가 아니라 "클러스터의 상태를 전부 Git으로 설명할 수 있는가" 였다는 점이다. 다음 글에서는 이 클러스터 자체를 만든 Terraform 구성을 따라가본다.

0개의 댓글