sayu.day

GitOps 심화 (하): 환경 설정, 시크릿 관리, Progressive Delivery

GitOps의 철학과 ArgoCD·Flux 도입을 다룬 상편에 이어, 하편에서는 실제 운영 단계에서 부딪히는 세 가지 과제를 다룹니다. 동일한 애플리케이션을 여러 환경에 배포하는 설정 관리, Git에 비밀 값을 안전하게 두는 시크릿 관리, 그리고 CI/CD 파이프라인 통합과 Progressive Delivery입니다. 각 절의 끝에는 실무 의사결정에 바로 쓸 수 있는 선택 가이드를 정리했습니다.

환경별 설정 관리: Kustomize vs Helm

실제 프로덕션에서는 동일한 애플리케이션을 여러 환경(dev, staging, prod)에 배포합니다. 환경마다 달라지는 값은 대략 다음과 같습니다.

항목 dev staging prod
replicas 1 2 5
CPU request 100m 200m 500m
DB host dev-db.local staging-db.local prod-db.aws
Log level debug info warn
Ingress domain dev.example.com staging.example.com example.com

환경마다 완전히 별개의 YAML을 관리하면 중복 코드가 대량 발생하고, 한 곳을 수정할 때마다 모든 환경에 동기화해야 하며, 실수로 누락되는 변경이 생깁니다. 해결책은 Kustomize 또는 Helm으로 공통 부분(Base)과 환경별 차이(Overlays/Values)를 분리하는 것입니다.

Kustomize: 템플릿 없는 설정 관리

Kustomize는 템플릿 없이 YAML을 패치하는 방식입니다. Kubernetes 1.14부터 kubectl에 내장되었습니다.

  • Base: 공통 리소스 정의
  • Overlays: 환경별 패치
  • No templating: Go template 같은 문법이 없음
  • Pure YAML: 결과물도 순수 YAML
myapp/
├── base/
│   ├── kustomization.yaml
│   ├── deployment.yaml
│   ├── service.yaml
│   └── configmap.yaml
└── overlays/
    ├── dev/
    │   ├── kustomization.yaml
    │   └── patch-replicas.yaml
    ├── staging/
    │   ├── kustomization.yaml
    │   └── patch-resources.yaml
    └── prod/
        ├── kustomization.yaml
        ├── patch-replicas.yaml
        ├── patch-resources.yaml
        └── patch-ingress.yaml

Base에는 공통 리소스를 정의합니다.

# base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - deployment.yaml
  - service.yaml
  - configmap.yaml

commonLabels:
  app: myapp
# base/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 1
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
      - name: myapp
        image: myapp:latest
        ports:
        - containerPort: 8080
        resources:
          requests:
            cpu: 100m
            memory: 128Mi
          limits:
            cpu: 200m
            memory: 256Mi

Overlay는 base를 참조하면서 환경별 차이만 패치합니다.

# overlays/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - ../../base

namespace: production

commonLabels:
  environment: production

images:
  - name: myapp
    newTag: v1.2.3

patches:
  - path: patch-replicas.yaml
  - path: patch-resources.yaml
# overlays/prod/patch-replicas.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 5
# overlays/prod/patch-resources.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  template:
    spec:
      containers:
      - name: myapp
        resources:
          requests:
            cpu: 500m
            memory: 512Mi
          limits:
            cpu: 1000m
            memory: 1024Mi

빌드와 적용은 다음과 같습니다.

# 결과 미리보기
kubectl kustomize overlays/prod

# 직접 적용
kubectl apply -k overlays/prod

# 또는 ArgoCD에서
argocd app create myapp-prod \
  --repo https://github.com/myorg/myapp.git \
  --path overlays/prod \
  --dest-server https://kubernetes.default.svc

패치 전략은 세 가지입니다. 기본은 Strategic Merge Patch로, 배열의 특정 요소를 이름으로 매칭해 수정합니다. 더 정밀한 제어가 필요하면 JSON Patch를 씁니다.

# overlays/prod/kustomization.yaml
patches:
  - target:
      kind: Deployment
      name: myapp
    patch: |-
      - op: replace
        path: /spec/replicas
        value: 5
      - op: add
        path: /spec/template/spec/containers/0/env/-
        value:
          name: NEW_VAR
          value: "some-value"

기존 값을 완전히 대체할 때는 op: replace로 전체 필드를 덮어쓸 수 있습니다. 또한 Kustomize 3.7 이상에서는 Components로 여러 Overlay가 공유하는 패치 모음(고가용성, 모니터링, 보안 등)을 만들 수 있습니다.

# components/high-availability/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component

patches:
  - patch: |-
      apiVersion: apps/v1
      kind: Deployment
      metadata:
        name: myapp
      spec:
        replicas: 3
        strategy:
          type: RollingUpdate
          rollingUpdate:
            maxSurge: 1
            maxUnavailable: 0
# overlays/prod/kustomization.yaml
resources:
  - ../../base

components:
  - ../../components/high-availability
  - ../../components/monitoring

Helm: 템플릿 기반 패키지 매니저

Helm은 템플릿 기반 패키지 매니저입니다. Chart라는 패키지 형태로 애플리케이션을 배포합니다.

  • Chart: 패키지 (템플릿 + 기본값)
  • Values: 설정 값
  • Release: Chart의 설치된 인스턴스
  • Repository: Chart 저장소
