헬스체크가 3달 동안 기록만 하고 아무한테도 안 알렸다
실패 31건이 쌓이는 동안 알림은 0건이었다. 실패마다 알리면 메일 58통, 연속 2회 기준이면 4통. 쌓인 기록으로 먼저 세보고 상태 전이 방식으로 붙인 과정
#모니터링 #헬스체크 #알림 #Node.js #장애대응
지난 글 에서 헬스체크 기록으로 status 페이지를 만들었다. 막대를 보면 90일 동안 노란 칸이 몇 개 있다. 근데 그 실패들이 일어난 순간에 나한테 온 신호는 하나도 없었다. 헬스체크는 6월부터 돌고 있었지만 결과를 기록만 하고, 누구한테도 알리지 않았다. 이 글에서는 거기에 다운/복구 알림을 붙이면서, 어떤 기준으로 알릴지를 쌓인 기록으로 먼저 따져본 과정을 정리해본다. 알림 기능이 없던 건 아니다 모니터링 서버에 알림 기능 자체는 있었다. alert_rules 테이블에 규칙을 넣으면 60초마다 checkAlerts() 가 돌면서 메일을 보낸다. 근데 이게 보는 건 세 가지뿐이었다. if (rule.metric === 'cpu') currentValue = cpu.currentLoad; else if (rule.metric === 'memory') currentValue = memPercent; else if (rule.metric === 'disk') currentValue = maxDiskUse; else continue; 헬스체크 쪽 runHealthChecks() 는 결과를 테이블 두 개에 넣고 끝났다. 서비스가 죽으면 대시보드 불이 빨개지긴 하는데, 그걸 보려면 내가 대시보드를 열어두고 있어야 한다. alert_history 를 열어보니 9월 전까지 들어 있는 건 세 줄이었다. 2026-06-06 19:27 [Cpu 사용률] cpu 10% (현재: 14.6%) 2026-06-06 19:46 [cpu사용률 ] cpu 1% (현재: 3.7%) 2026-08-18 14:39 [cpu] cpu 40% (현재: 62.4%) 앞의 두 줄은 알림 기능을 만든 날 메일이 오는지 보려고 넣은 규칙이다. 3달 동안 서비스 장애로 나간 알림은 한 통도 없었다. 실패할 때마다 알리면 어떻게 되나 제일 단순한 방법은 체크가 실패할 때마다 메일을 보내는 거다. 이게 괜찮은지 바로 만들기 전에 쌓인 기록에 대입해봤다. 엔드포인트별로 실패가 연속된 구간을 잘라보니 이렇게 나왔다. 전체 체크 약 27만 회 실패 31회 실패 구간 29개 1회 실패 후 바로 복구 27개 2회 연속 실패 2개 (8/14 커플앱 API, 8/18 블로그) 29개 구간 중 27개가 한 번 실패하고 다음 체크에서 바로 돌아왔다. 대부분 timeout of 10000ms exceeded 다. 실패마다 다운 메일, 복구마다 복구 메일을 보내면 58통인데, 그중 54통은 메일을 보고 들어갔을 때 이미 멀쩡한 상황이다. 이런 알림은 몇 번 받으면 안 읽게 되고, 진짜 장애 메일도 같이 묻힌다. 연속 실패 임계치 그래서 fail_threshold 를 뒀다. 기본값은 2회 연속 실패다. 같은 기록에 대입하면 다운이 2건, 복구 메일까지 합쳐 4통이다. 대가는 감지가 늦어진다는 것이다. 5분 간격이면 두 번째 실패까지 최대 10분이 걸린다. 개인 서버에서 10분 늦게 아는 것보다 헛메일에 무뎌지는 게 더 나쁘다고 봤다. 임계치는 엔드포인트마다 바꿀 수 있어서, 급한 서비스는 1로 내리면 된다. 대신 임계치에 못 미친 실패도 화면에서는 보여야 해서 헬스체크 표의 상태를 셋으로 나눴다. 정상, 불안정 1/2 (노란색), 다운 N분째 (빨간색)다. 지금 관리 화면의 헬스체크 표는 이렇게 생겼다. 열 개가 전부 정상이라 노란색이나 빨간색은 안 보이지만, 오른쪽 알림 열에서 엔드포인트마다 메일을 켜고 끌 수 있고 임계치도 따로 둔다. 그럼 다운 상태에선 매번 보내나 임계치를 넘었다고 체크할 때마다 보내면, 장애가 1시간 가는 동안 5분 간격으로 열두 통이 온다. 그래서 엔드포인트마다 up , down 상태를 들고 있다가 상태가 바뀌는 순간에만 보낸다. 업타임로봇 같은 서비스가 하는 방식이다. async function evaluateAlert(ep, healthy, detail) { const threshold = ep.fail_threshold ?? 2; const prevState = ep.alert_state || 'up'; const failures = healthy ? 0 : (ep.consecutive_failures ?? 0) + 1; let downSince = ep.down_since ? new Date(ep.down_since) : null; let state = prevState; if (!healthy) { if (!downSince) downSince = new Date(); if (prevState === 'up' failures = threshold) state = 'down'; } else { state = 'up'; } await pool.execute( 'UPDATE health_endpoints SET consecutive_failures=?, alert_state=?, down_since=? WHERE id=?', [failures, state, healthy ? null : downSince, ep.id] ); if (state === prevState) return; // 안 바뀌었으면 아무것도 안 한다 if (!ep.alerts_enabled) return; // 상태는 갱신하되 메일만 건너뛴다 if (state === 'down') await sendDownMail(to, ep, detail); else await sendRecoveryMail(to, ep, Date.now() - downSince.getTime()); } 핵심은 if (state === prevState) return 한 줄이다. 다운으로 확정되면 한 통, 복구되면 한 통, 그 사이엔 아무것도 안 보낸다. alerts_enabled 가 꺼져 있어도 상태 갱신은 계속한다는 점도 신경 썼다. 알림을 끈 채로 다운됐다가 켜면, 상태가 이미 down 이라 켜자마자 다운 메일이 한꺼번에 튀어나오지 않는다. 다운타임은 언제부터 세나 복구 메일에 다운타임을 넣으려다 한 가지를 정해야 했다. down_since 를 다운이 확정된 시점에 기록하면, 임계치 2회 기준으로 첫 실패부터 확정까지의 5분이 빠진다. 장애는 첫 실패 때 이미 시작됐는데 메일에는 5분 짧게 찍힌다. 그래서 down_since 는 첫 실패 때 기록하고 정상으로 돌아올 때만 비운다. 위 코드의 if (!downSince) downSince = new Date() 가 그 부분이다. 1회 실패 후 바로 복구되면 메일 없이 조용히 지워진다. 기존 테이블에 컬럼 붙이기 health_endpoints 에 컬럼이 일곱 개 필요했다. alerts_enabled , fail_threshold , consecutive_failures , alert_state , down_since , notify_email , last_error . 이 프로젝트는 서버가 뜰 때 initDb() 가 CREATE TABLE IF NOT EXISTS 로 테이블을 만든다. 근데 이건 테이블이 이미 있으면 아무것도 안 하니 컬럼이 추가되지 않는다. 마이그레이션 도구를 들이기엔 과해서, 중복 컬럼 에러만 무시하는 헬퍼를 하나 만들었다. async function ensureColumn(conn, table, column, definition) { try { await conn.query(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); console.log(`[DB] ${table}.${column} 컬럼 추가`); } catch (err) { if (err.code !== 'ER_DUP_FIELDNAME') throw err; } } await ensureColumn(conn, 'health_endpoints', 'fail_threshold', 'INT NOT NULL DEFAULT 2'); await ensureColumn(conn, 'health_endpoints', 'alert_state', "ENUM('up','down') NOT NULL DEFAULT 'up'"); 컬럼 이름이나 타입을 바꾸는 건 못 하는 반쪽짜리지만, 지금처럼 덧붙이기만 할 때는 이걸로 충분하다. 받는 사람은 엔드포인트별 notify_email , 없으면 전역 설정의 알림 메일, 그것도 없으면 로그인 허용 이메일의 첫 번째 순서로 정한다. 설정을 하나도 안 해도 나한테는 온다. 테스트 알림은 실제로 받아보기 전엔 믿을 수 없다. 아무것도 떠 있지 않은 포트를 가리키는 테스트 엔드포인트를 임계치 1로 등록했다. [Health] [테스트] 알림 확인 다운 — 요청 실패: connect ECONNREFUSED 127.0.0.1:59999 (연속 1회 실패) 다운 메일이 왔고, URL을 정상 주소로 바꾸니 1분 뒤 복구 메일이 왔다. [Health] [테스트] 알림 확인 복구 — 다운타임 약 1분 확인하고 테스트 엔드포인트는 지웠다. 그리고 이 작업을 하다가 이음, 답정너 두 서비스가 헬스체크 대상에서 아예 빠져 있던 것도 발견했다. 등록하고 나서야 PM2 앱 10개, Apache vhost 10개, 헬스체크 10개가 1:1로 맞았다. 첫 실전 알림을 붙이고 나흘 뒤인 9월 13일 밤, 여행 API에서 실패가 찍혔다. 23:07:04 여행 API HTTP 503 (기대값 200) 23:10:04 여행 API 정상 한 번 실패하고 3분 뒤 돌아왔다. 임계치 2에 못 미치니 헬스체크 메일은 안 나갔다. 설계한 대로다. 근데 같은 시각에 다른 알림이 나갔다. 23:08:07 travel-server 크래시 루프 — 최근 10분 동안 3회 재시작 (누적 94회) 23:12:08 travel-server 안정화 — 재시작 멈춤 (지속 4분) 23:39:07 travel-server 크래시 루프 — 최근 10분 동안 3회 재시작 (누적 99회) 23:43:07 travel-server 안정화 — 재시작 멈춤 (지속 4분) 이건 같은 날 같이 만든 PM2 크래시 루프 감시가 보낸 거다. travel-server가 10분에 세 번씩 죽고 있었고, 헬스체크는 그중 딱 한 번 죽어 있던 순간에 걸려서 503을 본 거다. 23시 39분 두 번째 크래시 루프 때는 헬스체크가 한 번도 실패하지 않았다. PM2가 바로 되살려서 체크가 도착했을 땐 멀쩡했기 때문이다. 이 사각지대는 PM2 크래시 루프 글 에서 따로 정리했다. 둘을 같이 두니 역할이 나뉜다. 헬스체크는 "지금 응답하는가"를, PM2 감시는 "조용히 계속 죽고 있는가"를 본다. 이날은 헬스체크가 흘려보낸 걸 PM2 감시가 잡았다. 반대로 DNS나 Apache가 문제면 프로세스는 멀쩡하니 헬스체크만 잡는다. 남는 생각 모니터링을 만들면 그래프와 기록을 쌓는 데서 멈추기 쉽다. 나도 3달을 그렇게 뒀다. 근데 기록은 누가 봐야 의미가 있고, 새벽에 그걸 봐줄 사람은 없다. 그렇다고 모든 실패를 알리면 결국 그 메일도 안 보게 된다. 알림을 붙이기 전에 쌓인 기록으로 "이 규칙이었으면 몇 통이 왔을까" 를 먼저 세보길 추천한다. 내 경우엔 58통과 4통의 차이였다.