직접 만든 모니터링 서버 100일, 잘한 것과 고쳐야 할 것
27만 번 체크한 기록을 다시 뜯어봤다. 공개 도메인으로 체크한 건 잘한 선택이었고, 자기 자신을 감시하는 구조와 순차 체크, 끝없이 쌓이는 이력, 로그에 남는 토큰은 고쳐야 한다
#모니터링 #회고 #운영 #헬스체크 #Node.js
6월 6일에 모니터링 서버를 처음 띄웠으니 100일이 조금 넘었다. 그동안 status 페이지 , 다운 알림 , PM2 크래시 루프 감시 를 차례로 붙였다. 기능 이야기는 앞의 글들에 썼으니, 이번에는 쌓인 기록을 다시 뜯어보면서 잘한 것과 고쳐야 할 것을 정리해본다. 고쳐야 할 것 쪽이 더 길다. 100일 동안 쌓인 숫자 감시 대상 10개 (PM2 앱, Apache vhost와 1:1) 총 체크 271,360회 (9월 15일 기준) 실패 31회 평균 응답 25ms 5초 넘게 걸린 응답 49회 health_history 20.5MB 잘한 것: 실제 도메인으로 체크한다 헬스체크 대상은 localhost:5000 같은 내부 포트가 아니라 https://blog.jaeyonging.com 같은 실제 주소로 등록했다. 모니터링 서버가 같은 머신에 있으니 내부 포트로 찌르는 게 더 빠르고 간단하긴 하다. 근데 방문자는 내부 포트로 들어오지 않는다. DNS를 조회하고, Apache가 TLS를 풀고, 프록시로 넘겨서 앱에 닿는다. 실제 주소로 체크하면 그 경로 전체 를 같이 검사하게 된다. 지금 status 페이지를 열면 이렇게 보인다. 블로그 막대 중간에 노란 칸이 두 개 있는데, 하나는 8월 14일이고 다른 하나가 바로 아래에서 이야기할 8월 18일이다. 8월 18일 기록에 그 차이가 남아 있다. 14:39:21 블로그 getaddrinfo EAI_AGAIN blog.jaeyonging.com 14:39:21 커플앱 API getaddrinfo EAI_AGAIN couple.jaeyonging.com 14:44:22 블로그 getaddrinfo EAI_AGAIN blog.jaeyonging.com 앱은 멀쩡했고 DNS 조회가 실패한 거다. 내부 포트로 체크했다면 남지 않았을 기록이다. 같은 초에 CPU 62.4% 알림도 찍혀 있는데, 둘이 관련 있는지는 모른다. 그날 메트릭이 남아 있지 않기 때문이다. 이건 아래에서 다시 나온다. 잘한 것: 새 도구를 들이지 않았다 업타임로봇이나 Grafana 같은 걸 쓰지 않고 다른 프로젝트와 똑같은 Express, MySQL, React 구조로 만들었다. 추가 비용이 없고, 새 서비스를 올릴 때 포트 관리 화면에서 등록하면 Apache vhost와 헬스체크가 같이 생긴다. 그리고 데이터가 내 DB에 있다. 앞 글에서 "실패마다 알렸다면 몇 통이었나"를 SQL로 바로 세볼 수 있었던 것도, 이 글의 숫자들도 전부 그 덕이다. 물론 대가도 있다. 남이 만든 도구라면 이미 해결돼 있었을 문제를 내가 하나씩 밟는다. 아래가 그 목록이다. 고쳐야 할 것 1: 모니터링 서버가 자기 자신을 감시한다 status 페이지에서 "모니터링" 항목의 가동률은 99.99%다. 근데 이 숫자는 믿으면 안 된다. 모니터링 서버가 죽으면 헬스체크도 같이 멈춘다. 멈춘 동안은 실패가 쌓이는 게 아니라 기록 자체가 없고 , 가동률은 기록된 체크 중 성공 비율이라 죽어 있던 시간은 계산에 안 들어간다. 서버 전체가 내려가면 알림 메일도 못 보낸다. 가장 큰 장애일수록 조용해지는 구조다. PM2 기준으로 monitor-server는 이 글을 쓸 때 누적 8번, 지금은 9번 재시작했다. 배포할 때 직접 재시작한 것도 섞인 숫자라 전부 장애는 아니겠지만, 그 사이에 빈 구간이 얼마나 있었는지는 이 구조로는 확인할 방법이 없다. 고치는 방법은 바깥에서 한 번 더 보는 것이다. healthchecks.io 같은 외부 heartbeat 서비스에 모니터링 서버가 주기적으로 신호를 보내고, 신호가 끊기면 외부에서 알리게 하면 된다. 아직 안 했다. 고쳐야 할 것 2: 체크를 하나씩 순서대로 한다 8월 14일 새벽 기록에는 이상한 모양이 있다. 02:04:04 모니터링 timeout (10009ms) 02:04:14 커플앱 API timeout (10003ms) 02:04:24 코인 트래커 timeout (10007ms) 02:04:54 포트폴리오 19ms 02:05:04 여행 API timeout (10004ms) 02:05:14 게임 timeout (10007ms) 02:06:04 taro timeout (10001ms) 02:07:04 블로그 timeout (10003ms) 02:08:04 커플앱 API timeout (10003ms) 실패가 정확히 10초 간격 으로 찍혀 있다. 스케줄러가 엔드포인트를 for 문 안에서 await 로 하나씩 체크하기 때문이다. 앞의 요청이 10초 타임아웃에 걸리면 뒤의 요청은 그만큼 기다렸다 출발한다. for (const ep of endpoints) { const res = await axios({ url: ep.url, timeout: 10000, validateStatus: null }); // ... 기록, 알림 판정 } 문제는 두 가지다. 하나는 체크 시각이 밀려서 기록이 부정확해진다는 것이다. 다른 하나는 겹침이다. 스케줄러는 setInterval 로 60초마다 도는데, 대상 10개가 전부 타임아웃이면 한 바퀴에 100초가 걸린다. 앞 바퀴가 안 끝났는데 다음 바퀴가 시작된다. 기록을 뒤져보니 같은 엔드포인트가 50초 안에 두 번 체크된 경우가 3번 있었다. 고치는 건 어렵지 않다. 동시에 보내고, 앞 바퀴가 돌고 있으면 다음 바퀴는 건너뛰면 된다. let running = false; async function runHealthChecks() { if (running) return; // 앞 바퀴가 안 끝났으면 이번 바퀴는 건너뛴다 running = true; try { const endpoints = await dueEndpoints(); await Promise.allSettled(endpoints.map(checkOne)); } finally { running = false; } } 고쳐야 할 것 3: 다 같이 실패하면 서비스 장애가 아니다 같은 8월 14일 기록을 다시 보면, 4분 사이에 당시 등록돼 있던 8개 중 7개가 타임아웃이 났다. 일곱 개 앱이 그 시각에 동시에 문제를 일으켰을 가능성보다는, 서버나 네트워크, Apache처럼 공통 경로 에 문제가 있었을 가능성이 훨씬 높다. 근데 status 페이지에는 서비스 일곱 개가 각자 장애를 겪은 것처럼 찍힌다. 게다가 원인도 모른다. 그 시각의 CPU나 메모리를 보고 싶었는데, metrics_history 는 24시간만 보관해서 이미 지워졌다. 위의 8월 18일 DNS 실패도 마찬가지다. 여기엔 두 가지가 필요하다. 같은 시간대에 여러 서비스가 함께 실패하면 개별 장애 대신 공통 장애로 묶어서 보여주는 것, 그리고 장애가 난 구간 전후의 메트릭은 보관 기간과 상관없이 남겨두는 것이다. 고쳐야 할 것 4: 체크 이력이 끝없이 쌓인다 health_history 에는 지우는 코드가 없다. 하루 3,200행 정도씩 늘어서 지금 27만 행, 20.5MB다. 크기 자체는 아직 괜찮은데, status 페이지는 90일치만 쓰고 그것도 날짜별 집계만 본다. 캐시가 빈 상태에서 /api/status 첫 요청이 0.75초 걸리는 것도 이 GROUP BY 때문이다. 이대로 1년이면 100만 행을 넘는다. 방법은 이미 이 프로젝트 안에 있다. 방문 통계는 Apache 로그가 로테이션으로 지워지기 전에 일별 집계를 visit_stats 테이블에 옮겨 담는다. 헬스체크도 날짜별 체크 수와 성공 수를 따로 쌓아두고, 원본은 실패 행만 오래 남기고 성공 행은 30일쯤 지나면 지우면 된다. 고쳐야 할 것 5: SSE 토큰이 Apache 로그에 남는다 대시보드의 실시간 그래프는 SSE로 받는다. 브라우저의 EventSource 는 요청 헤더를 붙일 수 없어서 JWT를 쿼리스트링으로 넘기고 있다. GET /api/metrics/stream?token=eyJhbGciOi... 쿼리스트링은 URL의 일부라 Apache 접근 로그에 그대로 남는다. 설마 하고 로그를 찾아봤더니 9월 9일자에 토큰 원문이 찍힌 줄 이 있었다. 토큰 수명이 24시간이라 지금은 만료됐지만, 그 24시간 동안은 로그를 읽을 수 있는 사람이 내 대시보드에 들어올 수 있었다는 뜻이다. 이 대시보드에는 웹 터미널도 있다. 방법은 몇 가지 있다. 스트림 전용으로 수십 초짜리 일회용 티켓을 발급해서 그걸 쿼리로 넘기거나, 토큰을 쿠키로 옮기거나, Apache 로그 포맷에서 쿼리스트링을 빼는 것이다. 첫 번째가 제일 깔끔해 보인다. 고쳐야 할 것 6: 자잘한 것들 다운 알림 메일의 "모니터 대시보드 열기" 링크가 /healthcheck 를 가리킨다. 8월에 관리 화면을 /dashboard 아래로 옮기면서 없어진 경로라, 지금 누르면 공개 status 페이지로 튕긴다. 메일 코드는 경로를 옮긴 뒤에 썼는데도 옛 경로를 넣었다. 헬스체크 화면의 수동 체크 버튼은 스케줄러와 다른 코드를 탄다. 결과를 기록은 하지만 연속 실패 수나 알림 상태는 건드리지 않는다. 같은 체크인데 경로가 둘이라, 한쪽만 고치면 둘이 어긋난다. CORS가 origin: '*' 로 열려 있다. 인증은 헤더의 토큰으로 하니 당장 뚫리는 건 아니지만 열어둘 이유도 없다. 덧붙임: 다음 날 세 개는 고쳤다 이 글을 올린 다음 날, 위 목록 중 급한 세 개를 먼저 고쳤다. 먼저 토큰이다. SSE와 웹 터미널은 이제 JWT를 URL에 싣지 않는다. 연결 직전에 POST /api/auth/stream-ticket 으로 30초짜리 1회용 티켓을 받아서 그걸 넘긴다. 서버 메모리에만 있는 무작위 문자열이고 한 번 쓰면 바로 지워지니, 로그에 남아도 다시 쓸 수 없다. 예전처럼 ?token= 으로 붙으면 이제 401이 난다. 다만 티켓이 1회용이라 브라우저의 SSE 자동 재연결은 401로 끝난다. 그래서 연결이 끊기면 새 티켓을 받아 직접 다시 붙도록 훅을 고쳤다. 편하자고 넣은 자동 재연결이 보안을 조이니 오히려 방해가 된 셈이다. 순차 체크는 위에 적은 코드 그대로 Promise.allSettled 로 동시에 보내고, 앞 바퀴가 돌고 있으면 건너뛰게 했다. 재시작하고 기록을 보니 서비스 6개가 같은 초에 찍혀 있었다. 메일 링크는 /dashboard/healthcheck 로 경로만 바로잡으면 되는 일이었다. 외부 heartbeat, 동시 실패 묶기, 이력 보관 정책, 수동 체크 경로, CORS는 아직 그대로다. 남는 생각 직접 만든 모니터링은 만든 사람의 사각지대를 그대로 닮는다. 나는 앱이 죽는 경우만 생각했고, 모니터링 자신이 죽는 경우나 전부 같이 죽는 경우는 생각하지 않았다. 기능 목록으로는 멀쩡해 보였는데, 기록을 시간순으로 늘어놓고 나서야 10초 간격으로 찍힌 실패가 보였다. 그래도 기록을 내 DB에 쌓아둔 덕에 이렇게 되돌아볼 수는 있었다. 직접 만든 모니터링이 있다면, 몇 달쯤 지났을 때 실패 기록을 시간순으로 한 번 쭉 늘어놓고 보길 추천한다.