CI 글에서 CI가 “검증된 이미지”를 GHCR에 올리는 데까지 왔습니다. 그런데 이미지 하나가 레지스트리에 있다고 서비스가 뜨는 건 아닙니다. 그 이미지를 어디에, 어떤 환경 설정으로 어떤 방식으로 외부에 노출해서 띄울지를 선언해야 합니다. 그것이 Kubernetes 매니페스트의 몫입니다.

여러 환경을 관리해야 한다는 문제가 있습니다. PeekCart는 로컬 minikube에서 매니페스트를 시행착오로 다듬고 부하 측정은 GKE에서 했습니다. 두 환경은 이미지를 가져오는 방법도, 외부 노출 방식도, 리소스 예산도 다릅니다. 가장 쉬운 길은 k8s/minikube/와 k8s/gke/에 매니페스트 사본을 통째로 두 개 만드는 것입니다. 하지만 그 방식은 곧 “한쪽만 고치고 다른 쪽을 깜빡하는” drift를 일으킵니다. 이번 글은 그 drift를 막기 위해 Kustomize base/overlays로 “공통은 한 번, 차이만 patch로” 선언한 구조와, minikube에서 시작해 GKE로 옮겨간 환경 진화, 그리고 그 과정에서 monitoring 스택을 base에서 떼어낸 결정을 정리합니다.

이 글에서 쓰는 용어는 다음 뜻으로 읽으면 됩니다.

용어이 글에서의 의미
매니페스트(manifest)K8s에 “이 리소스를 이 상태로 둬라”라고 선언하는 YAML. Deployment, Service, PVC 등
Kustomize매니페스트를 템플릿 없이 겹쳐 쓰는 도구. base를 두고 overlay에서 patch로 차이만 덮는다. kubectl -k에 내장
base환경과 무관한 공통 매니페스트 묶음. 어느 환경에서도 똑같은 부분
overlay특정 환경(minikube/gke)이 base에 적용하는 차이(patch)의 묶음
patchbase의 일부 필드만 덮어쓰는 조각. strategic merge(gvk+name 기준 병합)와 JSON6902(경로 기준 조작) 두 방식
drift같아야 할 두 설정이 시간이 지나며 서로 어긋나는 것. 매니페스트 복제의 고질병
minikube로컬 머신에 단일 노드 K8s를 띄우는 도구. 빠른 반복엔 좋지만 리소스가 host에 묶임
GKEGoogle Kubernetes Engine. 관리형 K8s. PeekCart는 부하 측정용으로 사용
NodePort / LoadBalancer / ClusterIPService의 외부 노출 방식. 순서대로 “노드 포트 직접 / (클라우드)LB / 클러스터 내부 전용”
HPAHorizontalPodAutoscaler. CPU 같은 지표로 Pod 수를 자동 조절
ServiceMonitorPrometheus Operator의 CRD. “이 서비스의 메트릭을 긁어가라”를 선언. CRD가 먼저 깔려 있어야 적용됨
CRDCustomResourceDefinition. K8s 기본에 없는 리소스 타입을 추가하는 정의. ServiceMonitor는 이 위에 산다
Helm여러 매니페스트를 한 묶음(차트)으로 설치해 주는 K8s 패키지 매니저. Prometheus/Grafana처럼 구성요소가 많은 도구를 통째로 깔 때 쓴다

쿠버네티스가 처음이라면

이 글의 주제는 K8s에 어떻게 올릴지를 선언하는 일입니다. 그래서 등장하는 구성요소 몇 가지만 먼저 알아두면 됩니다.

  • 컨테이너 / Pod: 우리 앱(app.jar를 담은 Docker 이미지, CI 글 참고)이 실제로 실행되는 단위. K8s에서 컨테이너는 Pod라는 단위로 묶여 실행됩니다. Pod는 언제든 죽거나 다시 뜰 수 있는 일회용이라고 생각하면 됩니다.
  • Deployment: “이 Pod를 몇 개 띄우고, 죽으면 다시 살려라”를 K8s에 맡기는 선언. 우리가 Pod를 직접 만들지 않고 Deployment에 “peekcart Pod 1개 유지해줘”라고 적으면 K8s가 알아서 그 개수를 지킵니다.
  • Service: Pod는 종료되고 다시 실행되며 IP가 계속 바뀝니다. 그 앞에 고정된 주소 하나를 세워 “이 이름으로 오는 요청을 살아있는 Pod 중 하나로 연결”해 주는 게 Service. 외부에 어떻게 노출하느냐(ClusterIP / NodePort / LoadBalancer)가 환경마다 갈리는 대표 지점입니다.
  • PVC (PersistentVolumeClaim): Pod가 일회용이라 그 안에 쌓은 데이터도 Pod와 함께 사라집니다. MySQL 데이터처럼 살아남아야 하는 것은 PVC라는 별도 디스크에 둡니다. “디스크 한 장 주세요”라는 요청서라고 보면 됩니다.
  • Namespace: 리소스를 담는 구획(폴더). PeekCart는 앱과 인프라를 peekcart, 모니터링 도구를 monitoring 구획에 나눠 담습니다.
  • 선언형과 kubectl apply: 위 모든 것을 매니페스트(위 용어 표 참고)에 “원하는 상태”로 적고 kubectl apply로 제출하면, K8s가 현재 상태를 그 선언에 맞춥니다. 명령형은 “이렇게 해라”라고 적고, 선언형은 “이런 상태였으면 좋겠다”라고 적습니다. K8s는 선언형입니다. 이 글의 base/overlays는 결국 그 YAML을 환경별로 어떻게 조직하느냐의 이야기입니다.

