Skip to content

CH07 - 규모 확장 ​

이 저장소는 「AI 시대에 개발자가 알아야 하는 인프라 구성 배포 with 클로드 코드」 책 스터디를 진행하며 정리한 내용을 다룹니다.

7장에서는 세 가지를 해결한다.

  • 멀티 노드풀로 워크로드별 노드를 분리
  • App of Apps 패턴으로 늘어나는 앱을 체계적으로 관리
  • 멀티 테넌시로 고객별 격리 환경 구축

멀티 노드풀 ​

현재는 모니터링을 하는 프로메테우스와 API가 같은 노드에 있어서 매트릭 수집에 CPU를 많이 잡아먹으면 Notiflex API가 느려지는 구조적 문제가 있다
이 문제를 해결하기 위해 모니터링은 모니터링 노드에서, API는 API노드로 분리한다

nodeSelector + 멀티 노드풀 방식을 사용하여 GKE에서 역할별 노드풀을 생성하고 Pod의 nodeSelector로 어떤 노드풀에 배치할지 지정한다

1. 생성한 노드풀 명령어 ​

​

bash
# api-pool
gcloud container node-pools create api-pool \
  --cluster=notiflex-cluster --zone=asia-southeast1-a \
  --machine-type=e2-medium --disk-type=pd-standard --disk-size=50 \
  --num-nodes=1 --spot \
  --workload-metadata=GKE_METADATA

# worker-pool
gcloud container node-pools create worker-pool \
  --cluster=notiflex-cluster --zone=asia-southeast1-a \
  --machine-type=e2-standard-2 --disk-type=pd-standard --disk-size=50 \
  --num-nodes=1 --spot \
  --workload-metadata=GKE_METADATA

...

2. YAML 수정 사항 ​

파일: notiflex-platform/k8s/smb/rollout.yaml

diff
     spec:
       serviceAccountName: notiflex-api
+      nodeSelector:
+        cloud.google.com/gke-nodepool: api-pool
       containers:
  • notiflex-api Rollout Pod을 api-pool 노드에만 배치하도록 nodeSelector 추가
  • nodeSelector 키는 GKE가 노드풀 생성 시 자동으로 붙이는 cloud.google.com/gke-nodepool 라벨 사용 (커스텀 라벨 미사용)

3. api-pool 확인 ​

리소스 부족으로 1로 줄였던걸 2로 변경하고 api-pool 상태를 확인한다

bash
kubectl --context gke-sysnet4admin_book_gitaiops get pods -n notiflex -o wide | grep notiflex-api
notiflex-api-96d5dfc4f-4s2dz   1/1     Running   0          55s   10.0.0.10   gke-notiflex-cluster-api-pool-9a14cf3b-zg12       <none>           1/1
notiflex-api-96d5dfc4f-8tm2x   1/1     Running   0          18m   10.0.0.9    gke-notiflex-cluster-api-pool-9a14cf3b-zg12       <none>           <none>

책에서는 api-pool, default-pool, worker-pool, ops-pool 이렇게 총 4개의 pool을 설정하라고 했지만 GKE In-use regional external IPv4 addresses 할당량 이슈로 ops-pool은 제외하고 설정하였다

App of Apps 패턴 ​

지금까지 만든 ArgoCD 어플리케이션만 해도

  • Notiflex Api
  • 프로메테우스
  • loki + Fluent Bit
  • Argo Rollouts
  • valkey
  • ...

등 많다. 앞으로 카프카, Tempo 그리고 테넌트별 배포까지 추가될 것을 고려하면 지금 시점에 App of Apps 패턴을 도입하는 것이 적절하다.

  • kubectl apply로 관리하면 누락이 생기고 전체 상태를 파악하기 어려움
  • 하나의 루트 어플리케이션이 하위 어플리케이션들을 자동 배포하는 구조.

App of Apps 패턴은 root-app이 하위 여러 apps를 감시하도록하여 대장 앱이 배포되면 나머지 하위 앱도 배포되는 방식이다

디렉터리 구조 ​

tree
argocd/
├── root-app.yaml      # 루트 Application, argocd/apps/ 를 감시
└── apps/
    └── notiflex-smb.yaml

argocd/ 바로 밑에는 root-app.yaml만 두고, 나머지 Application들은 전부 argocd/apps/ 밑으로 옮겼다. 루트가 자기 자신을 sync 대상으로 잡아버리는 순환을 막기 위함.

