지난주 목요일 오전 9시 30분, prod 클러스터 중 하나에서 결제 관련 파드들이 줄줄이 죽기 시작했다. OOMKilled. 30분간 결제 실패율이 치솟았고, 우리는 그 30분 동안 장님이었다.
회고 정리.
무슨 일이 있었나
한 서비스가 점점 메모리를 먹다가 limit(1Gi) 찍고 죽음. 재시작. 다시 먹음. 죽음. 이게 10분마다 반복. 처음엔 로드 스파이크 때문인 줄 알았는데 트래픽은 오히려 평소보다 낮았다.
원인
어제 배포한 PR에 있던 캐시 로직. 요청별 temp 데이터를 모듈 레벨 dict에 쌓는데, 만료 로직이 잘못돼서 삭제 안 되고 계속 쌓임. 배포 직후엔 쌓이는 속도가 느려서 테스트 환경에서 안 걸림. 12시간 쯤 지나니 1Gi 넘음.
코드 대략 이런 모양이었다.
_cache = {}
def get_or_set(key, fn, ttl=60):
now = time.time()
if key in _cache:
val, exp = _cache[key]
if exp > now:
return val
val = fn()
_cache[key] = (val, now + ttl) # 만료된 key는 영원히 안 지워짐
return val
TTL 체크만 있고 eviction이 없다. key가 매 요청마다 유니크(request id가 섞여있었음)했기 때문에 증가만 함. 아 이건 진짜 허무한 버그.
왜 바로 못 잡았나
1) 메모리 알람 임계치가 85%인데 pod은 limit에 닿으면 그냥 kill. 알람이 울릴 시간이 없음.
2) prometheus 수집 주기 30초라 짧은 라이프사이클 pod의 메모리 그래프가 "어? 막 생겼다 사라지네" 수준으로만 보임.
3) 로그에 OOMKilled 로그 이벤트 집계 대시보드가 없었음. kubectl describe pod으로 일일이 봐야 알 수 있었다.
고친 것
- 해당 캐시 로직을 cachetools TTLCache로 교체 (내부에 eviction 있음)
- kube_pod_container_status_last_terminated_reason="OOMKilled" 메트릭을 alertmanager에 등록
- 메모리 limit의 80% 닿으면 pre-alert (scrape 주기도 15초로)
- 모든 서비스에
GOMEMLIMIT혹은 resource 기반 인메모리 캐시 상한 명시 규칙 추가
개인적 교훈
"모듈 레벨 dict에 뭐 쌓는 순간 이게 무한 성장 가능한지 자문해라." 작년 이맘때도 비슷한 거 한 번 냈었다. 같은 실수 반복. 그래서 이번엔 린트 룰로라도 박을까 고민 중. 아니면 최소한 코드리뷰 체크리스트에 넣든가.
OOM은 보이지 않는 버그라 무섭다. 부하 테스트만으로는 못 잡고 시간이 지나야 드러난다.