Wintersalmon | Blog

두 개의 컨트롤 플레인, 명확한 소유권: 상태는 FluxCD, 가시성은 ArgoCD

5 min read

게임이 멀티플레이어로 출시되기 시작하면서 main에 푸시할 때마다 친구들의 매치가 중단되는 일이 벌어졌고, 그래서 이전 포스트의 클러스터에는 staging 레인과 시각적인 sync 게이트가 필요해졌습니다. 3주 후, FluxCD는 여전히 모든 것을 쓰고 ArgoCD는 대시보드 역할을 맡게 되었습니다. ArgoCD가 functions 네임스페이스에서 의존하는 SOPS 시크릿을 복호화할 수 없었기 때문입니다. 거래는 깔끔했습니다: 쓰는 쪽 하나, 보는 쪽 하나, 소유권 다툼 없음.

  • Staging: 동일한 클러스터, 모든 이름에 -stag 접미사, min-scale: 0, 별도의 mongodb-stag (5Gi PVC, replica set rs0-stag).
  • WireGuard에서 MongoDB로 가는 길은 세 가지 독립적인 수정이 필요했습니다 — route, masquerade, NetworkPolicy — 그리고 URI에 directConnection=true까지.
  • ArgoCD를 writer로 두는 안은 SOPS에서 죽었고, FluxCD가 단독 writer로 남았으며, ArgoCD는 argocd.wintersalmon.com에서 read-only + 수동 sync UI가 되었습니다.
  • Loki + functions/_shared/src/logger/의 의존성 없는 JSON 로거 덕분에 서비스 간 로그 검색이 단일 쿼리가 되었습니다.
  • 한 달 뒤: 커밋되었지만 kustomization.yaml에 등록되지 않은 Secret이 조용히 reconcile되지 않았습니다.

Staging은 클러스터를 공유하지만 이름은 공유하지 않습니다

두 번째 클러스터는 교과서적인 답이지만, 저에게는 박스가 하나뿐입니다. 대신 정한 규칙: 같은 네임스페이스, 모든 리소스에 -stag 접미사, 컨트롤 플레인 외에는 공유 상태 없음. mongodb-stag은 별도의 mongodb-stag-credentials를 가진 자체 StatefulSet으로 동작합니다. 모든 Knative 함수는 apps/functions-stag/ 아래에 min-scale: 0(staging은 cold-start로 충분합니다)으로 복제됩니다. Ingress는 stag-api.wintersalmon.com에 둡니다. 새로 만든 staging 브랜치의 CI는 이미지에 -stag 태그를 붙이고, FluxCD의 기존 ImageUpdateAutomation이 이미 ./infra/home-cluster/apps를 스캔하고 있었기 때문에 stag용 ImagePolicyImageRepository 리소스만 추가하니 그대로 동작했습니다.

비용은 접미사 없이 kubectl apply 한 번만 잘못 쳐도 prod를 짓밟을 수 있다는 점입니다. 이득은 단일 노드 클러스터에서 네임스페이스 수준 리소스(pull-secrets, network policies, kourier-proxy)를 중복으로 두지 않아도 된다는 점입니다.

WireGuard에서 MongoDB로 가는 데 수정이 하나가 아니라 세 개 필요했습니다

제 Mac의 Compass가 홈 VPN을 통해 두 개의 Mongo를 모두 읽어야 했습니다. 동작하기 전까지 세 가지 다른 방식으로 실패했고, 모두 PR #198에 기록되어 있습니다.

  • Route: WireGuard AllowedIPs에는 10.100.0.0/24만 있었습니다. k8s service CIDR 10.43.0.0/16이 양쪽 peer 모두에 빠져 있어, ClusterIP로 가는 패킷이 기본 라우트로 빠져나가 죽었습니다.
  • Masquerade: iptables -t nat -I POSTROUTING 1 -s 10.100.0.0/24 -d 10.43.0.0/16 -j MASQUERADE. 위치 1이 핵심입니다 — 위치 6에 두면 KUBE-POSTROUTINGFLANNEL-POSTRTG 뒤에 자리잡는데, 둘 다 일찍 return합니다.
  • NetworkPolicy: pod 방화벽 체인 KUBE-POD-FW-...는 허용되지 않은 source에 대해 REJECT with icmp-port-unreachable로 끝납니다. 새로운 정책 allow-ingress-from-wireguardipBlock: 10.100.0.0/24을 두어 mongodbmongodb-stag의 27017 포트를 열었습니다.

그제야 Compass가 연결되었지만, rs.status()를 실행하니 멤버 hostname이 localhost:27017로 돌아왔고, 거기로 다이얼하다가 ECONNREFUSED 127.0.0.1:27017을 뱉었습니다. 해결책: URI에 directConnection=true를 넣어 replica-set discovery를 건너뛰는 것. 증상만 봐서는 어느 것도 추측할 수 없는 문제이며, 그래서 docs/guides/infrastructure/operational-runbook.md의 runbook이 존재합니다.

ArgoCD는 SOPS와의 writer 자리 다툼에서 졌습니다

