-
이코에코(Eco²) GitOps #04 Operator와 Helm-charts를 오가며 겪은 시행착오들이코에코(Eco²)/Kubernetes Cluster+GitOps+Service Mesh 2025. 11. 24. 20:18
🛎️ 본 포스팅은 구현이 완료된 사안만 다룹니다. 현재 이코에코 14-nodes cluster는 ap-northeast-2 리전에 배포돼 있습니다.
v0.7.4~0.7.5 사이, SG/Calico/ArgoCD를 겨우 안정화해둔 상태에서 ALB Controller, ExternalDNS, External Secrets Operator, 데이터 스택(Postgres/Redis/RabbitMQ)까지 GitOps 안으로 끌어들이는 과정을 서술한다.
0. ALB Controller 전에, 지금 트래픽이 어떻게 흐르는지부터
ALB가 Pod를 인지하는 경로

네트워크를 구성하기 위해선 이코에코 클러스터의 특징을 살펴야 한다. 근본적으로 EC2 노드들은 단일 진입점인 ALB와 동일한 VPC(Logical Router)에 존재하지만, Calico VXLAN으로 클러스터 내 오버레이를 구현한 상황이기에, K8s 클러스터 외부 컴포넌트들은 내부 파드들을 인지하지 못한다.
그래서 AWS와 K8s를 이어줄 중간 관리자가 필요했다. 이 역할을 하는 컴포넌트가 ALB Controller와 Ingress다.
먼저 Ingress가 노드/파드를 알아야 한다. NodePort로 파드를 직접 노드 레벨로 노출했다. ALB Controller는 Ingress를 감시(watch)하며, Ingress가 변경될 때마다 ALB Controller는 AWS ELB API를 호출해 Target Group에 노드 IP:31666을 등록하고, 다시 감시 상태로 돌아간다.Client <-> Pod 트래픽 경로

