헬스체크는 정상인데 알림은 안전하지 않았다
DiskPressure 복구 뒤 오래된 주차 snapshot을 직전값처럼 비교해 푸시가 나갔고, 이를 freshness guard로 막은 기록
한강자리 출시 후 운영 중에, 장애가 끝났다고 생각한 직후 푸시 2건이 나갔다.
한강자리 API는 다시 응답하고 있었다. 공개 /health도 정상으로 돌아왔다.
K3s pod도 다시 떠 있었고, 워커도 실행 중이었다. 보통 여기까지 보면 복구가
끝났다고 말하고 싶어진다.
하지만 복구 직후 만들어진 알림은 그렇지 않았다. 주차 워커가 장애 전 마지막 snapshot과 복구 후 첫 snapshot을 바로 비교했고, 그 차이를 새 변화로 해석했다. 그 결과 주차 임계치 알림 2건이 실제 사용자에게 발송됐다.
이번 일에서 틀린 것은 푸시 outbox 자체가 아니었다. APNs 전송도, 사용자 설정 매칭도, suppression rule도 각각의 단계에서는 예상대로 움직였다. 문제는 더 앞에 있었다.
이전 snapshot이 정말 “직전 상태”라고 볼 수 있나?
장애 뒤에도 이벤트는 남았다
먼저 흔들린 것은 운영 노드였다.
한강자리 백엔드는 미니PC 위의 K3s에서 돈다. API, 워커, Postgres, Redis가
같은 운영 노드 안에서 움직인다. 이 노드의 /var 사용률이 거의 가득 차면서
K3s가 DiskPressure를 표시했다.
서비스 코드가 배포되면서 생긴 장애는 아니었다. 서비스 밖 백업 데이터가 같은 파일시스템을 채웠고, 그 영향이 운영 pod를 실행하는 환경으로 들어왔다. Kubernetes에서 디스크 압박은 단순한 저장 공간 문제가 아니다. pod가 Evicted되거나 새로 뜨지 못하면 API, DB, Redis, 워커 중 일부가 같이 흔들린다.
처음 복구 절차는 비교적 분명했다. 운영 디스크 공간을 확보하고, K3s 상태를
되돌리고, /health를 확인했다. 이후에는 /var 여유 공간과 DiskPressure를
모니터링 알림 대상에 넣고, 운영 디스크에는 최신 백업만 남기도록 정리했다.
그런데 복구는 워커를 다시 켜는 데서 끝나지 않았다.
워커가 멈춘 동안에도 마지막 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: 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면이라면, 사용자는 그 변화를 알고 싶을 수 있다.
하지만 이번 비교는 “조금 전”이 아니었다. 장애로 생긴 긴 공백을 사이에 둔 두 값이었다. 시스템은 오래된 값을 직전값처럼 믿었고, 복구 뒤 처음 본 상태를 방금 일어난 변화처럼 말했다.
/health는 이벤트 품질을 보장하지 않는다
/health가 정상이라는 말은 시스템이 요청을 받을 수 있다는 신호다. 외부 요청이
API 프로세스까지 닿았고, 그 프로세스가 응답했다는 뜻에 가깝다. DB와 Redis 준비
상태는 /readyz에서 따로 봐야 한다. 워커가 최신 이벤트를 안전하게 만들고
있는지는 또 다른 문제다.
그 신호는 필요하다. 하지만 이번 문제에는 충분하지 않았다.
사용자가 앱을 열었을 때는 복구 후 들어온 최신 주차 상태를 보여줘야 한다. 캐시도 갱신해야 한다. 장애 전 값보다 복구 후 값을 보여주는 것이 더 정직하다.
푸시는 다르다.
푸시는 현재 상태 자체가 아니라 변화에 반응한다. 변화는 두 시점의 비교에서 나온다. 두 시점 사이가 너무 멀면 현재값이 최신이어도 “방금 바뀌었다”고 말할 수 없다.
그래서 복구 뒤 필요한 처리는 하나가 아니었다.
| 처리 | 복구 직후 선택 |
|---|---|
| 주차 상태 저장 | 계속한다 |
| Redis 캐시 갱신 | 계속한다 |
| 앱과 위젯 응답 | 최신 상태를 사용한다 |
| 푸시 fact 생성 | 이전 snapshot이 오래됐으면 멈춘다 |
| outbox에 넣기 | fact가 이미 만료됐으면 멈춘다 |
같은 snapshot이라도 읽기 응답에는 쓸 수 있고, 푸시 변화 감지에는 쓰면 안 되는 순간이 있다. 이 차이를 코드에 넣어야 했다.
오래된 이전값은 비교하지 않았다
주차 snapshot을 비교하기 전에 freshness guard를 넣었다.
한강자리 주차 상태는 짧은 간격으로 수집한다. 현재 설정에서는 기본 수집 간격이 30초이고, 여기에 최대 60초 jitter가 붙는다. 주차 상태의 stale window는 6분이다.
이 숫자는 복구 공백을 끊는 데도 그대로 썼다.
이전 snapshot과 현재 snapshot의 observed_at 차이가 6분을 넘으면, 현재
snapshot은 저장하지만 푸시 fact는 만들지 않는다.
current.observed_at - previous.observed_at > 6분
이 조건에 걸리면 previous_snapshot_stale로 기록하고 끝낸다.
테스트로 경계를 고정했다. 6분 차이는 정상 수집 지연으로 보고 fact를 만들 수 있다. 6분 1초 차이는 복구 공백으로 보고 fact를 만들지 않는다. 이번 장애처럼 몇 시간 전 snapshot과 복구 후 snapshot을 바로 비교하는 일도 이 경로에서 멈춘다.
여기서 중요한 점은 데이터를 버리지 않는다는 것이다.
복구 후 현재 주차 상태는 DB에 남긴다. 캐시도 갱신한다. 앱과 위젯은 최신 값을 읽는다. 다만 그 값이 오래된 이전값과 만났을 때 푸시 fact로 바뀌지 않게 한다.
장애 복구 중에는 이 구분이 중요했다. 현재 상태를 숨기면 사용자는 더 오래된 정보를 보게 된다. 반대로 오래된 이전값과 비교해 푸시를 보내면, 시스템이 모르는 변화를 아는 척하게 된다.
outbox 앞에서 한 번 더 걸렀다
fact 생성 단계만 막으면 끝이라고 보기 어려웠다.
푸시는 바로 전송되지 않는다. fact를 만들고, 사용자 설정에 맞는지 확인하고, outbox에 넣고, dispatcher가 APNs로 보낸다. 이 사이에도 시간이 흐른다. 워커가 밀려 있거나 재시작 직후 backlog를 처리하면, fact를 만들 때는 유효했던 이벤트가 outbox에 들어가기 전에는 이미 오래된 이벤트가 될 수 있다.
그래서 parking fact의 expires_at도 같은 6분 window를 쓰게 했다. candidate
builder는 outbox를 만들기 전에 expires_at을 확인한다.
flowchart TB
Current["새 스냅샷 수집"] --> Store["DB 저장 / Redis 캐시 갱신"]
Current --> Age{"이전 스냅샷과<br/>6분 넘게 벌어졌나?"}
Age -->|예| StopA["fact 생성 중단"]
Age -->|아니오| Fact["푸시 fact 생성"]
Fact --> Expired{"fact가 만료됐나?"}
Expired -->|예| StopB["outbox에 넣기 전 중단"]
Expired -->|아니오| Outbox["outbox에 넣기"]
첫 guard는 오래된 이전값과 현재값을 비교하지 않게 한다. 두 번째 guard는 이미 늦어진 fact가 발송 대기열에 들어가지 않게 한다. 비슷해 보여도 막는 실패 지점이 다르다.
억제 이유도 따로 남겼다. 이전 snapshot이 오래돼 fact를 만들지 않은 경우는
previous_snapshot_stale, 이미 만료된 fact를 outbox 앞에서 멈춘 경우는
fact_expired_before_enqueue로 본다.
이 reason code는 운영할 때 차이를 만든다. 푸시가 줄었다는 사실만 보면 원인을 모른다. 오래된 이전값을 끊은 것인지, outbox 앞에서 만료 fact를 버린 것인지, 사용자 설정 때문에 멈춘 것인지가 구분된다.
일부 알림은 포기했다
이 수정은 모든 변화를 알림으로 보내겠다는 선택이 아니다. 오히려 반대다.
장애 중 실제로 주차장이 여유에서 만차로 바뀌었을 수 있다. 복구 뒤에도 만차라면 알림을 보내고 싶을 수 있다. 하지만 시스템은 그 변화가 언제 일어났는지 모른다. 장애 전 마지막 값과 복구 후 첫 값만 보고 “방금 만차가 됐다”고 말할 수는 없다.
그래서 일부 알림을 포기했다.
앱 화면은 최신 상태를 보여준다. 사용자는 갱신 시각과 현재 상태를 보고 판단할 수 있다. 푸시는 다르다. 앱 밖에서 사용자의 주의를 끌고, 이동 판단에 영향을 준다. 모르는 변화를 아는 척하는 알림은 보내지 않는 편이 낫다.
이번 수정은 “알림을 줄이자”가 아니었다.
복구 뒤에 만들어진 변화라고 말할 수 있는 알림만 보내자는 결정이었다.
복구 확인은 푸시까지 봐야 했다
이전까지 복구 확인은 주로 실행 상태에 가까웠다.
- API가 응답하는가.
- pod가 다시 떠 있는가.
- 워커가 다시 실행되는가.
- DB와 Redis가 붙는가.
- 배포 상태가 원하는 revision과 맞는가.
이제는 한 가지를 더 본다.
- 복구 뒤 새로 만들어지는 이벤트가 사용자에게 보내도 되는가.
이 질문은 /health 하나로 답할 수 없다. 워커 재시작 뒤 첫 수집 시각,
source freshness, suppression reason, outbox 증가량을 같이 봐야 한다.
이번 수정 뒤에도 같은 유형의 주차 임계치 outbox가 추가로 생기지 않는지
확인했다.
운영 노드의 /var와 Kubernetes DiskPressure도 단순한 인프라 지표로만 보지
않게 됐다. 디스크 압박은 워커 중단으로 이어질 수 있고, 워커 중단은 오래된
snapshot 비교로 이어질 수 있고, 그 비교는 사용자 푸시로 이어질 수 있다.
복구 완료는 프로세스가 다시 켜진 순간이 아니었다. 공개 /health가 200을
돌려주는 순간도 아니었다. 복구 뒤 만들어지는 데이터와 이벤트가 사용자에게
보내도 되는지 확인해야 했다.
이번 사고 뒤 한강자리의 복구 기준은 조금 바뀌었다.
서버가 살아났는지만 보지 않는다. 살아난 서버가 다시 사용자에게 무슨 말을 걸기 시작했는지도 본다.