정상 헬스체크가 놓친 알림 위험
복구 직후 오래된 snapshot 비교가 만든 오발송과 이를 막은 freshness 조건
한강자리 출시 후 운영 장애를 복구한 직후 푸시 2건이 나갔다.
한강자리 API는 다시 응답하고 있었다. healthcheck도 정상으로 돌아왔다. K3s pod도 다시 떠 있었고, 워커도 실행 중이었다. 보통 여기까지 보면 복구가 끝났다고 판단한다.
복구 직후 주차 워커는 장애 전 마지막 snapshot과 복구 후 첫 snapshot을 바로 비교했고, 그 차이를 새 변화로 해석했다. 그 결과 주차 임계치 알림 2건이 실제 사용자에게 발송됐다.
APNs 전송, 사용자 설정 매칭과 suppression rule은 기록된 정책대로 움직였다. 오래된 이전 snapshot을 직전 상태로 사용한 변화 감지가 오발송의 시작점이었다.
장애 뒤에 남은 이벤트
장애는 운영 노드의 디스크 부족에서 시작됐다.
한강자리 백엔드는 집에 둔 작은 서버 위의 K3s에서 돈다. API, 워커, Postgres, Redis가
같은 운영 노드 안에서 움직인다. 운영 디스크가 거의 가득 차면서
K3s가 DiskPressure를 표시했다.
서비스 밖 백업 데이터가 운영 pod와 같은 파일시스템을 채웠다.
Kubernetes는 디스크 여유 공간이나 inode가 eviction threshold에 닿으면
DiskPressure node condition을 보고한다. 이 상태에서는 pod eviction이나 새 pod
스케줄링 제한이 API, DB, Redis와 워커에 함께 영향을 줄 수 있다.
운영 디스크 공간을 확보하고 K3s 상태를 되돌린 뒤 healthcheck를 확인했다.
이후에는 운영 디스크 여유와 DiskPressure를
모니터링 알림 대상에 넣고, 운영 디스크에는 최신 백업만 남기도록 정리했다.
워커가 다시 실행된 뒤에는 첫 snapshot의 비교 대상도 확인해야 했다.
워커가 멈춘 동안에도 마지막 snapshot은 Postgres에 남아 있었다. 워커가 다시 돌자 새 snapshot도 들어왔다. 각각은 유효한 주차 상태였다. 그래서 기존 로직은 둘을 비교했다.
기존 로직은 수집 공백을 확인하지 않고 두 값을 비교했다.
sequenceDiagram autonumber participant Worker as 주차 워커 participant DB as Postgres participant Fact as fact 저장기 participant Push as 푸시 파이프라인 participant User as 사용자 Worker->>DB: 장애 전 스냅샷 저장 (AVAILABLE 169) Note over Worker,DB: K3s DiskPressure 동안 주차 워커 중단 Note over Worker,DB: 이 구간의 주차 변화는 수집되지 않음 Worker->>DB: 복구 후 스냅샷 저장 (FULL 0) Worker->>DB: 비교할 이전 스냅샷 조회 DB-->>Worker: 장애 전 스냅샷 반환 Worker->>Fact: 이전값과 현재값 비교 Note right of Fact: 이전값이 얼마나 오래됐는지 확인하지 않음 Fact->>Push: parking_space_threshold fact 생성 Push->>User: 푸시 2건 발송
연속된 수집 흐름에서는 이 비교를 사용한다. 조금 전 40면이던 잔여 대수가 지금 20면이라면, 사용자는 그 변화를 알고 싶을 수 있다.
하지만 이번 비교는 “조금 전”이 아니었다. 장애로 생긴 긴 공백을 사이에 둔 두 값이었다. 코드는 오래된 값을 직전값으로 사용해 복구 후 첫 상태를 새 변화로 처리했다.
healthcheck 밖의 이벤트 품질
이 서비스의 healthcheck 정상 응답은 요청이 API 프로세스에 도달해 응답을 받았다는 뜻이다. DB와 Redis 준비 상태는 readiness probe에서 확인한다. 두 검사 모두 워커가 비교한 snapshot의 시간 간격이나 새 push fact의 최신성은 검증하지 않는다.
사용자가 앱을 열면 복구 후 수집한 최신 주차 상태를 보여주고 캐시도 그 값으로 갱신해야 한다.
푸시 후보는 두 시점의 상태 차이에서 생긴다. 두 시점 사이가 너무 멀면 현재값이 최신이어도 “방금 바뀌었다”고 말할 수 없다.
복구 직후에는 읽기 응답과 변화 감지에 서로 다른 조건을 적용했다.
| 처리 | 복구 직후 선택 |
|---|---|
| 주차 상태 저장 | 계속한다 |
| Redis 캐시 갱신 | 계속한다 |
| 앱과 위젯 응답 | 최신 상태를 사용한다 |
| 푸시 fact 생성 | 이전 snapshot이 오래됐으면 멈춘다 |
| outbox에 넣기 | fact가 이미 만료됐으면 멈춘다 |
복구 후 snapshot은 읽기 응답과 cache에는 사용하되, 오래된 이전값과의 비교에서는 push fact를 만들지 않는다.
비교에서 제외한 오래된 이전값
주차 snapshot을 비교하기 전에 freshness guard를 넣었다.
한강자리 주차 상태는 짧은 간격으로 수집한다. 수집 주기보다 충분히 긴 stale window를 정해 두고, 같은 값을 복구 공백을 끊는 기준으로 썼다.
이전 snapshot과 현재 snapshot의 observed_at 차이가 stale window를 넘으면, 현재
snapshot은 저장하지만 푸시 fact는 만들지 않는다.
current.observed_at - previous.observed_at > stale window
이 조건에 걸리면 previous_snapshot_stale로 기록하고 끝낸다.
테스트로 경계를 고정했다. stale window 안의 차이는 정상 수집 지연으로 보고 fact를 만들 수 있다. window를 넘는 차이는 복구 공백으로 보고 fact를 만들지 않는다. 이번 장애처럼 오래전 snapshot과 복구 후 snapshot을 바로 비교하는 일도 이 경로에서 멈춘다.
복구 후 현재 주차 상태는 DB에 남긴다. 캐시도 갱신한다. 앱과 위젯은 최신 값을 읽는다. 다만 그 값이 오래된 이전값과 만났을 때 푸시 fact로 바뀌지 않게 한다.
현재 상태는 읽기 경로에 제공하고, 발생 시점을 확인할 수 없는 변화는 푸시 경로에서 제외한다.
outbox 앞의 추가 필터
푸시는 바로 전송되지 않는다. fact를 만들고, 사용자 설정에 맞는지 확인하고, outbox에 넣고, dispatcher가 APNs로 보낸다. 이 사이에도 시간이 흐른다. 워커가 밀려 있거나 재시작 직후 backlog를 처리하면, fact를 만들 때는 유효했던 이벤트가 outbox에 들어가기 전에는 이미 오래된 이벤트가 될 수 있다.
그래서 parking fact의 expires_at도 같은 stale window를 쓰게 했다. candidate
builder는 outbox를 만들기 전에 만료 여부를 확인한다.
flowchart TB
Current["새 스냅샷 수집"] --> Store["DB 저장 / Redis 캐시 갱신"]
Current --> Age{"이전 스냅샷과<br/>stale window를 넘었나?"}
Age -->|예| StopA["fact 생성 중단"]
Age -->|아니오| Fact["푸시 fact 생성"]
Fact --> Expired{"fact가 만료됐나?"}
Expired -->|예| StopB["outbox에 넣기 전 중단"]
Expired -->|아니오| Outbox["outbox에 넣기"]
첫 guard는 오래된 이전값과 현재값을 비교하지 않게 한다. 두 번째 guard는 이미 늦어진 fact가 발송 대기열에 들어가지 않게 한다. 비슷해 보여도 막는 실패 지점이 다르다.
reason code에는 이전 snapshot이 오래돼 fact를 만들지 않은 경우, 이미 만료된 fact를 outbox 앞에서 멈춘 경우와 사용자 설정에 따른 억제를 구분해 남겼다.
일부 알림의 의도적 포기
장애 중 실제로 주차장이 여유에서 만차로 바뀌었을 수 있다. 복구 뒤에도 만차라면 알림을 보내고 싶을 수 있다. 하지만 시스템은 그 변화가 언제 일어났는지 모른다. 장애 전 마지막 값과 복구 후 첫 값만 보고 “방금 만차가 됐다”고 말할 수는 없다.
그래서 일부 알림을 포기했다.
앱 화면은 최신 상태를 보여준다. 사용자는 갱신 시각과 현재 상태를 보고 판단할 수 있다. 푸시는 다르다. 앱 밖에서 사용자의 주의를 끌고, 이동 판단에 영향을 준다. 모르는 변화를 아는 척하는 알림은 보내지 않는 편이 낫다.
복구 뒤 연속된 snapshot으로 확인한 변화만 새 알림 후보로 만든다.
푸시까지 포함한 복구 확인
이전까지 복구 확인은 주로 실행 상태에 가까웠다.
- API가 응답하는가.
- pod가 다시 떠 있는가.
- 워커가 다시 실행되는가.
- DB와 Redis가 붙는가.
- 배포 상태가 원하는 revision과 맞는가.
복구 확인에 다음 항목을 추가했다.
- 복구 뒤 새로 만들어지는 이벤트가 사용자에게 보내도 되는가.
DiskPressure 뒤에는 pod와 healthcheck 상태에 더해 워커의 첫 수집 시각, source
freshness, suppression reason과 outbox 증가량을 확인한다. 이번 수정 뒤 같은
유형의 주차 임계치 outbox가 추가되지 않은 것까지 확인하고 복구를 마쳤다.
DiskPressure와 eviction 조건은 Kubernetes의 Node-pressure Eviction 문서에서 확인했다.
healthcheck와 readiness의 범위는 Kubernetes probe 문서와 서비스 설정을 함께 확인했다.
Comments
아직 댓글이 없습니다. 첫 댓글을 남겨주세요.
검토 대기 중