mychart/
├── Chart.yaml           # 차트 메타데이터
├── values.yaml          # 기본 values
├── values-dev.yaml      # 환경별 values
├── values-prod.yaml
├── templates/
│   ├── _helpers.tpl     # 템플릿 헬퍼
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── configmap.yaml
│   ├── ingress.yaml
│   └── NOTES.txt        # 설치 후 출력 메시지
└── charts/              # 의존 차트
# Chart.yaml
apiVersion: v2
name: mychart
version: 1.0.0
appVersion: "1.2.3"
description: My application chart
type: application

dependencies:
  - name: postgresql
    version: "12.x.x"
    repository: https://charts.bitnami.com/bitnami
    condition: postgresql.enabled

템플릿은 Go template 문법으로 작성합니다.

# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "mychart.fullname" . }}
  labels:
    {{- include "mychart.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "mychart.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "mychart.selectorLabels" . | nindent 8 }}
    spec:
      containers:
      - name: {{ .Chart.Name }}
        image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
        ports:
        - containerPort: {{ .Values.service.port }}
        resources:
          {{- toYaml .Values.resources | nindent 12 }}
        {{- if .Values.env }}
        env:
          {{- range $key, $value := .Values.env }}
          - name: {{ $key }}
            value: {{ $value | quote }}
          {{- end }}
        {{- end }}

환경별 차이는 values 파일로 관리합니다.

# values.yaml (기본값)
replicaCount: 1

image:
  repository: myapp
  tag: ""
  pullPolicy: IfNotPresent

service:
  type: ClusterIP
  port: 8080

resources:
  requests:
    cpu: 100m
    memory: 128Mi
  limits:
    cpu: 200m
    memory: 256Mi

ingress:
  enabled: false
  host: ""

env: {}
# values-prod.yaml
replicaCount: 5

image:
  tag: v1.2.3

resources:
  requests:
    cpu: 500m
    memory: 512Mi
  limits:
    cpu: 1000m
    memory: 1024Mi

ingress:
  enabled: true
  host: myapp.example.com
  tls:
    - secretName: myapp-tls
      hosts:
        - myapp.example.com

env:
  LOG_LEVEL: warn
  DB_HOST: prod-db.example.com
# 로컬 차트 설치
helm install myapp-prod ./mychart -f values-prod.yaml -n production

# 리포지토리에서 설치
helm repo add bitnami https://charts.bitnami.com/bitnami
helm install nginx bitnami/nginx -f my-values.yaml

# 업그레이드
helm upgrade myapp-prod ./mychart -f values-prod.yaml

# 롤백
helm rollback myapp-prod 1

Helm Hooks를 쓰면 특정 시점에 작업을 실행할 수 있습니다. 예를 들어 pre-upgrade 훅으로 DB 마이그레이션 Job을 업그레이드 전에 실행하는 식입니다.

Hook 시점
pre-install 설치 전
post-install 설치 후
pre-upgrade 업그레이드 전
post-upgrade 업그레이드 후
pre-delete 삭제 전
post-delete 삭제 후
pre-rollback 롤백 전
post-rollback 롤백 후

하이브리드: Helm + Kustomize

ArgoCD와 Flux 모두 Helm 차트에 Kustomize 패치를 적용하는 post-rendering을 지원합니다. 써드파티 차트에 커스텀 레이블·어노테이션을 추가하거나, 차트가 지원하지 않는 설정을 패치하거나, 조직 표준 정책을 강제할 때 유용합니다.

# ArgoCD: Helm 차트 + Kustomize post-rendering
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: nginx-prod
spec:
  source:
    repoURL: https://charts.bitnami.com/bitnami
    chart: nginx
    targetRevision: 15.0.0
    helm:
      values: |
        replicaCount: 3
    kustomize:
      patches:
        - target:
            kind: Deployment
            name: nginx
          patch: |-
            - op: add
              path: /spec/template/metadata/annotations
              value:
                custom-annotation: "added-by-kustomize"

Flux에서는 HelmRelease의 postRenderers 필드로 동일한 패턴을 구성합니다.

선택 가이드

flowchart TB
    Start[환경별 설정 관리 필요] --> Q1{써드파티 소프트웨어?}

    Q1 -->|Yes| Helm1[Helm 사용<br/>이미 차트가 있음]
    Q1 -->|No| Q2{복잡한 조건부 로직?}

    Q2 -->|Yes| Helm2[Helm 사용<br/>if/range 템플릿]
    Q2 -->|No| Q3{팀이 YAML에 익숙?}

    Q3 -->|Yes| Kustomize1[Kustomize 사용<br/>순수 YAML 유지]
    Q3 -->|No| Q4{배포 대상이 많음?}

    Q4 -->|Yes| Helm3[Helm 사용<br/>재사용성 중요]
    Q4 -->|No| Kustomize2[Kustomize 사용<br/>단순함 우선]