yaml
# argocd/root-app.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: root-app
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/opjt/notiflex-platform.git
    targetRevision: main
    path: argocd/apps
    directory:
      recurse: true
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

directory.recurse: true가 없으면 argocd/apps/ 하위 서브디렉터리(나중에 smb/, enterprise/ 등으로 나뉘면)에 있는 Application을 못 읽는다고 해서 미리 켜뒀다.

기존 argocd/notiflex-smb.yaml은 argocd/apps/notiflex-smb.yaml로 옮기고 sync-wave 어노테이션을 추가하였다

yaml
metadata:
  annotations:
    argocd.argoproj.io/sync-wave: "2" # 0: 인프라, 1: 플랫폼(모니터링·Kafka), 2: 애플리케이션

인프라 -> 플랫폼 -> 앱 순서로 sync 되게 wave를 나눠두면, 나중에 Kafka나 Tempo가 추가돼도 의존성 순서가 깨지지 않는다. 지금은 앱이 하나뿐이라 큰 의미는 없지만 8장부터 앱이 늘어날 걸 대비한 세팅

bash
kubectl --context gke-sysnet4admin_book_gitaiops apply -f argocd/root-app.yaml

적용 후 확인:

bash
kubectl --context gke-sysnet4admin_book_gitaiops get applications -n argocd

NAME           SYNC STATUS   HEALTH STATUS
notiflex-smb   Synced        Healthy
root-app       Synced        Healthy

기존에 있던 notiflex-smb가 root-app 관리로 넘어오면서도 다운타임 없이 그대로 Healthy 상태를 유지했다. 이제 새 앱을 추가하고 싶으면 argocd/apps/에 YAML 파일만 넣으면 root-app이 알아서 인식한다.

멀티 테넌시 ​

Notiflex는 B2B 알림 SaaS이다. 여러 기업 고객(테넌트)에게 서비스를 제공하려면 테넌트 간 데이터와 리소스를 격리해야 함.

고객사가 요구하는 것은 '우리 데이터'가 다른 고객과 섞이면 안 된다' 라는 것이다.

이렇게 여러 기업 고객에게 서비스를 제공하면서 서로의 데이터와 리소스에 접근할 수 없도록 격리하는 것을 멀티 테넌시(Multi-tenancy)라고 한다

격리 방법 비교 ​

방식격리 수준장점단점
Namespace 분리 + RBAC논리적추가 도구 없음, 단순, 리소스 효율적네트워크 격리 약함, 노이지 네이버 가능
Namespace + NetworkPolicy논리적+네트워크네트워크 수준 격리 추가CNI 플러그인 의존, 설정 복잡
vCluster가상 클러스터테넌트별 가상 Control Plane, 강한 격리추가 리소스, 운영 복잡성 증가
클러스터별 분리물리적가장 강한 격리비용 2배+, 관리 복잡

지금 쓰는 클러스터는 e2-medium 노드 3개짜리 단일 클러스터라서 vCluster나 클러스터별 분리는 비현실적이다. 그래서 Namespace 분리 + RBAC 방식을 선택한다.

  • K8s 기본 기능만으로 격리: 추가 도구 설치 없이 Namespace + RBAC만으로 구현 가능
  • ArgoCD 통합: 테넌트별 Application을 방금 만든 App of Apps 패턴(argocd/apps/)에 그대로 얹을 수 있음
  • 리소스 효율: 클러스터를 늘리지 않고 단일 클러스터에서 여러 테넌트 운영

핵심 개념 정리:

  • 테넌트(Tenant): 동일한 인프라를 공유하는 독립적인 사용자 그룹. B2B SaaS에서는 기업 고객 하나가 테넌트 하나.
  • Namespace: K8s의 논리적 격리 단위. 같은 클러스터 안에서 리소스를 그룹으로 나눈다.
  • RBAC: 누가(ServiceAccount/User) 어떤 리소스에 어떤 동작(get/list/create)을 할 수 있는지 정의.
  • cross-namespace DNS: <service>.<namespace>.svc.cluster.local 형식으로 다른 Namespace의 Service에 접근. 테넌트가 공유 Valkey(valkey-primary.notiflex.svc.cluster.local)에 접근할 때 이 패턴을 쓴다.
  • 노이지 네이버(Noisy Neighbor): 한 테넌트가 리소스를 과다 사용해서 다른 테넌트에 영향을 주는 문제. ResourceQuota로 완화한다.

Namespace 분리는 완전한 격리는 아니고(네트워크 레벨 격리는 약함) 논리적 격리에 가깝지만, 지금 규모에서 멀티테넌시의 핵심 개념(격리, 공유 리소스 접근, cross-namespace 통신)을 경험하기엔 충분하다.