이 연재는 PeekCart를 네 단계로 진행했습니다. Phase 1은 단일 Spring Boot 모놀리스로 도메인을 구현한 단계, Phase 2는 캐시, 분산 락, Kafka와 Outbox로 성능과 정합성을 보강한 단계, Phase 3은 CI, Kubernetes, 관측성, 부하 테스트로 운영 환경을 검증한 단계, Phase 4는 서비스별로 나눈 MSA 단계입니다.

이번 학습에서 확인하고 싶은 질문은 다음과 같습니다.

  1. minikube는 왜 초기 검증에는 충분했고 부하 측정에는 부족했을까?
  2. 환경별로 매니페스트를 복사하는 대신 base/overlays로 나눈 이유는 무엇일까? 무엇을 base에 두고 무엇을 overlay에 둘까?
  3. minikube에서 GKE로 전환할 때 실제로 바뀌는 매니페스트는 어디일까? (이미지, 노출, 리소스, 스토리지)
  4. monitoring 스택을 base에서 떼어낸 이유는 무엇일까? base가 “환경 무관”이 아니었다는 건 무슨 뜻일까?
  5. “self-contained overlay”라면서 왜 apply -k overlays/gke/ 단독 실행은 실패할까?
  6. Phase 4에서 서비스가 N개로 늘면 이 구조는 “파일 추가”만으로 확장될까?

문제 상황: 환경은 둘인데 매니페스트는 하나여야 한다

Phase 1, 2까지 PeekCart의 인프라는 docker-compose.yml 하나였습니다. MySQL/Redis/Kafka를 컴포즈로 띄우고 Spring Boot는 로컬에서 bootRun으로 실행했습니다. Phase 3의 과제는 이것을 K8s 위에 올리는 것이고 여기서 “환경이 둘”이라는 문제가 처음 생깁니다.

  • minikube (로컬): K8s 매니페스트는 한 번에 맞지 않습니다. apply → describe → 로그 확인 → 수정 → 다시 apply를 수십 번 왕복하며 다듬어야 합니다. 이 반복을 클라우드에서 하면 느리고 돈이 듭니다. 그래서 CI, 매니페스트 작성, 관측성 스택 구성은 로컬 minikube에서 했습니다.
  • GKE (클라우드): 부하 테스트와 HPA 검증은 측정의 정확도가 핵심입니다. minikube는 host 16GB 안에 앱, 인프라, Prometheus, 부하 도구가 전부 공존해 서로 간섭하고 부하 발생기 자체가 병목이 돼 수치를 왜곡합니다. 그래서 측정은 GKE로 옮겼습니다.

두 환경은 다음이 다릅니다.

항목minikubeGKE
이미지 출처로컬 빌드 (레지스트리 pull 안 함)Artifact Registry
외부 노출NodePort 30080Internal LoadBalancer
앱 리소스base 기본값 (작게)e2-standard-4 기준 상향
스토리지minikube 기본 SCstandard-rwo 명시
HPA없음min=1 / max=3

이 차이를 다루는 가장 단순한 방법은 k8s/minikube/와 k8s/gke/에 전체 매니페스트를 환경마다 따로 두는 것입니다. 하지만 그러면 공통 부분(예: probe 설정, Service 포트, 컨테이너 환경변수)을 고칠 때마다 두 곳을 동기화해야 합니다. 한쪽만 고치고 다른 쪽을 깜빡하는 순간 두 환경이 미묘하게 달라지고(“minikube에선 되는데 GKE에선…”), 이는 CI 글에서 CI로 해결하려 했던 “내 머신에선 되는데”가 인프라 레이어에서 재발하는 것과 같습니다. Phase 4에서 서비스가 늘면 이 drift 비용은 서비스 수 × 환경 수로 폭증합니다.

그래서 필요한 건 **“공통은 한 번만 쓰고, 환경 차이만 따로 선언”**하는 구조입니다. 이것이 Kustomize base/overlays입니다.


선택한 설계

1. base/overlays: 공통은 base, 차이만 patch