관점 Kustomize Helm
학습 곡선 낮음 (순수 YAML) 중간 (Go template)
디버깅 쉬움 (YAML 그대로) 어려움 (템플릿 렌더링 필요)
조건부 로직 제한적 강력함 (if/range)
재사용성 Component로 가능 Chart로 패키징
의존성 관리 없음 Chart dependencies
버전 관리 Git으로 직접 Chart 버전 + values
에코시스템 kubectl 내장 ArtifactHub, 수많은 차트
GitOps 친화성 높음 중간 (렌더링 필요)

Kustomize는 내부 애플리케이션 배포, replicas·resources·env 정도의 단순한 환경별 차이, 순수 YAML 유지, 명확한 Git diff가 필요할 때 적합합니다. Helm은 nginx·postgresql·prometheus 같은 써드파티 소프트웨어 설치, 복잡한 조건부 로직, 재사용 가능한 패키지 배포, 의존성 관리가 필요할 때 적합합니다.

도구 장점 단점 사용 시기
Kustomize 순수 YAML, 학습 쉬움, kubectl 내장 조건부 로직 제한 내부 앱, 단순 환경 차이
Helm 강력한 템플릿, 패키지화, 에코시스템 디버깅 어려움 써드파티, 복잡한 로직
하이브리드 양쪽 장점 활용 복잡도 증가 Helm 차트 커스터마이징

실무에서는 내부 앱은 Kustomize, 인프라 컴포넌트는 Helm으로 관리하는 하이브리드 접근이 일반적입니다.

시크릿 관리: Sealed Secrets, ESO, SOPS

GitOps의 핵심 원칙은 Git을 Single Source of Truth로 삼는 것입니다. 하지만 Kubernetes Secret을 그대로 Git에 저장하면 어떻게 될까요.

# 절대 이렇게 하면 안 됩니다
apiVersion: v1
kind: Secret
metadata:
  name: db-credentials
type: Opaque
data:
  username: YWRtaW4=          # base64는 암호화가 아님
  password: c3VwZXJzZWNyZXQ=  # 누구나 디코딩 가능
# 즉시 평문 노출
echo "c3VwZXJzZWNyZXQ=" | base64 -d
# 출력: supersecret

Base64는 인코딩이지 암호화가 아닙니다. Git 히스토리에 한 번 들어가면 영구히 노출됩니다. 해결 방향은 두 갈래입니다. 암호화해서 Git에 저장하거나(Sealed Secrets, SOPS), Git에는 참조만 두고 실제 값은 외부 저장소에 두는 것입니다(External Secrets Operator).

flowchart TB
    subgraph Problem [GitOps Secrets 딜레마]
        SSOT[Git = Single Source of Truth]
        Secret[Secrets도 Git에 있어야?]
        Risk[평문 노출 위험]
    end

    SSOT --> Secret
    Secret --> Risk

    subgraph Solutions [해결 방법]
        Encrypted[암호화해서 Git에 저장]
        External[외부 저장소 참조]
    end

    Risk --> Encrypted
    Risk --> External

    Encrypted --> SealedSecrets[Sealed Secrets]
    Encrypted --> SOPS[SOPS]
    External --> ESO[External Secrets Operator]
    External --> CSI[Secrets Store CSI Driver]

해결책 1: Sealed Secrets

Sealed Secrets는 Bitnami에서 개발한 Kubernetes 컨트롤러로, 해당 클러스터에서만 복호화할 수 있는 암호화된 Secret을 Git에 저장합니다.

sequenceDiagram
    participant Dev as 개발자
    participant CLI as kubeseal CLI
    participant Controller as Sealed Secrets Controller
    participant K8s as Kubernetes API

    Note over Controller: RSA 키 쌍 보유 (private/public)

    Dev->>CLI: 원본 Secret YAML 제공
    CLI->>Controller: 공개키 요청
    Controller-->>CLI: 공개키 반환
    CLI->>CLI: 공개키로 암호화
    CLI->>Dev: SealedSecret YAML 생성

    Dev->>K8s: git push → GitOps → SealedSecret 적용
    K8s->>Controller: SealedSecret 감지
    Controller->>Controller: 비밀키로 복호화
    Controller->>K8s: 일반 Secret 생성
# 컨트롤러 설치
helm repo add sealed-secrets https://bitnami-labs.github.io/sealed-secrets
helm install sealed-secrets sealed-secrets/sealed-secrets \
  -n kube-system

# CLI 설치 (macOS)
brew install kubeseal

사용 흐름은 세 단계입니다.

# 1. 원본 Secret 생성 (클러스터에 적용하지 않음)
kubectl create secret generic db-credentials \
  --from-literal=username=admin \
  --from-literal=password=supersecret \
  --dry-run=client -o yaml > secret.yaml

# 2. Sealed Secret으로 암호화
kubeseal --format yaml < secret.yaml > sealed-secret.yaml

# 3. 원본 삭제, Sealed Secret만 Git에 커밋
rm secret.yaml
git add sealed-secret.yaml
git commit -m "Add encrypted database credentials"
# 생성된 SealedSecret
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
  name: db-credentials
  namespace: default
spec:
  encryptedData:
    username: AgBy8BQ...암호화된_데이터...==
    password: AgCtr2I...암호화된_데이터...==
  template:
    metadata:
      name: db-credentials
      namespace: default
    type: Opaque

SealedSecret은 어떤 조건에서 복호화를 허용할지 범위(scope)를 지정할 수 있습니다.

