보물찾기 엔진: 잘못된 Prometheus 규칙 하나가 어떻게 Veltrix 이벤트 전체를 망쳤는가
요약
피크 로드 상황에서 Rails 앱의 성능을 유지하려던 시도가 잘못된 Prometheus 규칙과 LLM 환각 문제로 인해 실패한 사례를 다룹니다. 부하 테스트, 벡터 스토어 지연, 모니터링 설정 오류가 시스템 전체에 미치는 영향을 분석합니다.
핵심 포인트
- 피크 로드 시 p99 응답 시간 관리를 위한 인프라 설계의 중요성
- Prometheus 알림 규칙 설정 시 윈도우 시간과 임계값의 정밀도 필요성
- LLM 답변의 문법 마스크 미설정으로 인한 환각(Hallucination) 위험
- 사전 캐싱 시 메모리 사용량(OOM) 및 리소스 예측의 중요성
우리가 실제로 해결하려 했던 문제
우리의 진짜 목표는 화려한 LLM 프롬프트나 실시간 리더보드가 아니었습니다. 모든 팀이 동시에 코드를 스캔하고, 새로운 힌트를 요청하며, 한정 시간 동안 제공되는 파워업(power-up)을 차지하기 위해 옆 사람보다 더 높은 입찰을 시도하는 피크 로드(peak load) 상황에서 Rails 앱의 p99 응답 시간을 450ms 미만으로 유지하는 것이었습니다. 우리는 Locust를 사용하여 5,000명의 동시 사용자(concurrent users)를 대상으로 벤치마킹을 수행했고, 가장 느린 엔드포인트가 /next-hint임을 확인했습니다. 이 엔드포인트는 pgvector의 벡터 스토어(vector store)를 호출하며 쿼리당 180ms가 소요되었습니다. 이로 인해 Rails 라우팅(routing), 속도 제한(rate-limiting)을 위한 Redis 읽기, 그리고 우리의 커스텀 동시성 제한기(concurrency limiter)에 할당된 시간은 270ms뿐이었습니다.
마케팅 슬라이드에는 AI라고 적혀 있었지만, 제품 팀이 실제로 원했던 것은 부하 상황에서도 녹아내리지 않는 힌트 스케줄러(hint scheduler)였습니다. 우리는 데이터 과학 인턴이 작성한 1,553줄짜리 llama.cpp 래퍼(wrapper)를 힌트 엔드포인트에 덧붙였고, 야간 크론 잡(cron job)을 통해 가능한 모든 답변을 캐싱할 수 있다고 생각했습니다. 이 래퍼는 자체 테스트 세트에서 3.2%의 알려진 환각(hallucination) 비율을 보였지만, 답변에 반드시 위치 이름만 포함되도록 강제하는 문법 마스크(grammar mask)를 설정한 사람은 아무도 없었습니다. 그래서 누군가 "다음 힌트가 어디에 숨겨져 있나요?"라고 물었을 때, 엔진은 행사장 지도에 지하 묘실(crypt)이 없음에도 불구하고 "사그라다 파밀리아(Sagrada Familia)의 지하 묘실 아래 당신의 의자 밑에 있습니다"라고 즐겁게 답변했습니다. 한 사용자의 스크린샷이 바이럴(viral)을 타면서, 갑자기 이벤트 전체가 사기처럼 보이게 되었습니다.
우리가 처음 시도했던 것 (그리고 실패한 이유)
첫 번째 해결책은 명백했습니다. /next-hint 엔드포인트의 에러 예산(error budget)을 10%에서 30%로 높여서, 벡터 쿼리가 지연될 때 오토스케일러(auto-scaler)가 더 많은 포드(pod)를 생성하도록 하는 것이었습니다. 우리는 벡터 스토어가 따라잡을 수 있을 것이라 생각하며, HPA 대상 CPU를 70%에서 85%로 업데이트하는 Helm 차트를 배포했습니다. 5분 후, 우리가 Kubernetes 문서에서 복사해 온 Prometheus의 크리티컬 규칙(critical rule)이 발동되었습니다:
expr: rate(http_requests_total{status=~"5.."}[5m]) / rate(http_requests_total[5m]) > 0.1
해당 규칙은 5분(5-minute)의 윈도우(window)를 사용했지만, 우리의 트래픽 급증(traffic spike)은 정확히 6분 12초 동안 지속되었습니다. 이는 커넥션 풀 고갈(connection pool exhaustion)로 인해 발생한 5xx 에러를 PromQL 함수가 평균화하기에는 너무 짧은 시간이었습니다. 알림 임계값(alert threshold)은 1분 윈도우 기준으로 >0.05였어야 했으나, 전날 밤에 온콜(on-call) 근무자가 교체되면서 아무도 이를 잡아내지 못했습니다. 우리는 규칙 자체가 실패의 원인이었다는 것을 깨닫기 전까지 바르셀로나와 싱가포르에 있는 두 개의 클러스터(cluster)를 해체했습니다.
우리는 또한 llama.cpp 래퍼(wrapper)를 통해 가능한 모든 위치를 미리 실행함으로써 벡터 캐시(vector cache)를 워밍업(warm)하려고 시도했습니다. 캐시는 37분 만에 워밍업되었지만, 사전 실행 과정에서 18 GiB의 RAM을 사용했고 가장 작은 VM 클래스에서 OOM 킬(OOM kills)을 유발했습니다. 데이터 과학 인턴이 모델이 전체 어휘(vocabulary)를 메모리에 버퍼링한다는 사실을 문서화해두지 않았기 때문에, 우리는 --gpu-layers 0 옵션을 사용하여 CPU 전용 추론(inference)을 강제하도록 Docker 이미지를 다시 빌드하는 데 두 시간을 더 소비했습니다. 이 조치로 RAM 사용량은 2.1 GiB로 돌아왔지만, p99 지연 시간(latency)은 450 ms에서 680 ms로 증가했습니다.
아키텍처 결정 (The Architecture Decision)
우리는 llama 래퍼를 완전히 제거하고, hints, venues, 그리고 지리적 근접성(geospatial proximity)을 위한 사전 계산된 인접 리스트(adjacency list)라는 세 개의 테이블을 조인(join)하는 Postgres 구체화된 뷰(materialized view)로 교체했습니다. 매일 밤 02:00 UTC에 REFRESH MATERIALIZED VIEW CONCURRENTLY mv_hints_geo 작업을 실행하는 잡(job)이 돌아갔으며, 이 작업은 11분이 소요되고 3 GiB의 임시 공간(temp space)을 사용했습니다. 리프레시(refresh)가 완료된 후, Rails 앱은 단순히 다음과 같이 실행되었습니다:
HintsGeo.find_by(venue_id: current_venue.id, sequence: next_sequence).text
지연 시간은 2–7 ms로 떨어졌습니다. 우리는 투자자용 피치 덱(investor deck)에서는 여전히 이것을 AI라고 불렀지만, 진짜 AI는 뷰를 언제 리프레시할지 결정하는 크론 잡(cron job)이었습니다.
두 번째 결정은 알림 창(alerting window)을 5분에서 1분으로 전환하고, 알림을 베뉴 샤드(venue shard)별로 그룹화하는 것이었습니다. 우리는 5분 단위의 창이 샤드별 스파이크(spike)를 숨긴다는 사실을 깨달았고, 따라서 하나의 거대한 알림 대신 샤드당 하나의 알림을 생성하는 두 번째 Prometheus 규칙 템플릿을 구축했습니다. 또한 5xx 에러율을 실제 요청률(request rate)과 비교하는 보조 체크 기능을 추가했습니다. 만약 에러율이 20%를 초과하고 요청률이 분당 10,000회 미만이라면, 이는 해당 샤드가 이미 죽었으며 연쇄 장애(cascade)가 불가피함을 의미하므로 페이지(page) 알림을 억제(suppress)했습니다.
이후의 수치들이 말해준 것
이벤트 당일, 우리는 6,800명의 동시 접속자(concurrent users)를 기록했으며 Rails p99는 320ms를 유지했습니다. 벡터 스토어(vector store) 지연 시간(latency)은 170ms로 일정하게 유지되었지만, 요청의 94%가 구체화된 뷰(materialized view)를 타게 되면서 부하 상황에서도 더 이상 이를 기다릴 필요가 없었습니다. Prometheus 규칙은 정확히 두 번 발생했는데, 두 번 모두 네트워크 연결을 잃은 샤드 때문이었으며, 온콜(on-call) 담당자가 30초 후에 수동으로 트래픽을 배출(drain)했습니다.
구체화된 뷰(materialized view) 리프레시로 인해 스테이징(staging) 환경의 야간 다운타임이 11분 추가되었지만, 운영(production) 환경에는 더 이상 래퍼(wrapper)가 필요하지 않았기에 이를 수용했습니다. 우리는 모든 힌트 요청을 BigQuery에 기록했으며, 환각(hallucination) 횟수를 계산하는 Dataflow 파이프라인은 변경 후 첫 10,000개의 요청에서 환각이 0건임을 보여주었습니다. /next-hint의 에러율은 0.4% 미만으로 유지되었고, 오토스케일러(auto-scaler)는 CPU 65% 이상에서 한 번도 트리거되지 않았습니다.
내가 다르게 했을 일
실제 이벤트와 일치하는 트래픽 패턴 하에서 부하 테스트(load test)를 거치지 않았다면, 데이터 과학 인턴에게 운영 래퍼(production wrapper)의 소유권을 맡기지 않았을 것입니다. 또한 모든 알림 규칙(alert rule)이 운영 환경에 배포되기 전에, 지난 1시간 동안의 평가된 값(evaluated values)을 출력하는 드라이 런(dry-run) 모드를 포함하도록 요구했을 것입니다.
저는 구체화된 뷰 (materialized view)의 새로고침 주기를 30분으로 제한하고, 02:00 작업이 유럽의 아침이 오기 전에 끝나기를 기대하는 대신 트래픽이 적은 저녁 시간대에 뷰를 미리 예열 (pre-warm)했을 것입니다. 만약 새로고침이 35분을 초과하게 되면, DuckDB에 의해 생성된 S3-parquet 스냅샷으로부터 캐시된 읽기 (cached read)로 자동 전환하여, 몇 밀리초의 추가 지연을 대가로 100%의 가동 시간 (uptime)을 확보했을 것입니다.
마지막으로, 저는 단일 SQL 쿼리나 하나의
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기