넘겨받기부터 롤백 훈련까지
손으로 설치한 18개 구성 요소를 서비스 중단 없이 Git 관리로 옮기고, 직접 고치기·잘못된 커밋·Argo CD 정지를 훈련한 기록
GitOps는 클러스터에 무엇이 떠 있어야 하는지를 Git 저장소에 적어 두고, 클러스터 안의 도구가 그 상태를 계속 맞추게 하는 운영 방식입니다. Argo CD는 그 도구로, Git을 주기적으로 읽어 클러스터와 다르면 맞추고(동기화), 누가 클러스터를 직접 바꾸면 Git 상태로 되돌립니다(selfHeal). 이번 편에서는 1~8편에서 helm과 kubectl로 손으로 설치한 18개 구성 요소를 Argo CD 3.6.1이 관리하도록 옮겼습니다. 먼저 차이(diff)를 확인해 Argo CD 표시만 추가되는 것을 보고 동기화해서, 4분 37초 동안 서비스 중단 없이 넘겨받았습니다. 비밀값은 Sealed Secrets로 암호화해 Git에 올렸고, Argo CD 로그인은 7편의 Keycloak SSO로 붙여 그룹별로 관리자·읽기 전용 권한을 나눴습니다. 장애 훈련에서는 직접 고친 설정이 2.2초 만에 되돌아가고, 잘못된 커밋이 git revert로 3분 안에 복구되는 것을 확인했습니다. 반대로 Argo CD를 멈췄다 켤 때 repo-server가 없으면 Argo CD가 스스로 복구하지 못한다는 한계도 찾았습니다.
1~8편의 kind 클러스터(노드 3개, Envoy Gateway, kube-prometheus-stack, Loki·Fluent Bit, cert-manager 사설 CA HTTPS, Keycloak 2개 + PostgreSQL 2개, OpenTelemetry·Tempo)를 이어서 씁니다. Argo CD 차트 argo-cd 10.15.0(Argo CD v3.6.1), Sealed Secrets 차트 2.22.0(컨트롤러 0.41.2), argocd·kubeseal CLI는 공식 릴리스에서 체크섬을 확인하고 설치했습니다. Git 저장소는 GitHub 비공개 저장소 하나를 쓰며, 비밀번호·클라이언트 비밀값·봉인 키는 화면과 Git에 평문으로 남기지 않았습니다. 처음에는 HashiCorp Vault를 검토했지만, 로컬 환경에서 재시작마다 잠금 해제(unseal)를 해야 하는 운영 부담 때문에 Sealed Secrets로 정했습니다.
이번 편에서 만드는 구성
8편까지는 설치와 설정 변경을 모두 helm install, helm upgrade --reuse-values, kubectl apply로 손으로 했습니다. 이 방식은 "지금 클러스터에 무엇이 어떤 값으로 떠 있는가"가 사람 기억과 명령 기록에만 남는다는 문제가 있습니다. 실제로 kube-prometheus-stack은 4·6·7·8편에서 여섯 번 업그레이드되어, 최종 설정이 무엇인지 확인하려면 helm get values를 직접 봐야 했습니다.
이번 편에서는 이 모든 설정을 Git 저장소 하나에 모으고, Argo CD가 그 저장소를 기준으로 클러스터를 관리하게 바꿉니다.
| 구성 요소 | 위치 | 역할 |
|---|---|---|
| GitHub 비공개 저장소 | github.com | 클러스터의 원하는 상태(Helm 값, 매니페스트, 암호화된 비밀값) |
| Argo CD 3.6.1 | argocd 네임스페이스 | Git을 읽어 클러스터와 비교하고 동기화, 직접 바뀐 것은 되돌림 |
| Sealed Secrets 0.41.2 | kube-system | Git에 올린 암호문(SealedSecret)을 클러스터 안에서만 풀어 Secret으로 만듦 |
| Application 20개 | argocd | 1~8편 구성 요소 18개 + Argo CD 설정 + 루트(App of Apps) |
| Keycloak(7편) | keycloak | Argo CD 로그인(SSO)과 그룹 기반 권한 |
글의 순서는 GitOps 개념과 비밀값 방식 비교 → 저장소 준비 → Argo CD·Sealed Secrets 설치 → 기존 Helm 설치 넘겨받기 → SSO → 장애 훈련 3가지 → 모니터링과 체크리스트입니다.
GitOps와 Argo CD의 동작 방식
흔히 쓰는 CI/CD 배포는 파이프라인(GitHub Actions 등)이 클러스터에 접속해 kubectl apply나 helm upgrade를 실행하는 push 방식입니다. GitOps는 반대로, 클러스터 안의 도구가 Git을 읽어 와서 스스로 맞추는 pull 방식입니다.
| 구분 | push 방식(CI가 배포) | GitOps(Argo CD가 가져옴) |
|---|---|---|
| 클러스터 접근 권한 | CI 시스템이 클러스터 관리자 권한을 가짐 | 클러스터 밖에는 권한이 없어도 됨(Argo CD가 Git을 읽기만 함) |
| 누가 직접 바꾸면 | 다음 배포 때까지 그대로 남음 | 차이를 감지하고 Git 상태로 되돌림(selfHeal) |
| 지금 상태 확인 | 마지막 배포 기록을 찾아봐야 함 | Git의 최신 커밋이 곧 원하는 상태 |
| 롤백 | 이전 버전으로 다시 배포 | git revert 후 커밋 |
Argo CD는 여러 구성 요소로 나뉩니다. 이 구조를 알아야 뒤의 장애 훈련 결과를 이해할 수 있습니다.
| 구성 요소 | 하는 일 |
|---|---|
| application-controller | 클러스터의 실제 상태와 원하는 상태를 비교하고 동기화·selfHeal을 실행하는 핵심 |
| repo-server | Git을 받아 Helm·kustomize로 매니페스트를 그려(render) controller에 넘김 |
| server | 웹 화면과 API, 로그인(SSO) 처리 |
| redis | 그려진 매니페스트와 상태 캐시 |
| applicationset-controller | ApplicationSet으로 Application을 여러 개 생성(이번 편에서는 쓰지 않음) |
자주 나오는 용어도 정리합니다.
- Application: "이 Git 경로(또는 Helm 차트)를 이 네임스페이스에 배포한다"는 Argo CD의 단위입니다.
- Sync 상태: Git과 클러스터가 같으면 Synced, 다르면 OutOfSync입니다.
- Health 상태: 리소스가 실제로 정상인지(Healthy, Progressing, Degraded)입니다. Synced여도 Healthy가 아닐 수 있습니다.
- 자동 동기화(automated): Git이 바뀌면 사람이 버튼을 누르지 않아도 적용합니다.
- selfHeal: 클러스터가 직접 바뀌면 Git 상태로 되돌립니다.
- prune: Git에서 지운 리소스를 클러스터에서도 지웁니다. 실수 한 번이 데이터 삭제로 이어질 수 있어 신중하게 켭니다.
비밀값을 Git에 어떻게 두나
GitOps에서 가장 먼저 부딪히는 문제는 비밀값입니다. 7편에서 Keycloak 관리자 비밀번호, 사용자 비밀번호, Grafana OAuth 클라이언트 비밀값을 클러스터 안에서 무작위로 만들어 Secret에만 넣었습니다. Git이 "원하는 상태의 전부"가 되려면 이 값들도 어딘가에 있어야 하지만, 평문으로 Git에 올릴 수는 없습니다.
| 방식 | Git에 올라가는 것 | 장점 | 단점 |
|---|---|---|---|
| Secret을 손으로 만들기 | 이름만 참조 | 가장 간단 | 클러스터를 다시 만들 때 수동 단계가 남음 |
| Sealed Secrets | 클러스터 공개 키로 암호화한 SealedSecret | 서버가 따로 없고 재시작해도 손댈 것 없음 | 컨트롤러 개인 키를 잃으면 복구 불가 |
| SOPS | 암호화된 파일 | 여러 키 관리 서비스와 연동 | Argo CD 플러그인(KSOPS)이 필요 |
| External Secrets + Vault | "어느 경로를 읽을지"만 | 비밀값을 한곳에서 관리, 회전·감사 | Vault 서버 운영, 재시작 때 잠금 해제(unseal) 필요 |
Argo CD 공식 문서는 비밀값을 "대상 클러스터에서 채우는 방식"(Sealed Secrets, External Secrets 등)을 강하게 권장합니다. argocd-vault-plugin처럼 매니페스트를 그리는 단계에서 비밀값을 끼워 넣으면, 그려진 매니페스트가 Argo CD의 Redis 캐시에 평문으로 남기 때문입니다. https://argo-cd.readthedocs.io/en/stable/operator-manual/secret-management/
처음에는 HashiCorp Vault를 검토했습니다. 운영 모드 Vault는 재시작할 때마다 잠긴 상태로 뜨고 unseal 키로 풀어야 합니다. 클라우드에서는 KMS로 자동으로 풀 수 있지만, 로컬에서는 WSL이나 PC를 다시 켤 때마다 사람이 풀어야 합니다. 그래서 이 실습에서는 서버 없이 동작하는 Sealed Secrets를 골랐습니다.
저장소 준비: 비공개 저장소와 구조
GitHub에 비공개 저장소를 하나 만들었습니다. 로컬 작업 폴더는 블로그 노트(Obsidian 볼트) 안에 두었는데, 볼트 자체도 Git 저장소이고 플러그인이 10분마다 자동으로 커밋·push하고 있었습니다. 이 상태로 볼트 안에 새 저장소를 만들면 두 가지 문제가 생깁니다.
- 바깥 저장소가 안쪽 저장소를 파일이 아니라 커밋 번호(gitlink)로만 기록하고 경고가 반복됩니다. → 바깥 저장소의
.gitignore에 새 폴더를 추가했습니다. - Windows Git의
core.autocrlf=true때문에 줄바꿈이 섞입니다. → 새 저장소에.gitattributes(* text=auto eol=lf)를 넣어 저장소에는 항상 LF로 들어가게 했습니다.
볼트 저장소의 하위 폴더를 Argo CD가 직접 읽게 하는 방법도 있지만, 그러면 Argo CD 접근 키로 볼트 전체(개인 노트)를 읽을 수 있게 됩니다. Argo CD가 읽는 저장소는 배포에 필요한 것만 담도록 분리하는 것이 원칙입니다.
├─ bootstrap/ # 처음에 한 번만 kubectl apply
│ ├─ root.yaml # App of Apps 루트 Application
│ └─ oci-helm-repositories.yaml
├─ apps/ # 구성 요소별 Application 20개(파일 앞 숫자 = 설치 순서)
├─ platform/<구성 요소>/ # Helm 값과 매니페스트, SealedSecret
└─ docs/ # Git으로 관리하지 못하는 수동 단계 기록