kubeseal --scope strict          # 기본: 동일 namespace + name만 허용
kubeseal --scope namespace-wide  # 동일 namespace 내 다른 이름 허용
kubeseal --scope cluster-wide    # 모든 namespace에서 사용 가능
Scope 이름 변경 네임스페이스 변경 보안 수준
strict 불가 불가 높음
namespace-wide 가능 불가 중간
cluster-wide 가능 가능 낮음

컨트롤러의 비밀키가 유출되면 모든 SealedSecret이 복호화되므로 키 백업이 필수입니다.

# 키 백업
kubectl get secret -n kube-system \
  -l sealedsecrets.bitnami.com/sealed-secrets-key \
  -o yaml > sealed-secrets-key-backup.yaml

# 키 복원 (재해 복구 시)
kubectl apply -f sealed-secrets-key-backup.yaml
kubectl delete pod -n kube-system -l app.kubernetes.io/name=sealed-secrets

한계는 세 가지입니다. 첫째, 클러스터 종속이라 다른 클러스터에서는 복호화할 수 없습니다. 둘째, 키 로테이션이 복잡해 키 변경 시 모든 SealedSecret을 재암호화해야 합니다. 셋째, 컨트롤러 장애 시 Secret을 생성할 수 없는 단일 실패점이 됩니다.

해결책 2: External Secrets Operator (ESO)

External Secrets Operator는 AWS Secrets Manager, HashiCorp Vault 같은 외부 비밀 관리 시스템에서 값을 가져와 Kubernetes Secret으로 동기화합니다. 핵심은 Git에 **참조 정보(ExternalSecret)**만 저장하고, 실제 비밀 값은 외부 저장소에 둔다는 점입니다.

flowchart LR
    subgraph External [외부 Secret 저장소]
        AWS[AWS Secrets Manager]
        Vault[HashiCorp Vault]
        GCP[GCP Secret Manager]
        Azure[Azure Key Vault]
    end

    subgraph Cluster [Kubernetes Cluster]
        ESO[External Secrets<br/>Operator]
        ExtSecret[ExternalSecret CR]
        K8sSecret[Kubernetes Secret]
    end

    ExtSecret -->|참조| ESO
    ESO -->|API 호출| AWS & Vault & GCP & Azure
    ESO -->|생성/동기화| K8sSecret
helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets \
  -n external-secrets --create-namespace

먼저 외부 저장소와의 연결(SecretStore 또는 클러스터 전역 ClusterSecretStore)을 설정합니다. AWS라면 IRSA(IAM Role for Service Account)로 인증합니다.

# ClusterSecretStore (클러스터 전역)
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
  name: aws-secrets-manager
spec:
  provider:
    aws:
      service: SecretsManager
      region: ap-northeast-2
      auth:
        jwt:
          serviceAccountRef:
            name: external-secrets-sa
            namespace: external-secrets
# IRSA용 ServiceAccount
apiVersion: v1
kind: ServiceAccount
metadata:
  name: external-secrets-sa
  namespace: external-secrets
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789:role/external-secrets-role

ExternalSecret은 외부 값과 Kubernetes Secret 키의 매핑을 정의합니다.

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: db-credentials
  namespace: production
spec:
  # 동기화 주기
  refreshInterval: 1h

  secretStoreRef:
    name: aws-secrets-manager
    kind: ClusterSecretStore

  target:
    name: db-credentials
    creationPolicy: Owner

  data:
    - secretKey: username      # K8s Secret의 키
      remoteRef:
        key: prod/database     # AWS Secrets Manager의 경로
        property: username     # JSON 내 속성

    - secretKey: password
      remoteRef:
        key: prod/database
        property: password

JSON 전체를 한 번에 가져올 수도 있습니다.

spec:
  dataFrom:
    - extract:
        key: prod/database  # JSON 전체를 가져와 각 키를 Secret 데이터로
Provider 설명
AWS Secrets Manager AWS 네이티브
AWS Parameter Store SSM 파라미터
HashiCorp Vault 온프레미스/클라우드
GCP Secret Manager GCP 네이티브
Azure Key Vault Azure 네이티브
1Password 팀 비밀 공유
Doppler SaaS 비밀 관리

장점은 Git에 비밀 값이 전혀 없고, 자동 로테이션을 지원하며, 중앙 집중식으로 관리할 수 있다는 것입니다. 단점은 외부 서비스 의존성, 네트워크 지연, AWS Secrets Manager 같은 서비스의 추가 비용입니다.

해결책 3: SOPS

SOPS(Secrets OPerationS)는 Mozilla에서 개발한 파일 레벨 암호화 도구입니다. YAML, JSON, ENV 파일에서 키는 평문으로 두고 값만 선택적으로 암호화합니다. age 키, AWS/GCP KMS, Azure Key Vault, PGP를 암호화 키로 쓸 수 있습니다.

# 암호화 후에도 구조가 읽힙니다
apiVersion: v1
kind: Secret
metadata:
  name: db-credentials
stringData:
  username: ENC[AES256_GCM,data:6FLiRc8=,iv:...,tag:...,type:str]
  password: ENC[AES256_GCM,data:HdNqmY3WUr8=,iv:...,tag:...,type:str]