원래 계획: 프로덕션은 ArgoCD를 writer로 두고, FluxCD를 잘라내고, 이미지 promotion은 GitHub Action으로 하는 것. 컷오버 Phase 5에서 빠르게 벽을 만났습니다 — ArgoCD는 SOPS로 암호화된 시크릿을 복호화하지 못합니다. functions 네임스페이스는 mongodb-stag-credentials, jwt-secret, ghcr-pull-secret을 가지고 있습니다. ArgoCD는 이들을 건너뛰거나 ciphertext 그대로 apply할 텐데, 둘 다 쓸모없습니다.

수정안(태스크 로그의 “Architecture (Revised)” 항목에 기록)은 짧습니다:

레이어 관리자
인프라, 시크릿, 공유 리소스 FluxCD
Staging 앱 (ImageUpdateAutomation으로 자동 배포) FluxCD
프로덕션 앱 FluxCD writer + ArgoCD 대시보드 / 수동 sync
Staging 시각화 ArgoCD (read-only)

ArgoCD는 cert-manager 및 ingress-nginx와 같은 패턴으로 FluxCD HelmRelease를 통해 설치됩니다. 프로덕션 이미지 자동 업데이트는 꺼져 있고($imagepolicy 마커 제거), promotion은 Git에서 태그를 올려주는 workflow_dispatch 액션이며, 그 후 ArgoCD에 “OutOfSync”가 뜨면 사람이 Sync를 누릅니다. architect 에이전트가 이 결정에 이름을 붙여주었습니다: 깔끔함은 “시크릿이 실제로 복호화되는 것”에 집니다.

벤더 없이 Loki, 라이브러리 없이 JSON 로그

Loki는 Phase 7로 들어왔습니다: Helm chart, 7일 retention, local-path의 10Gi PVC 하나, Grafana에 datasource 등록. Promtail이 pod stdout을 수집합니다. 흥미로운 부분은 writer 쪽입니다 — functions/_shared/src/logger/의 의존성 0인 JSON 로거, pino도 winston도 없습니다. Bun의 네이티브 console.info(JSON.stringify(...))가 충분히 빠르고 sink는 stdout 하나뿐입니다. createLogger(service)는 level별 메서드를 반환하고, withRequestLogging(handler, logger)은 요청당 requestId, method, path, statusCode, durationMs가 담긴 info 라인을 하나 찍습니다.

Promtail의 pipeline은 levelservice를 인덱싱된 label로 승격(낮은 카디널리티)하고 requestId는 라인 안에 남겨 두어 쿼리 시점에 | json으로 추출하게 합니다. api-logs.yaml 대시보드는 service, level, 자유 텍스트로 필터링합니다. “지난 1시간 동안 game-submit-event의 모든 500 에러를 찾아라”는 한 쿼리로 끝납니다.

Kustomization은 명시한 것만 인정합니다 — 빠진 항목은 조용히 건너뜁니다

5주 후, notifier-api-go pod 두 개가 ImagePullBackOffCreateContainerConfigError 상태에 빠졌습니다. 진단은 docs/task-log/20260424-pod-issues/progress.md에 있습니다. Secret notifier-credentials는 작성되고, SOPS로 암호화되고, infra/home-cluster/apps/functions-go/notifier-credentials.secret.yaml에 커밋되어 있었지만 — 형제 kustomization.yaml에는 추가된 적이 없었습니다. FluxCD는 거기 등록된 다른 모든 것을 깔끔하게 reconcile했습니다. Secret은 그저 클러스터에 존재하지 않았습니다. 실행 중이던 pod이 이전에 캐시된 이미지로 돌고 있었기 때문에 버그는 보이지 않다가, ImageUpdateAutomation이 새로운 태그를 굴렸을 때 비로소 크래시가 드러났습니다. 같은 함정에 Deployment가 참조하지만 인프라로 만들어진 적 없는 doc-search-api-config ConfigMap도 걸렸습니다.

해결책은 도구가 아니라 워크플로우입니다: 새로운 리소스 파일을 만들 때마다, 다음에 여는 파일은 형제 kustomization.yaml입니다. 린터는 향후 과제입니다.

#gitops #observability

AI 워크플로우 메모

Claude는 어떤 YAML도 작성하기 전에 staging 계획을 5단계 phased 로드맵으로 먼저 썼습니다 — 이전 포스트의 모니터링 롤아웃에서 효과를 본 것과 같은 패턴입니다. ArgoCD-as-writer 설계가 SOPS에 부딪혔을 때, architect 에이전트에게 ArgoCD-as-writer와 FluxCD-writer-with-ArgoCD-visualization을 정면 비교해 달라고 요청했고, SOPS의 비대칭성이 메시지 두 번 만에 결정을 내려 주었습니다. pod 이슈에서 얻은 교훈은 제 프롬프팅도 바꿔 놓았습니다 — Secret이나 ConfigMap을 추가할 때, 이제 저는 Claude에게 기존 kustomization.yaml의 모든 리소스를 열거하고 새 파일이 등록되었는지 확인하도록 명시적으로 요청합니다. 그 한 가지 습관이 silent-failure 케이스를 출시 전에 잡아냅니다.


Hungjoon

I'm Hungjoon, a software engineer based in South Korea. This is my long-form notebook — homelab, Kubernetes, AI infra, and whatever else keeps me up at night.