Kustomize는 템플릿 엔진(Helm의 Go template 같은)이 아닙니다. base 매니페스트를 그대로 두고 overlay가 그 위에 patch를 겹쳐 일부 필드만 덮어씁니다. PeekCart의 k8s/ 구조는 이렇습니다.

k8s/
├── base/                          # 환경 무관 공통
│   ├── namespace.yml
│   ├── infra/{mysql,redis,kafka}/ # 디렉토리당 단일 통합 파일
│   ├── services/peekcart/         # Phase 3: 모놀리스 단일 서비스
│   │   ├── deployment.yml         # Deployment + Service (ClusterIP)
│   │   ├── configmap.yml
│   │   ├── secret.yml
│   │   └── servicemonitor.yml
│   └── kustomization.yml
├── overlays/
│   ├── minikube/                  # 로컬 검증
│   │   ├── kustomization.yml
│   │   └── patches/               # imagePullPolicy: Never, NodePort
│   └── gke/                       # 부하 테스트 / 운영
│       ├── kustomization.yml      # patches + images + hpa
│       ├── hpa.yml
│       ├── README.md
│       └── patches/               # LoadBalancer, 리소스 상향, PVC SC
└── monitoring/                    # base에서 분리 (아래 3절)

환경이 달라도 똑같으면 base, 환경마다 달라야 하면 overlay patch 로 나눕니다. base의 deployment.yml을 보면 환경과 무관한 것만 들어 있습니다.

# k8s/base/services/peekcart/deployment.yml (발췌)
spec:
  replicas: 1
  template:
    spec:
      containers:
        - name: peekcart
          image: ghcr.io/kimgyuilli/peakcart:latest
          resources:
            requests: { cpu: 250m, memory: 512Mi }   # ← 보수적 기본값
            limits:   { cpu: 1000m, memory: 1Gi }
          startupProbe:    { httpGet: { path: /actuator/health/liveness, port: 8080 }, failureThreshold: 30, periodSeconds: 5 }
          readinessProbe:  { httpGet: { path: /actuator/health/readiness, port: 8080 }, periodSeconds: 10 }
          livenessProbe:   { httpGet: { path: /actuator/health/liveness,  port: 8080 }, periodSeconds: 10 }
---
apiVersion: v1
kind: Service
spec:
  type: ClusterIP        # ← 가장 보수적인 노출. 환경별로 overlay가 바꾼다

여기서 probe는 K8s가 Pod에게 주기적으로 적용하는 상태 검사입니다. 셋의 역할이 다릅니다. startupProbe는 “느린 첫 부팅이 끝났는지”를 보고(끝날 때까지 아래 두 검사를 미뤄, 부팅 중인 앱을 성급히 죽이지 않습니다), readinessProbe는 “지금 요청을 받아도 되는지”를 보며(실패하면 Service가 그 Pod로 트래픽을 안 보냅니다), livenessProbe는 “죽었는지”를 봅니다(실패하면 컨테이너를 재시작합니다). 셋 다 앱의 /actuator/health(Spring Boot가 제공하는 상태 엔드포인트)에 요청합니다. 이 상태 검사 규칙은 어느 환경에서도 같으니 base에 둡니다.

반면 image, resources, Service.type은 환경마다 달라야 하므로, base에는 가장 보수적인 기본값(GHCR 이미지, 작은 리소스, ClusterIP)만 두고 overlay가 덮습니다. base가 그 자체로도 (가장 작은 환경에선) 동작하도록 두는 것이 핵심이고 overlay는 “차이”만 표현하면 됩니다.

2. minikube overlay와 gke overlay: patch가 표현하는 차이

overlay의 kustomization.yml은 resources: [../../base]로 base를 통째로 가져온 뒤 patches:로 차이만 얹습니다. 두 환경의 patch를 나란히 보면 “무엇이 환경 차이인가”가 그대로 드러납니다.

minikube: 로컬 빌드 이미지를 쓰고(레지스트리에서 pull하지 않음), minikube service로 접속할 NodePort를 엽니다.

# overlays/minikube/patches/peekcart-deployment.yml (JSON6902)
- op: add
  path: /spec/template/spec/containers/0/imagePullPolicy
  value: Never            # ← 로컬에 빌드된 이미지만 사용, pull 시도 안 함

# overlays/minikube/patches/peekcart-service.yml
- op: replace
  path: /spec/type
  value: NodePort
- op: add
  path: /spec/ports/0/nodePort
  value: 30080

gke: Artifact Registry 이미지, VPC 내부 전용 LoadBalancer, e2-standard-4에 맞춘 리소스 상향, 명시적 StorageClass, 그리고 HPA.

# overlays/gke/patches/peekcart-deployment.yml (strategic merge)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: peekcart                               # ← 이 name 으로 base 의 Deployment 를 찾는다
  namespace: peekcart