sops:
  age:
    - recipient: age1...
      enc: |
        -----BEGIN AGE ENCRYPTED FILE-----
        ...
        -----END AGE ENCRYPTED FILE-----
  lastmodified: "2024-01-15T10:00:00Z"
  mac: ENC[AES256_GCM,...]
  version: 3.8.1

키가 평문이므로 Git diff가 의미를 가집니다. 어떤 키가 변경되었는지 바로 알 수 있습니다. 시작은 age 키 방식이 간단합니다.

# age, SOPS 설치
brew install age sops

# 키 쌍 생성
age-keygen -o key.txt
# Public key: age1abc...

# .sops.yaml 설정 (레포지토리 루트)
cat > .sops.yaml << EOF
creation_rules:
  - path_regex: .*secrets.*\.yaml$
    age: age1abc...  # 공개키
EOF
# 암호화
sops -e secrets.yaml > secrets.enc.yaml

# 복호화
sops -d secrets.enc.yaml > secrets.yaml

# 제자리 편집 (복호화 → 편집 → 저장 시 재암호화)
sops secrets.enc.yaml

Flux는 SOPS를 네이티브로 지원합니다.

# 복호화 키를 Secret으로 저장
apiVersion: v1
kind: Secret
metadata:
  name: sops-age
  namespace: flux-system
stringData:
  age.agekey: |
    # created: 2024-01-15
    # public key: age1abc...
    AGE-SECRET-KEY-1...

---
# Kustomization에서 복호화 활성화
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: my-app
  namespace: flux-system
spec:
  decryption:
    provider: sops
    secretRef:
      name: sops-age

ArgoCD는 플러그인으로 지원합니다(kustomize build . | sops -d /dev/stdin 형태의 config management plugin 등록). 조직 규모가 커지면 age 대신 KMS를 붙여 환경별로 다른 키를 쓸 수 있습니다.

# .sops.yaml
creation_rules:
  - path_regex: .*prod.*secrets.*\.yaml$
    kms: arn:aws:kms:ap-northeast-2:123456789:key/abc-def

  - path_regex: .*dev.*secrets.*\.yaml$
    kms: arn:aws:kms:ap-northeast-2:123456789:key/xyz-123

보안 모범 사례

어떤 솔루션을 쓰든 다음 네 가지는 공통으로 적용합니다.

  1. 최소 권한 원칙: ESO ServiceAccount에는 필요한 경로의 secretsmanager:GetSecretValue 정도만 부여합니다.
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["secretsmanager:GetSecretValue"],
      "Resource": ["arn:aws:secretsmanager:*:*:secret:prod/*"]
    }
  ]
}
  1. 네임스페이스 격리: 팀별 SecretStore를 네임스페이스 단위로 분리하고 전용 IAM Role을 연결합니다.
  2. 자동 로테이션: ESO의 refreshInterval로 주기 동기화하고, AWS Secrets Manager의 자동 로테이션(Lambda)을 활성화합니다.
  3. 감사 로깅: CloudTrail 등에서 GetSecretValue 이벤트로 Secret 접근 이력을 추적합니다.

선택 가이드

flowchart TB
    Start[Secrets 관리 전략 선택] --> Q1{외부 비밀 관리 시스템<br/>이미 사용 중?}

    Q1 -->|Yes| ESO[External Secrets Operator]
    Q1 -->|No| Q2{멀티 클러스터?}

    Q2 -->|Yes| Q3{중앙 비밀 저장소<br/>도입 가능?}
    Q3 -->|Yes| ESO
    Q3 -->|No| SOPS[SOPS + age/KMS]

    Q2 -->|No| Q4{단순함 우선?}
    Q4 -->|Yes| SealedSecrets[Sealed Secrets]
    Q4 -->|No| SOPS
관점 Sealed Secrets ESO SOPS
Git에 저장되는 것 암호화된 SealedSecret 참조(ExternalSecret) 암호화된 파일
복호화 위치 클러스터 내 외부 저장소 클러스터/로컬
멀티 클러스터 불가 (각 클러스터 키 다름) 가능 (중앙 저장소) 가능 (같은 키 공유)
비밀 로테이션 수동 자동 (refreshInterval) 수동
외부 의존성 없음 있음 (Vault, AWS 등) 선택적 (age vs KMS)
Flux 지원 지원 지원 네이티브 지원
ArgoCD 지원 지원 지원 플러그인 지원
학습 곡선 낮음 중간 중간
상황 권장 솔루션
단일 클러스터, 빠른 시작 Sealed Secrets
AWS/GCP/Azure 사용, 중앙 관리 External Secrets Operator
멀티 클러스터, Flux 사용 SOPS + age
기존 Vault 인프라 ESO + Vault
규제 요구사항 (감사 로그 필요) ESO + AWS Secrets Manager

CI/CD 통합과 Progressive Delivery

전통적인 파이프라인에서는 CI와 CD가 하나의 파이프라인에서 연속으로 실행됩니다. GitOps에서는 둘이 명확히 분리됩니다. CI는 아티팩트(이미지)를 생성하고, CD는 클러스터 내부의 GitOps Agent가 담당합니다. CI가 클러스터에 직접 접근하지 않는다는 것이 핵심 원칙입니다.

