조용히 죽는 동기화를 잡는 하트비트 크론 — 재시작만 하고 끝내는 감시는 반쪽이다
환경: livesync-bridge(Deno) 2대(라즈베리파이·미니PC) · CouchDB 3.3(미니PC 로컬) · tailscale · cron
TL;DR / 빠른 해결
에러를 안 내고 멎는 고장은 로그 감시로 못 잡는다. 로그에 아무것도 안 남기 때문이다. 이럴 땐 살아 있으면 계속 변하는 값을 하나 만들어 그게 멈추는지를 본다.
- 정본 쪽 기기가 10분마다 동기화 대상 파일에 현재 시각(epoch)을 적는다.
- 감시 쪽 기기가 10분마다 그 파일을 읽어, 시각이 25분보다 낡았으면 동기화가 죽은 것으로 판정하고 컨테이너를 재시작한다.
- 재시작하고 알림 보내고 끝내면 안 된다. "재시작했다"는 복구됐다는 뜻이 아니다. 재시작 후 새 하트비트가 실제로 건너오는지까지 확인해서, 알림이 복구 성공/실패를 말하게 한다.
- 감시 파일은 점(dot)으로 시작하는 hidden 파일로 둔다. 이유는 이 시리즈의 다음 글에서 따로 다룬다.
왜 필요했나
이 글은 지금 다시 실행해 재현한 게 아니라, 그때 남긴 운영기록을 바탕으로 내가 다시 정리한 것이다. 시리즈 전체의 배경은 노션 대신 Obsidian 자가호스팅에 정리해뒀다.
이 시리즈에는 브리지가 push만 하고 pull은 조용히 죽어 있던 편이 있다. 로그에 에러가 0건이었고, 그래서 며칠 동안 아무도 몰랐다. 그 사이 뒤처진 기기가 옛 파일을 최신인 것처럼 올리고 있었다.
복구 자체는 컨테이너 재시작이면 됐다. 문제는 언제 재시작해야 하는지를 아무도 모른다는 것이었다. 원인을 확정하지 못했으니 재발을 막을 수도 없었다. 그래서 막는 건 접고, 빨리 잡는 쪽으로 방향을 틀었다.
설계: 살아 있으면 계속 변하는 값
감시 대상을 "에러가 났는가"가 아니라 "최근에 데이터가 흘렀는가"로 잡는다.
정본 쪽(CouchDB와 같은 호스트라 이 사건에서 한 번도 죽지 않은 기기)에서 10분마다 볼트 파일에 현재 시각을 쓴다. 이 파일은 동기화를 타고 감시 쪽으로 흘러야 한다. 감시 쪽이 그 파일을 읽었을 때 시각이 충분히 최근이면 그 경로가 살아 있다는 증거가 된다. 낡아 있으면 그 경로가 죽은 것이다.
핵심은 실제로 죽었던 경로만 검사한다는 점이다. 여기서 죽은 건 정본 → 파이 방향(pull)이었고, 하트비트도 정확히 그 방향으로 흐른다. 반대 방향은 이 감시가 보지 않는다.
쓰는 쪽은 이렇게 생겼다.
#!/usr/bin/env bash
set -u
HB="/path/to/vault/3_Resources/system/.pull_heartbeat.md"
now=$(date +%s)
tmp="${HB}.hbtmp.$$"
cat > "$tmp" <<EOF
---
type: system
auto: true
---
# pull heartbeat (자동 생성 — 편집 금지)
epoch: $now
utc: $(date -u +"%Y-%m-%d %H:%M:%S")
EOF
mv -f "$tmp" "$HB"임시파일에 쓰고 mv로 덮는 atomic write다. 파일이 반쯤 쓰인 상태로 읽히거나 동기화되는 걸 막는다. 그리고 이건 브리지를 하루 열네 번 죽이던 그 패턴이기도 하다. 그 크래시는 브리지가 볼트를 들여다보는 파일 감시자가 chokidar일 때 났다.
이 하트비트가 쓰이는 볼트를 들여다보는 건 정본 기기 쪽 브리지다. 설정을 열어보니 그쪽도 useChokidar: false다. 설정상으로는 chokidar가 아니라 Deno 네이티브 워처를 쓴다는 뜻이고, 그렇다면 파이에서 터지던 chokidar 쪽 조건은 여기에 없다. 다만 정본 기기 쪽을 언제 그렇게 맞췄는지까지는 기록에서 확인하지 못했다.
크론은 쓰는 쪽과 검사하는 쪽 둘 다 10분 간격이다. 쓰는 쪽 크론은 이 한 줄이다.
*/10 * * * * /path/to/pull-heartbeat-write.sh
임계값을 왜 25분으로 잡았나
쓰는 주기가 10분인데 판정 임계값을 10분으로 두면 안 된다. 정상 상황에서도 오탐이 난다. 쓰기 시각과 검사 시각이 어긋나 있고, 동기화가 흐르는 데도 시간이 걸리기 때문이다.
25분이면 하트비트를 두 번 연속 놓쳐야 판정이 선다. 한 번 밀린 걸로는 안 울리고, 두 번 연속 안 오면 그건 사고다. 이게 오탐과 늦은 감지 사이의 절충이다.
여기에 30분 쿨다운을 걸었다. 임계값보다 일부러 길게 잡은 값이다. 마지막 재시작 시각을 마커 파일에 남겨두고 그 안에는 다시 재시작하지 않으니, 한 번의 사고가 다음 판정이 서기도 전에 두 번째 재시작을 부르지 않는다. 재시작해도 복구가 안 되는 상황에서 10분마다 컨테이너를 흔드는 것도 이걸로 막힌다.
재시작만 하고 끝내면 반쪽이다
처음 버전은 "임계값 초과 → docker restart → 텔레그램 알림"까지였다. 하루 써보고 이게 불충분하다는 게 드러났다.
알림에 적힌 건 "재시작했다"뿐이다. 그런데 사용자가 알고 싶은 건 "지금 동기화가 되고 있나"다. 재시작이 늘 통한다는 보장이 없으니, 알림을 받고도 결국 사람이 들어가서 확인해야 했다. 그러면 감시를 붙인 의미가 절반은 사라진다.
그래서 자가검증을 붙였다. 재시작 뒤에 이렇게 한다.
- 75초 기다린다. 컨테이너가 뜨고 초기 스캔을 하는 시간이다.
- 정본 쪽에 새 하트비트를 강제로 쓰게 한다. 이때 고유 마커를 하나 얹는다(위에 실은 스크립트는 주기 실행분이고, 마커는 이 강제 실행 경로에서 붙인다). 다음 크론 주기를 기다리지 않으려는 것이고, 마커가 있어야 "이번에 건너온 것"인지 "원래 있던 것"인지 구분된다.
- 그 마커가 감시 쪽에 도착하는지 최대 50초 동안 폴링한다.
- 도착하면 복구 확인, 안 오면 복구 실패, 수동 확인 필요로 알린다.
75초와 50초는 이 하드웨어에서 걸리던 시간을 보고 잡은 값이지 계산으로 뽑은 숫자가 아니다. 근거를 따로 적어두지도 않았다.
이러면 알림 한 줄로 상황이 끝난다. 침묵으로 추측할 필요가 없다.
만들면서 걸린 것들
- 알림 토큰은 하드코딩하지 않는다. 실행 시점에 환경변수 파일에서 읽는다. 동기화되는 볼트에 든 스크립트에 토큰을 박아두면 그 볼트가 닿는 모든 기기와 모든 백업본에 토큰이 같이 간다.
- 하트비트 파일은 hidden(
.으로 시작)으로 둔다. 세 이름을 거치고 나서야 여기로 왔다 — 볼트에 보이는 이름으로 두는 동안은 다른 기기의 Obsidian에서 충돌이 계속 났다. 그 얘기는 다음 글에서 따로 다룬다. - 감시 비용은 사실상 공짜다. 실측으로 감시 기기 load 0.2 미만, 브리지 CPU 0.01% 수준이었다. 10분 간격 크론 두 개와 파일 한 줄 읽기라 부하를 걱정할 자리가 아니다.
- 자가검증이 원격 실행을 요구한다. 감시 쪽이 정본 쪽에 하트비트를 강제로 쓰게 해야 해서, 키 기반 SSH가 필요하다. 비밀번호를 물으면 크론에서 멈추므로
-o BatchMode=yes를 붙이고, 셸이 아니라 크론 환경에서 실제로 도는지 따로 확인해야 한다.
교훈 / 체크리스트
- 조용히 죽는 고장에는 "에러 감시"가 안 통한다. 살아 있으면 계속 변하는 값을 하나 만들고, 그게 멈추는지를 본다. 하트비트는 그걸 제일 싸게 만드는 방법이다.
- 하트비트는 실제로 죽었던 경로를 그대로 통과해야 한다. 다른 경로로 흐르는 신호는 그 고장을 못 잡는다.
- 임계값은 주기의 두 배 이상으로. 한 번 밀린 걸로 울리면 오탐이 쌓이고, 오탐이 쌓이면 알림을 무시하게 된다.
- 자동 복구는 "했다"가 아니라 "됐다"까지 알려야 한다. "재시작했다"로 끝나면 결국 사람이 확인하러 들어가야 한다.
- 복구가 실패했을 때 조용하지 않게 한다. 성공만 알리는 감시는 침묵의 의미가 두 가지가 되어(정상이거나, 감시 자체가 죽었거나) 신뢰할 수 없다.