새벽 2시, Loki 인덱스가 터졌다
지난주 화요일 새벽 2시였다. 핸드폰이 울렸다. 처음엔 무시했다. 두 번째 울렸을 때 눈이 번쩍 떠졌다. 화면에는 loki-write Pod 절반이 OOMKill로 죽고 있다는 알림이 떠 있었다. 멘탈이 나갔다.
우리 팀이 Loki를 도입한 건 2년 전이고, 그동안 큰 사고는 없었다. 노드 40대 정도 클러스터에서 하루에 약 1.2TB 로그가 들어오는 규모다. 그런데 그날 밤 갑자기 인덱스가 비정상적으로 커지면서 ingester가 메모리를 다 잡아먹었다. 새벽 4시까지 PC 앞에 있었다. 이 글은 그때 무슨 일이 있었고, 결국 어떻게 푼 건지에 대한 회고다.
사고 시작 — 시리즈 수가 갑자기 30배
처음 본 건 Grafana에 띄워둔 Loki 자체 모니터링 대시보드였다. loki_ingester_memory_streams가 정상치인 약 18만 개에서 갑자기 600만 개 가까이로 튀어 있었다. 한 시간 사이에 일어난 일이다.
# 직전 24시간 평균
loki_ingester_memory_streams ≈ 180,000
# 사고 시점
loki_ingester_memory_streams ≈ 5,800,000
스트림 수가 30배가 됐다는 건, 어디선가 라벨 조합이 폭증했다는 뜻이다. Loki를 좀 다뤄본 사람이라면 이 숫자만 봐도 등에 식은땀이 흐를 거다. Loki는 시리즈가 적고 stream이 길게 살아있을 때 가장 효율적인 시스템이다. 반대로 가면 인덱스도 커지고 ingester 메모리도 같이 따라간다.
topk(20, count by (job, namespace) (
rate({namespace=~".+"}[5m])
))
이걸 돌려봤더니 한 namespace의 한 job이 압도적이었다. payment-gateway-v2. 가만 보니 그날 오후에 새 버전이 롤아웃됐다는 게 떠올랐다. 슬랙을 뒤져보니 동료가 "v2 카나리 시작합니다"라고 18시쯤 올린 메시지가 있었다.
진짜 원인을 찾기까지
처음엔 단순히 트래픽이 늘어난 건가 싶어서 ingester replica를 4개 더 띄웠다. 임시로는 살았다. 근데 30분쯤 지나니까 다시 OOM이 나기 시작했다. 이건 트래픽 문제가 아니라 시리즈 문제다.
logcli series 로 확인했다.
logcli series '{namespace="payment-gateway-v2"}' --since=1h | wc -l
결과는 약 240만 개. 같은 네임스페이스의 v1은 6만 개도 안 됐는데. 라벨을 하나씩 까봤다.
logcli series '{namespace="payment-gateway-v2"}' --since=1h \
| head -5
여기서 범인이 보였다. 새 버전에서 request_id를 라벨로 그대로 쏘고 있었다. UUID가. 라벨에. 들어가고 있었다.
이게 어떻게 통과됐냐고? Promtail이 아니라 OTel Collector를 통해서 들어가고 있었는데, OTel Collector의 loki exporter는 기본 설정상 attribute hint를 라벨로 승격시킨다. v2 코드에서 log.attributes["loki.attribute.labels"] = "request_id,user_id,trace_id" 로 명시적으로 지정해놨다는 걸 한참 뒤에야 알게 됐다. 누군가가 "검색을 빠르게 하려고" 추가했단다. 솔직히 그 마음은 이해한다. 근데 Loki는 그렇게 쓰는 시스템이 아니다.
응급 처치 — 일단 라벨을 막자
새벽 2시 30분, 일단 출혈을 멈춰야 했다. 두 가지를 동시에 했다.
첫째, Loki에 limits_config로 강제 컷을 걸었다.
limits_config:
max_label_names_per_series: 15
max_streams_per_user: 200000
max_global_streams_per_user: 5000000
cardinality_limit: 100000
기존 설정에서 max_global_streams_per_user가 100000000으로 사실상 무한이었다. 이게 문제였다. 한도가 없는 시스템은 결국 한도까지 간다. 이번에 배운 교훈 중 하나다.
둘째, 문제의 OTel Collector 파이프라인에 processor를 끼워서 loki.attribute.labels hint를 빈 문자열로 덮어버렸다.
processors:
attributes/strip-loki-hints:
actions:
- key: loki.attribute.labels
action: delete
- key: loki.resource.labels
action: delete
service:
pipelines:
logs/payment:
receivers: [otlp]
processors: [attributes/strip-loki-hints, batch]
exporters: [loki]
이걸 적용하고 5분쯤 지나니까 신규로 생기는 stream 수가 떨어지기 시작했다. 살았다.
근본 원인 — structured metadata로 옮기기
응급 처치는 응급 처치고, 진짜 문제는 request_id 같은 걸 검색하고 싶다는 요구사항 자체는 정당하다는 점이었다. 거래 추적이 필요한 팀인데 어떻게 안 쓰겠나.
여기서 Loki 2.9에서 들어와서 3.x에서 안정화된 structured metadata 기능이 답이 됐다. 라벨이 아닌 메타데이터로 들고 가되, 쿼리 시점에는 필터링이 가능한 구조다. OTel 데이터를 자연스럽게 처리하기 위해 설계된 기능이라 우리 케이스에 딱 맞았다.
설정은 의외로 간단했다. Loki 쪽에 allow를 켜고:
limits_config:
allow_structured_metadata: true
OTel Collector 쪽에서는 hint 자체를 빼버리면 자동으로 attribute가 structured metadata로 들어간다. 즉, 위에서 loki.attribute.labels를 delete한 그 설정이 이미 절반은 한 셈이었다.
쿼리는 이렇게 바뀌었다.
# Before — 라벨 사용 (시리즈 폭발)
{namespace="payment-gateway-v2", request_id="abc-123"}
# After — structured metadata 사용
{namespace="payment-gateway-v2"} | request_id="abc-123"
문법 차이가 미묘하지만 내부 동작은 완전히 다르다. 후자는 라벨 인덱스를 건드리지 않는다. 압축된 청크를 디코드하면서 메타데이터로 필터링한다. 그래서 검색이 약간 느려질 수는 있는데, 우리 케이스에서는 P95가 800ms → 2.1s 정도로 늘었다. 받아들일 만한 트레이드오프였다. 어차피 request_id로 찾는 건 디버깅 시점이지 항상 하는 쿼리가 아니다.
아직 풀고 있는 것들
이 사고 이후로 우리 팀은 Loki 앞에 가드레일을 좀 더 두기로 했다. 정리하자면:
OTel Collector 레벨에서 라벨 hint 자체를 화이트리스트 방식으로만 허용하기로 했다. 기본은 다 잘라내고, 필요한 팀이 별도로 신청해야 hint가 통과된다. PR 템플릿에 "이 라벨이 카디널리티 몇 정도일지 예상해주세요"라는 문구를 넣었다. 이건 의외로 효과가 있다. 사람들이 한 번 멈춰서 생각하게 만든다.
그리고 Loki에 cardinality alert을 추가했다. 시리즈가 평균 대비 2배 이상 튀면 새벽이 아닌 시점에라도 알림이 오게. 이건 Loki 자체 메트릭으로 충분히 만들 수 있다.
(
sum(loki_ingester_memory_streams)
/
avg_over_time(sum(loki_ingester_memory_streams)[1d:1h] offset 1d)
) > 2
아직 검증 중인 부분도 있다. structured metadata로 옮긴 뒤로 청크 사이즈가 평균 12% 정도 커졌는데, 장기적으로 S3 비용에 어떤 영향을 줄지는 한 분기는 더 봐야 알 것 같다. 지금까지 본 그래프상으로는 큰 차이는 아닌데, 데이터가 더 쌓여봐야겠다.
마무리
새벽에 호출받고 4시까지 깨어있는 건 너무 싫다. 솔직히 그날 이후로 Loki 문서를 처음부터 다시 읽었다. 우리가 라벨을 너무 만만하게 봐왔다는 걸 인정해야 했다. "라벨로 빨리 찾으면 좋잖아"라는 직관이 시계열 데이터베이스에선 정반대로 작용한다는 걸, 머리로는 알았는데 몸으로는 모르고 있었다.
혹시 비슷한 사고 겪으신 분 있으면 어떻게 가드레일 거셨는지 댓글로 공유해주시면 감사하겠다. 우리도 아직 답을 다 찾은 건 아니라서.