flowchart TB
    subgraph CI [CI Pipeline - 소스 레포]
        Code[코드 변경] --> Build[빌드]
        Build --> Test[테스트]
        Test --> Push[이미지 Push]
        Push --> UpdateManifest[매니페스트 업데이트]
    end

    subgraph GitOps [GitOps 레포]
        Manifest[K8s Manifests]
    end

    subgraph CD [CD - GitOps Agent]
        Agent[ArgoCD / Flux]
        Cluster[Kubernetes Cluster]
    end

    UpdateManifest -->|PR 생성 또는 직접 커밋| Manifest
    Manifest -->|Watch| Agent
    Agent -->|Sync| Cluster
관점 통합 CI/CD 분리된 CI + GitOps
배포 권한 CI가 클러스터 접근 클러스터 내부 Agent만 접근
감사 추적 CI 로그에만 기록 Git 커밋 히스토리
롤백 재빌드 필요 git revert
환경 일관성 CI 파이프라인에 의존 Git이 SSOT

CI에서 매니페스트를 업데이트하는 세 가지 방법

이미지 빌드 후 GitOps 레포의 매니페스트를 갱신하는 방법입니다.

방법 1: CI에서 직접 커밋

# .github/workflows/build.yaml
name: Build and Update Manifest

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout source
        uses: actions/checkout@v4

      - name: Build and push image
        run: |
          docker build -t ghcr.io/myorg/myapp:${{ github.sha }} .
          docker push ghcr.io/myorg/myapp:${{ github.sha }}

      - name: Update GitOps repo
        env:
          GITHUB_TOKEN: ${{ secrets.GITOPS_PAT }}
        run: |
          git clone https://$GITHUB_TOKEN@github.com/myorg/gitops-repo.git
          cd gitops-repo

          # Kustomize 사용 시
          cd apps/myapp/overlays/prod
          kustomize edit set image ghcr.io/myorg/myapp:${{ github.sha }}

          git config user.name "GitHub Actions"
          git config user.email "actions@github.com"
          git add .
          git commit -m "Update myapp to ${{ github.sha }}"
          git push

방법 2: PR 생성. 코드 리뷰 프로세스와 자동화된 테스트를 거쳐 승인 후 배포할 수 있어 직접 커밋보다 안전합니다.

      - name: Create PR to GitOps repo
        uses: peter-evans/create-pull-request@v6
        with:
          token: ${{ secrets.GITOPS_PAT }}
          repository: myorg/gitops-repo
          branch: update-myapp-${{ github.sha }}
          title: "Update myapp to ${{ github.sha }}"
          body: |
            Automated image update
            - Commit: ${{ github.sha }}
            - Build: ${{ github.run_id }}
          commit-message: "chore: update myapp image to ${{ github.sha }}"

방법 3: 이미지 태그 자동 업데이트. CI는 이미지만 푸시하고 GitOps 쪽 도구가 레지스트리를 감시해 매니페스트를 자동 갱신합니다. 아래에서 자세히 다룹니다.

ArgoCD Image Updater

ArgoCD Image Updater는 컨테이너 레지스트리를 주기적으로 스캔해 새 이미지 태그가 발견되면 매니페스트를 갱신하고, ArgoCD가 이를 Sync합니다.

kubectl apply -n argocd \
  -f https://raw.githubusercontent.com/argoproj-labs/argocd-image-updater/stable/manifests/install.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: myapp
  namespace: argocd
  annotations:
    # Image Updater 활성화
    argocd-image-updater.argoproj.io/image-list: myapp=ghcr.io/myorg/myapp

    # 업데이트 전략: semver
    argocd-image-updater.argoproj.io/myapp.update-strategy: semver

    # SemVer 제약
    argocd-image-updater.argoproj.io/myapp.allow-tags: regexp:^v[0-9]+\.[0-9]+\.[0-9]+$

    # Git에 커밋 (write-back)
    argocd-image-updater.argoproj.io/write-back-method: git
    argocd-image-updater.argoproj.io/git-branch: main
spec:
  source:
    repoURL: https://github.com/myorg/gitops.git
    path: apps/myapp
전략 설명 예시
semver SemVer 최신 v1.2.3 < v1.2.4 < v1.3.0
latest 최신 푸시된 태그 날짜/시간 기준
name 알파벳순 최신 a < b < c
digest 특정 태그의 digest 변경 :latest의 실제 이미지 변경

write-back 방식은 git(직접 커밋)과 argocd(오버라이드)가 있습니다. argocd 방식은 Git에 기록되지 않아 GitOps 원칙에 위배되므로, 프로덕션에서는 git 방식을 권장합니다.

Flux Image Automation

Flux는 Image Automation을 핵심 기능으로 내장하고 있습니다. 세 개의 리소스로 구성합니다.

# 1. ImageRepository: 레지스트리 스캔
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImageRepository
metadata:
  name: myapp
  namespace: flux-system
spec:
  image: ghcr.io/myorg/myapp
  interval: 5m
  secretRef:
    name: ghcr-auth

---
# 2. ImagePolicy: 태그 선택 정책
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImagePolicy
metadata:
  name: myapp
  namespace: flux-system