spec:
  template:
    spec:
      containers:
        - name: peekcart                       # ← 컨테이너도 이름으로 매칭 (인덱스 아님)
          resources:
            requests: { cpu: 500m,  memory: 1Gi }
            limits:   { cpu: 2000m, memory: 2Gi }

# overlays/gke/patches/peekcart-service.yml (strategic merge)
apiVersion: v1
kind: Service
metadata:
  name: peekcart                               # ← gvk + name 으로 base 의 Service 매칭
  namespace: peekcart
  annotations:
    networking.gke.io/load-balancer-type: "Internal"
spec:
  type: LoadBalancer

여기서 설계 결정 하나를 확인해야 합니다. gke patch는 strategic merge, minikube patch는 JSON6902를 섞어 쓴다. 둘의 차이는 “어떻게 대상을 찾고 어떻게 덮는가”입니다.

  • JSON6902(op/path/value)는 JSON Pointer 경로로 대상을 가리킵니다. 경로가 항상 인덱스인 건 아니지만 이 예시처럼 리스트 안의 항목(/spec/template/spec/containers/0/...)을 찍을 때 인덱스 취약성이 생깁니다. containers/0은 “0번째 컨테이너”라, base에 사이드카가 추가돼 순서가 바뀌면 엉뚱한 컨테이너에 patch가 붙습니다.
  • strategic merge는 patch의 apiVersion+kind+metadata.name(gvk + name)으로 base 리소스를 찾고 annotations 같은 map은 키 단위로 병합합니다. 그래서 gke patch는 헤더에 kind: Deployment / name: peekcart를 명시하고(그 덕에 target: 지정도 불필요), 컨테이너도 name으로 매칭합니다. base에 다른 컨테이너나 annotation이 추가돼도 안전합니다.

gke patch 를 이 방식으로 쓴 의도는 다음과 같습니다. “strategic merge: container는 name(peekcart)으로 매칭되므로 base에 sidecar가 추가되어도 잘못된 컨테이너에 resources가 붙지 않음 (과거 JSON6902 containers/0 인덱스 방식 대비 개선).” 즉 Phase 4에서 서비스 메시 사이드카 등이 추가될 가능성을 내다보고 리소스와 노출처럼 구조가 진화할 부분은 인덱스 의존을 버린 것입니다. 반면 PVC의 StorageClass 일괄 적용은 “PVC 종류 전체”를 대상으로 해야 해서 JSON6902 + target이 더 맞습니다.

# overlays/gke/patches/pvc-storageclass.yml (JSON6902 + target)
- op: add
  path: /spec/storageClassName
  value: standard-rwo
# kustomization.yml에서 target: { kind: PersistentVolumeClaim } 으로
# base의 mysql/redis/kafka PVC 3건에 한 번에 적용

이미지 경로도 overlay가 바꿉니다. gke kustomization.yml의 images: 필드가 GHCR 경로를 Artifact Registry로 치환합니다(PROJECT_ID_PLACEHOLDER는 apply 직전 kustomize edit set image로 로컬에서만 채우고 커밋하지 않습니다. 「한계」절에서 다룹니다).

GKE 전용으로 추가되는 hpa.yml은 overlay의 resources:에 더해집니다.

# overlays/gke/hpa.yml (발췌)
spec:
  minReplicas: 1
  maxReplicas: 3
  metrics:
    - type: Resource
      resource: { name: cpu, target: { type: Utilization, averageUtilization: 60 } }
  behavior:
    scaleUp:   { stabilizationWindowSeconds: 30 }    # 빨리 늘리고
    scaleDown: { stabilizationWindowSeconds: 300 }   # 천천히 줄인다

이 선언을 풀면 “peekcart Pod의 평균 CPU 사용률이 60%를 넘으면 Pod 수를 최대 3개까지 자동으로 늘리고(min=1), 부하가 빠지면 다시 줄여라”는 뜻입니다. 즉 트래픽에 따라 Pod 수가 스스로 1과 3 사이를 오갑니다.

HPA가 gke overlay에만 있는 건 의도입니다. minikube는 8GB 예산상 max=3 scale-out을 받아낼 여유가 없어(Pod를 늘리다 메모리가 부족하면 기존 Pod가 쫓겨나는 eviction 위험), HPA 검증 자체가 GKE의 일이기 때문입니다. scaleUp 30초 / scaleDown 300초의 비대칭은 “부하엔 빠르게(30초) 대응해 늘리되, 줄일 땐 천천히(300초), 잠깐 부하가 출렁일 때마다 Pod를 늘렸다 줄였다 하는 flapping을 막는다”는 표준 운영 감각입니다. 이 HPA가 실제로 1에서 3으로 떴는지는 동시 주문 부하 테스트 글(세션 C)에서 검증합니다.

3. monitoring을 base에서 떼어낸다. base의 “환경 무관”을 진짜로 만들기