.gitignore에는 개인 키, kubeconfig, 백업 파일, 평문 Secret 파일 패턴을 넣고, 커밋 전에는 민감정보 검사를 돌려 46개 파일 모두 통과한 것을 확인했습니다.
Argo CD 설치: 처음 한 번은 helm으로
Argo CD도 결국 Argo CD가 관리하게 할 것이지만, 처음 한 번은 누군가 설치해야 합니다(부트스트랩). 같은 차트와 값 파일로 helm 설치를 한 뒤, 나중에 Argo CD가 자기 자신을 넘겨받게 했습니다.
| 항목 | 기본값 | 바꾼 값 | 이유 |
|---|---|---|---|
| server.insecure | false | true | TLS는 Gateway(6편)에서 끝냄. 끄지 않으면 Argo CD가 다시 HTTPS로 돌려보내 무한 리다이렉트 |
| url | 없음 | https://argocd.localhost:8443 | SSO 콜백과 링크의 기준 주소 |
| timeout.reconciliation | 120s(+지터 60s) | 그대로 | 로컬 클러스터는 GitHub 웹훅을 받을 수 없어 이 주기로 Git을 확인 |
| Application 헬스 체크 | 없음 | Lua 스크립트 추가 | App of Apps에서 설치 순서(sync wave)가 앞 Application의 Healthy를 기다리게 함 |
| dex, notifications | 켜짐 | 끔 | Keycloak과 OIDC로 직접 연결, 알림은 다루지 않음 |
| metrics | 꺼짐 | ServiceMonitor(release: kps) | 4편 Prometheus로 수집 |
-n argocd --create-namespace -f platform/argocd/values.yaml --wait
설치는 48초 걸렸습니다. 6편 인증서에 argocd.localhost를 추가하자 cert-manager가 인증서를 다시 발급했고(SAN: grafana, keycloak, argocd), HTTPRoute를 붙여 https://argocd.localhost:8443이 200을 반환했습니다. HTTP로 들어오면 HTTPS로 넘어가는 것(301)과 Grafana가 그대로 200인 것도 함께 확인했습니다.