spec:
  imageRepositoryRef:
    name: myapp
  policy:
    semver:
      range: ">=1.0.0"

---
# 3. ImageUpdateAutomation: Git 업데이트
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImageUpdateAutomation
metadata:
  name: myapp
  namespace: flux-system
spec:
  interval: 5m
  sourceRef:
    kind: GitRepository
    name: myapp
  git:
    checkout:
      ref:
        branch: main
    commit:
      author:
        name: Flux
        email: flux@myorg.com
      messageTemplate: |
        Auto-update images

        {{range .Changed.Changes}}
        - {{.OldValue}} -> {{.NewValue}}
        {{end}}
    push:
      branch: main
  update:
    path: ./deploy
    strategy: Setters

업데이트 위치는 매니페스트의 마커 주석으로 지정합니다.

# deployment.yaml
spec:
  template:
    spec:
      containers:
      - name: myapp
        image: ghcr.io/myorg/myapp:v1.2.3  # {"$imagepolicy": "flux-system:myapp"}

Flux가 새 버전을 발견하면 이 라인의 태그를 v1.2.4로 자동 교체해 커밋합니다.

Progressive Delivery: 배포 전략 비교

Progressive Delivery는 새 버전을 점진적으로 배포해 위험을 최소화하는 전략입니다.

전략 다운타임 리소스 롤백 속도 위험 노출
Recreate 있음 낮음 느림 높음
Rolling 없음 일시 증가 중간 중간
Blue-Green 없음 2배 빠름 낮음
Canary 없음 약간 증가 빠름 낮음

Argo Rollouts

Argo Rollouts는 Kubernetes Deployment를 대체하는 Rollout CRD로 Blue-Green, Canary 배포를 제공합니다.

kubectl create namespace argo-rollouts
kubectl apply -n argo-rollouts \
  -f https://github.com/argoproj/argo-rollouts/releases/latest/download/install.yaml

Canary 배포는 단계별 트래픽 가중치와 대기·승인 지점을 선언합니다.

apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: myapp
spec:
  replicas: 5
  revisionHistoryLimit: 3
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
      - name: myapp
        image: ghcr.io/myorg/myapp:v1.2.3
        ports:
        - containerPort: 8080
  strategy:
    canary:
      steps:
      - setWeight: 10          # 10% 트래픽
      - pause: {duration: 5m}  # 5분 대기
      - setWeight: 30
      - pause: {duration: 5m}
      - setWeight: 50
      - pause: {}              # 수동 승인 대기
      - setWeight: 100

      # 분석 실행 (자동 롤백)
      analysis:
        templates:
        - templateName: success-rate
        startingStep: 1
        args:
        - name: service-name
          value: myapp

AnalysisTemplate으로 메트릭 기반 자동 롤백을 구성합니다.

apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
  name: success-rate
spec:
  args:
  - name: service-name
  metrics:
  - name: success-rate
    interval: 1m
    failureLimit: 3
    successCondition: result[0] >= 0.95
    provider:
      prometheus:
        address: http://prometheus.monitoring:9090
        query: |
          sum(rate(http_requests_total{
            service="{{args.service-name}}",
            status=~"2.."
          }[5m])) /
          sum(rate(http_requests_total{
            service="{{args.service-name}}"
          }[5m]))

Blue-Green은 active/preview 서비스를 나누고 승격 전 분석을 붙입니다.

  strategy:
    blueGreen:
      activeService: myapp-active
      previewService: myapp-preview
      autoPromotionEnabled: false  # 수동 승인
      prePromotionAnalysis:
        templates:
        - templateName: smoke-test

ArgoCD Application의 automated sync(prune, selfHeal)와 함께 쓰면 Git 변경이 Rollout 전략을 따라 점진 배포됩니다.

Flagger

Flagger는 Flux와 함께 사용되는 Progressive Delivery 도구로, Istio, Linkerd, NGINX Ingress 등과 통합됩니다. Flux의 HelmRelease로 설치하며(meshProvider: istio, metricsServer 지정), 기존 Deployment를 그대로 두고 Canary CRD가 이를 참조합니다.

apiVersion: flagger.app/v1beta1
kind: Canary
metadata:
  name: myapp
  namespace: production
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: myapp

  # Istio VirtualService 자동 생성
  service:
    port: 8080
    targetPort: 8080

  # 분석 설정
  analysis:
    interval: 1m           # 분석 주기
    threshold: 5           # 최대 실패 횟수
    maxWeight: 50          # 최대 Canary 트래픽
    stepWeight: 10         # 단계별 증가량

    metrics:
    - name: request-success-rate
      thresholdRange:
        min: 99
      interval: 1m

    - name: request-duration
      thresholdRange:
        max: 500
      interval: 1m

    # Webhook 테스트
    webhooks:
    - name: smoke-test
      type: pre-rollout
      url: http://flagger-loadtester/
      timeout: 30s
      metadata:
        type: bash
        cmd: "curl -s http://myapp-canary:8080/health | grep ok"