base/overlays를 처음 도입했을 때, monitoring 스택(Prometheus Helm values, Grafana 대시보드/Alert, ServiceMonitor)도 base/monitoring/ 아래 있었습니다. 그런데 머지 직후 리뷰에서 base가 사실은 환경 무관이 아니었다는 게 드러났습니다.

  • values-prometheus.yml이 첫 줄부터 “minikube 경량 설정”이었습니다. retention: 6h, Prometheus memory 512Mi, Grafana NodePort 30030. 전부 minikube 8GB 예산에 정해진 값이라 GKE에선 과소하거나 부적합했습니다.
  • ServiceMonitor는 peekcart 네임스페이스인데 디렉토리는 base/monitoring/(나머지는 monitoring 네임스페이스). “디렉토리 = 관심사”와 “디렉토리 = 네임스페이스” 두 축이 섞여 혼란스러웠습니다.
  • install.sh가 Helm으로 monitoring 네임스페이스를 만들고 base가 그 네임스페이스에 의존하는 ConfigMap을 포함했습니다. Helm이 만든 걸 Kustomize가 재사용하는 순환 의존.

결정은 monitoring 전체를 base에서 분리하는 것이었습니다. 결과 구조:

k8s/monitoring/
├── namespace.yml          # monitoring NS 생성의 단일 주체
├── shared/                # 환경 무관: 대시보드 JSON, Grafana Alert
│   └── kustomization.yml  # configMapGenerator가 *.json → ConfigMap
├── minikube/
│   ├── values-prometheus.yml   # retention 6h, NodePort 30030, 경량 limits
│   └── install.sh
└── gke/
    ├── values-prometheus.yml   # retention 24h, Internal LB, PVC standard-rwo, 상향 limits
    └── install.sh

이 분리로 두 가지를 동시에 얻습니다. (1) base는 이제 진짜로 환경 무관이 됐습니다. peekcart 네임스페이스 앱/인프라만 남습니다. (2) ServiceMonitor는 “앱이 자기 메트릭을 노출하는 방법”이라는 관심사를 따라 base/services/peekcart/로 이동했습니다. 서비스에 속한 것은 서비스 디렉토리에 둔다는 원칙이 생겼고 이것은 Phase 4에서 서비스마다 ServiceMonitor를 동봉하는 패턴으로 자연 확장됩니다.


구현 구조: fresh 클러스터에 4단계로 올린다

base/overlays/monitoring이 나뉘어 있으니 fresh 클러스터 배포는 한 줄로 실행할 수 없는 순서 있는 4단계입니다. minikube 기준:

flowchart TD
    A["1. kubectl apply -f monitoring/namespace.yml<br/>(monitoring NS 단일 생성)"] --> B["2. bash monitoring/minikube/install.sh<br/>(kube-prometheus-stack Helm<br/>= ServiceMonitor CRD 등록)"]
    B --> C["3. kubectl apply -k monitoring/shared/<br/>(대시보드/Alert ConfigMap)"]
    C --> D["4. kubectl apply -k overlays/minikube/<br/>(app + infra + ServiceMonitor)"]
    D -.->|"CRD 충족 안 되면 실패"| B

순서가 강제되는 이유는 CRD 의존성입니다. CRD(CustomResourceDefinition)는 쉽게 말해 쿠버네티스에게 새로운 리소스 종류를 가르치는 일입니다. Deployment, Service는 K8s가 처음부터 아는 단어지만 ServiceMonitor는 기본에 없는 단어, Prometheus가 추가하는 종류라, Prometheus 도구를 먼저 설치해야 K8s가 그 단어를 인식합니다. 그래서 2단계를 건너뛰고 4단계를 치면, K8s가 ServiceMonitor라는 단어 자체를 몰라 no matches for kind "ServiceMonitor"로 실패합니다. GKE도 같은 4단계이고 install 진입점(monitoring/gke/install.sh)만 환경별로 다릅니다.

그 설치를 2단계가 Helm으로 합니다(Helm은 위 용어 표 참고). 우리 앱과 인프라는 Kustomize로 직접 관리하고 구성 규모가 큰 모니터링 도구만 Helm에 맡깁니다. 이 도구 혼재의 트레이드오프는 「한계」절에서 다룹니다.

여기서 “self-contained overlay”라는 말의 정확한 의미를 알 수 있습니다. overlay를 self-contained로 설계했지만 그것이 apply -k overlays/minikube/ 단독으로 fresh 클러스터에 성공한다는 뜻은 아니다. ServiceMonitor의 CRD 선행 의존은 cert-manager, Istio, ArgoCD(각각 인증서, 서비스메시, 배포 자동화 도구로, 모두 자기 CRD를 먼저 깔아야 쓸 수 있습니다) 같은 K8s 생태계의 흔한 패턴과 똑같습니다.