차트 기본값에서 관리자 계정(admin)이 켜져 있고, 초기 비밀번호는 argocd-initial-admin-secret에 들어 있습니다. 이 실습에서는 SSO가 멈췄을 때 들어갈 비상용 계정으로 남겨 두었습니다(7편 Grafana와 같은 원칙). 운영에서는 비밀번호를 바꾼 뒤 초기 Secret을 지우고, 비상용 계정의 사용 기록을 감사합니다.
Sealed Secrets: 암호문만 Git에
설치부터 문제가 하나 있었습니다. 여러 글에 나오는 차트 저장소 주소가 404를 반환했습니다.
Error: ... failed to fetch https://bitnami-labs.github.io/sealed-secrets/index.yaml : 404 Not Found
# 현재 주소
helm repo add sealed-secrets https://bitnami.github.io/sealed-secrets
차트 2.22.0(컨트롤러 0.41.2)을 kube-system에 설치했습니다(18초). 이름은 kubeseal이 기본으로 찾는 sealed-secrets-controller로 맞췄습니다. 설치하자마자 컨트롤러가 봉인 키 쌍을 만듭니다. 이 개인 키를 잃으면 Git에 올린 SealedSecret을 아무도 풀 수 없으므로, 가장 먼저 저장소 밖에 백업했습니다.
kubectl -n kube-system get secret -l sealedsecrets.bitnami.com/sealed-secrets-key -o yaml \
> /root/k8s-backup/sealed-secrets-key-20261108.yaml # 권한 600, Git·볼트 밖
kubeseal CLI는 GitHub 릴리스에서 받아 체크섬 파일로 확인(OK)한 뒤 설치했습니다. 기존 Secret 4개는 값이 화면이나 파일에 평문으로 남지 않도록 파이프로만 흘려 암호화했습니다.
kubectl -n keycloak get secret lab-realm-secrets -o json \
| python3 -c '...이름·네임스페이스·data만 남김...' \
| kubeseal --format yaml > platform/keycloak/sealed-realm-secrets.yaml
| Secret | 네임스페이스 | 키 | 용도 |
|---|---|---|---|
| keycloak-bootstrap-admin | keycloak | username, password | Keycloak 첫 관리자(7편) |
| lab-realm-secrets | keycloak | grafana-client-secret, 사용자 3명 비밀번호 | realm 가져오기(7편) |
| grafana-oauth | monitoring | client-secret | Grafana SSO(7편) |
| grafana-admin | monitoring | admin-user, admin-password | Grafana 관리자(차트가 만들던 값을 옮김) |
SealedSecret이 만들 Secret과 같은 이름의 Secret이 이미 있으면, 컨트롤러는 "관리하지 않는 Secret"이라며 넘겨받지 않습니다. 기존 Secret에 sealedsecrets.bitnami.com/managed=true 주석을 먼저 붙이자, 동기화 후 소유자가 SealedSecret으로 바뀌고 값은 그대로 유지됐습니다.
SealedSecret은 기본으로 이름과 네임스페이스에 묶여(strict) 암호화됩니다. 같은 값을 다른 네임스페이스에서 쓰려면 그 네임스페이스용으로 따로 암호화해야 합니다. 또 컨트롤러는 30일마다 새 봉인 키를 추가하므로(옛 키는 남아 기존 SealedSecret은 계속 풀림), 키 백업도 그때마다 다시 해야 합니다.
저장소 연결: 읽기 전용 deploy key
Argo CD가 비공개 저장소를 읽으려면 자격 증명이 필요합니다. 저장소 하나만, 읽기만 할 수 있는 GitHub deploy key를 썼습니다.
- WSL에서 ed25519 키 쌍을 만들고 개인 키는 권한 600으로 저장소 밖에 둡니다.
- 공개 키만 GitHub 저장소 Settings → Deploy keys에 등록하고, "Allow write access"는 체크하지 않습니다.
- 개인 키는 파일에서 바로 Argo CD의 저장소 Secret으로 넣습니다(화면에 출력하지 않음). 이 Secret은 Git에 넣을 수 없는, 처음 한 번 손으로 넣는 비밀값입니다.
--from-literal=type=git --from-literal=url=<비공개 저장소의 SSH 주소> \
--from-file=sshPrivateKey=/root/k8s-backup/argocd-deploy-key --dry-run=client -o yaml \
| kubectl label --local -f - argocd.argoproj.io/secret-type=repository -o yaml | kubectl apply -f -
cert-manager(quay.io), Envoy Gateway(docker.io), Tempo·OTel Operator(ghcr.io)는 처음 설치할 때 OCI 레지스트리에서 받은 차트라, Argo CD에도 OCI Helm 저장소로 등록했습니다. 공개 레지스트리라 자격 증명이 없어 이 등록 파일은 Git에 그대로 둡니다. argocd repo list에서 Git 저장소 1개와 OCI 저장소 4개가 모두 Successful이었습니다.
argocd CLI는 --core 모드로 썼습니다. Argo CD API 서버 대신 kubeconfig로 직접 동작해서, 관리자 비밀번호를 꺼내지 않고도 diff와 동기화를 할 수 있습니다.
App of Apps와 설치 순서(sync wave)
Application 20개를 하나씩 kubectl apply하면 다시 손으로 관리하는 것과 같습니다. 그래서 "apps/ 폴더의 Application을 모두 만든다"는 루트 Application 하나만 처음에 넣고, 나머지는 루트가 만들게 했습니다. 이것을 App of Apps 패턴이라고 합니다.

