핵심 한 줄

vCPU 쿼터 32로 38 vCPU 리그를 돌릴 수 있었다 — terraform apply가 통과했으니까. 그런데 DB를 리사이즈하려는 순간 VcpuLimitExceeded가 거부했다. DB가 뜨자마자 파드 커넥션 풀이 죽어 있었고 요청의 99.9%가 0ms에 실패했다. 실험 결과가 아니라, 실험하러 가다 밟은 지뢰들이다.

Scale 시리즈 — k3s 수평 스케일아웃

1막 · 여러 대로 안전하게 00 · 01 · 02 · 03 · 04 2막 · 늘리면 정말 빨라지나 05 · 06 · 07 · 08


1. 쿼터 32에서 38 vCPU를 돌린 방법

ap-northeast-2 기본 On-Demand Standard vCPU 쿼터는 5다. 리그 전체를 띄우기에는 턱없이 모자라서 신청을 넣어 32로 늘렸다 — 쿼터 코드 L-1216C47A.

그런데 리그 노드를 전부 세어 보면 38 vCPU다. 32짜리 쿼터에 38을 어떻게 올렸나.

terraform apply가 그냥 통과했다. 정확히는 — EC2가 RunInstances를 병렬로 받을 때, 쿼터 계량에 랙이 걸려 순간적인 초과를 통과시킨 것이다. 이미 뜬 인스턴스는 AWS가 소급해서 내리지 않는다. 그래서 리그 전체가 쿼터 위에 서 있게 됐다.

버스트 착시(burst illusion): RunInstances가 동시에 쏟아질 때 쿼터 계량이 실시간을 따라가지 못하면 순간 한도를 넘겨도 통과한다. 인스턴스는 정상 가동되지만, 리그 총 vCPU가 쿼터를 초과한 상태로 굳어버린다.

문제는 그 다음이었다.

2. VcpuLimitExceeded — 멈추는 건 시작이 아니라 재시작

6편 진단 결과, payment_db EC2 인스턴스의 vCPU를 2 → 4로 올리기로 했다. 절차는 단순하다: stop → 타입 변경 → start.

stop까지는 됐다. start에서 막혔다.

An error occurred (VcpuLimitExceeded) when calling the StartInstances operation:
You have requested more vCPU capacity than your current vCPU limit of 32 allows
for the instance bucket that the specified instance type belongs to.

RunInstances(신규 생성)가 병렬 버스트에서 계량을 통과시켰던 것과 달리, StartInstances(단발 재시작)는 현재 사용량을 정확하게 재검한다. 이미 running 상태인 인스턴스들을 전부 합산하면 — 38 vCPU에서 멈춘 payment_db의 2 vCPU를 빼도 — 신규 타입(4 vCPU)을 더하면 쿼터를 넘긴다.

여기서 주의할 점이 하나 있다. stopped 인스턴스는 vCPU 집계에 포함되지 않는다. 달리 말하면, 지금 멈춰 있는 노드들이 여유를 만들어줄 수 있다.

리그에서 현재 실험에 쓰지 않는 워커 노드 두 개가 있었다 — pay-2와 pay-3, 각각 4 vCPU씩 합계 8 vCPU. 이 둘을 stop하면 집계에서 빠지므로 여유가 생긴다.

running 합계 38 vCPU
- payment_db(stopped)  -2 vCPU = 36
- pay-2(stopped)       -4 vCPU = 32
- pay-3(stopped)       -4 vCPU = 28
+ payment_db 4 vCPU 재시작  = 30  ← 쿼터 32 이내

순서대로 실행했고, StartInstances가 통과했다.

임시방편이다. 쓰지 않는 노드를 멈춰서 쿼터 여유를 만드는 건 리그 규모를 줄이는 것과 같다. 구조적 해법은 두 가지다: 함대 총 vCPU를 쿼터 이내로 설계하거나, 쿼터를 미리 신청한다. terraform apply가 통과했다고 쿼터가 충분한 게 아니다 — 버스트 착시는 한 번뿐이다.

3. DB가 뜨자마자 파드 커넥션이 죽어 있었다

