ArgoCD ApplicationSet으로 PR 프리뷰 환경 자동화하기
PR 올릴 때마다 QA 환경 하나씩 띄우는 거, 다들 어떻게 하시나요. 우리 팀도 한참 동안은 PR 댓글에 /deploy preview 같은 슬래시 커맨드를 달고 Jenkins job을 트리거하는 방식을 썼는데, 결국에는 항상 누군가 환경을 안 지워서 노드가 터져 있곤 했다. 그래서 작년 말부터 ArgoCD ApplicationSet의 Pull Request generator로 전환했다. 지금까지 6개월 정도 돌려보니 만족도가 꽤 높아서, 셋업 가이드를 정리해둔다.
이 글은 GitHub + ArgoCD 2.x 환경 기준이다. GitLab/Gitea도 거의 비슷하다.
어떻게 동작하나
핵심은 단순하다. ApplicationSet이 GitHub API를 폴링해서 열려 있는 PR 목록을 가져오고, 각 PR마다 Application 리소스를 자동 생성한다. PR이 닫히거나 머지되면 해당 Application이 삭제되면서 네임스페이스/리소스가 정리된다.
폴링 주기는 기본 30분이지만, 실무에서는 거의 항상 webhook을 같이 쓴다. PR open/close 이벤트가 오면 즉시 reconcile이 트리거되어 1분 안에 프리뷰 환경이 뜬다.
기본 셋업
먼저 GitHub용 ApplicationSet 매니페스트.
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: preview-myapp
namespace: argocd
spec:
generators:
- pullRequest:
github:
owner: my-org
repo: myapp
tokenRef:
secretName: github-pr-token
key: token
labels:
- preview
requeueAfterSeconds: 1800
template:
metadata:
name: 'preview-myapp-{{.number}}'
spec:
project: default
source:
repoURL: https://github.com/my-org/myapp.git
targetRevision: '{{.head_sha}}'
path: deploy/preview
helm:
parameters:
- name: image.tag
value: '{{.head_sha}}'
- name: ingress.host
value: 'pr-{{.number}}.preview.example.com'
destination:
server: https://kubernetes.default.svc
namespace: 'preview-{{.number}}'
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
여기서 중요한 포인트 몇 가지.
labels: [preview]로 필터링한 건 의도적이다. 모든 PR이 아니라 preview 라벨이 붙은 PR만 프리뷰 환경을 띄운다. 안 그러면 dependabot PR까지 다 환경을 생성해서 클러스터가 금세 가득 찬다. 우리 팀은 PR 템플릿에 체크박스를 두고, 체크하면 GitHub Actions이 자동으로 라벨을 붙이게 했다.
targetRevision: '{{.head_sha}}'도 중요하다. 브랜치 이름이 아니라 commit SHA를 박아야 PR에 새 커밋이 푸시될 때마다 ArgoCD가 인식해서 재배포한다. 브랜치명으로 두면 head가 옮겨가도 ArgoCD가 새로 sync 안 한다.
Webhook 연동
폴링만 두면 사용자 체감이 별로다. PR 올렸는데 5분, 10분 기다려야 환경이 뜨면 그냥 로컬에서 돌려보고 만다. webhook은 꼭 붙이자.
ArgoCD의 ApplicationSet controller는 /api/webhook 엔드포인트로 webhook을 받는다. 보통 ArgoCD server와 같은 ingress 뒤에 있으므로, GitHub repo 설정에서 https://argocd.example.com/api/webhook을 추가하면 된다.
# argocd-secret에 webhook secret 등록
apiVersion: v1
kind: Secret
metadata:
name: argocd-secret
namespace: argocd
stringData:
webhook.github.secret: <random-string>
GitHub Repo Settings → Webhooks → Add webhook에서 Content type을 application/json으로, Secret에 위의 값을 넣고, Events는 "Pull requests"와 "Pushes"를 선택한다. Push 이벤트도 켜야 PR 안에서 새 커밋 푸시했을 때 즉시 재배포된다.
환경 격리
PR 하나당 네임스페이스 하나가 뜨는데, 격리를 어떻게 할지가 의외로 골칫거리다. 몇 가지 우리가 한 것들.
Resource quota를 ApplicationSet template에 박는다. Application이 생성될 때 네임스페이스도 같이 만들어지므로, sync wave를 활용해 ResourceQuota를 먼저 적용한 다음 앱이 뜨게 한다. CPU/메모리 상한을 안 걸면 누군가 메모리 leak이 있는 PR 올렸을 때 노드가 그냥 죽는다.
NetworkPolicy로 prod와 격리한다. 프리뷰 환경에서 prod DB를 건드리는 일이 생기지 않도록 egress NetworkPolicy를 두고, 별도로 prod-clone DB를 사용한다. 이거 안 하면 누가 마이그레이션 테스트하다가 prod DB에 ALTER TABLE 날리는 사고가 난다 (실제로 다른 회사에서 본 적 있다).
Ingress 호스트를 PR 번호로 분리. pr-123.preview.example.com 식으로. wildcard cert 하나 발급받아두면 신경 쓸 게 없다. 인증은 OAuth proxy를 ingress 앞에 두고 organization 멤버만 통과시킨다.
자주 만나는 함정
쭉 운영해보니 몇 가지 패턴이 반복된다.
첫째, GitHub API rate limit. fine-grained PAT 쓰면 5000 req/h인데, ApplicationSet이 30분마다 폴링하면서 webhook 이벤트도 받으면 작은 조직에선 문제 없지만 PR이 많은 monorepo에선 빠르게 소진된다. GitHub App을 만들어서 그 토큰을 쓰면 instance당 15000 req/h로 늘어난다. 큰 차이다.
둘째, dangling 네임스페이스. PR이 strangely 닫혀서 ApplicationSet이 인식 못 하는 경우가 가끔 있다. 우리 팀은 별도로 CronJob을 하나 두고 kubectl get ns -l preview=true에서 7일 이상 안 쓰인 네임스페이스는 삭제한다. 보험이다.
셋째, PR 라벨 변경 감지 지연. webhook으로 label 이벤트를 받게 해도 ApplicationSet이 인식하는 데 약간 텀이 있다. 사용자가 라벨 붙이자마자 환경 떠야 한다고 기대하면 실망한다. 보통 30초~1분 정도 걸린다고 안내해야 한다.
결론
처음 셋업할 때는 좀 귀찮은데, 한 번 안정화시키면 정말 손이 안 간다. PR 댓글에 "프리뷰 환경 띄워줘" 같은 트리거가 사라지고, 그냥 라벨 하나로 자동화되니까 리뷰어 입장에서도 편하다. 다만 클러스터 리소스는 좀 넉넉히 잡아두자. 우리 팀은 프리뷰 전용 노드 풀을 따로 두는데, spot 인스턴스로 구성해서 비용도 크게 안 든다.
다음에는 ApplicationSet의 matrix generator를 활용해 서비스 메시 환경에서 PR 프리뷰 띄우는 케이스도 정리해볼까 한다. 그건 좀 더 까다롭다.