overlay가 self-contained라는 건 **“monitoring 네임스페이스 리소스를 포함하지 않고, 외부 상태를 만들거나 변형하지 않는다”**는 의미입니다. 즉 overlay를 적용해도 monitoring 스택을 건드리지 않는다는 격리 보장이지, 무의존 보장이 아닙니다. 이 구분을 흐리면 “왜 단독 apply가 실패하지?”에서 헤맵니다.

base kustomization.yml에는 의도적으로 빠진 게 하나 있습니다: namespace: 필드입니다.

# k8s/base/kustomization.yml (발췌)
# kustomize `namespace:` 필드는 사용하지 않습니다 — 모든 리소스를 한 NS 로 강제하기 때문.
resources:
  - namespace.yml
  - infra/mysql/mysql.yml
  - ...
  - services/peekcart/servicemonitor.yml

Kustomize의 namespace: 필드는 모든 리소스를 한 네임스페이스로 강제 변환합니다. 이 필드를 안 쓰기로 한 결정의 출발점은 base/overlays를 처음 도입한 시점입니다. 그때 base는 peekcart(앱 리소스)와 monitoring(Grafana ConfigMap/Alert) 두 네임스페이스에 걸쳐 있어서 한 NS로 강제하는 필드를 쓸 수 없었습니다.

이후 monitoring을 base에서 떼어내면서 현재 base는 전부 peekcart 단일 네임스페이스입니다(base/kustomization.yml도 “모든 리소스는 peekcart NS”라고 선언합니다). 즉 지금은 namespace: 필드를 쓸 수도 있게 됐지만 base는 여전히 그 필드 대신 각 매니페스트가 metadata.namespace를 직접 명시하는 방식을 유지합니다. 이 선택이 「한계」절의 위험과 직결됩니다.


한계와 트레이드오프

metadata.namespace 누락이 default 네임스페이스로 새는 위험

base가 namespace: 필드를 안 쓰는 대가입니다. 각 매니페스트가 metadata.namespace: peekcart를 직접 적어야 하는데 새 리소스를 추가하면서 이 줄을 깜빡하면 그 리소스는 조용히 default 네임스페이스로 갑니다. Kustomize의 namespace: 필드를 썼다면 자동으로 교정됐을 실수가, 여기선 안전망 없이 통과합니다. base/overlays를 도입할 때부터 이 위험을 알고 있었습니다. “신규 리소스에서 metadata.namespace 누락 시 default NS로 새는 위험. Kustomize의 안전망 없음. 리뷰어가 명시적으로 확인해야 함 (자동 lint 미도입).” Phase 4에서 서비스가 늘수록 이 수동 확인의 부담이 커지므로, lint 도입이 부채 점검 글의 후보입니다.

이미지 경로 치환과 운반이 수동이다. GitOps가 아니다

gke overlay의 이미지 경로는 PROJECT_ID_PLACEHOLDER입니다. apply 직전에 operator가 로컬에서 채워야 합니다.

cd k8s/overlays/gke
kustomize edit set image \
  ghcr.io/kimgyuilli/peakcart=asia-northeast3-docker.pkg.dev/<YOUR_PROJECT>/peekcart/peekcart:latest
kubectl kustomize .               # ← 렌더링 확인
kubectl apply -k .                # ← 배포 순서 4단계 (cwd = overlays/gke)
git restore kustomization.yml     # ← apply 후 반드시 원복 (커밋하면 안 됨)

게다가 이미지를 GHCR에서 Artifact Registry로 옮기는 것도 수동(crane copy)입니다. CI 글의 파이프라인은 GHCR까지만 push하고 거기서 GKE 레지스트리로 옮기는 작업은 파이프라인 밖입니다. README에 그 이유가 적혀 있습니다: “Phase 3 부하 테스트는 측정 빈도가 낮아 CI 자동화 대신 수동 복사를 사용.” 측정이 드물고 일회성이라 자동화 우선순위를 낮춘 의도된 수동이지만 결과적으로 이 구조는 GitOps가 아니다. “git에 선언된 상태 = 클러스터 상태”가 맞지 않고 git restore를 깜빡하면 placeholder가 그대로 커밋되거나 로컬 치환이 누출될 여지가 있습니다. 서비스가 N개가 되면 이 수동 작업이 어떻게 되는지는 뒤의 Phase 4 절에서 봅니다.

Helm과 Kustomize가 한 트리에 섞여 있다

monitoring 스택(kube-prometheus-stack)은 Helm으로, 앱/인프라는 Kustomize로 관리합니다. 한 k8s/ 아래 두 도구가 공존합니다. Helm 단일화도 검토했지만 “환경 차이가 patch 3~4개 수준”인데 Helm의 Go template은 과하다고 봤습니다. 대신 kube-prometheus-stack은 직접 작성하기엔 너무 크니 Helm 차트를 그대로 씁니다. install.sh가 helm upgrade --install로 이 복잡성을 감추지만 **“왜 어떤 건 apply -k이고 어떤 건 bash install.sh인가”**를 배포 순서로 외워야 하는 인지 비용은 남습니다.