인스턴스 리사이즈는 EC2 stop/start로 진행된다. EC2가 멈췄다 뜨면 — 컨테이너 런타임도 같이 재시작되므로 — docker run으로 떠 있던 MySQL 컨테이너가 새로 생성된다. 새 컨테이너니까 내부 프로세스 ID, 소켓, 모든 것이 새로 시작된다.

그런데 k3s 클러스터에 떠 있는 payment 파드는 그 사실을 모른다.

Hikari 커넥션 풀은 이전 DB 컨테이너와 맺었던 TCP 커넥션들을 그대로 들고 있다. 옛 소켓은 이미 서버 측에서 닫혔지만, 클라이언트(파드) 측에서는 아직 ESTABLISHED처럼 보인다. OS 레벨에서 연결이 죽었다는 신호가 오기 전까지 풀은 그 커넥션들을 “유효”하다고 믿는다.

k6 부하를 걸었다. 결과가 이상했다.

rps: 210
fail: 99.9%
p50: 0ms  ←
p95: 0ms  ←

p50 = 0ms는 응답이 느린 게 아니라, 요청 즉시 실패하고 있다는 뜻이다. DB를 의심했지만, seed 스크립트를 k6에서 직접 MySQL로 찔렀더니 성공했다. DB 자체는 살아 있다. 파드의 커넥션만 오염된 것이다.

해법은 명확했다: 파드를 롤링 재시작해서 새 커넥션을 강제로 맺게 한다.

kubectl rollout restart deployment/payment-service

재시작 후 부하를 다시 걸었더니 fail 0%, 수치 정상.


Hikari 기본 설정에는 connectionTestQuery와 keepaliveTime 같은 옵션이 있어 죽은 커넥션을 주기적으로 정리한다. 그러나 DB 컨테이너가 즉시 재생성되면 그 주기를 기다릴 이유가 없다. 운영에서 DB 인스턴스(또는 컨테이너)를 재시작할 때 파드 롤링 재시작을 함께 묶는 게 낫다. 아니면 DB 앞에 연결을 중개하는 프록시(ProxySQL, PgBouncer 등)를 두면 파드가 재시작 없이도 커넥션을 다시 라우팅할 수 있지만 — 이 리그는 그 레이어 없이 직접 연결이다.

4. 오케스트레이션의 자잘한 것들

세 번째 함정은 크지 않다. 하지만 실수한 건 실수한 것이니 기록한다.

Duplicate key — seed 타이밍 충돌. 여러 노드에서 시드를 동시에 심을 때 PREFIX="$(date +%s)" 방식으로 유니크 키를 만들었다. 초 단위라 노드 두 개가 같은 초에 실행되면 같은 prefix가 나온다. 즉, 두 시드가 같은 paymentKey를 생성해 Duplicate key 에러. 해법은 prefix에 노드 식별자나 랜덤 suffix를 추가하는 것이다.

InnoDB 버퍼풀 콜드. DB 컨테이너가 재생성되면 버퍼풀이 비어 있다. 첫 몇 분간 쿼리가 전부 디스크를 태우므로 지표가 비정상적으로 보인다. 부하 실측 전에 워밍업 트래픽을 먼저 흘리거나, 적어도 “지금 콜드 상태”임을 알고 숫자를 읽어야 한다. 모르고 보면 DB가 느려졌다고 오진하게 된다.


이 편은 실험 결과가 아니다. 실험을 하러 가다 밟은 지뢰들이다. vCPU 쿼터 초과, 커넥션 오염, seed 충돌, 버퍼풀 콜드 — 어느 하나도 “k8s가 이렇게 동작한다”는 기능이 아니라, “오케스트레이션 환경에서 운영한다는 건 이런 것도 포함된다”는 현실이다.

재현하려는 사람을 위해 적어두자면: terraform apply가 통과했다는 건 인프라가 목표 상태에 도달했다는 뜻이지, 쿼터 제약에서 자유롭다는 뜻이 아니다. DB를 재시작할 때 파드 커넥션 상태는 항상 의심하라. 처음 숫자가 이상해 보이면 워밍업부터 의심하라.

한 줄

이런 함정까지가 “여러 대로 굴린다”의 실체다. 그리고 이 모든 결정 — 쿼터 계산, 커넥션 설정, 재시작 순서 — 이 압축된 매니페스트를 한 줄씩 따라가는 것이 08편이다.