sequenceDiagram
    participant Git as GitOps Repo
    participant Flux as Flux
    participant Flagger as Flagger
    participant Istio as Istio
    participant Prom as Prometheus

    Git->>Flux: 새 이미지 감지
    Flux->>Flux: Deployment 업데이트

    Flagger->>Flagger: Canary Deployment 생성
    Flagger->>Istio: VirtualService 업데이트 (10% canary)

    loop 분석
        Flagger->>Prom: 성공률 확인
        alt 성공률 >= 99%
            Flagger->>Istio: 트래픽 증가 (20%, 30%, ...)
        else 실패
            Flagger->>Istio: 롤백 (100% primary)
            Flagger->>Flagger: Canary 삭제
        end
    end

    Flagger->>Flux: Primary 업데이트
    Flagger->>Istio: 100% primary

선택 가이드

관점 Argo Rollouts Flagger
주 생태계 ArgoCD Flux
적용 방식 Deployment를 Rollout CRD로 대체 기존 Deployment를 targetRef로 참조
배포 전략 Canary, Blue-Green Canary (트래픽 점진 증가)
트래픽 제어 steps의 setWeight/pause 선언 Istio·Linkerd·NGINX Ingress 통합
자동 롤백 근거 AnalysisTemplate (Prometheus 쿼리) metrics thresholdRange + webhooks

ArgoCD 중심이면 Argo Rollouts, Flux와 서비스 메시 조합이면 Flagger가 자연스럽습니다.

프로덕션 GitOps 워크플로우

모든 요소를 종합하면 다음 흐름이 됩니다.

flowchart TB
    subgraph Dev [개발]
        Code[코드 작성]
        PR[Pull Request]
        Review[코드 리뷰]
    end

    subgraph CI [CI Pipeline]
        Build[빌드]
        Test[테스트]
        Push[이미지 Push]
    end

    subgraph GitOps [GitOps Repo]
        ImageUpdate[Image Updater<br/>또는 Flux Image Automation]
        Manifest[K8s Manifests]
    end

    subgraph CD [CD - Progressive Delivery]
        Agent[ArgoCD / Flux]
        Rollout[Argo Rollouts / Flagger]
        Canary[Canary 분석]
    end

    subgraph Prod [Production]
        Cluster[Kubernetes Cluster]
        Monitor[Prometheus/Grafana]
    end

    Code --> PR --> Review
    Review -->|Merge| Build --> Test --> Push
    Push -->|새 이미지 감지| ImageUpdate
    ImageUpdate --> Manifest
    Manifest --> Agent
    Agent --> Rollout --> Canary
    Canary -->|성공| Cluster
    Canary <-->|메트릭 수집| Monitor

    Canary -->|실패| Rollback[자동 롤백]

레포지토리 구조는 클러스터, 인프라, 앱, 이미지 자동화를 분리하는 형태로 수렴합니다.

gitops-repo/
├── clusters/
│   ├── dev/
│   │   └── kustomization.yaml
│   ├── staging/
│   │   └── kustomization.yaml
│   └── prod/
│       └── kustomization.yaml
│
├── infrastructure/
│   ├── base/
│   │   ├── ingress-nginx/
│   │   ├── cert-manager/
│   │   ├── monitoring/
│   │   └── flagger/
│   └── overlays/
│       ├── dev/
│       └── prod/
│
├── apps/
│   ├── frontend/
│   │   ├── base/
│   │   │   ├── deployment.yaml
│   │   │   ├── service.yaml
│   │   │   ├── canary.yaml        # Flagger Canary
│   │   │   └── kustomization.yaml
│   │   └── overlays/
│   │       ├── dev/
│   │       ├── staging/
│   │       └── prod/
│   └── backend/
│       └── ...
│
└── image-automation/              # Flux Image Automation
    ├── image-repositories.yaml
    ├── image-policies.yaml
    └── image-update-automation.yaml

정리

하편에서 다룬 세 주제를 요약하면 다음과 같습니다.

주제 핵심 메시지
환경별 설정 관리 내부 앱은 Kustomize(순수 YAML), 써드파티·복잡 로직은 Helm(템플릿), 필요 시 post-rendering 하이브리드
시크릿 관리 단순 시작은 Sealed Secrets, 중앙 관리·자동 로테이션은 ESO, 멀티 클러스터·Flux는 SOPS
CI/CD 통합 CI는 이미지 생성까지만, 배포는 GitOps Agent가 담당, 릴리스는 Rollouts·Flagger로 점진 배포

GitOps 도입은 다음 성숙도 단계를 따라 올라가는 것이 자연스럽습니다.

flowchart LR
    L1[Level 1<br/>Git에 매니페스트 저장]
    L2[Level 2<br/>GitOps Agent 도입]
    L3[Level 3<br/>환경별 설정 분리]
    L4[Level 4<br/>Secrets 자동화]
    L5[Level 5<br/>Progressive Delivery]

    L1 --> L2 --> L3 --> L4 --> L5

권장 순서는 ArgoCD 또는 Flux 설치, 간단한 앱으로 워크플로우 경험, Kustomize로 환경별 설정 분리, Sealed Secrets 또는 ESO 도입, Image Updater 자동화, 마지막으로 Argo Rollouts 또는 Flagger로 Progressive Delivery 구축입니다. GitOps는 단순한 도구가 아니라 Git을 중심으로 선언적 인프라를 관리하고 자동화된 Reconciliation으로 일관성을 유지하는 운영 철학입니다.