CI CD

ArgoCD ApplicationSet matrix generator로 N×M 배포를 정리하는 법

gfrog 2026. 4. 26. 11:42
반응형

클러스터가 늘어나고 환경이 늘어나면 어느 시점에 Application YAML이 폭발한다. 우리 팀도 그랬다. 클러스터 6개에 환경(dev/stg/prod) 3개, 거기에 공통으로 들어가는 플랫폼 컴포넌트 8개를 곱하니 144개의 Application 리소스가 git에 쌓였다. 사람이 손으로 관리할 수 있는 규모를 넘은 지 오래였다.

이걸 ApplicationSet의 matrix generator로 정리한 과정을 적어둔다. Argo CD 공식 문서에는 패턴이 짧게만 소개돼 있고 실전에서 부딪히는 디테일은 잘 안 보여서, 우리 팀이 정착시킨 구성을 그대로 옮긴다. Argo CD 3.0 기준이지만 2.10 이상이면 거의 동일하게 동작한다.

왜 matrix generator인가

ApplicationSet에는 List, Cluster, Git, SCM, PullRequest 등 여러 generator가 있다. 단일 generator로도 어느 정도까지는 갈 수 있다. 그런데 "이 8개 컴포넌트를 6개 클러스터의 3개 환경에 다 깔아라"는 요구가 들어오면, 단일 generator로는 표현이 이상해진다. List에 144개 항목을 박아 넣거나, 클러스터마다 ApplicationSet을 따로 만들거나 둘 중 하나다. 둘 다 결국 사람이 손으로 관리하는 양이 똑같다.

matrix는 두 generator의 출력을 카르테시안 곱으로 합쳐준다. "컴포넌트 목록"과 "클러스터 목록"을 따로 정의하고, matrix가 이 둘을 곱해서 컴포넌트 × 클러스터 조합을 자동으로 만든다. 한쪽만 바꿔도 전체가 따라 움직인다는 게 핵심이다. 새 클러스터를 추가했을 때 git에서 한 줄(또는 secret 하나)만 손대면 그 클러스터에 8개 컴포넌트가 자동으로 다 깔린다.

다만 한 가지 명심할 게 있다. matrix는 자식 generator를 두 개까지만 받는다. "컴포넌트 × 클러스터 × 환경" 같은 3차원 곱은 직접적으론 안 된다. 이 제약을 어떻게 우회했는지는 뒤에서 다룬다.

기본 구조

가장 단순한 형태부터 보자. git의 clusters/ 폴더에 cluster-config.json 파일을 두고, apps/ 폴더에 컴포넌트별 디렉토리를 둔다.

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: platform-components
  namespace: argocd