구성 요소 사이에는 순서가 있습니다. 예를 들어 Certificate는 cert-manager의 CRD가 있어야 만들 수 있고, Grafana는 관리자 Secret이 먼저 있어야 파드가 뜹니다. Application에 argocd.argoproj.io/sync-wave 주석으로 순서를 정했습니다.
| wave | Application | 먼저 있어야 하는 이유 |
|---|---|---|
| -6 | sealed-secrets | 다른 앱의 SealedSecret을 풀어야 함 |
| -5 | monitoring-secrets | Grafana 관리자·OAuth Secret이 Grafana 파드보다 먼저 |
| -4 | kps | ServiceMonitor 등 모니터링 CRD를 다른 차트가 사용 |
| -3 | cert-manager, envoy-gateway, cnpg, keycloak-operator | 인증서·Gateway·DB·Keycloak CRD와 운영자 |
| -2 | certificates, gateway, cluster-dns, otel-operator | 위 CRD를 쓰는 리소스, OTel 웹훅 인증서는 cert-manager가 발급 |
| -1 | loki, fluent-bit, tempo, argocd-config | 관측 도구와 Argo CD 설정 |
| 0 | keycloak, observability, monitoring, argocd | Keycloak·Collector·데이터 소스, Argo CD 자기 관리 |
Argo CD 1.8부터 Application 리소스 자체의 헬스 판단이 기본에서 빠졌습니다. 그래서 App of Apps에서 wave를 나눠도, 부모는 자식 Application을 "만들기만" 하고 바로 다음 wave로 넘어갑니다. argocd-cm에 Application 헬스 체크(Lua)를 넣어야 "앞 wave의 자식이 Healthy가 될 때까지" 기다립니다.
Helm 차트 Application은 차트 저장소와 Git의 값 파일을 함께 쓰는 다중 소스(sources)로 만들었습니다. 그리고 releaseName을 처음 helm으로 설치할 때의 릴리스 이름(kps, eg 등)과 똑같이 맞췄습니다. 이름이 다르면 리소스 이름이 바뀌어, 기존 것을 넘겨받는 대신 새로 하나 더 만들게 됩니다.
기존 Helm 설치 넘겨받기: diff 먼저, 동기화는 나중에
가장 위험한 순간은 이미 운영 중인 구성 요소를 Argo CD가 처음 동기화할 때입니다. Git의 값이 실제 설치와 조금만 달라도, 동기화하는 순간 그 차이가 운영 환경에 그대로 반영됩니다. 그래서 처음에는 모든 Application의 자동 동기화를 꺼 둔 채로 넣고, 차이부터 확인했습니다.
argocd app diff 결과를 리소스별로 나눠 "Argo CD 추적 표시(argocd.argoproj.io/tracking-id)만 추가됨 / 새로 만들어짐 / 그 밖의 변경"으로 분류하는 스크립트를 만들고, 그 밖의 변경이 0인 앱만 동기화했습니다.
| Application | 추적 표시만 | 그 밖의 변경 | 내용 |
|---|---|---|---|
| kps | 114개 | 1개 | Grafana가 관리자 Secret을 kps-grafana → grafana-admin에서 읽음(의도한 변경) |
| argocd | 44개 | 0 | - |
| cert-manager | 41개 | 0 | - |
| loki | 15개 | 0 | - |
| otel-operator | 14개 | 0 | - |
| keycloak-operator | 9개 | 5개 | RoleBinding subject에 namespace 명시(kustomize 변환, 무해) |
| keycloak | 6개 | 0(새 리소스 2) | SealedSecret 2개 추가 |
나머지 앱도 모두 추적 표시만 추가되는 차이였습니다. 8편까지 helm으로 쌓아 온 값과 Git에 옮긴 값이 정확히 일치한다는 뜻입니다.
kube-prometheus-stack의 Grafana는 관리자 비밀번호를 지정하지 않으면 차트가 무작위로 만듭니다. Argo CD는 helm install이 아니라 helm template으로 차트를 그려서 적용하므로, 차트가 그릴 때마다 무작위 값이 달라질 수 있습니다. 그래서 같은 값을 SealedSecret(grafana-admin)으로 옮기고 grafana.admin.existingSecret으로 읽게 했습니다.
envoy-gateway 동기화가 실패했습니다: CustomResourceDefinition "httproutes.gateway.networking.k8s.io" is invalid: metadata.annotations: Too long: may not be more than 262144 bytes. 기본 동기화(클라이언트 측 apply)는 리소스 전체를 last-applied-configuration 주석에 복사해 두는데, Gateway API CRD는 이 주석 한도(256KiB)를 넘습니다. 재시도마다 같은 실패가 반복되어 작업을 중단시켰고(서비스 영향 없음), 차트 Application에 ServerSideApply=true를 추가해 해결했습니다.
수정 커밋을 push한 뒤 wave 순서대로 동기화했습니다. 18개가 4분 37초(21:22:24~21:27:01) 만에 모두 Synced·Healthy가 됐고, 시작과 끝 모두 Grafana·Keycloak·Argo CD가 200이었습니다. 의도한 대로 Grafana만 관리자 Secret을 바꾸면서 한 번 재시작됐습니다(kps 79초).