운영 정리가 사람 손에 달려 있다. 크레딧 누수 리스크

GKE는 측정할 때만 띄우고 끝나면 지웁니다(비용 최소화). 그런데 클러스터를 지워도 PD(영구 디스크)나 예약 IP는 orphan으로 남아 계속 과금될 수 있습니다. GKE 운영 체크리스트로 이것을 막지만 전부 수동입니다.

# 실제 정리 스크립트 기준, zone/이름은 asia-northeast3-a 단일 zone
gcloud container clusters delete peekcart-loadtest --zone=asia-northeast3-a
gcloud compute instances delete peekcart-loadgen   --zone=asia-northeast3-a
gcloud compute disks list     --filter="zone:(asia-northeast3-a)"   # orphan PD 확인
gcloud compute addresses list --filter="region:(asia-northeast3) OR global"  # 예약 IP 확인

billing alert를 ₩50,000에 걸어 안전망을 두긴 했지만 체크리스트를 빠뜨리면 크레딧이 계속 소모되는 구조입니다. 이것은 “측정 시에만 기동”이라는 비용 전략이 떠안는 본질적 리스크입니다.


이 구조가 보장한 것과 못한 것

배포 구조가 무엇을 보장하고 무엇을 보장하지 못하는지 나눠 보겠습니다.

보장하는 것

  • 공통 매니페스트의 단일 소스. probe, 포트, 환경변수 같은 공통 설정은 base 한 곳에만 있습니다. minikube/gke를 따로 고칠 일이 없으니 복제 drift가 구조적으로 차단됩니다. 환경 차이는 overlay patch에 국소화돼 “이 환경이 base와 무엇이 다른가”를 patch 파일만 보면 압니다. 리뷰와 롤백 단위가 명확합니다.
  • 두 환경의 실제 배포 검증. minikube에서 CI, 매니페스트, 관측성 스택을 끝까지 돌렸고 GKE에서 부하 테스트와 HPA 검증을 수행했습니다. base/overlays로 두 환경에서 실제로 떠봤습니다.
  • Phase 4 확장이 파일 추가로 끝나는 구조. 서비스를 늘릴 때 base/services/ 아래 형제 디렉토리를 추가하고 base/kustomization.yml에 경로 한 줄만 더하면 됩니다. 기존 파일 수정이 없어 PR 리뷰가 단순합니다.

보장하지 못하는 것

  • 단독 apply -k의 self-contained 배포: ServiceMonitor의 CRD 선행 의존 때문에 4단계 순서를 지켜야 합니다(「구현 구조」 절).
  • 선언 = 상태(GitOps): 이미지 경로 치환과 운반이 수동이라, git에 적힌 것이 곧 클러스터 상태인 모델이 아닙니다.
  • 환경 간 수치 비교: 로컬 검증에서 클라우드 측정으로 옮긴 환경 진화의 비용입니다. minikube와 GKE는 CPU, 메모리, 네트워크, 스토리지가 전부 달라 같은 부하를 줘도 TPS를 같은 축에서 비교할 수 없습니다. 다만 minikube 단계에는 실제 부하 측정 기록 자체가 없어(거긴 매니페스트 시행착오용이었습니다) 비교 대상이 애초에 없으니 실질 피해는 없습니다. 환경을 바꾸면 이전 수치와 단절된다는 점은 측정 설계에서 늘 기억할 트레이드오프이고, 그래서 이후 부하 테스트 글들의 리포트는 항상 “GKE / asia-northeast3 / e2-standard-4”라는 환경 스펙을 함께 적어 둡니다.
  • 자동 정리: orphan 리소스 정리가 수동 체크리스트에 의존합니다.

CI 글에서 본 패턴이 여기서도 반복됩니다. 핵심 동작은 단단히 검증하고, 일부 보장은 공백으로 남겨 둡니다. 배포 구조의 공백은 “self-contained 자동화, GitOps, 자동 정리” 세 가지입니다.


Phase 4 MSA에서는 어떻게 바뀌는가

지금 k8s/는 “모놀리스 한 덩어리(services/peekcart/)를 두 환경에 배포”하는 구조입니다. Phase 4에서 서비스가 N개로 갈라지면 이 구조가 확장됩니다.

flowchart LR
    subgraph Now["Phase 3 (모놀리스)"]
        B1["base/services/<br/>peekcart/"] --> O1["overlays/<br/>{minikube,gke}"]
    end
    subgraph P4["Phase 4 (MSA)"]
        B2["base/services/<br/>api-gateway/<br/>order-service/<br/>payment-service/<br/>user-service/<br/>product-service/<br/>notification-service/"] --> O2["overlays/<br/>{minikube,gke}<br/>(구조 동일)"]
    end
    Now --> P4