spec:
  goTemplate: true
  generators:
  - matrix:
      generators:
      - git:
          repoURL: https://github.com/our-org/platform-gitops
          revision: main
          directories:
          - path: apps/*
      - clusters:
          selector:
            matchLabels:
              tier: workload
  template:
    metadata:
      name: '{{.path.basename}}-{{.name}}'
    spec:
      project: platform
      source:
        repoURL: https://github.com/our-org/platform-gitops
        targetRevision: main
        path: '{{.path.path}}'
      destination:
        server: '{{.server}}'
        namespace: platform
      syncPolicy:
        automated:
          prune: true
          selfHeal: true

여기서 두 generator가 만들어내는 파라미터 키가 다르다는 점이 중요하다. git directories generator는 .path.basename, .path.path 같은 키를, cluster generator는 .name, .server를 만든다. matrix는 그냥 두 출력을 머지해서 같이 쓰게 해준다. 이름이 겹치면 뒤에 오는 generator가 이긴다.

goTemplate: true는 꼭 켜라. 옛날 fasttemplate({{}} 안에 그냥 키 이름만 들어가는 형식)은 nested 필드 접근이 빈약해서 조금만 복잡해져도 막힌다. Argo CD 2.6부터는 Go 템플릿이 안정 지원이고, 우리는 이걸로 통일하면서 문법 디버깅 시간이 절반으로 줄었다.

환경별 차이를 어떻게 표현할까

문제는 같은 클러스터라도 환경에 따라 값이 다르다는 거다. 예를 들어 같은 prometheus 컴포넌트라도 dev 클러스터에서는 retention 7일, prod에서는 30일을 쓰고 싶다.

우리는 클러스터 secret의 라벨에 환경을 박아두는 방식을 쓴다.

apiVersion: v1
kind: Secret
metadata:
  name: prod-apne2-1
  namespace: argocd
  labels:
    argocd.argoproj.io/secret-type: cluster
    tier: workload
    env: prod
    region: apne2
type: Opaque
stringData:
  name: prod-apne2-1
  server: https://...
  config: |
    ...

그리고 ApplicationSet 템플릿에서 라벨을 꺼내 쓴다. cluster generator는 .metadata.labels.<key> 형태로 라벨에 접근할 수 있다.

template:
  spec:
    source:
      path: 'apps/{{.path.basename}}/overlays/{{.metadata.labels.env}}'

이렇게 하면 apps/prometheus/overlays/prod, apps/prometheus/overlays/dev 같은 식으로 환경별 Kustomize overlay를 자동 매칭한다. matrix를 3차원으로 만들 필요 없이, "환경" 차원이 클러스터 라벨에 흡수됐다고 보면 된다. 운영 입장에서도 이쪽이 더 자연스럽다. 클러스터는 어차피 한 환경에만 속하니까.

잘 안 알려진 함정 두 개

여기서부터가 진짜다. 단순 예제는 잘 돌지만 운영에서 부딪히는 케이스 두 개.

첫째, selector가 비어 있는 generator는 전부를 매칭한다. cluster generator의 selector를 빼먹으면 in-cluster까지 포함해서 전부 다 잡는다. ApplicationSet이 in-cluster(보통 argocd가 떠 있는 클러스터)에까지 워크로드를 깔아버려서 패닉이 온 적이 있다. 항상 라벨 셀렉터를 명시해라. 우리는 tier: workload 라벨이 없는 클러스터에는 앱이 절대 안 가도록 모든 ApplicationSet에 강제한다.

둘째, 변경이 한 번에 너무 많이 퍼진다. matrix는 잘 작동할수록 무서워진다. 예를 들어 git 쪽 directories를 바꿔서 apps/new-component/를 추가하면, 다음 reconcile에서 6개 클러스터에 동시에 새 앱이 생성된다. 이게 의도한 거면 좋지만, 의도하지 않은 변경(예: 누가 apps/test/ 디렉토리를 실수로 push)이 동시에 6배로 퍼지는 경우도 있다.

이걸 막기 위해 우리는 syncPolicy.preserveResourcesOnDeletion: true와 ApplicationSet의 progressive sync(strategy: RollingSync)를 항상 같이 쓴다. progressive sync는 라벨 기반으로 클러스터를 그룹핑해서 순차적으로 적용한다.

spec:
  strategy:
    type: RollingSync
    rollingSync:
      steps:
      - matchExpressions:
        - key: env
          operator: In
          values: [dev]
      - matchExpressions:
        - key: env
          operator: In
          values: [stg]
      - matchExpressions:
        - key: env
          operator: In
          values: [prod]

dev → stg → prod 순으로 퍼진다. 한 단계가 Healthy가 되기 전까지 다음 단계로 안 넘어간다. 이걸 켜고 나서부터는 "전체 클러스터에 동시 배포 사고"가 없어졌다. 대신 새 앱이 prod까지 도달하는 데 보통 10~20분이 걸린다. 우리는 이게 적절한 트레이드오프라고 본다.

마이그레이션은 한 번에 하지 마라

기존에 손으로 관리하던 144개 Application을 이걸로 한꺼번에 갈아엎으면 안 된다. ApplicationSet은 자기가 만든 Application의 이름과 매칭되는 기존 리소스를 "내 거"로 인식하지 않는다. 그래서 마이그레이션 중에 잠깐 양쪽이 동시에 존재하는 구간이 생기면 sync가 꼬인다.

우리가 한 방법은:

  1. 새 ApplicationSet을 만들되, template name에 prefix를 붙여서 기존과 절대 안 겹치게 한다 ('as-{{.path.basename}}-{{.name}}').
  2. 새 Application들이 다 Healthy 떠는지 일주일 정도 본다.
  3. 기존 Application들을 컴포넌트별로 하나씩 cascade 옵션 false로 삭제한다 (kubectl delete application ... --cascade=orphan). 이렇게 하면 실제 워크로드는 안 죽는다.
  4. ApplicationSet template name에서 prefix를 떼고 다시 reconcile.

3번에서 cascade=orphan을 빼먹으면 워크로드가 한 번 깡그리 사라졌다 다시 생긴다. 절대 까먹지 마라. 우리는 staging에서 한 번 당해보고 알았다.

정리

상황 matrix를 쓰면 좋다 쓰지 말아야 한다
같은 컴포넌트 묶음을 여러 클러스터에 깐다 O  
클러스터마다 컴포넌트 구성이 완전 다르다   O (그냥 클러스터별 ApplicationSet)
환경별 값만 약간 다르다 O (cluster label로 흡수)  
3차원 이상 곱이 진짜 필요하다 △ (matrix 안에 matrix 넣는 패턴은 지원되지만 추천 안 함)  

ApplicationSet matrix가 만능은 아니다. 하지만 N×M의 반복이 git에서 보일 만큼 정형화돼 있다면, 한 번 정리하면 그 다음부턴 "클러스터 추가 = 라벨 하나" 수준으로 운영이 단순해진다. 클러스터 늘릴 때마다 PR 몇십 개씩 만들던 게 사라졌다. 그 효과만으로도 도입 가치가 있었다.

다음에는 ApplicationSet과 함께 쓰는 ArgoCD Notifications, 그리고 PR 단위로 임시 환경을 띄우는 PullRequest generator도 정리해볼 생각이다.

반응형