사용자는 HTTPS 443으로 단일 진입점인 ALB에 접속하고, ALB는 라우팅 결과를 Target Group으로 넘긴다.
Target Group은 앞서 기록된 Ingress로 트래픽을 전달하고, Ingress는 다시 NodePort+TargetPort로 노출된 파드에게 전달한다.
컴포넌트별 역할을 정리하면 아래와 같다.- ALB
- TLS 종료(HTTPS → HTTP), WAF, Path/Host 기반 라우팅을 맡는다.
/api/*,/argocd/*,/grafana/*같은 걸 ALB 레벨에서 Target Group별로 잘라서 보내준다.
- Target Group
- ALB 입장에선 “이 뒤에 있는 노드들에게 트래픽만 전달하면 된다”는 서버 묶음.
- 이 TG 뒤를 클러스터 NodePort 서비스로 묶었다. 파드를 노드 레벨로 노출시켜 ALB-->Pod.
- 초반엔 instance 타겟 모드도 써봤다가 cross-node 통신 이슈 때문에 최종적으로 ip 모드로 정착했다.
- NodePort Service
- 각 워크로드가 NodePort를 열어두고, TG가 그 포트로 전달한다.
- SG는 AWS 인프라 레벨(노드)까지만 책임지고, 클러스터 내 Pod 간 통신은 모두 NetworkPolicy가 담당한다.
- Ingress (domain-ingress)
- 외부 호스트/경로(api.dev.growbin.app /api/v1/locations/…)를 Service(NodePort 31666)와 연결해 주는 쿠버네티스 리소스.
- 자체적으로 포트를 열지 않고, 지정된 Service의 NodePort와 Endpoints를 참조해 “어느 노드의 어떤 Pod가 이 요청을 처리할지”를 라우팅한다.
- AWS Load Balancer Controller가 이 Ingress 스펙을 감시해 Target Group, Listener, ALB 규칙을 생성·유지하면서 위의 ALB → Target Group → NodePort Service 흐름이 완성된다.
이렇게 맞춰놓으면, L7 레벨(HTTPS, 라우팅, WAF)은 ALB가 책임지고,
L4~Pod 레벨(스케줄링, 격리, 제어)은 K8s가 가져가는, EKS와 유사한 패턴이 된다.
1. 왜 굳이 ALB → TG → NodePort 구조를 택했나
1.1 AWS와 K8s의 경계를 분명히 긋고 싶었다
Ingress Controller(ALB Controller), CLB/NLB, SG, NodePort를 한 덩어리로 묶어 생각하면 장애가 났을 때 어디서 막힌 건지부터가 헷갈린다. 먼저 아래와 같이 잘라두고 싶었다.- AWS 레벨 (ALB + TG + SG)
- 인터넷에서 들어와서 “어느 노드의 어느 포트까지”는 AWS가 책임.
- HTTPS 종료, L7 라우팅, WAF, 도메인 연결, Route53 연동까지 여기서 끝낸다.
- Kubernetes 레벨 (Service + Pod + NetworkPolicy)
- 노드 안에서의 일, 즉 Pod 스케줄링, 도메인별 네임스페이스 격리,
- Pod 간 통신 차단/허용은 전부 여기에서만 다룬다.
그래서 SG도 결국 Cluster SG 하나로 단일화했고,
(ALB용 SG는 별도지만, 클러스터 노드들은cluster_sg하나만 쓴다)
NodePort 범위 전체를 열어두는 대신, Pod 간 세밀한 통제는 NetworkPolicy에서만 표현하도록 했다.이 과정은 따로 문서로도 기록해 뒀다.
→ SECURITY_GROUP_SIMPLIFICATION.md
“SG는 노드 레벨 방화벽, Pod 레벨은 NetworkPolicy.”
이 구분은 이후 North-South와 East-West 트래픽 분리로 발전한다.
1.2 보다 직관적인 트래픽 흐름과 적은 hop을 원했다
- Ingress → Service(NodePort) → Pod 한눈에 보이도록 NodePort를 쓰면 “노드 IP:31666 → targetPort 8000”이라는 단순한 구성으로 정리할 수 있어, 장애 분석 시에도 “ALB까지 OK, TG까지 OK, 그다음 노드 포트까지 OK…”처럼 hop별 확인이 수월하다.
- 중간 프록시 제거: ClusterIP만 두면 외부에서 파드로 직접 들어올 통로가 없어 별도 프록시가 한 단계 더 필요하다. NodePort 구조는 ALB → TG → NodePort → Pod까지 바로 이어져 중간 레이어가 줄고, 라우팅 규칙도 Ingress 하나에서 관리된다.
1.3 ClusterIP가 아닌 NodePort를 선택한 이유
- North-South: ALB/Target Group은 노드 IP만 볼 수 있으므로 NodePort(Service type)로 파드를 노출하고, AWS Load Balancer Controller가 Endpoints → NodePort 정보를 읽어 Target Group을 구성한다. 외부 요청은 ALB → NodePort → Ingress → Pod 순으로 흐르고, 중간에 별도 프록시 계층이 필요하지 않는다.
- East-West: 서비스 간 통신은 Calico VXLAN 오버레이(L2) 위에서 ClusterIP를 이용한다. 예를 들어 Character
/internal/characters/rewards같은 API는character-api.character.svc.cluster.localClusterIP를 통해 호출되고, Calico가 Pod IP를 VXLAN 터널(UDP 4789)로 캡슐화해 전달하므로 외부 노출 경로와 완전히 분리된다. DB 부트스트랩 Job, CronJob 등 백그라운드 작업도 동일한 L2 오버레이 위에서 동작한다.
North-South(외내부 수직 이동)과 East-West(내부 수평 이동)의 개념적 분리는 전직장 네트워크팀에 있을 때 인지한 개념이다.
Rakuten CNP로 SDN용 CNI인 Kube-OVN을 도입 검토할 때로 DataPlane에 Overlay를 입혀 수평 터널링 망을 구축해 책임을 분리한다. 이 경우 NS, EW 모두 최소홉으로 구성이 가능하고, 성격이 다른 두 트래픽 플로우에 정책을 분리해서 적용이 가능하다.
North-South 구간에는 AWS WAF, TLS 종료, Route53, Security Group 같은 L7/L4 정책을 집중하고
East-West 구간에는 Calico NetworkPolicy, 네임스페이스 격리, 서비스별 허용 리스트를 적용할 수 있다.
2. self-managed를 굳이 EKS처럼 구성하게 된 이유
2.1 세부 시스템을 만지면서도, 상위 레이어는 친숙하게 가져가고 싶었다
밑단은 커스텀 K8s지만 홀로 운용하는 입장에선 운영 난이도를 낮출 추상화가 필요하다.
이코에코에서는 k8s 구축과 낮은 운영 난이도, 그 둘을 한 번에 이뤄야 했다.- 아래쪽 (kubeadm, Calico, SG, Terraform)
- 인프라 실험/복기용으로 최대한 많이 만져본다.
- 위쪽 (ALB, ExternalDNS, ExternalSecrets, Helm/Kustomize, ArgoCD, Sync Wave)
- EKS에서도 그대로 이식될 만한 레이어를 만든다.
그래서 Calico/SG 쪽은 VXLAN, Typha, 포트 5473, self 규칙 같은 걸 꽤 깊게 팠고
Ingress/ALB/Route53/Secrets/DataStack은 공식 가이드라인을 참고하며 쌓았다.2.2 ALB Controller가 첫 관문이었기 때문에
GitOps 관점에서 보면, ALB Controller는 퍼블릭 트래픽이 GitOps 세계로 들어오는 첫 관문이다.
- dev 환경에서는 clusters/dev/apps/15-alb-controller.yaml이 platform/helm/alb-controller/dev 경로를 가리키고 있고,
- ArgoCD Root-App Sync 시 Wave 15 부근에서 ALB Controller가 항상 먼저 올라온다.
clusters/dev/apps/15-alb-controller.yaml를 열어보면 dev 클러스터에서 ALB Controller는 여기서 관리되고 Git에 적힌 spec대로만 돈다”는 게 명시적으로 보인다.
이렇게 출입구를 확실하게 GitOps 아래에 고정해두니 그 위에 Auth / 다른 API / 도메인별 Ingress 얘기를 쌓아 올리기도 훨씬 수월했다.
정리하면,
- ALB → TG → NodePort → Pod 구조로 SG/NetworkPolicy 레이어를 분리하고,
나중에 EKS로 가더라도 애플리케이션 레이어를 그대로 들고 갈 수 있게 하려는 선택 - self-managed를 EKS처럼 굴리게 된 이유는 로우 레벨 인프라는 직접 만지면서도, 클러스터 외부 인프라 장애지점까지 관리 포인트가 늘어나지 않게 하려는 선택
이제 이 위에 ALB Controller를 Helm/ArgoCD에서 어떻게 정의했는지, 각 값(clusterName, SG, VPC, Subnet 등)을 어디서 끌어오는지 풀어보면 될 것 같다.
3. 그럼 이제 ALB Controller부터 제대로 올리자
3.1 Helm + ArgoCD로 ALB Controller 패턴 잡기
예전엔 ALB Controller를 Ansible로 설치하거나, helm install 한 번 돌려놓고 방치하는 식으로 썼다.
버전, CRD, IAM 설정이 섞이다 보니 업그레이드할 때마다 사람의 기억에 의존하는 구조였고,
GitOps 관점에서도 “누가, 언제, 어떤 버전으로 올렸는지”를 알기 어려웠다. 그래서 0.7.4에서 플랫폼 계층 Helm 스택을 아예 다시 짰다.
디렉터리 구조는 문서에 이렇게 정의해뒀다.platform/ helm/ alb-controller/ base/ application.yaml kustomization.yaml dev/ kustomization.yaml patch-application.yaml prod/ ... crds/ alb-controller/{base,dev,prod}/kustomization.yaml핵심은
- CRD는 platform/crds에서 Kustomize로 따로 관리 (Helm 설치 전)
- 실제 Helm Chart는 platform/helm/alb-controller/{dev,prod} 아래 ArgoCD Application으로 관리
- Sync Wave는 인프라 계층만 모아 Wave 10~20대에 배치해서 항상 먼저 올라오게 함
`application.yaml`은 대략 이런 느낌으로 통일했다.apiVersion: argoproj.io/v1alpha1 kind: Application metadata: name: alb-controller namespace: argocd annotations: argocd.argoproj.io/sync-wave: "15" spec: project: platform source: repoURL: https://aws.github.io/eks-charts chart: aws-load-balancer-controller targetRevision: 1.8.3 helm: releaseName: aws-load-balancer-controller valuesObject: clusterName: eco2-dev serviceAccount: create: false name: aws-load-balancer-controller destination: server: https://kubernetes.default.svc namespace: kube-system syncPolicy: automated: prune: trueselfHeal: true여기서 민감 값(VPC ID, IAM Role ARN)은 직접 박지 않고,
`valuesObject` 대신 Secret/ExternalSecret을 통해 `valueFrom.secretKeyRef`로 넘기는 식으로 정리했다.
prune 설정을 켜 ArgoCD 캐싱으로 인한 미 sync 오류를 지속 수정하고 싶었다.
참고한 공식 문서들:
- AWS Load Balancer Controller: <https://kubernetes-sigs.github.io/aws-load-balancer-controller/latest/>
- Argo CD Helm Application 패턴: <https://argo-cd.readthedocs.io/en/stable/operator-manual/declarative-setup/>
4. ExternalDNS로 Route53 수동 작업 걷어내기
4.1 수동 Route53 → ExternalDNS
처음에는 API/ArgoCD/Grafana 도메인 레코드를 Route53에서 수동으로 등록했었다.
Ingress 바뀔 때마다 ALB DNS를 확인하고 A/ALIAS를 수동으로 넣는 식이라 실수하기 좋은 지점이었다.
그래서 ExternalDNS로 전환했다.
- Chart는 `bitnami/external-dns` 대신 AWS 공식 쪽 레퍼런스를 참고해서 구성
- Helm Application은 `platform/helm/external-dns/{dev,prod}`에서 관리
- Sync Wave는 인프라 계층과 같이 15~20 근처에 붙임
Application 스펙은 대략 이런 모양이다.apiVersion: argoproj.io/v1alpha1 kind: Application metadata: name: external-dns namespace: argocd annotations: argocd.argoproj.io/sync-wave: "18" spec: project: platform source: repoURL: https://kubernetes-sigs.github.io/external-dns/ chart: external-dns targetRevision: 1.14.0 helm: valuesObject: provider: aws txtOwnerId: eco2-dev domainFilters: - dev.growbin.app policy: sync destination: server: https://kubernetes.default.svc namespace: kube-system syncPolicy: automated: prune: true selfHeal: true이렇게 해두고 나면:
- 새 Ingress를 만들 때 `external-dns.alpha.kubernetes.io/hostname`만 적어주면
- ExternalDNS가 Ingress/Service 주석을 감지해서 Route53 레코드를 자동으로 생성/갱신해준다.
참고 문서:
- ExternalDNS 공식 문서: <https://kubernetes-sigs.github.io/external-dns/>
이전에는 Route53 관련 수동 스크립트까지 있었는데,
0.7.4 쯤에 ExternalDNS 가이드 문서(`docs/troubleshooting` 계열) 추가하면서
DNS는 이제 ExternalDNS가 책임으로 완전히 이전됐다.
5. External Secrets Operator: SSM/Secrets Manager → K8s Secret
5.1 왜 External Secrets이었나
이전에는 K8s Secret을 직접 kubectl apply 하거나,
Ansible 템플릿에서 값을 찍어 넣는 방식으로 관리했다.
문제는 비밀번호/토큰 값이 Git에는 안 남더라도, Ansible 변수 파일이나 로컬 history에 남는 일이 자꾸 생기고 시크릿 값을 바꾸면 AWS SSM/Secrets Manager → K8s Secret 라인을 항상 사람이 다시 맞춰줘야 했다.
그래서 External Secrets Operator(ESO) 를 도입했다.
ESO는 AWS SSM / Secrets Manager / Vault 같은 외부 비밀 관리 시스템을 K8s Secret으로 동기화해 주는 Operator다.
GitOps 기준으로는, Secret의 진짜 소유권을 K8s 밖으로 빼는 역할에 더 가깝다.
1. SecretStore / ClusterSecretStore: 어디에서 가져올 건지에 대한 정의
먼저 어디서 비밀 값을 가져올 건지부터 CRD로 선언한다.
- SecretStore: 네임스페이스 스코프
- ClusterSecretStore: 클러스터 전체에서 재사용하는 글로벌 스토어
예를 들면, AWS SSM 기반 `ClusterSecretStore`는 이런 식이다.apiVersion: external-secrets.io/v1beta1 kind: ClusterSecretStore metadata: name: aws-ssm spec: provider: aws: service: SSM region: ap-northeast-2 auth: jwt: serviceAccountRef: name: external-secrets-sa namespace: platform여기까지가 ESO가 어떤 자격증명(IAM/서비스어카운트)으로, 어느 리전에 있는 어떤 외부 시스템에 붙을지를 정의하는 단계다.
2. ExternalSecret: "어떤 키를, 어떤 Secret으로 만들 건지" 정의
그 다음 핵심은 `ExternalSecret` CRD다.
여기서 외부 키 → K8s Secret 키 매핑을 전부 선언적으로 적어둔다.apiVersion: external-secrets.io/v1beta1 kind: ExternalSecret metadata: name: postgres-auth-secret namespace: auth spec: refreshInterval: 1m secretStoreRef: name: aws-ssm kind: ClusterSecretStore target: name: postgres-auth creationPolicy: Owner data: - secretKey: POSTGRES_PASSWORD remoteRef: key: /eco2/dev/database/auth/postgres-password위 스펙이 의미하는 건 아래와 같다.
- `/eco2/dev/database/auth/postgres-password` 라는 SSM 파라미터를
- `auth` 네임스페이스 안의 `postgres-auth` Secret으로 만든다
- 그 안에서 `POSTGRES_PASSWORD`라는 키로 노출한다.
- 그리고 이 동기화를 1분마다(= drift 나면 자동 수선) 돌린다.3. 컨트롤 루프: ESO가 하는 일
이코에코 첫 포스팅에서 설명한 Reconcile 패턴을 따른다.
ESO 컨트롤러는 끊임없이 다음 루프를 돈다.- `SecretStore` / `ClusterSecretStore` 목록을 보고,
- `ExternalSecret`들을 전부 스캔하면서
- 어떤 외부 키(예: `/eco2/dev/...`)를
- 어떤 K8s Secret으로 매핑해야 하는지 파악하고,
- 외부 API(AWS SSM/Secrets Manager 등, 이코에코의 경우 SSM이다.)에 붙어서 값들을 읽어오고,
- 그걸 기반으로 K8s `Secret` 리소스를 생성/갱신한다.
- `refreshInterval`이 지나면 다시 한 번 전체를 훑으면서 drift를 감지하고 동기화한다.
공식 문서 표현대로 하면 ExternalSecret / SecretStore / ClusterSecretStore 같은 CRD를 통해 외부 비밀 시스템의 추상 레이어를 제공하고, 컨트롤러가 이 선언(Desired State)에 맞춰 외부 API ↔ K8s Secret 간 동기화 루프를 유지하는 구조다.
4. GitOps 관점에서 ESO를 선택한 이유
ESO를 채택한 이유는 아래와 같다.:
- 비밀번호 자체는 분리되고, Git에는 “어떤 파라미터를 어디로 주입할지”만 남길 수 있어서다.
- SSM/Secrets Manager: 실제 민감 정보의 Source of Truth
- Git + ExternalSecret: “이 Secret을 어떤 앱/네임스페이스에서 쓸지”에 대한 정의
- K8s Secret: ESO가 자동으로 재생성해 주는 캐시 계층이렇게 세 구조로 나눠두면,
- 비밀번호가 바뀔 때는 AWS 콘솔/CLI에서만 바꾸면 되고,
- ESSO가 알아서 새 값을 Kubernetes Secret으로 뿌려준다.
- Git 쪽에는 외부 키 Path(`/eco2/dev/...`)만 남기면 되니까, 리뷰/감사/문서화할 때도 부담이 덜하다.
요약하면, ESO는 Secret을 K8s에서 완전히 빼버리진 못하지만, K8s를 더 이상 Source of Truth로 보지 않게 만들어 주는 Operator에 가깝다. 이 덕에 이코에코 인프라에서는 데이터베이스/Redis/Auth/OAuth 설정 대부분을 SSM Path + ExternalSecret 조합으로 풀었고 나중에 Auth API 쪽 OAuth 플로우를 정리할 때도 “이 값은 어디서 오는가?”라는 질문에 답하기 편해졌다.5.2 ClusterSecretStore + ExternalSecret 패턴
코드베이스로 살피면 아래와 같다.
apiVersion: external-secrets.io/v1beta1 kind: ClusterSecretStore metadata: name: aws-ssm spec: provider: aws: service: SSM region: ap-northeast-2 auth: jwt: serviceAccountRef: name: external-secrets-sa namespace: platformapiVersion: external-secrets.io/v1beta1 kind: ExternalSecret metadata: name: postgres-auth-secret namespace: auth spec: refreshInterval: 1m secretStoreRef: name: aws-ssm kind: ClusterSecretStore target: name: postgres-auth creationPolicy: Owner data: - secretKey: POSTGRES_PASSWORD remoteRef: key: /eco2/dev/database/auth/postgres-password\External Secrets + SSM 패턴의 가장 큰 이점은 코드베이스로 Secret을 관리하기 용이하다.
SSM에 Secret을 쌓아두고 간단한 명시로 주입, 싱크로 배포까지 가능하니 부가적인 노동을 크게 낮춰준다.
재사용성이 증가한 점도 좋았다. API 개발 때 은근 손이 자주 간다. 기대보다 유용해서 만족하는 구성 중 하나다.
6. 데이터 스택(포스트그레스/레디스/래빗MQ) CRD/CR 분리
6.1 CRD와 Instance를 갈라놓기
처음에는 Redis/PostgreSQL/RabbitMQ Operator + Instance CR을 한 Kustomize 트리에서 섞어서 관리하고 있었다.
- CRD + Operator Deployment + Instance CR이 한 번에 설치
- Sync Wave가 꼬이면 CR이 CRD보다 먼저 올라가서 에러
- 롤백/업그레이드 시 어디까지 되돌려야 할지 항상 헷갈림
그래서 0.7.4 ~ 0.7.5 사이에 CRD/Operator/Instance를 완전히 분리했다.
관련 커밋들이 아래와 같다.
- [cf72a26](https://github.com/eco2-team/backend/commit/cf72a26)) `refactor/data-cr-structure`
- [d90b180](https://github.com/eco2-team/backend/commit/d90b180)) `fix: centralize crd management`
- [95e061b](https://github.com/eco2-team/backend/commit/95e061b)) `feat: add ot-redis crds`
- Redis Operator 교체: [36f65c0](https://github.com/eco2-team/backend/commit/36f65c0)) `feat(redis-operator): use ot container kit helm chart`문서 기준으로는
- CRD: `platform/crds/*`
- Operator(Helm): `platform/helm/*-operator/{dev,prod}`
- Instance CR: `workloads/domains/*` or `platform/cr/{dev,prod}` 쪽으로 분리이렇게 갈라놓고, Sync Wave도 다음처럼 맞췄다.
- Wave 20: Operators (CloudNativePG, ot-redis, RabbitMQ Operator 등)
- Wave 30~40: 데이터 클러스터(Instance CR)
이 구조는 Helm Platform Stack 가이드에도 같이 녹여놨다.
→ HELM_PLATFORM_STACK_GUIDE.md
7. CRD/CR에서 다시 Helm으로 돌아오기까지
이 구간은 Operator를 얼마나 들여다봤는지와 결국 왜 Helm으로 돌아왔는지에 대한 서술에 가깝다.7.1 전 컴포넌트 Operator 구축을 진지하게 검토했었다
Ansible을 줄이고 GitOps로 재구축하는 과정에서
구상했던 그림은 Terraform + User-Data + Operator + ArgoCD였다. 이걸 문서로 옮긴 게 아래 두 개다.- Node Lifecycle Operator 스펙:
docs/operator/OPERATOR-DESIGN-SPEC.md - 데이터 Operator 개념 정리:
docs/data/DATA_OPERATOR_DESCRIPTION.md
7.1.1 Node Lifecycle Operator (NLO)
NLO 쪽 스펙을 보면, 당시 내가 뭘 바랐는지 그대로 드러난다.
“EC2 인스턴스가 Join만 하면 Provider ID / Labels / Taints / Phase를 Operator가 다 맞춰주면 좋겠다.”
문서 상 요약은 이렇다.
목표:- EC2 → K8s Node로 Join된 후 자동으로 설정 완료
- Provider ID 설정 (aws:///AZ/INSTANCE_ID)
- Labels: workload, domain, phase 자동 적용
- Taints: database / monitoring 노드를 자동 격리
- Ansible 없이 Node 상태 Drift 복구
기술스택
- 언어: Go
- Framework: Kubebuilder
- CRD: NodeConfig
NodeConfig CRD (Desired State) ↓ Node Lifecycle Operator (Controller/Reconcile Loop) ↓ Kubernetes Node API ↓ AWS EC2 메타데이터 / 태그 조회 ↓ Node Labels / Taints / ProviderID 패치위는 Node Life Cycle의 로직 디자인 초안이다
Terraform이 EC2만 만들고 User-Data로 kubeadm join만 해두면 NLO가 알아서 Node 상태를 drift 없이 유지. 이걸로 하고 싶었던 건 노드 레벨 Ansible을 완전히 제거하는 것이었다. 그렇지만 위 커스텀 오퍼레이터는 구현 시간의 부족으로 디자인 단계에서 끝났다.7.1.2 Data Operators (Postgres/Redis/RabbitMQ)
데이터 쪽은 'Operator 패턴으로 갈아타볼까?'를 진지하게 고민했었다.
이때 쓴 문서가 이거다.
→DATA_OPERATOR_DESCRIPTION.mdOperator vs Bitnami Helm Chart를 비교는 아래와 같다.
Operator:- CRD + Controller
- Self-healing, Failover, Backup 등 도메인 지식 내장
- 선언적 API (postgresql.yaml 하나로 클러스터 구성)
Bitnami Helm Chart:
- StatefulSet/Deployment 템플릿
- Operator 아님
- 수동 Helm upgrade에 의존대표적으로 고민했던 조합들:
- PostgreSQL: CloudNativePG vs. Bitnami
postgresql - Redis: Spotahome/OT Redis Operator vs. Bitnami
redisChart - RabbitMQ: RabbitMQ Cluster Operator vs. Bitnami
rabbitmq
그리고 Sync Wave 기준으로는:
- Wave 20: Data Operator (CloudNativePG / ot-redis / RabbitMQ Operator)
- Wave 30~40: Instance CR (각 도메인별 DB/Cache/MQ 클러스터)
실제 Tree도 이런 식으로 쪼갰다.
platform/ crds/ postgres-operator/ redis-operator/ rabbitmq-operator/ helm/ postgres-operator/{dev,prod} redis-operator/{dev,prod} rabbitmq-operator/{dev,prod} workloads/ data/ postgres/ redis/ rabbitmq/ overlays/ dev/ prod/여기까지는 구조도 정갈하고, 문서도 그럴듯했다.
문제는 “내가 당장 운영할 수 있느냐”였다.
7.2 실제로 도입했던 Operator들
Operator를 전면 도입하진 않았지만, 몇 개는 실제로 넣었다가, 유지 또는 롤백까지 해봤다.
7.2.1 External Secrets Operator (계속 사용)이건 남겼다. 이유는 간단하다.
- AWS SSM/Secrets Manager에 이미 파라미터를 많이 쌓아뒀고
- K8s Secret을 직접 관리하는 건 복잡도가 오히려 증가한다.
구조는 아까 썼던 것처럼
ClusterSecretStore로 AWS Provider 설정ExternalSecret으로/eco2/{env}/database/...경로를 K8s Secret으로 투영
공식 문서: https://external-secrets.io/latest/
실질적으로는 Secret은 AWS를 SSOT로, K8s는 Projection인 구조를 만들고 싶었던 거라, 이 Operator는 끝까지 유지하는 쪽을 택했다.
7.2.2 데이터 Operators (부분 도입, 다시 Helm으로 회귀)
Redis는 ot-redis Operator라는 오픈소스 오퍼레이터를 사용했었다.- CRD 추가:
95e061bfeat: add ot-redis crds - Operator Chart 도입:
36f65c0feat(redis-operator): use ot container kit helm chart - 이후 CRD/CR 구조/Overlay 정리:
cf72a26refactor/data-cr-structured90b180fix: centralize crd management
위 과정을 거치며 얻게된 점은 아래와 같다.
- Redis 스펙을 CRD로 선언해둘 수 있어서 이 도메인 Redis 클러스터가 어떤 파라미터를 가지는지를 Git에서 바로 확인 가능
- 파드가 다운되더라도 Operator가 Reconcile를 거치며 자동으로 배포
그만큼 Operator 자체를 운영해야 하는 부담이 같이 따라왔다.
- CRD 버전이 올라갈 때, 기존 CR과의 호환성 검증
- Operator 차트/이미지 버전 업그레이드 시, Wave/헬스체크/리소스 조합 테스트
Postgres/RabbitMQ도 유사한 이슈가 발생했다.
CloudNativePG, RabbitMQ Operator 스펙들을 살피며 CR을 생성했지만 올바르게 배포된 건 Postgres 뿐이었다. Redisㅇ하 RabbitMQ의 오픈소스 오퍼레이터의 경우 CR로 호환을 맞추는 작업에서 크래시가 계속됐다.
그래서 0.7.5 즈음엔 이런 결론으로 정리했다.- 당장 꼭 필요한 Operator들만 남기자
- External Secrets / (향후 여력 생기면) Node Lifecycle 수준
- 데이터 스택은 Helm Chart + Kustomize overlay로 먼저 고정시켜두자
- 나중에 “운영을 더 길게 가져가야 할 때” 다시 Operator로 옮겨가는 걸 고려
이때 참고한 게 위의 데이터 Operator 문서다.
→DATA_OPERATOR_DESCRIPTION.md
여기서도 “Operator 패턴 vs Bitnami Chart”를 개념적으로 분리해두고,
현 시점에서는 Bitnami Helm Chart에 더 무게를 둔 상태로 마무리했다.
7.3 결국 Helm-charts로 되돌아온 이유
정리하면, Operator를 고민하다가 데이터 스택만큼은 다시 Helm으로 돌아간 이유는 세 가지 정도다.
- 운영 복잡도 대비 이득이 아직 애매했다
- 6개월 단위로 운영하는 상용 서비스라면 Operator의 self-healing / failover 가치가 크겠지만,
- 지금 이 프로젝트는 해커톤 + 개인 인프라 실험 사이 어딘가에 있었다.
- 그 스코프에서 “Operator를 온전히 이해하고 책임질 수 있나?”라고 물어봤을 때, 솔직히 아니다 싶었다.
- Helm + Kustomize + Sync Wave만으로도 꽤 많은 걸 할 수 있다
- CRD는
platform/crds에서 관리 - Chart는
platform/helm에서 관리 - Instance 스펙은
workloads/base + overlays/{env}로 관리 - Wave 20(Infra/Operators) → 40(Data Clusters) → 80(App) 패턴만 잘 지키면,
“Git 한 번 보고 전체 그림이 들어오는” 상태까지는 충분히 만들 수 있었다.
- CRD는
- 시간과 토큰은 한정돼 있다
- Node Lifecycle Operator까지 Go/Kubebuilder로 제대로 만들어 넣으려면,
최소 며칠 단위의 집중 시간이 필요했다. - 이미 Helm/GitOps 리팩토링에 토큰을 많이 태운 상황에서,
우선순위를 “플랫폼 안정화 + Auth/도메인 API” 쪽에 두기로 했다.
- Node Lifecycle Operator까지 Go/Kubebuilder로 제대로 만들어 넣으려면,
그래서 현재 기준 아키텍처는
TERRAFORM-OPERATOR-PIPELINE문서에 있는 “After” 그림에서
“Operator” 부분의 일부만 적용된 상태라고 보는 게 맞다.
→docs/deployment/gitops/TERRAFORM-OPERATOR-PIPELINE.mdTerraform Apply ├ EC2 user-data (OS/K8s 설치) ├ SSM Parameters └ null_resource (kubectl apply) ├ Node Lifecycle Operator CRD (미완) ├ External Secrets Operator ✔ └ ArgoCD Root App ✔ ArgoCD Root App (App-of-Apps) ├ Wave -1: CRDs / Namespaces ├ Wave 0 20: ALB / ExternalDNS / ESO / Monitoring ├ Wave 30 40: Data Clusters (Helm Charts) └ Wave 80: Applications (Auth 외 도메인 API들)이 글에서 다룬 범위는 아래와 같다.
- v0.7.3 이후 GitOps 2.0 구조 정리
- v0.7.4에서 SG/Calico/Helm-Kustomize 분리
- v0.7.5에서 ArgoCD 안정화 + ExternalDNS/External Secrets + 데이터 스택 배포
다음은 이코에코 Sync-Wave 변천사에 대해 다루겠다.'이코에코(Eco²) > Kubernetes Cluster+GitOps+Service Mesh' 카테고리의 다른 글
이코에코(Eco²) GitOps #06 - Namespace · RBAC · NetworkPolicy를 한 뿌리에서 (3) 2025.11.25 이코에코(Eco²) GitOps #05 Sync Wave (0) 2025.11.25 이코에코(Eco²) GitOps #03 네트워크 안정화 (0) 2025.11.24 이코에코(Eco²) GitOps #02 Ansible 의존성 감소, Kustomize Overlays 패턴 적용 (0) 2025.11.24 이코에코(Eco²) GitOps #01 AWS + IaC 기반 K8s 클러스터 구축기 (0) 2025.11.12 - ALB