넘겨받은 뒤에는 Grafana 관리자 로그인(grafana-admin Secret 값)과 alice의 Keycloak SSO 로그인이 그대로 되는 것을 확인하고, 자동 동기화(selfHeal)를 켜는 커밋을 push했습니다. prune은 설정 ConfigMap만 있는 두 앱(monitoring, observability)에만 켜고, DB가 있는 keycloak과 기반 구성 요소는 꺼 두었습니다.
마지막으로 helm 설치 기록 Secret(sh.helm.release.v1.*) 15개를 지웠습니다. kps는 업그레이드를 거듭해 기록이 6개였습니다. 이 기록은 "helm이 이 앱을 설치했다"는 메모일 뿐이라, 지워도 파드와 서비스는 그대로입니다. 남겨 두면 누군가 helm upgrade를 실행했을 때 Argo CD와 다른 값으로 덮어쓰고, selfHeal이 되돌리는 다툼이 생깁니다. 삭제 후 helm list -A는 비었고 20개 앱은 계속 Synced·Healthy였습니다.

keycloak 앱을 열면 Git의 SealedSecret이 클러스터에서 Secret으로 풀리고, Keycloak 리소스가 StatefulSet과 파드로, CloudNativePG Cluster가 DB 파드와 PVC로 이어지는 관계가 한 화면에 보입니다.
Argo CD 로그인을 Keycloak SSO로
7편에서 Grafana를 Keycloak SSO로 붙였듯이, Argo CD도 Keycloak으로 로그인하고 그룹별로 권한을 나누려고 했습니다. 그런데 막히는 지점이 세 가지 있었습니다.
| 막힌 지점 | 원인 | 해결 |
|---|---|---|
| realm에 그룹·스코프 추가 | KeycloakRealmImport는 새 realm 생성만 하고 기존 realm을 고치지 않음 | 관리 API(kcadm)로 한 번 실행, 스크립트는 저장소 docs/에 기록 |
| 클라이언트를 리소스로 생성 | KeycloakOIDCClient(v2alpha1)는 미리보기 기능 client-admin-api:v2를 켜야 동작, groups 매퍼 필드도 없음 | 운영 Keycloak에 미리보기 기능을 켜지 않고 kcadm으로 생성, 비밀값은 SealedSecret에서 |
| 파드 안에서 Keycloak 주소 | Argo CD 서버 파드가 발급자 주소 https://keycloak.localhost:8443을 직접 호출하는데, 파드 안에서는 이 이름이 풀리지 않음 | CoreDNS rewrite + 8443 포트 Service(Git 관리) |
KeycloakOIDCClient를 만들자 상태에 Cannot create/update because the server does not have client-admin-api:v2 enabled가 표시됐습니다. 7편의 Grafana는 토큰 교환 주소를 내부 주소로 따로 지정할 수 있었지만, Argo CD는 발급자 주소 하나로 설정을 찾기 때문에 파드 안에서도 브라우저와 같은 주소로 Keycloak에 닿아야 합니다. 적용 전 Argo CD 서버 파드에서 getent hosts keycloak.localhost는 rc=2(이름 없음)였습니다.
rewrite stop name exact keycloak.localhost lab-gateway-https.envoy-gateway-system.svc.cluster.local answer auto
# 같은 Envoy 파드를 가리키는 고정 이름 Service: 8443 → Envoy HTTPS 리스너(10443)
apiVersion: v1
kind: Service
metadata: { name: lab-gateway-https, namespace: envoy-gateway-system }
spec:
selector: { gateway.envoyproxy.io/owning-gateway-name: lab-gateway }
ports: [{ port: 8443, targetPort: 10443 }]
Envoy Gateway가 만드는 Service는 이름에 해시가 붙고 80·443만 열려 있어서, 고정 이름과 8443 포트를 가진 Service를 따로 만들었습니다. 적용 후 파드에서 keycloak.localhost가 Gateway의 서비스 IP로 풀렸습니다.
kcadm 스크립트는 groups 클라이언트 스코프(그룹 이름을 토큰의 groups 클레임에 넣는 매퍼)를 realm 기본 스코프로 등록하고, argocd-admins(alice)·argocd-viewers(bob) 그룹과 argocd 클라이언트를 만듭니다. 관리자 계정은 Keycloak 파드의 환경 변수에서 읽고, 클라이언트 비밀값은 SealedSecret이 만든 Secret에서 표준 입력으로 넘겨 화면에 나오지 않게 했습니다.
kcadm.sh get client-scopes -q name=groups가 이름 필터를 무시하고 첫 항목을 돌려줘서, 스크립트가 "groups 스코프가 이미 있다"고 잘못 판단했습니다. 결과 목록을 받아 이름으로 직접 거르도록 고쳤습니다. 수동 스크립트일수록 "다시 실행해도 같은 결과"가 되도록 확인하는 단계가 필요합니다.
Argo CD 쪽 설정은 값 파일에 넣었습니다. 클라이언트 비밀값은 $Secret이름:키 형식으로 SealedSecret이 만든 Secret을 가리키고, 6편 사설 CA의 공개 인증서를 rootCA로 넣었습니다. 그룹이 없는 사용자는 아무 권한도 없게 기본 역할을 비웠습니다.
cm:
oidc.config: |
name: Keycloak
issuer: https://keycloak.localhost:8443/realms/lab
clientID: argocd
clientSecret: $argocd-oidc:client-secret
requestedScopes: ["openid", "profile", "email", "groups"]
enablePKCEAuthentication: true
rootCA: |
-----BEGIN CERTIFICATE----- # 6편 사설 CA(공개 인증서)
rbac:
policy.default: ""
scopes: "[groups]"
policy.csv: |
g, argocd-admins, role:admin
g, argocd-viewers, role:readonly


