장애 알림을 41번 보냈는데 0건 도착했다 — 알림 경로가 감시 대상과 같이 끊길 때

commitlog·2일 전
post-thumbnail

문제: 실패 알림은 같은 유형 3번째 실패에서 장애를 정확히 감지했지만, 메신저 전송이 작업과 같은 DNS 조회 단계에서 함께 끊겨 41번 모두 실패했고, 복구 알림까지 사라졌습니다.
바꾼 것: 장애가 났다는 사실과 알렸다는 사실을 따로 저장하고, 전송하지 못한 장애는 복구 뒤 첫 전송이 성공할 때 요약 1건으로 보내게 바꿨습니다.

사이드 프로젝트 데몬에 실패 알림을 단 지 이틀 뒤, 첫 실제 장애가 났습니다. 외부 API를 주기적으로 호출하는 작업이 50분 가까이 실패했는데, 메신저에는 장애 알림도 복구 알림도 오지 않았습니다. 흔적은 로그 파일에만 남았습니다.

알림을 단 이유

알림을 달기 전에는 이 작업이 계속 실패하는데도 9일 동안 몰랐습니다. 다른 작업은 API 키 인증이 막혀 401 에러가 7일 남짓 이어졌습니다. 코드를 보니 실패하면 경고 로그만 남기고 함수를 빠져나갔고, 실패 쪽에 알림 호출이 아예 없었습니다.

그래서 알림을 만들되, 실패할 때마다 울리게 하지는 않았습니다. 한 번 실패하고 다음 주기에 멀쩡해지는 오류가 실제로 있었기 때문입니다.

  • 같은 유형의 실패가 연속 3번이면 장애 알림 1번
  • 복구되면 1번 더
  • 같은 장애로는 다시 보내지 않음
  • 장애 알림은 하루 1번까지

며칠 치 실패 로그에 돌려 보니 실패 152건이 알림 4건으로 줄었습니다. 다만 이 계산은 알림이 전부 도착한다고 가정한 값이었습니다.

원인 1: 알림이 감시 대상과 같은 길을 탔다

데몬 1개 · 작업과 알림 전송이 같은 프로세스 기준 (로그는 재구성)

장애 때 로그를 시간순으로 놓으면 이랬습니다.

job failed: lookup api.example.com: no such host        (3rd in a row → incident)
send alert failed: lookup messenger.example: no such host
job failed: lookup api.example.com: no such host
send alert failed: lookup messenger.example: no such host
...                                                      (41 send attempts, 0 delivered)

감지는 정확했습니다. 처음 두 번은 다른 에러였고, 같은 유형 3번째 실패에서 장애로 판단해 메신저로 보내려 했습니다. 그런데 작업과 알림 전송이 같은 데몬의 같은 이름 조회에 기대고 있었고, 둘 다 주소를 찾는 단계에서 실패했습니다. 나중에 보니 데몬이 도는 노트북의 Wi-Fi 연결이 끊겨 네트워크 설정이 사라져 있었습니다.

감시는 고장 나지 않았습니다. 끊긴 건 알려 주는 길이었습니다.

원인 2: '알렸다' 표시가 전송 성공에 묶여 있었다

복구 알림까지 사라진 건 다른 이유였습니다. 구조를 줄이면 이렇습니다.

// reconstructed, simplified
func onFailure(s *state, kind string) {
	s.streak[kind]++
	if s.streak[kind] < 3 || s.notified {
		return
	}
	if err := send("incident"); err != nil {
		return // retry on the next failure
	}
	s.notified = true // set only when the send succeeds
}

func onSuccess(s *state) {
	if s.notified {
		send("recovered")
	}
	s.reset()
}

못 보낸 알림을 다음 실패 때 다시 보내려고, notified는 전송이 성공해야 세웠습니다. 41번이 전부 실패했으니 표시는 한 번도 서지 않았고, 작업이 다시 성공했을 때 복구 코드는 열린 장애가 없다고 판단했습니다. 각 줄은 설계대로였는데, 합치니 장애 하나가 메신저에서 통째로 사라졌습니다.

'장애가 열렸다'와 '사람에게 알렸다'를 한 표시로 합친 게 문제였습니다.

테스트가 못 본 순서

이 경로에는 테스트가 9개 있었고 전부 통과했습니다. 전송 실패 테스트도 있었습니다. 3번 실패하는 동안 전송기를 고장 냈다가, 고친 뒤 한 번 더 실패시켜 알림이 가는지 보는 테스트였습니다.

실제 장애는 순서가 달랐습니다. 전송은 마지막 실패 때까지 끊겨 있었고, 그다음은 성공이었습니다. 재시도가 다음 실패에 얹혀 있으면, 실패가 끝나는 순간 다시 보낼 기회도 끝납니다. 테스트에는 '전송 실패 바로 뒤에 성공'이 오는 순서가 없었습니다.

바꾼 것

전송에 실패한 장애는 따로 저장해 두고, 복구 뒤 첫 전송이 성공할 때 요약 1건으로 보냅니다. 언제부터 언제까지, 몇 번 실패했고, 알림을 몇 번 못 보냈는지를 담습니다.

// reconstructed, simplified
func onFailure(s *state, kind string) {
	// ... same detection as before
	if err := send("incident"); err != nil {
		s.pending.record(now, err) // stored apart from "notified"
		return
	}
	s.notified = true
	s.pending.clear()
}

func onSuccess(s *state) {
	switch {
	case s.notified:
		send("recovered")
	case s.pending.exists():
		if send(s.pending.summary()) == nil {
			s.pending.clear() // keep it if the summary fails too
		}
	}
	s.reset()
}

테스트를 먼저 썼습니다. 전송 실패 바로 뒤에 성공이 오는 경우, 요약 전송까지 실패하는 경우, 중간에 프로세스가 재시작되는 경우입니다. 대기 중인 요약은 프로세스 밖 저장소에 두어 재시작해도 남게 했습니다.

남은 것

이 요약 경로는 아직 실제 장애에서 돌아간 적이 없습니다. 그리고 장애 중에는 여전히 알 수 없습니다. 네트워크가 끊기면 알림도 같이 끊기니까요. 바꾼 건 '나중에라도 안다'까지입니다. 하루 1번 상한 때문에 같은 날 2번째 장애가 묻히는 것도 그대로입니다.

실패 알림을 붙일 때는 알림이 타는 프로세스·네트워크·이름 조회를 적어 보고, 감시 대상과 겹치는 칸이 있는지부터 보시길 권합니다.

영상

같은 사례를 화면으로 따라가는 영상

https://youtu.be/_dBYTa3UfHo

profile
실제 프로덕션에서 내린 개발·인프라 판단과 그 근거를 기록합니다.

1개의 댓글

연속 3번에 알림 1번이라는 규칙이 깔끔해서 적어 뒀어요

답글 달기