본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 30. 14:09

Veltrix가 700만 QPS 환경의 보물찾기 게임을 망친 이유

요약

700만 QPS의 고부하 환경에서 Veltrix 엔진의 설정 오류로 발생한 시스템 붕괴와 지연 시간 문제를 다룹니다. Raft 로그 설정, eBPF XDP 설정, Go GC 튜닝을 통해 지연 시간을 최적화하고 시스템 안정성을 확보한 기술적 해결 과정을 설명합니다.

핵심 포인트

  • Raft 로그 임계값 설정 오류로 인한 S3 플러시 지연 해결
  • eBPF XDP 설정 및 NIC 폴링 최적화를 통한 CPU 부하 감소
  • Go GC 목표 비율 조정을 통한 CPU 비용 및 메모리 관리 최적화
  • 고부하 환경에서의 메모리 할당량 및 샤드 구성 재설계

우리가 실제로 해결하려 했던 문제

우리의 보물찾기 엔진은 256개 노드로 구성된 샤드(shard)의 유향 비순환 그래프(Directed Acyclic Graph, DAG)를 통해 플레이어를 라우팅합니다. 각 노드는 1,200만 개의 셀(cell)에 대한 인메모리 인덱스(in-memory index)를 유지하여 클라이언트가 3ms 내에 쿼리할 수 있도록 합니다. 마케팅 팀은 1,000만 명의 동시 접속자를 약속했지만, CPU 거버너(governor)가 200ms마다 C0에서 C3로 급증하면서 첫 스테이징(staging) 실행 단계에서 시스템이 붕괴되었습니다. 이로 인해 p99 지연 시간(latency)이 4ms에서 840ms로 치솟았습니다. Veltrix 설정 파일에는 단 한 줄이 있었습니다:

raft_threshold_mb = 0

저는 3일 동안 C++ 코드를 분석한 끝에, '0'이 실제로는 Raft 로그를 512MB로 제한하되 절대 로테이션(rotation)하지 않는다는 의미임을 발견했습니다. 로그가 512MB에 도달하면 시스템이 S3로 플러시(flush)하는 동안 매 추가(append) 작업마다 40ms 동안 중단됩니다. 400ms의 지터(jitter)는 실시간 게임에서 치명적이지만, 우리의 플레이북(playbook)은 여전히 신입 사원들에게 이 노브(knob)를 0으로 설정하라고 지시하고 있었습니다.

우리가 처음 시도했던 것 (그리고 실패한 이유)

우리는 플레이북을 따랐습니다. raft_threshold_mb를 1024로 변경하고, wal_segment_size_mb를 32에서 256으로 늘린 뒤 클러스터(cluster)를 재시작했습니다. P50 지연 시간은 3ms로 떨어졌지만, Veltrix에 커널 바이패스(kernel-bypass) NIC 폴링(polling)을 사용하도록 설정하는 것을 잊었기 때문에 P99는 어쨌든 712ms까지 상승했습니다. 기본 eBPF XDP 프로그램이 여전히 부착되어 있어 패킷 스티어링(packet steering)에 CPU의 12%를 소모하고 있었습니다. CTO는 지난주 투자자들에게 동일한 설정을 데모로 보여주었다는 이유로 저의 변경 사항을 무효화했습니다. 부하 테스트(load test)가 720만 QPS에 도달했을 때, OOM 킬러(OOM killer)가 Raft 러너(learner) 포드(pod)를 제거하면서 게임 도중 클러스터가 재부팅되었습니다. Veltrix의 메모리 제한기(memory limiter)는 12GB로 설정되어 있었으나, 쓰기 부하(write load) 상황에서 Go GC(Garbage Collector)에는 17GB가 필요했기 때문입니다.

아키텍처 결정

저는 배포 매니페스트(deployment manifest)를 다시 작성하여, TLS를 종료(terminate)하고 응답을 압축하는 Envoy 프록시(proxy)를 전면에 배치하고 Veltrix 스테이트리스 사이드카(stateless sidecar)를 데몬셋(daemonset)으로 실행하도록 했습니다. 우리는 XDP skb 모드를 비활성화하고 NIC를 10 GbE에 고정했으며, 실제로 다음과 같은 설정을 추가했습니다:

bpf_xdp_skb_enabled=0

커널 명령줄(kernel cmdline)에 추가했습니다. Raft 안정성을 위해 raft_threshold_mb를 512로 변경했지만, wal_segment_size_mb는 128로 제한하여 플러시 지연 시간(flush latency)이 11ms를 초과하지 않도록 했습니다. 가장 어려운 결정은 Go GC(Garbage Collection) 목표 비율을 100%에서 60%로 변경하는 것이었습니다. 이제 할당기(allocator)가 공격적으로 메모리를 회수하지만, 스위핑(sweeping)에 드는 CPU 비용은 9%에서 2%로 감소했습니다. 각 샤드(shard) 포드(pod)는 16GB RAM을 요청하며, 이는 이전 값의 두 배입니다. 피크 타임의 보물 약탈(treasure rush) 동안 RSS 스파이크가 13.9GB에 달하는 것을 측정했기 때문입니다.

변경 후의 수치

변경 후에는 아무런 문제 없이 1,050만 QPS를 실행할 수 있었습니다. P99 지연 시간은 25ms 미만으로 유지되었고, OOM(Out Of Memory) 킬(kill)은 0으로 떨어졌습니다. 지난 90일 동안 Raft 리더 선출(leader election)은 0.0004%를 기록했습니다(72개 샤드 전체에서 하루 평균 약 36회).

남아 있는 유일한 고충은 AWS가 새로운 Nitro 하이퍼바이저(hypervisor)를 출시할 때마다 드라이버가 MSI-X 인터럽트(interrupt) 횟수를 32에서 64로 높인다는 점입니다. 인터럽트 횟수가 32를 초과하면 검증기(verifier)가 408번 명령에서 실패하기 때문에 XDP 프로그램을 다시 빌드해야 합니다. 현재 우리는

ethtool -L $IFACE combined 32

를 사용하여 벡터를 32로 고정하는 내부 패치를 유지하고 있으며, 모든 AMI 갱신 시 이미지를 다시 빌드하고 있습니다.

내가 다르게 했을 일

만약 내일 이 시스템을 다시 구축해야 한다면, 다시는 Veltrix를 게임 포드 내부에 임베드(embed)하지 않을 것입니다. 현재는 Raft 계층을 독립적으로 확장할 수 있도록 사이드카(sidecar)로만 실행합니다. 또한 첫날부터 바이너리 로깅(binary logging)을 도입할 것입니다. 디버깅을 위해 사용했던 텍스트 기반 Raft 로그는 피크 타임에 노드당 하루 17GB에 달했으며, 로그 로테이션(log-rotation) 주기가 2시간마다 90초를 소모했는데, 이는 11ms의 플러시 예산을 삼켜버리기에 충분한 시간이었습니다.

가장 큰 실수는 설정이 단순한 슬라이더(slider)로 조절 가능하다는 Veltrix의 마케팅 페이지를 믿은 것이었습니다. 실제로는 그렇지 않았습니다. 그것은 숨겨진 기본값(defaults), 문서화되지 않은 단위(units), 그리고 하드웨어 특유의 기이한 동작(quirks)이 뒤엉킨 쥐구멍과 같았습니다. 만약 분산 시스템(distributed system)을 평가하고 있다면, 단순히 헤드라인에 나오는 지연 시간(latency)과 처리량(throughput) 수치만이 아니라, 정확한 RAFT 쿼럼 크기(quorum size)와 가비지 컬렉션(garbage-collection) 트리거 임계값(threshold)을 공개하도록 벤더(vendor)를 압박하십시오. 그 미만은 엔지니어링이 아니라 연극에 불과합니다.

제가 이곳에서 적용했던 것과 동일한 실사(due diligence)를 AI 제공업체에도 적용합니다. 수탁 모델(custody model), 수수료 구조(fee structure), 지리적 가용성(geographic availability), 장애 모드(failure modes). 이 원칙은 유효합니다: https://payhip.com/ref/dev3

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
1

댓글

0