브라우저 로그인 흐름을 curl로도 재현해 세 사용자의 권한을 확인했습니다.
| 사용자 | 그룹 | 보이는 앱 | kps 동기화 |
|---|---|---|---|
| alice | argocd-admins | 20개 | 허용(200) |
| bob | argocd-viewers | 20개 | 거부(403) |
| carol | 없음 | 0개 | 거부(403) |

bob은 앱을 볼 수는 있지만 SYNC를 누르면 permission denied: applications, sync로 거부됩니다. 사람 계정 관리를 Keycloak 한 곳에 모으고, 권한은 그룹으로 주는 7편의 원칙이 Argo CD에도 그대로 적용됐습니다.
훈련 1: 누가 kubectl로 직접 바꾸면
GitOps에서 가장 흔한 사고는 누군가 급하게 kubectl로 클러스터를 직접 고치는 것입니다. Grafana를 0개로 줄여 봤습니다.
21:50:27 Argo CD가 replicas=1로 되돌림 (+2.2초, initiatedBy: automated)
21:50:42 Grafana 다시 200 (+16.8초), 그사이 0.5초 간격 확인 20회 실패
selfHeal은 2.2초 만에 설정을 되돌렸지만, Grafana 파드가 다시 뜨는 동안 16.8초는 접속이 되지 않았습니다. selfHeal은 "원래대로 돌려놓는 것"이지 "사고가 나지 않게 막는 것"이 아닙니다.
GitOps로 옮긴 뒤에는 사람에게 클러스터 쓰기 권한(kubectl)을 넓게 주지 않는 것이 짝을 이룹니다. 긴급 조치가 필요하면 Git에 커밋하거나, 해당 Application의 자동 동기화를 잠깐 끄고 작업한 뒤 결과를 Git에 반영합니다. 그렇지 않으면 selfHeal이 긴급 조치를 몇 초 만에 되돌립니다.
훈련 2: 잘못된 커밋과 git revert 롤백
OTel Collector(8편)의 이미지 태그를 존재하지 않는 0.999.0으로 바꿔 커밋하고, 웹훅 없이 기본 주기로 반영되는 시간을 쟀습니다.
| 시각 | 일 | 경과 |
|---|---|---|
| 21:51:06 | 잘못된 커밋 push | - |
| 21:54:20 | Argo CD 반영, 새 파드 ErrImagePull → ImagePullBackOff 반복 | 3분 14초 |
| (그동안) | 옛 Collector 파드는 1/1 Running 유지, 상태는 Synced·Progressing | - |
| 21:56:14 | git revert 커밋 push | - |
| 21:59:09 | Argo CD 반영 | 2분 55초 |
| 21:59:12 | Synced·Healthy | +3초 |