enterprise 테넌트 구성 ​

k8s/enterprise/ 디렉터리에 새 테넌트용 매니페스트를 생성한다.

  • rollout.yaml: notiflex-api Rollout (enterprise 네임스페이스, nodeSelector는 smb와 동일하게 cloud.google.com/gke-nodepool: api-pool)
  • service.yaml, service-preview.yaml: Canary용 stable/preview Service
  • argocd/apps/notiflex-enterprise.yaml: root-app이 감시하는 argocd/apps/에 추가 -> App of Apps 구조 덕에 파일만 추가하면 root-app이 알아서 인식
bash
kubectl --context gke-sysnet4admin_book_gitaiops get applications -n argocd

NAME                  SYNC     HEALTH
notiflex-enterprise   Synced   Healthy
notiflex-smb          Synced   Healthy
root-app              Synced   Healthy

argocd CLI로 계층 구조 확인 (port-forward + login 필요):

bash
kubectl --context gke-sysnet4admin_book_gitaiops port-forward svc/argocd-server -n argocd 8080:443 &
argocd login localhost:8080 --username admin --password <비번> --insecure
argocd app resources root-app

GROUP        KIND         NAMESPACE  NAME                 ORPHANED
argoproj.io  Application  argocd     notiflex-enterprise  No
argoproj.io  Application  argocd     notiflex-smb         No

Valkey는 공유, GCP 서비스 계정은 smb만 ​

enterprise를 만들면서 Valkey를 새로 설치했나 헷갈렸는데, 확인해보니 새로 설치한 게 아니라 기존 notiflex Valkey를 그대로 공유하고 있었다.

  • VALKEY_ADDR: valkey-primary.notiflex.svc.cluster.local:6379 (cross-namespace DNS)로 접근
  • /id 호출해보면 카운터가 notiflex 쪽에서 쌓인 값에 이어서 증가함

GCP 서비스 계정도 확인하니 notiflex-api@... 만 있고 smb 쪽 Rollout에만 serviceAccountName으로 연결돼 있었다. enterprise는 처음엔 그냥 K8s Secret(valkey-enterprise)에 평문 비밀번호를 넣어서 볼륨 마운트하는 방식으로 만들었다.

시크릿 관리를 Secret Manager로 통일 ​

이 저장소가 public이라서, valkey-secret.yaml에 실제 비밀번호를 그대로 커밋하면 GitHub에 평문으로 노출된다. 처음엔 파일로 커밋하지 않고 kubectl create secret으로 클러스터에 직접 만들어서 git에는 올리지 않도록 하려했으나, K8s Secret 자체가 base64라 근본적으로 평문이나 다름없음.

그래서 결국 enterprise도 ch6.2에서 smb에 적용했던 방식(Secret Manager + CSI + Workload Identity)으로 전환

bash
# 1. K8s SA 생성 + Workload Identity annotation
kubectl create serviceaccount notiflex-api -n enterprise
kubectl annotate serviceaccount notiflex-api -n enterprise \
  iam.gke.io/gcp-service-account=notiflex-api@opjt-gitaiops-project.iam.gserviceaccount.com

# 2. GCP SA에 enterprise KSA용 바인딩 추가
gcloud iam service-accounts add-iam-policy-binding \
  notiflex-api@opjt-gitaiops-project.iam.gserviceaccount.com \
  --member="serviceAccount:opjt-gitaiops-project.svc.id.goog[enterprise/notiflex-api]" \
  --role="roles/iam.workloadIdentityUser"

k8s/enterprise/secret-provider-class.yaml을 새로 만들어서 smb와 같은 secret(valkey-password)을 가리키게 설정

yaml
spec:
  provider: gke
  parameters:
    secrets: |
      - resourceName: "projects/opjt-gitaiops-project/secrets/valkey-password/versions/latest"
        path: "valkey-password"

rollout.yaml의 volume도 secret 타입에서 csi 타입으로 교체:

diff
       volumes:
       - name: secrets
-        secret:
-          secretName: valkey-enterprise
+        csi:
+          driver: secrets-store-gke.csi.k8s.io
+          readOnly: true
+          volumeAttributes:
+            secretProviderClass: notiflex-secrets

Canary 배포로 반영되고, /id 호출해서 정상 동작(카운터 계속 증가) 확인 후 이제 필요없어진 평문 Secret(valkey-enterprise) 삭제