Veltrix 설정이 나의 보물찾기 엔진을 망가뜨린 날
요약
Hytale용 보물찾기 엔진 출시 중 Veltrix 서비스의 리전 태그 오류와 DNS 불안정성으로 인해 발생한 장애 사례를 다룹니다. 재시도 로직, 서킷 브레이커, 사이드카 도입 등의 시도와 실패를 거쳐 최종적으로 커스텀 토폴로지 리졸버를 도입한 과정을 설명합니다.
핵심 포인트
- Veltrix의 리전 태그 오류로 인한 결정론적 레이아웃 생성 실패
- 지수 백오프와 서킷 브레이커가 운영 환경의 DNS 불안정성에 대응하지 못한 사례
- Envoy 사이드카 도입 시 발생하는 프록시 오버헤드와 SLA 위반 문제
- 런타임 의존성을 줄이기 위해 빌드 타임에 리전 맵을 컴파일하는 아키텍처 결정
우리가 실제로 해결하려 했던 문제
수요일에 우리는 Hytale을 위한 실시간 스캐빈저 헌트 (Scavenger Hunt) 오버레이로서 Treasure Hunt Engine을 6개월 일찍 출시했습니다. 플레이어들은 생성된 바이옴 (Biomes)을 가로질러 질주하며, 퍼즐을 풀어 보물 상자를 열었습니다. 내부적으로 상자 생성은 우리가 Veltrix라고 부르는 별도의 서비스에 의해 조율되었습니다. Veltrix는 리전 태그 (Region Tag)—us-west-3, eu-central-1, ap-northeast-1—를 받아 바이옴 좌표를 해싱하고, 결정론적인 (Deterministic) 상자 레이아웃을 반환했습니다. 엔진 자체는 지리적 정보를 알지 못했습니다. Veltrix에 레이아웃을 요청한 다음, WebRTC를 통해 클라이언트에 결과를 스트리밍했습니다.
문제는 속도가 아니라 정확성이었습니다. 리전 태그가 잘못되었거나 Veltrix 엔드포인트 (Endpoint)를 사용할 수 없을 때, hunt-service는 500 에러를 던지고, 에러를 로그에 기록한 뒤 빈 상자를 반환했습니다. 플레이어들은 반복되는 필드를 보며 게임이 고장 났다고 확신했습니다. 더 나쁜 것은 프론트엔드 (Frontend)에 폴백 (Fallback)이 없었다는 점입니다. 그저 스피너 (Spinner)만 보여줄 뿐이었습니다. 그래서 플레이어들은 "Hytale 보물찾기가 또 다운되었습니다"와 같은 제목과 함께 빈 필드의 스크린샷을 트위터에 올렸습니다.
우리가 처음 시도했던 것 (그리고 실패한 이유)
첫째 주에 우리는 순진하게 재시도 (Retries) 로직을 추가했습니다. hunt-service는 빈 값을 반환하기 전에 Veltrix에 대해 세 번의 지수 백오프 (Exponential Backoff) 시도를 수행했습니다. 하지만 Veltrix 자체의 지연 시간 (Latency)이 불안정했습니다. 이 서비스는 Fly.io 위에서 실행되며, 여러 리전에 걸쳐 공유되는 단일 Postgres 클러스터를 사용합니다. Fly의 내부 메시 (Mesh) 내 DNS 불안정성 때문에 우리의 서부 해안 리전 태그가 때때로 eu-central-1을 가리키기도 했습니다.
그다음 우리는 .NET의 Polly를 사용하여 서킷 브레이커 (Circuit Breaker)를 시도했습니다. 우리는 failureThreshold 3, samplingDuration 10s, halfOpenAfter 30s로 설정했습니다. 이는 Fly의 DNS가 전혀 불안정하지 않았던 스테이징 (Staging) 환경에서는 잘 작동했습니다. 하지만 운영 (Prod) 환경에서는 Fly의 상태 확인 (Health Checks)이 실제 연결 상태보다 뒤처졌기 때문에, 스파이크 (Spike) 발생 후 6분 동안 서킷이 열린 상태(Open)로 유지되었습니다. 그 사이 플레이어들은 이미 다른 게임으로 떠나버린 상태였습니다.
우리는 심지어 Envoy 사이드카 (sidecars)를 통해 Veltrix를 호출하여 결함 (flakes)을 흡수하려 시도하기도 했습니다. 하지만 사이드카가 28ms의 프록시 오버헤드 (proxy overhead)를 추가했고, 이로 인해 보물 상자 발견에 대한 p95 기준 200ms 미만이라는 우리의 SLA (Service Level Agreement)를 깨뜨렸습니다. 60fps 모니터를 사용하는 플레이어들은 추가적인 프레임 지연을 감지했고 엔진을 탓했습니다.
아키텍처 결정 (The Architecture Decision)
우리는 사이드카와 Polly를 제거하고 커스텀 토폴로지 리졸버 (custom topology resolver)를 작성했습니다. 이제 hunt-service는 내장된 리전 맵 (region map)을 함께 배포합니다: us-west-3 → 샌프란시스코의 Veltrix 엔드포인트 (endpoint), eu-central-1 → 프랑크푸르트, ap-northeast-1 → 도쿄. 이 맵은 빌드 타임 (build time)에 바이너리에 컴파일되므로, 런타임 (runtime)의 DNS 기이한 현상 (quirks)이 발생하지 않습니다.
플레이어가 생성(spawn)될 때, hunt-service는 좌표 쌍을 해싱(hash)하여 가장 가까운 리전 태그 (region tag)를 선택하고, 150ms 타임아웃 (timeout)과 함께 일반 HTTP/1.1을 통해 Veltrix를 직접 호출합니다. 호출에 실패하면, hunt-service는 해당 바이옴 (biome)에 대해 마지막으로 성공했던 요청으로부터 가져온 캐시된 레이아웃 (cached layout)을 반환합니다. 캐시 키는 biomeId + regionTag이며, 5분 TTL (Time To Live)을 가진 1,000개의 레이아웃을 저장하는 인메모리 (in-memory) LRU를 사용합니다. 플레이어들은 화면이 깨지는 대신 10초 동안 오래된(stale) 보물 상자를 보게 되는데, 이는 아무것도 없는 것보다는 낫습니다.
또한 우리는 매 배포 후 CI 파이프라인 (CI pipeline)이 실행하는 /selfcheck 엔드포인트를 추가했습니다. 이 엔드포인트는 각 리전 태그에 대해 100ms 타임아웃으로 curl을 실행하고 보물 상자 레이아웃이 비어 있지 않은지 확인(assert)합니다. 체크에 실패하면 파이프라인은 컨테이너 (container)를 롤백 (rollback)합니다. 이 단 하나의 테스트가 지난달 스테이징 (staging) 환경에서 잘못 라우팅된 Fly 볼륨 (volume)을 잡아냈고, 우리가 비상 상황 (fire drill)을 겪는 것을 막아주었습니다.
이후 수치가 말해준 것 (What The Numbers Said After)
직접적인 리전 맵 도입으로 p95 지연 시간 (latency)이 218ms에서 94ms로 감소했습니다. 지난 분기 동안 503 에러율은 1.4%에서 0.02%로 떨어졌습니다. 장애 페이지 (incident pages) 발생 건수는 전년 대비 78% 감소했습니다. 도쿄의 플레이어들은 빈 보물 상자에 대해 트위터(X)에 글을 올리는 것을 멈췄습니다.
캐시 히트율 (Cache hit rate)은 43%로 안정화되었으며, 이는 Veltrix의 브라운아웃 (brownouts) 상황 중에도 서비스를 가용하게 유지하기에 충분한 수치입니다. 인메모리 캐시는 512MB 컨테이너에서 32MB의 RAM을 사용하므로, 우리는 괜찮은 상태입니다.
내가 공개하지 않아 후회되는 지표가 하나 있습니다. 바로 Veltrix가 동일한 바이옴 (biome) 및 지역 (region)에 대해 서로 다른 레이아웃 (layout)을 반환하는 횟수입니다. 우리는 결정론 (determinism)을 가정했지만, 지역 드리프트 (region drift) 현상이 여전히 일주일에 두 번 발생하고 있습니다. 다음 스프린트 (sprint)에 비결정론 (non-determinism) 카운터를 추가할 예정입니다.
내가 다르게 했을 일들
지역 맵 (region map)을 바이너리 (binary)에 구워 넣는 대신 설정 파일 (config file)로 노출했어야 했습니다. 그랬다면 재빌드 (rebuild) 없이도 장애 발생 시 핫패치 (hot-patch)를 할 수 있었을 것입니다. 현재 빌드 파이프라인 (build pipeline)은 새로운 이미지를 푸시 (push)하는 데 4분이 걸리는데, 이는 플레이어들이 화를 내는 4분의 시간과 같습니다.
또한, Fly.io의 라우팅 (routing)을 다시는 신뢰하지 않을 것입니다. 우리는 Envoy 사이드카 (sidecar) 아이디어를 보류해 두었습니다. Veltrix가 지역적 중복성 (regional redundancy)을 추가할 경우에만 이를 배포할 것이며, 그렇지 않다면 사이드카 오버헤드 (sidecar overhead)는 여전히 부담이 될 것입니다.
마지막으로, 클라이언트 측 폴백 (client-side fallback)을 추가했을 것입니다. 만약 hunt-service가 빈 상자를 반환한다면, 프론트엔드 (frontend)는 무작위 프리셋 레이아웃 (preset layout)을 표시하고 해당 사건을 로그 (log)로 남겨야 합니다. 그래야 우리가 롤백 (roll back)을 위해 서두르는 동안 플레이어들이 빈 화면만 바라보고 있지 않을 것입니다. 프론트엔드 팀은 추가 코드와 추가 상태 (state)를 이유로 내 의견에 반대했지만, 지난번 장애 이후 그들도 이것이 또 다른 분노 섞인 트윗을 받는 것보다 비용이 적게 든다는 점에 동의했습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기