그대로 가는 것

  • base/overlays/monitoring의 3분할 구조. 서비스가 늘어도 “공통은 base, 환경 차이는 overlay, 관측성은 별도 트리”라는 경계는 유지됩니다. ServiceMonitor를 base/services/peekcart/로 옮긴 결정이 여기서 도움이 됩니다: 각 서비스가 자기 servicemonitor.yml을 동봉하는 패턴으로 자연 확장됩니다.
  • 공통 인프라 base. mysql/redis/kafka는 서비스가 갈라져도 (Phase 4 초기엔) 공통 인프라로 base에 남습니다. 서비스별 DB 분리는 그 위에서 점진적으로 일어납니다.
  • strategic merge 우선 결정. 사이드카가 추가될 가능성을 내다보고 인덱스 의존을 버린 결정이, 서비스 메시가 들어올 Phase 4에서 실제로 효과를 냅니다.

바뀌는 것

  • services/peekcart/ 하나가 서비스별 디렉토리 N개로. order-service/, payment-service/, api-gateway/ 등이 각자 deployment.yml + service.yml + servicemonitor.yml을 base에 둡니다. base/overlays 패턴 덕에 이것은 파일 추가이지 구조 변경이 아닙니다.
  • 이미지가 N개, 운반과 치환도 N배. 「한계」에서 본 수동 이미지 운반과 PROJECT_ID 치환이 서비스 수만큼 늘어납니다. 모놀리스에선 “측정이 드물어 수동”이 통했지만 서비스 N개에선 수동이 한계에 부딪힙니다. 여기서 GitOps(ArgoCD/Flux) 또는 CI 자동 운반이 비로소 본질적으로 필요해진다.
  • HPA가 서비스마다 따로: 단, 위치는 여전히 환경 쪽. 지금은 peekcart 하나에 HPA 하나가 GKE overlay에만 있습니다(minikube는 예산상 제외). Phase 4에선 트래픽 특성이 다른 서비스마다 독립 HPA가 필요해지지만, 주문 서비스와 알림 서비스의 scale 기준은 같을 수 없습니다. HPA는 “환경마다 켜고 끄는” 관심사라 Phase 3과 동일하게 overlays/gke/ 쪽에 서비스별로 추가됩니다. 즉 서비스 수만큼 늘되, base는 그대로 두고 overlay에서 늘어납니다.

그래서 Phase 4 진입 전 짚을 점

  • metadata.namespace 누락 lint를 도입하고 가야 한다. 서비스가 N개면 수동 리뷰로 default 네임스페이스 누출을 막는 건 비현실적입니다. kubectl apply --dry-run=server -k overlays/<env>(서버 검증)나 kubectl kustomize overlays/<env> | kubeconform(렌더 결과를 스키마 검사)처럼 렌더 산출물을 자동 검증하는 단계를 게이트에 넣는 게 부채 점검 글의 항목입니다.
  • 이미지 운반과 치환을 자동화해야 한다. 위 「바뀌는 것」에서 본 수동 운반은 서비스 1개라 견딘 것이고, N개에선 GitOps로 “선언 = 상태”를 회복하는 게 배포 안정성의 전제가 됩니다.
  • base의 “공통 인프라” 경계를 다시 그어야 한다. 서비스별 DB 분리가 시작되면 mysql 하나를 base에 두는 가정이 맞지 않습니다. 무엇을 끝까지 공통으로 두고 무엇을 서비스 안으로 넣을지는, Phase 4에서 common 모듈에 무엇을 둘지(CI 글과 부채 점검 글)와 같은 결의 질문입니다.

자료는 어떤 질문에 연결해서 읽을까

질문같이 읽을 자료이 글에서 연결되는 지점
base/overlays와 patch(strategic merge vs JSON6902)의 동작Kustomize 공식 가이드, Introduction / Patches”base/overlays”, patch 방식 선택 절
Service 노출 방식(ClusterIP/NodePort/LoadBalancer)의 차이Kubernetes 공식 문서, Serviceminikube NodePort vs GKE Internal LB
HPA가 지표로 Pod 수를 조절하는 원리와 stabilization windowKubernetes 공식 문서, Horizontal Pod Autoscalinggke hpa.yml scaleUp/scaleDown 비대칭
CRD와 Operator 패턴, ServiceMonitor의 CRD 의존Prometheus Operator 문서, Design / ServiceMonitor4단계 배포 순서, self-contained 해석
GitOps에서 “선언 = 상태”가 의미하는 것Argo CD 문서, Core Concepts이미지 수동 치환이 GitOps가 아닌 이유
GKE의 Internal LoadBalancer와 StorageClass(standard-rwo)Google Cloud 문서, GKE Networking / Storagegke patch의 LB annotation, PVC SC