Argo CD의 HISTORY AND ROLLBACK에는 두 커밋이 모두 "automated sync policy"로 배포된 기록이 남았고, 잘못된 버전이 4분 50초 동안 활성 상태였던 것도 보입니다. 배포 자체는 매번 1초 만에 끝났고, 시간 대부분은 Argo CD가 Git의 변경을 알아채기까지(폴링 120초 + 지터) 걸렸습니다.
- Deployment의 롤링 업데이트 덕분에 새 파드가 뜨지 못해도 옛 파드가 계속 일했습니다. 트레이스 수집이 끊기지 않았습니다. StatefulSet처럼 옛 파드를 먼저 내리는 리소스였다면 장애로 이어졌을 것입니다.
- 상태가 내내 "Synced·Progressing"이었습니다. Synced는 "Git대로 적용했다"는 뜻일 뿐 "정상"이 아닙니다. Degraded가 되려면 Deployment의 진행 기한(기본 600초)을 넘겨야 하므로, Progressing이 오래 이어지는 것도 알림 대상입니다.
- 롤백은 Argo CD 화면의 Rollback 버튼보다
git revert가 원칙입니다. 화면에서 되돌리면 클러스터만 예전 버전이 되고 Git에는 잘못된 커밋이 그대로 남아, 자동 동기화를 다시 켜는 순간 잘못된 버전으로 돌아갑니다.
로컬 클러스터는 GitHub 웹훅을 받을 수 없어 반영까지 최대 3분이 걸렸습니다. 운영에서는 Argo CD에 웹훅을 연결하면 커밋 직후 반영됩니다. 웹훅을 받을 수 없는 환경이라면 timeout.reconciliation을 줄이되, Git 서버와 repo-server 부하가 함께 늘어나는 것을 고려합니다.
훈련 3: Argo CD 자체가 멈추면
Argo CD의 controller, server, repo-server, applicationset을 모두 0개로 줄였습니다.
22:01:25 정지 중 드리프트: Grafana 0개로
22:01:56 30초 뒤에도 Grafana 0개(되돌리는 주체 없음)
22:01:57 controller만 1개로 복구
→ 4분이 넘도록 아무것도 복구되지 않음, 앱 16개 상태 Unknown
22:07:37 repo-server를 사람이 1개로
+39초 Argo CD가 argocd-server·applicationset을 스스로 복구
+187초 Grafana replicas 1로 복구
+202초 Grafana·Argo CD 모두 200
Argo CD가 멈춰도 이미 떠 있는 앱은 영향이 없었습니다. Argo CD는 배포 도구일 뿐 요청이 지나가는 경로에 있지 않기 때문입니다. 대신 멈춘 동안 생긴 드리프트는 아무도 되돌리지 않았습니다.
가장 중요한 발견은 controller만 되살렸을 때입니다. controller는 Git의 매니페스트를 직접 그리지 않고 repo-server에 맡깁니다. repo-server가 없으니 Argo CD 자신의 Application(argocd)조차 그릴 수 없어서, 자기 repo-server를 되살리지 못했습니다. Argo CD가 자기 자신을 관리하는 구성에는 이런 한계가 있습니다.
Argo CD 복구 절차서에는 "repo-server와 controller를 사람이 먼저 띄운다"가 들어가야 합니다. 운영에서는 repo-server를 2개 이상으로 두고, PodDisruptionBudget으로 동시에 내려가지 않게 하며, Argo CD 컴포넌트가 사라졌을 때 울리는 알림(아래 모니터링 절)을 둡니다.
복구 후 keycloak 앱 하나가 OutOfSync로 남았습니다. 앞에서 지운 KeycloakOIDCClient가 다시 생겨 있었는데, Git에서 빼는 커밋이 모든 앱에 반영되기 전에 지웠기 때문에 Argo CD가 옛 커밋 기준으로 다시 만든 것이었습니다. 이 앱은 prune이 꺼져 있어 Git에서 빠져도 자동으로 지워지지 않았습니다. 다시 지우자 20개 모두 Synced·Healthy가 됐습니다. Git에서 뺀 리소스는 반영을 확인한 뒤에 지워야 합니다.
모니터링: Argo CD 자신과 동기화 상태
Argo CD의 controller, server, repo-server 메트릭을 ServiceMonitor(release: kps)로 4편 Prometheus에 모았습니다. 앱 20개는 argocd_app_info에 sync_status·health_status 라벨로 나타납니다(이 시점 Synced·Healthy 20).
count(argocd_app_info{sync_status!="Synced"}) > 0
# 정상이 아닌 앱(Degraded, Missing, Progressing 지속)
count by (name, health_status) (argocd_app_info{health_status!="Healthy"})
# 동기화 실패
sum(increase(argocd_app_sync_total{phase!="Succeeded"}[30m])) > 0
# Argo CD controller·repo-server가 사라짐(훈련 3)
absent(up{job="argocd-application-controller-metrics"} == 1)
absent(up{job="argocd-repo-server-metrics"} == 1)
argocd_app_sync_total은 controller가 다시 시작되면 0부터 다시 셉니다(훈련 3 뒤 Succeeded 3). 그래서 누적값이 아니라 increase로 봅니다. Sealed Secrets 컨트롤러도 sealed_secrets_controller_condition_info, sealed_secrets_controller_unseal_requests_total 같은 메트릭을 내보내므로, 복호화 실패를 알림으로 받을 수 있습니다.
운영 체크리스트
이번 실습에서 확인한 것과 운영으로 옮길 때 추가로 할 일을 정리합니다.
- Argo CD가 읽는 저장소는 배포에 필요한 것만 담은 별도 저장소로 두고, 읽기 전용 deploy key로 연결한다.
- 비밀값은 평문으로 Git에 넣지 않는다. Sealed Secrets라면 봉인 키를 저장소 밖에 백업하고, 키가 갱신될 때(기본 30일)마다 백업도 다시 한다.
- 기존 설치를 넘겨받을 때는 자동 동기화를 끈 채로 diff를 먼저 보고, Argo CD 추적 표시 외의 차이를 0으로 만든 뒤 동기화한다.
- Helm 릴리스 이름(releaseName)을 기존 설치와 같게 맞추고, 넘겨받은 뒤에는 helm 설치 기록을 정리해 helm CLI와 Argo CD가 다투지 않게 한다.
- 큰 CRD를 가진 차트는 ServerSideApply로 동기화한다.
- 차트가 무작위로 만드는 값(관리자 비밀번호 등)은 existingSecret으로 바꿔 고정한다.
- App of Apps의 sync wave가 의미 있으려면 Application 헬스 체크를 넣는다.
- prune은 상태가 없는 설정부터 켜고, DB·인증서 같은 기반 구성 요소는 신중하게 판단한다.
- Git에서 뺀 리소스는 모든 앱에 반영된 것을 확인한 뒤에 지운다.
- selfHeal이 있어도 사람의 kubectl 쓰기 권한은 줄이고, 긴급 조치 절차(자동 동기화 잠시 끄기 → Git 반영)를 정해 둔다.
- Synced가 Healthy를 뜻하지 않으므로 Progressing·Degraded 지속과 동기화 실패에 알림을 건다.
- 롤백은 git revert로 하고, 웹훅을 연결해 반영 지연을 줄인다.
- Argo CD 복구 절차에 repo-server·controller 수동 기동을 넣고, repo-server는 2개 이상으로 둔다.
- Git으로 관리하지 못하는 수동 단계(이번 Keycloak kcadm)는 저장소에 스크립트로 남기고 다시 실행해도 같은 결과가 되게 만든다.
- SSO가 멈췄을 때 쓸 비상용 관리자 계정을 남기고, 사용 기록을 감사한다.
자주 묻는 질문
selfHeal을 켜면 장애 때 kubectl로 긴급 조치를 못 하나요?
그대로 두면 몇 초 안에 되돌아갑니다(이 실습에서 2.2초). 긴급 조치가 필요하면 해당 Application의 자동 동기화를 잠깐 끄고 작업한 뒤, 같은 변경을 Git에 커밋하고 다시 켭니다. 가장 좋은 것은 긴급 조치도 Git 커밋으로 하는 습관입니다.
prune은 왜 일부 앱에만 켰나요?
prune은 Git에서 지운 리소스를 클러스터에서도 지웁니다. 파일 이동이나 경로 실수 한 번으로 DB나 인증서가 지워질 수 있어서, 이 실습에서는 설정 ConfigMap만 있는 앱에만 켰습니다. 대신 Git에서 뺀 리소스를 사람이 정리해야 하며, 이번 훈련 3에서 그 대가(다시 생긴 리소스)도 확인했습니다.
Sealed Secrets 봉인 키를 잃어버리면 어떻게 되나요?
Git에 있는 SealedSecret을 아무도 풀 수 없습니다. 원래 값을 다시 만들어(비밀번호 재설정 등) 새 키로 다시 암호화해야 합니다. 그래서 설치 직후와 키 갱신 때마다 저장소 밖에 키를 백업하고, 새 클러스터에서는 컨트롤러보다 먼저 키를 복원합니다.
웹훅 없이 반영이 3분씩 걸리는 게 문제라면?
Argo CD 화면이나 CLI에서 해당 앱을 새로고침(refresh)하면 바로 Git을 다시 읽습니다. 근본적으로는 Git 서버의 웹훅을 Argo CD로 연결하는 것이 좋고, 웹훅을 받을 수 없는 환경이라면 timeout.reconciliation을 줄이되 Git과 repo-server의 부하를 함께 봅니다. 이 실습에서는 push 후 루트만 새로고침하면 자식 앱이 옛 커밋 기준으로 Synced로 보이는 일도 있어, 자식 앱까지 새로고침해야 했습니다.
작성 방식과 다음 편
이 글의 명령, 오류 메시지, 측정값은 1편의 실습 PC에서 직접 실행한 것이며, 설정은 Argo CD, Sealed Secrets, Keycloak, CoreDNS 공식 문서를 기준으로 했습니다. 비밀번호, 클라이언트 비밀값, 봉인 키, 저장소 접근 키는 출력하지 않았고, 화면의 계정 이름과 IP는 가렸습니다. 실행 기록 정리와 글 작성에 AI 도구를 함께 사용했습니다. 이로써 1편의 빈 클러스터에서 시작해 Gateway, 모니터링, 로그, HTTPS, SSO, 트레이스까지 쌓은 구성이 모두 Git 저장소 하나로 관리되는 상태가 됐습니다.

























