핵심 한 줄
payment.yaml의 줄 하나하나는 앞 7편에서 실제로 터진 상처에서 나왔다 — 이론이 아니라 실측이 새긴 흔적이다.
Scale 시리즈 — k3s 수평 스케일아웃
1. 매니페스트 전문
시리즈 내내 “payment.yaml”이라 불렀던 파일의 실제 모습이다. Deployment 부분 전체를 먼저 보고, 이후 줄별로 해부한다.
apiVersion: apps/v1
kind: Deployment
metadata: { name: payment, namespace: default }
spec:
replicas: 3
selector: { matchLabels: { app: payment } }
strategy:
# 전용 노드풀(노드당 payment 1, required anti-affinity)에선 maxSurge>0이 데드락:
# surge 파드가 갈 빈 노드가 없어 Pending 영구 정지(exp⑤ 발견). maxSurge:0으로
# 옛 파드 1개씩 종료 → 빈 노드에 재생성(preStop이 엔드포인트 레이스 흡수).
rollingUpdate: { maxSurge: 0, maxUnavailable: 1 }
template:
metadata: { labels: { app: payment } }
spec:
terminationGracePeriodSeconds: 40
# 격리 벤치: payment 전용 노드 풀에만 스케줄 (taint 통과 + label 매칭)
nodeSelector: { pool: payment }
tolerations:
- { key: role, value: payment, effect: NoSchedule }
affinity:
podAntiAffinity: # 전용 풀에서 노드당 정확히 1 (경합 0)
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector: { matchLabels: { app: payment } }
topologyKey: kubernetes.io/hostname
containers:
- name: payment
image: camelia9999/cancel-loadtest:payment-latest
imagePullPolicy: Always
ports: [{ containerPort: 8080 }]
lifecycle:
preStop:
exec: { command: ["sh", "-c", "sleep 8"] } # 엔드포인트 제거 대기 → 롤링 5xx↓
envFrom:
- configMapRef: { name: app-config }
env:
- { name: SPRING_DATASOURCE_URL, valueFrom: { configMapKeyRef: { name: app-config, key: PAYMENT_DB_URL } } }
- { name: SPRING_DATASOURCE_USERNAME, valueFrom: { secretKeyRef: { name: db-cred, key: PAYMENT_DB_USER } } }
- { name: SPRING_DATASOURCE_PASSWORD, valueFrom: { secretKeyRef: { name: db-cred, key: PAYMENT_DB_PASS } } }
- { name: JAVA_TOOL_OPTIONS, value: "-Xmx2g -XX:+UseG1GC" }
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 8080 }
initialDelaySeconds: 20
periodSeconds: 5
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
initialDelaySeconds: 40
periodSeconds: 10
resources: # requests == limits ⇒ Guaranteed QoS (CPU 격리)
requests: { cpu: "3", memory: "3Gi" }
limits: { cpu: "3", memory: "3Gi" }총 50줄 남짓. 길지 않다. 그런데 한 줄씩 뽑아보면 — 이 7편 동안 벌어진 사건들이 전부 여기 새겨져 있다.
2. 스케줄링 — 어느 노드에 붙이나
nodeSelector: { pool: payment }
payment 파드는 pool=payment 레이블이 붙은 노드에만 배치된다. 이걸 빼면 어떻게 되나? 스케줄러가 아무 노드에나 파드를 넣는다. 얼핏 유연해 보이지만 실측에서는 문제가 됐다.
05편에서 처음 “replica 3개로 처리량이 올랐다”는 숫자를 봤을 때, 그 숫자는 노드가 분리되지 않은 환경에서 나왔다. payment, order, risk 파드가 같은 노드에 섞여 있으면 CPU 사용 패턴이 간섭한다. 어느 파드가 CPU를 얼마나 썼는지, 어느 파드가 노이즈를 일으켰는지 — 격리 없이는 진단이 불가능하다.
전용 노드풀은 격리 벤치다. payment만 있는 노드에서 측정해야 payment의 수치를 신뢰할 수 있다.
tolerations: [{ key: role, value: payment, effect: NoSchedule }]
nodeSelector가 “payment가 가고 싶은 노드”를 지정한다면, toleration은 “그 노드의 taint를 통과하는 열쇠”다. 전용 노드풀에는 role=payment:NoSchedule taint가 걸려 있다. 이 taint의 의미는 “toleration 없는 파드는 이 노드에 스케줄하지 마라”는 것이다.
두 설정이 함께 있어야 완전한 격리가 된다.
- nodeSelector만 있고 taint가 없으면: payment가 payment 노드에 가긴 하지만, 다른 파드도 그 노드에 들어올 수 있다.
- taint만 있고 nodeSelector가 없으면: payment 노드는 방어되지만 payment 파드가 거기 가리라는 보장이 없다.
두 줄이 함께 작동해서 “payment 노드에는 payment만”이 된다.
podAntiAffinity: requiredDuringScheduling… topologyKey: kubernetes.io/hostname
스케줄러에게 요구사항을 건다. “같은 앱 레이블(app: payment)을 가진 파드가 이미 있는 노드에는 이 파드를 스케줄하지 마라 — required, 즉 이 조건을 만족하는 노드가 없으면 파드를 Pending 상태로 두어라.”
preferred가 아니라 required다. 이 차이가 03편에서 결정적이었다.
노드 하나가 죽었을 때 — 쿠버네티스는 그 노드 위 파드를 살아있는 노드로 재스케줄하려 한다. preferred였다면 “되도록 다른 노드로” 시도하지만 같은 노드에 두 번째 payment 파드가 들어오는 걸 허용한다. required면 “같은 노드에는 절대 안 된다”이므로, 빈 노드가 없을 때 재스케줄을 포기하고 Pending을 유지한다.
처음엔 “재스케줄이 거부되는 게 더 나쁜 거 아니냐”고 생각했다. 그런데 03편 실측이 알려준 건 — required 덕분에 “payment는 노드당 정확히 1개”라는 불변식이 유지됐고, 그 덕분에 두 파드가 같은 노드에서 CPU를 나눠 쓰는 noisy-neighbor 없이 정확한 격리가 보장됐다는 것이다.
3. 롤링 업데이트 — 배포 중에 뭐가 터지나
rollingUpdate: { maxSurge: 0, maxUnavailable: 1 }
04편의 핵심 발견이 이 두 숫자에 담겨 있다.
처음 배포 전략을 짤 때 maxSurge: 1로 설정했다. 쿠버네티스 기본 전략에 가까운 값이고, “새 파드를 먼저 올리고 나서 옛 파드를 내리는” 방식이라 롤링 중 가용성이 더 좋을 것 같았다. 그런데 전용 노드풀 + requiredDuringScheduling anti-affinity 조합에서 이 값은 데드락을 만든다.
순서를 따라가보면:
- 롤링 업데이트 시작. 쿠버네티스가 surge 파드(새 버전) 1개를 먼저 만들려 한다.
- 모든 payment 노드에는 이미 payment 파드가 하나씩 있다. anti-affinity required 조건 때문에 같은 노드에 들어갈 수 없다.
- 빈 payment 노드가 없다. surge 파드는 Pending.
- 쿠버네티스는 surge 파드가 Running이 되기를 기다리며 옛 파드를 내리지 않는다.
- 옛 파드를 내리지 않으니 빈 노드가 생기지 않는다. surge 파드는 영구 Pending.
maxSurge: 0으로 바꾸면 순서가 달라진다. “새 파드를 먼저 만들기”가 아니라 “옛 파드를 먼저 내리기”로 전략이 바뀐다. 옛 파드 1개를 종료하면 → 빈 노드가 생기고 → 새 파드가 그 자리에 들어간다.
전용 노드풀을 쓰는 한 maxSurge: 0은 선택이 아니라 필수다.
lifecycle.preStop: exec: ["sh", "-c", "sleep 8"]
maxSurge: 0으로 바꾼 뒤에도 롤링 중 5xx가 완전히 사라지지 않았다(preStop 없이 fail 0.7%). 04편에서 추적한 원인은 타이밍 레이스였다.
쿠버네티스가 파드를 종료할 때 일어나는 일은 두 채널에서 동시에 진행된다.
- API 서버가 Endpoints에서 해당 파드 IP를 제거하라는 신호를 보낸다.
- kubelet이 컨테이너에 SIGTERM을 보낸다.
문제는 Endpoints 제거 신호가 kube-proxy / Ingress 컨트롤러에 전파되는 데 시간이 걸린다는 것이다. 그 사이에 SIGTERM을 받은 컨테이너가 먼저 종료되면, 로드밸런서는 아직 그 IP로 트래픽을 보내고 있는데 받는 쪽이 없어 5xx가 발생한다.
preStop: sleep 8은 이 타이밍 갭을 메운다. 컨테이너에 SIGTERM이 오기 전, 먼저 8초를 기다린다. 그 8초 동안 엔드포인트 제거 신호가 전파되고, 로드밸런서가 해당 IP로 트래픽 보내기를 멈춘다. 그 다음에 컨테이너가 실제로 종료된다.
terminationGracePeriodSeconds: 40
preStop이 8초를 기다리는 동안, 그리고 그 이후 애플리케이션이 in-flight 요청을 처리하고 graceful shutdown을 완료하는 데까지 총 시간의 상한이 40초다.
계산: preStop sleep 8초 + 애플리케이션 drain 시간(Tomcat 기본 30초) = 38초 필요. 여기에 여유를 더해 40초. 이 값이 너무 작으면 취소 트랜잭션 중간에 프로세스가 강제 종료된다. 취소 시스템에서 트랜잭션 강제 종료는 “느린 것”보다 나쁘다 — 상태가 PROCESSING에 멈추고 스케줄러 복구를 기다려야 한다.
4. 자원 — 얼마나 줄 것인가
resources: requests == limits: { cpu: "3", memory: "3Gi" }
쿠버네티스는 requests < limits이면 파드를 Burstable QoS로 분류하고, requests == limits이면 Guaranteed QoS로 분류한다. 이름에서 풍기는 것보다 실질적인 차이가 크다.
Guaranteed QoS의 의미:
- 스케줄러가 이 파드에 CPU
3코어를 예약한다. 다른 파드가 가져갈 수 없다. - OOM Kill 우선순위가 낮다. 노드 메모리가 부족해도 Guaranteed 파드는 마지막에 죽는다.
- 노드의 CPU가 바빠도 이 파드는 예약된 3코어를 온전히 쓴다.
05편에서 처음 replica 3개의 처리량 수치가 튀어나왔을 때, 그 수치가 믿을 수 없었던 이유 중 하나가 바로 CPU 격리가 안 돼 있던 탓이었다. requests만 설정하고 limits를 열어두면 한 파드가 CPU를 과점할 수 있고, 다른 파드의 응답 시간이 출렁인다. 수치가 노이즈가 섞인 채로 나온다.
requests == limits로 고정하면 “각 파드는 정확히 이만큼만 쓴다”가 보장되고, 그제야 파드별 처리량을 신뢰할 수 있게 된다.
JAVA_TOOL_OPTIONS: "-Xmx2g -XX:+UseG1GC" — 메모리 limits가 3Gi인데 JVM heap을 2g로 고정한 것은, 나머지 1Gi를 JVM 오프히프(메타스페이스, 다이렉트버퍼, 스택)와 OS에 남겨두기 위해서다. heap을 limits와 같거나 가깝게 설정하면 JVM 오프히프 할당이 컨테이너 메모리 상한을 초과해 OOM Kill이 발생한다.
5. 프로브 — 언제 트래픽을 받고 언제 재시작하나
readinessProbe: initialDelaySeconds: 20, periodSeconds: 5
readinessProbe는 “이 파드가 트래픽을 받을 준비가 됐는가”를 묻는다. Spring Boot 애플리케이션이 컨테이너 안에서 뜨는 데는 JVM 시작 + Spring 컨텍스트 로딩 + Flyway 마이그레이션 체크까지 포함해 수십 초가 걸린다. initialDelaySeconds: 20은 그 시간을 기다리는 것이다.
readinessProbe가 실패하는 동안 쿠버네티스는 이 파드를 Endpoints에서 제외한다. 즉, 로드밸런서가 여기로 트래픽을 보내지 않는다. 프로브가 없으면? 컨테이너가 Running 상태가 되는 순간(프로세스 시작)부터 트래픽이 들어온다. Spring Boot가 아직 서블릿 컨텍스트를 초기화하는 중이어도. 결과: 초기 요청들이 연결 거부 또는 503으로 떨어진다.
periodSeconds: 5는 5초마다 체크한다는 뜻이다. 복구 감지 속도와 헬스체크 부하 사이의 균형점이다.
livenessProbe: initialDelaySeconds: 40, periodSeconds: 10
livenessProbe는 “이 파드가 아직 살아서 일을 하고 있는가”를 묻는다. 실패하면 컨테이너를 재시작한다.
initialDelaySeconds: 40 — readinessProbe(20초)보다 훨씬 늦게 시작하는 게 중요하다. 애플리케이션이 아직 초기화 중인데 livenessProbe가 먼저 실패하면 컨테이너가 무한 재시작 루프에 빠진다. 뜨는 시간보다 넉넉하게 설정해야 한다.
두 프로브의 역할이 다르다. readiness는 트래픽 제어(Endpoints 포함/제외), liveness는 생사 판정(재시작 여부). 둘을 같은 엔드포인트로, 같은 타이밍으로 설정하면 liveness 실패 → 재시작 → readiness 실패 → Endpoints 제외 → 재시작 → … 루프가 생길 수 있다.
6. 환경 주입 — DB URL과 크리덴셜은 어디서 오나
envFrom: configMapRef: { name: app-config }
ConfigMap app-config의 모든 키-값을 환경변수로 주입한다. 애플리케이션이 공유하는 설정값(타임아웃, 프로필, 피처 플래그 등)을 매니페스트에 직접 쓰지 않고 ConfigMap으로 분리하면, 같은 매니페스트로 다른 설정을 주입할 수 있다.
env: SPRING_DATASOURCE_URL, SPRING_DATASOURCE_USERNAME, SPRING_DATASOURCE_PASSWORD
DB 접속 정보 주입 방식이 세 가지가 혼재한다.
- URL은 ConfigMap에서 — 사설 IP라 시크릿이 아니어도 되지만 매니페스트에 하드코딩하면 IP가 바뀔 때 매니페스트를 고쳐야 한다.
- USERNAME / PASSWORD는 Secret에서 — 크리덴셜은 평문으로 YAML에 쓰는 순간 git에 남는다. Secret은 base64 인코딩이지 암호화는 아니지만, 적어도 실수로 diff에 평문이 드러나는 사태는 막는다.
payment_db는 클러스터 밖 외부 인스턴스다. 06편에서 병목의 정체가 클러스터 밖 DB로 좁혀졌을 때, 외부 DB라는 구조는 이미 전제였다 — Load 시리즈에서부터 “DB는 클러스터 밖에 고정”이었으니까. 이 env 블록이 그 외부 DB에 붙는 연결고리다.
7. 시리즈 회고 — 이 매니페스트가 생긴 이유
Load 시리즈는 단일 서버 위에서 취소 시스템을 쥐어짰다. 분산락 결함, fsync 병목, 동기 홉 오해 — 추측이 관측에 두 번 뒤집혔다. 그 끝에서 “한 대의 천장은 DB fsync가 정한다”는 결론을 들고 수평으로 나섰다.
Scale 1막은 “안전하게”를 먼저 다졌다. replica가 N개로 늘면 정합성이 깨지나(02편), 노드가 죽어도 복구되나(03편), 배포 중에 트랜잭션이 손상되나(04편). 이중차감, 중복 Kafka 발행, 롤링 중 5xx — 실제로 발생했고, 실제로 고쳤다.
2막은 “그래서 빨라지나”를 물었다. 처음 수치는 “replica 3개, 처리량 상승”이었다. 그 수치가 틀렸다. 06편의 4단 진단 끝에 벽은 다시 payment_db였다 — Load에서 만났던 그 벽. 노드가 3개로 늘어도, 파드가 3개로 늘어도, 모두가 같은 DB 앞에서 막혔다.
07편은 그 구조를 운영자 시각으로 되짚었다. 쿠버네티스가 추상화하는 것들 — 스케줄링, 롤링, 자가치유 — 이면에 있는 비용들.
그리고 이 매니페스트는 그 7편의 총합이다.
nodeSelector + tolerations — 격리 벤치 없이는 수치를 믿을 수 없다는 05편의 교훈.
podAntiAffinity: required — 노드 하나가 죽어도 불변식을 지킨다는 03편의 선택.
maxSurge: 0 — surge 파드 데드락을 실측으로 발견한 04편의 수정.
preStop: sleep 8 — 엔드포인트 전파 레이스를 직접 목격한 04편의 패치.
terminationGracePeriodSeconds: 40 — in-flight 취소가 중단되는 걸 막는 상한.
resources: requests == limits — CPU 격리 없이는 noisy-neighbor가 숫자를 오염시킨다는 05편의 결론.
readinessProbe / livenessProbe — 준비 안 된 파드에 트래픽이 가는 걸, 죽은 파드가 좀비로 남는 걸 막는 기본.
이론으로 쓴 줄은 없다. 각 줄이 실측에서 뭔가 터진 다음에 추가됐거나, 터지지 않도록 미리 막은 것이다.
개념을 더 깊이 파고 싶다면 쿠버네티스 정리를 이어서 보면 좋다. Scale 시리즈는 “왜 이 설정인가”를 실측으로 답했고, 쿠버네티스 정리는 그 설정들이 어떤 메커니즘 위에서 작동하는지를 다룬다.
마지막으로 — 수직에서 시작해서 수평에서 같은 벽을 다시 만났다. DB fsync는 Load에서도 벽이었고 Scale에서도 벽이었다. 그 벽이 두 시리즈를 관통하는 하나의 축이다. 다음 탐구의 출발점이 어디일지는, 이미 그 벽이 가리키고 있다. Load 시리즈(Load 00편)부터 다시 읽으면, 같은 길을 더 넓게 볼 수 있다.
한 줄
매니페스트 한 줄 한 줄이 앞 7편의 상처에서 나왔다 — YAML은 설정 파일이 아니라 실측 일지다.