공인 IP 없이 홈서버 감시하기: Cloudflare Worker와 KV로 만든 외부 워치독


들어가며: 감시자가 감시당하지 않는 자리가 없었다

홈서버를 하루 종일 돌리면 문득 궁금해집니다. "지금 잘 돌고 있나."

이 홈서버에는 이미 감시 장치가 여럿 있었습니다. 에이전트가 승인 요청을 쏟아내는지 지켜보는 감시, 자동화가 돌기 전에 자원이 남았는지 확인하는 사전 점검, 백업이 끝났는지 알려 주는 알림입니다. 그런데 셋 다 서버 안에서 돌았습니다.

여기에 구멍이 있습니다. 서버가 죽으면 알려 줄 사람이 없습니다. 감시 장치가 전부 안에 있으니 서버와 함께 멈추고, 멈췄다는 사실조차 알리지 못합니다. 감시자를 감시할 자리가 비어 있었습니다.

이 글은 그 자리를 Cloudflare Worker와 KV로 채운 기록입니다. 무료 플랜에서 동작하고, 홈 네트워크로 들어오는 연결은 하나도 열지 않았습니다.

이 일을 하게 된 배경

시작은 홈서버 감시가 아니었습니다. Cloudflare Workers로 할 만한 일이 무엇인지 후보를 추려 봤고, 세 가지가 나왔습니다.

  • 정적 사이트와 API를 엣지에서 서빙하기
  • 주기 작업을 별도 서버 없이 돌리기
  • 홈서버 밖에서 도는 감시자 두기

앞의 둘은 이미 홈서버 안에서 하던 일이거나 대체 수단이 있었습니다. 세 번째는 달랐습니다. 앞에서 말한 빈자리를 정확히 채우는 자리였고, "서버가 죽어도 살아 있는 곳에서 돌아야 한다"는 조건이 Workers에 맞았습니다. 그래서 세 번째로 정했습니다.

정하고 나서 조건을 세 가지로 나눴습니다.

  • 감시자는 감시 대상 밖에 있어야 합니다. 서버가 죽어도 경보를 보낼 수 있어야 합니다.
  • 홈 네트워크로 들어오는 연결을 새로 열지 않아야 합니다. 감시하려고 포트를 열면 그 포트가 곧 구멍이 됩니다.
  • 무료 한도 안에서 돌아야 합니다. 감시 자체가 비용이 되면 오래 못 갑니다.

설계 전에 실측한 것: 원안이 무너진 지점

설계를 확정하기 전에, 쓸 수 있는 권한과 닿을 수 있는 범위를 먼저 확인했습니다. 이 단계에서 원안이 무너졌습니다.

확인 항목결과
워커 배포 인증정상
KV·Workers·Queues 사용가능
DNS 레코드 조회권한 없음
터널 목록 조회비어 있음
접근 제어 앱 목록비어 있음
홈서버 SSH 접속불가
워커에서 홈 네트워크로 접속불가
홈서버 밖 서비스 헬스 경로200

원안은 워커가 집 안의 서비스에 직접 물어보는 방식이었습니다. 그런데 워커는 홈 네트워크에 닿지 못합니다. 닿게 만들려면 그 서비스를 인터넷에 노출해야 하는데, 그건 감시하려고 공격 표면을 만드는 일입니다. 처음에 피하려던 문제로 되돌아갑니다.

그래서 방향을 뒤집었습니다. 워커가 물어보는 대신, 홈서버가 알려 주기로 했습니다.

핵심 개념: 밀어 넣기와 신선도

  • 당겨오기(pull): 감시자가 내 서버로 접속해 상태를 확인합니다. 내 서버가 밖에서 도달 가능해야 하므로 포트를 열어야 합니다.
  • 밀어 넣기(push): 내 서버가 밖으로 신호를 보냅니다. 나가는 연결만 있으면 되고, 들어오는 연결은 0개입니다.

밀어 넣기로 바꾸면 신뢰의 방향이 달라집니다. 바깥의 서비스는 내 네트워크에 접근하지 못합니다. 받기만 합니다.

여기에 신선도(staleness) 판정을 붙입니다. 홈서버가 주기적으로 "살아 있다"는 신호를 보내고, 받는 쪽은 마지막 신호가 얼마나 오래됐는지 봅니다. 신호가 정해진 시간보다 오래 끊기면 이상으로 판정합니다.

여기서 중요한 성질이 하나 나옵니다. 죽은 서버는 "나 죽었다"고 보낼 수 없습니다. 그래서 상태 값을 보내는 방식만으로는 죽음을 감지할 수 없습니다. 반대로 신호의 부재를 감시하면, 죽음이 조용해도 드러납니다. 밀어 넣기와 신선도를 함께 쓰는 이유가 여기 있습니다.

전체 구조

구성은 네 조각입니다.

  • 홈서버: 자기 서비스 상태를 확인하고 신호를 밀어 넣습니다.
  • 워커: 신호를 받아 저장하고, 신선도를 판정하고, 이상하면 알립니다.
  • 저장소: 마지막 신호와 판정 결과를 담아 두는 공용 우체통입니다.
  • 조회 표면: 공개 요약 주소, 토큰이 필요한 상세 주소, 알림 경로.

공인 IP도, 포트포워딩도, 별도 VPN도 필요하지 않습니다. 홈서버에서 나가는 HTTPS 요청만 있으면 됩니다.

준비물

  • Cloudflare 계정 (무료 플랜으로 충분합니다)
  • Workers와 KV를 쓸 수 있는 권한
  • 홈서버 (리눅스, cron 또는 systemd 타이머)
  • curl
  • 알림을 받을 봇 토큰과 대화 ID (텔레그램을 쓴다면)

1단계: 워커와 KV를 만들고 배포한다

KV를 하나 만들고 워커에 묶습니다.

npx wrangler kv namespace create WATCHDOG_KV

출력된 식별자를 설정 파일에 넣습니다.

name = "my-watchdog"
main = "src/index.js"
compatibility_date = "2026-09-01"

[triggers]
crons = ["*/5 * * * *"]

[[kv_namespaces]]
binding = "WATCHDOG_KV"
id = "<KV_NAMESPACE_ID>"

배포합니다.

npx wrangler deploy

이 단계의 결과는 이랬습니다. 워커 주소와 등록된 스케줄이 함께 출력됐고, 공개 상태 페이지가 200으로 응답했습니다. 여기까지는 계획대로였습니다.

2단계: 신호를 받는 입구를 만든다

하트비트를 받는 경로를 하나 열고, 토큰이 맞을 때만 저장하게 했습니다.

if (path === '/api/heartbeat' && request.method === 'POST') {
  if (!authorized(request, env)) return json({ error: 'unauthorized' }, 401);

  const body = await request.json().catch(() => ({}));
  const ids = Array.isArray(body.targets) && body.targets.length
    ? body.targets.filter((x) => typeof x === 'string')
    : HEARTBEAT_TARGETS.map((t) => t.id);

  const now = Date.now();
  // 대상별로 마지막 수신 시각을 남긴다.
  await Promise.all(
    ids.map((id) => env.WATCHDOG_KV.put(`hb:${id}`, String(now), {
      expirationTtl: 7 * 86400,
    })),
  );

  // 누가 보냈는지도 남긴다. 나중에 전송 주체를 옮길 때 검증에 쓴다.
  if (typeof body.source === 'string' && body.source) {
    await env.WATCHDOG_KV.put(`srclast:${body.source}`, String(now), {
      expirationTtl: 7 * 86400,
    });
  }
  return json({ ok: true, received: ids.length });
}

토큰은 상수 시간 비교로 확인합니다. 문자열을 앞에서부터 비교하면, 어디까지 맞았는지가 응답 시간 차이로 새어 나갈 수 있습니다.

function authorized(request, env) {
  const expected = env.WATCHDOG_TOKEN;
  if (!expected) return false;
  const got = request.headers.get('x-watchdog-token')
    || new URL(request.url).searchParams.get('token') || '';
  if (got.length !== expected.length) return false;
  let diff = 0;
  for (let i = 0; i < expected.length; i += 1) {
    diff |= got.charCodeAt(i) ^ expected.charCodeAt(i);
  }
  return diff === 0;
}

검증 결과입니다. 토큰 없이 호출하면 401로 막혔고, 토큰을 넣어 보낸 신호는 200으로 받아 세 개 대상이 기록됐습니다.

3단계: 신선도를 판정하고 경보 규칙을 정한다

받은 시각과 지금 시각의 차이를 봅니다. 판정의 뼈대는 이것뿐입니다.

const STALE_MS = 12 * 60 * 1000; // 5분 주기면 12분이면 이상

async function checkHeartbeat(target, env) {
  const raw = await env.WATCHDOG_KV.get(`hb:${target.id}`);
  const at = raw ? Number(raw) : 0;
  const ageMs = at ? Date.now() - at : Infinity;
  return {
    id: target.id,
    ok: ageMs < STALE_MS,
    ageMs: Number.isFinite(ageMs) ? ageMs : null,
  };
}

임계값은 5분 주기에서 12분으로 잡았습니다. 두 번까지 건너뛴 것을 장애로 보지 않으면서, 실제 중단은 십여 분 안에 잡히는 값입니다. 너무 짧게 잡으면 일시적인 지연마다 경보가 나가고, 그러면 경보를 무시하게 됩니다.

경보 규칙은 설계 단계에서 이렇게 정했습니다.

항목값이유
직접 확인 대상연속 2회 실패 시 경보단발성 흔들림을 장애로 보지 않기 위해
신호 기반 대상1회 실패 시 경보5분 주기 자체가 이미 여러 번의 표본입니다
신선도 한계12분5분 주기에서 두 번 결번까지 허용
반복 경보1시간 쿨다운이상이 이어지는 동안 매번 보내면 알림이 폭주합니다
복구 알림정상화 시 1회다운만 알리고 복구를 알리지 않으면 상태를 알 수 없습니다
야간 무발송23시부터 8시까지자는 시간에 깨우지 않기 위해

야간에도 판정과 기록은 그대로 합니다. 보내지만 않습니다. 그래야 아침에 밤사이 무슨 일이 있었는지 확인할 수 있습니다.

function isQuietHours(now = new Date()) {
  const kst = new Date(now.getTime() + 9 * 3600 * 1000);
  const h = kst.getUTCHours();
  return h >= 23 || h < 8; // 23시부터 8시까지는 보내지 않는다
}

규칙을 정한 뒤에는 실제로 걸리는지 확인했습니다. 감시 대상 하나를 고의로 실패시켜 실험했고, 결과는 의도대로 나왔습니다. 1회차는 조용했고, 2회차에 경보가 발송됐고, 3회차는 쿨다운으로 다시 조용해졌습니다. 알림 경로 자체도 200으로 실발송을 확인했습니다. 야간 무발송 규칙은 하루치 전 시간대를 훑어 23시와 0시부터 6시까지 억제되고 7시부터 발송되는 것을 확인했습니다.

4단계: 공개 표면을 점검하고 줄인다

여기서 질문이 하나 나왔습니다. "이 주소는 누구나 접속할 수 있나?"

실제로 재 봤더니 그랬습니다.

경로인증 없이 접근보이는 것
상태 페이지200상태 화면 전체
상태 조회200서비스 이름, 상태, 응답 시간
즉시 검사, 신호 수신, 알림 시험401차단

토큰이 필요한 경로는 제대로 막혀 있었습니다. 문제는 그 앞이었습니다. 어떤 서비스가 돌아가는지, 무엇이 켜져 있는지가 그대로 보였습니다. 지금은 감시 대상이 몇 개뿐이지만, 이건 나중에 문제가 됩니다. 돌아가는 서비스 목록은 그 자체로 정찰 정보입니다.

여기서 설계상 중요한 제약을 만납니다. 홈서버가 신호를 밀어 넣어야 하므로, 워커는 밖에서 도달 가능해야 합니다. 접근 제어 뒤에 숨기면 홈서버도 못 들어옵니다. 그래서 "워커를 비공개로"는 선택할 수 없습니다.

대신 공개 표면을 줄였습니다. 공개 주소는 집계 한 줄만 돌려줍니다.

if (path === '/api/summary' && request.method === 'GET') {
  const last = JSON.parse((await env.WATCHDOG_KV.get('lastRun')) || 'null');
  return json({
    healthy: last ? last.healthy : null,
    atKst: last ? last.atKst : null,
    staleMinutes: last ? Math.floor((Date.now() - last.at) / 60000) : null,
    quiet: isQuietHours(),
  });
}

서비스 이름과 개별 응답 시간은 토큰이 있어야 보이는 주소로만 나갑니다.

  • 정상 여부, 마지막 검사 시각, 야간 여부는 공개합니다.
  • 서비스 이름과 개별 응답 시간은 토큰이 있어야 보입니다.
  • 신호를 보내는 입구도 토큰이 필요합니다.

적용한 뒤 다시 재 봤습니다. 공개 응답에서 서비스 이름이 하나도 나오지 않았고, 익명으로 상세 조회를 부르면 401이 돌아왔습니다. 토큰을 넣으면 전에는 보이던 내용이 그대로 조회됐습니다.

여기서 얻은 판단이 하나 있습니다. 경보 기능은 상태 페이지와 별개로 동작합니다. 페이지를 잠가도 감시는 그대로 돌아갑니다. 그래서 잠그는 쪽을 망설일 이유가 없었습니다.

5단계: 속도 제한을 실측해 본다

공개 주소가 생겼으니 호출량 제한을 붙였습니다. 붙이고 나서 실제로 막히는지 확인해 봤는데, 결과가 예상과 달랐습니다.

시험결과
한 요청 안에서 연속 호출61번째에서 정확히 걸림
서로 다른 요청 300건걸린 것 0건

한 요청 안에서는 정확히 걸리므로 장치 자체는 정상입니다. 그런데 개별 요청으로 흩어지면 걸리지 않습니다. 원인은 이 장치가 엣지 거점 단위로 세고, 그 값이 비동기로 전파되는 구조이기 때문입니다. 문서에도 정확한 계수용이 아니라고 적혀 있습니다.

즉 총량 방어가 필요하면 다른 수단이 필요합니다. 워커 앞단의 방화벽 규칙이나, 별도 카운터를 두는 방식이 그렇습니다.

진단 과정에서 걸린 함정도 하나 있었습니다. 이 장치는 실패해도 조용하고 대시보드에도 표시가 없습니다. 그래서 확인하려면 반환값을 직접 보는 수밖에 없었습니다. 한 요청 안에서 반복 호출해 보는 방법이 결정적이었는데, 요청이 흩어지는 변수를 없애야 원인이 드러났기 때문입니다. 확인용으로 만들었던 진단 경로는 확인 후 제거했습니다.

6단계: 보내는 주체를 게이트웨이에서 호스트로 옮긴다

처음에는 에이전트 게이트웨이의 스케줄러가 신호를 보냈습니다. 그러다 문제를 발견했습니다. 감시 입력이 감시 대상과 같은 프로세스에 묶여 있었습니다. 게이트웨이가 재시작하거나 멈추면, 나머지 서비스가 멀쩡해도 신호가 끊겨 다운으로 잡힙니다.

그래서 보내는 주체를 게이트웨이 밖, 호스트로 옮겼습니다. 호스트의 cron은 컨테이너와 게이트웨이와 무관하게 돕니다.

#!/bin/bash
set -uo pipefail
HOST="${WATCHDOG_HOST:-192.0.2.10}"
WORKER="https://my-watchdog.<계정-서브도메인>.workers.dev"
TOKEN=$(tr -d '[:space:]' < "${WATCHDOG_TOKEN_FILE:-/etc/my-watchdog/token}")

alive=()
if curl -sS -m 8 "http://$HOST:11434/api/tags" | grep -q '"models"'; then alive+=(llm); fi
if curl -sS -m 8 "http://$HOST:6333/healthz" | grep -qi 'passed'; then alive+=(vectordb); fi

[ "${#alive[@]}" -eq 0 ] && exit 0

targets=$(printf '%s\n' "${alive[@]}" | awk 'BEGIN{ORS=""} {printf "%s\"%s\"", (NR>1?",":""), $0}')
curl -sS -m 15 -X POST "$WORKER/api/heartbeat" \
  -H 'content-type: application/json' \
  -H "x-watchdog-token: $TOKEN" \
  -d "{\"targets\":[$targets],\"source\":\"host-cron\"}"

cron에 5분 주기로 등록합니다.

*/5 * * * * root WATCHDOG_TOKEN_FILE=/etc/my-watchdog/token /bin/bash /opt/my-watchdog/heartbeat.sh

설치할 때 신경 쓴 부분이 셋 있습니다.

  • 토큰은 호스트에만 두고 권한을 좁힙니다. 컨테이너가 죽어도 신호는 계속 나가야 하므로, 컨테이너 접근에 의존하지 않게 했습니다.
  • 기존 예약 작업을 건드리지 않습니다. 자기 파일만 멱등하게 교체합니다.
  • 성공할 때는 아무것도 출력하지 않습니다. 무인 실행에서 출력이 쌓이면 그 자체가 문제가 됩니다. 실패할 때만 한 줄을 남깁니다.

옮긴 뒤에는 정말 옮겨졌는지 확인해야 했습니다. 두 주체가 같은 저장소 키를 쓰기 때문에, 어느 쪽이 보냈는지 구분할 방법이 필요했습니다. 그래서 신호에 보낸 주체를 함께 적게 했습니다.

확인은 이렇게 했습니다.

시각호스트 크론게이트웨이 크론판정
1차 확인갱신됨갱신됨설치 직후 수동 실행을 넘어 주기 도달
게이트웨이 비활성화
2차 확인갱신됨증가 멈춤호스트 단독 동작 확정

게이트웨이 쪽 값이 더 이상 늘지 않은 것으로 그쪽이 멈춘 것을 확인했고, 그 뒤 게이트웨이 스케줄은 껐습니다. 둘 다 돌아도 같은 값을 덮어쓸 뿐이라 무해하지만, 목적이 게이트웨이 의존을 없애는 것이었으므로 남길 이유가 없었습니다.

7단계: 조회 수단 세 가지를 만든다

여기서 두 번째 질문이 나왔습니다. "그럼 결과는 어떻게 조회하나?"

돌아보니 답이 없었습니다. 상태를 만든 쪽은 알고 있었지만, 밖에서 볼 방법이 없었습니다. 만든 사람만 볼 수 있는 상태는 상태가 아닙니다. 그래서 세 가지를 갖췄습니다.

  • 명령줄 도구: 터미널과 자동화에서 씁니다. 사람이 읽는 요약과 원본 JSON 두 가지 출력을 지원합니다.
  • 상세 페이지: 브라우저에서 봅니다. 토큰을 한 번 넘기면 브라우저에 저장되어 이후에는 주소만 열면 됩니다.
  • 원시 API: 스크립트에서 직접 부릅니다.

조회할 때 주의할 점이 하나 있습니다. 저장소에 읽기 캐시가 있어서, 방금 보낸 신호가 바로 보이지 않을 수 있습니다. 체감상 60초 정도입니다. 이걸 모르면 정상 동작을 장애로 오진하게 됩니다. 실제로 처음 조회했을 때 값이 안 보여서 이상하다고 판단할 뻔했고, 75초쯤 뒤에 정상으로 나타났습니다. 손실이 아니라 지연이었습니다.

8단계: 전용 주소를 붙인다

기본 주소는 길고 의미가 없습니다. 전용 주소가 있으면 조회와 공유가 편해집니다. 다만 처음에는 권한이 없어 못 했고, 나중에 권한을 얻은 뒤에 붙였습니다.

권한을 얻는 방법은 이렇습니다. API로는 불가능했습니다. 토큰이 자기 권한을 바꾸는 경로가 막혀 있었고, 오류는 권한 없음으로 돌아왔습니다. 그래서 관리 화면에서 사람이 기존 토큰에 DNS 권한을 추가했습니다. 이 방법을 쓰면 토큰 값 자체는 바뀌지 않아서, 이미 저장해 둔 값을 다시 넣을 필요가 없습니다. 새로 발급하면 값이 바뀌므로 재등록이 필요합니다.

주소를 붙이는 방법은 두 가지였습니다.

  • 커스텀 도메인: 호스트명을 직접 붙입니다. 가장 깔끔하지만 권한이 충분해야 합니다.
  • 존 라우트: 라우트와 DNS 레코드를 각각 만듭니다. 권한이 좁은 토큰에서도 통과했습니다.

실제로는 앞의 방법이 막히고 뒤의 방법이 통과했습니다. 오류는 이 인증 방식에서 허용되지 않는 메서드라는 형태였습니다. 권한이 없다는 뜻이고, 요청이 틀렸다는 뜻은 아니었습니다.

존 라우트로 붙이는 절차는 두 단계입니다. 먼저 라우트를 만듭니다.

POST /zones/{zone_id}/workers/routes
{ "pattern": "watchdog.example.com/*", "script": "my-watchdog" }

그다음 DNS 레코드를 만듭니다.

POST /zones/{zone_id}/dns_records
{ "type": "AAAA", "name": "watchdog.example.com",
  "content": "100::", "proxied": true, "ttl": 1 }

여기서 두 가지가 중요합니다.

첫째, 프록시 표시를 켭니다. 트래픽이 Cloudflare를 거쳐야 라우트가 요청을 가로챕니다. 프록시를 끄면 Cloudflare를 우회해 곧바로 오리진을 찾으므로 라우트가 동작하지 않습니다.

둘째, 오리진 주소를 채우지 않습니다. 위 예시의 주소는 자리표시자입니다. 라우트가 요청을 먼저 잡으므로 이 주소는 쓰이지 않습니다. 여기에 집의 공인 IP를 적으면 어떻게 될까요. 라우트가 어떤 이유로든 빠지는 순간 그 주소가 그대로 노출됩니다. 애써 포트를 닫아 둔 의미가 사라집니다. 자리표시자를 쓰는 것은 게으름이 아니라 안전장치입니다.

붙인 뒤 세 경로를 모두 확인했습니다. 상태 페이지, 상세 페이지, 요약 API 모두 200이 나왔습니다. 기존 주소도 그대로 살아 있어서, 문제가 생기면 되돌릴 수 있습니다. 되돌리는 방법은 라우트와 DNS 레코드를 각각 삭제하는 것입니다.

9단계: 감시자를 감시한다

여기서부터는 실제로 겪은 실패입니다. 이 부분 때문에 글을 쓰기로 했습니다.

상태를 확인해 보니 마지막 검사 시각이 아홉 시간 전에 멈춰 있었습니다. 신호는 정상이었습니다. 홈서버는 계속 밀어 넣고 있었습니다. 그런데 판정이 돌지 않았습니다.

원인은 워커의 자체 스케줄이었습니다. 스케줄은 등록되어 있었고 코드도 정상이었는데 호출이 없었습니다. 재등록도 해봤지만 달라지지 않았습니다.

확인은 네 가지 방법으로 했고, 모두 같은 결론을 가리켰습니다.

관측결과
판정 결과의 시각수동 실행 시각에 멈춤
스케줄러가 실행될 때만 쓰는 값끝내 생기지 않음
실시간 로그요청 처리 흔적만 보이고 스케줄 실행 흔적 0건
주기 경계를 넘긴 반복 조회시각이 변하지 않음

무서운 점은 이것이 조용했다는 것입니다. 경보를 보내는 코드는 판정 함수 안에 있고, 판정 함수는 스케줄러와 즉시 검사 두 경로로만 불립니다. 판정이 안 돌면 경보도 나가지 않습니다. 겉으로는 평온합니다. 감시자가 죽었는데 감시자는 그 사실을 알리지 못합니다.

같은 증상이 커뮤니티에도 보고돼 있었습니다. 재등록, 스케줄 변경, 수동 실행 모두 효과가 없었다는 기록이었습니다. 즉 플랫폼 쪽 문제로 보이고, 우리가 고칠 수 있는 부분이 아닙니다. 그렇다면 방어를 우리 쪽에 둬야 합니다.

그래서 두 가지를 고쳤습니다.

첫째, 구동 경로를 하나 더 만들었습니다. 홈서버가 신호를 보낸 뒤에 판정도 함께 요청합니다. 스케줄러가 살아 있으면 같은 판정을 두 번 하는 셈이지만, 같은 결과를 덮어쓸 뿐이라 무해합니다.

# 하트비트 전송에 성공했다면 판정도 구동한다.
chk=$(curl -sS -m 20 -o /dev/null -w '%{http_code}' \
  "$WORKER/api/check?source=host-cron" \
  -H "x-watchdog-token: $TOKEN")

둘째, 감시자의 생존 상태를 조회 결과에 드러냈습니다. 스케줄러가 돌 때만 기록되는 값을 따로 두고, 그 값을 조회 응답에 실어 보냅니다.

// 스케줄러가 돌 때만 쓰는 기록. 이 값이 안 생기면 스케줄이 죽은 것이다.
async function scheduledRun(env) {
  const now = Date.now();
  let ok = true;
  try {
    await runChecks(env);
  } catch {
    ok = false;
  }
  await env.WATCHDOG_KV.put(
    'cron:last',
    JSON.stringify({ at: now, ok }),
    { expirationTtl: 30 * 86400 },
  );
}

조회 응답에서는 그 값의 나이를 계산해 함께 돌려줍니다. 주기가 5분이므로 15분을 넘기면 죽은 것으로 봅니다.

const rec = JSON.parse((await env.WATCHDOG_KV.get('cron:last')) || 'null');
cron = rec
  ? { ...rec, ageMinutes: Math.floor((Date.now() - rec.at) / 60000),
      alive: Date.now() - rec.at < 15 * 60 * 1000 }
  : { alive: false, note: '기록 없음' };

그리고 판정을 실제로 돌린 주체도 따로 남깁니다. 누가 마지막으로 판정을 돌렸는지 알면, 조회만으로는 구분되지 않는 상황을 가를 수 있습니다.

검사기 스케줄 : 정상 (3분 전 실행)
검사 구동     : host-cron (3분 전)

이 두 줄의 조합이 상황을 그대로 말해 줍니다.

  • 스케줄 정상, 구동 host-cron: 스케줄러가 살아 있고 호스트도 돕고 있습니다. 중복이지만 무해합니다.
  • 스케줄 죽음, 구동 host-cron: 스케줄러는 죽었지만 호스트가 대신 돌려 경보는 살아 있습니다.
  • 스케줄 정상, 구동 기록 없음: 판정이 아직 한 번도 돌지 않았을 수 있습니다. 구동을 한 번 해봐야 합니다.
  • 둘 다 멈춤: 감시 자체가 죽은 상태입니다. 이 조합이 가장 위험합니다.

고친 뒤 실제로 확인했습니다. 정해진 주기 경계에서 제가 손대지 않았는데 판정이 구동되어 시각이 갱신됐습니다. 설치된 스케줄이 새 스크립트를 쓰고 있다는 증거였습니다. 그리고 감시 대상이 전부 죽은 상황도 따로 실험했는데, 그때도 판정은 돌았습니다. 하트비트는 보낼 대상이 없어 생략되지만, 판정은 돌아야 경보가 나가기 때문입니다.

여기서 한 가지 짚고 넘어갈 점이 있습니다. 감시자를 감시하는 일은 보통 외부 서비스에 맡깁니다. 핑을 받아 두고 끊기면 알려 주는 서비스에 의존하는 방식입니다. 확실하지만 계정과 의존성이 하나 더 늘어납니다. 그 서비스가 죽으면 같은 문제가 한 층 위에서 반복됩니다.

이 구조에서는 그럴 필요가 없었습니다. 감시자가 이미 밖에 있고, 신호를 보내는 주체도 감시 대상 밖에 있습니다. 그래서 워커가 자기 생존을 스스로 드러내면 고리가 닫힙니다. 추가 계정도, 추가 서비스도 필요하지 않습니다.

보안과 주의사항

  • 토큰이 주소에 남습니다. 상세 페이지에 토큰을 넘기면 브라우저 저장소에 남으므로, 공유나 화면 캡처에 주의해야 합니다. 지우는 버튼을 함께 두는 편이 좋습니다.
  • 조회용 토큰과 전송용 토큰을 나누면 노출 위험이 줄어듭니다. 조회만 필요하면 조회 전용 토큰을 쓰는 편이 안전합니다.
  • 저장소 읽기 캐시를 기억합니다. 방금 보낸 값이 안 보이는 것은 정상입니다.
  • 속도 제한은 총량 방어가 되지 않습니다. 앞의 실측처럼 분산된 요청에는 걸리지 않습니다. 총량 방어가 필요하면 별도 수단이 필요합니다.
  • 토큰 파일은 권한을 좁혀 두고, 회전 절차를 정해 둡니다.
  • 전용 주소를 붙일 때 오리진 주소를 집의 IP로 채우지 않습니다.
  • 공개 주소에는 서비스 이름을 넣지 않습니다. 무엇이 돌아가는지는 알려 줄 필요가 없습니다.

트러블슈팅

  1. 경보가 오지 않습니다. 먼저 판정이 돌았는지 봅니다. 감시자 생존 상태와 마지막 검사 시각을 확인하고, 필요하면 판정을 직접 한 번 구동해 봅니다.
  2. 상태가 갱신되지 않습니다. 저장소 읽기 캐시 때문일 수 있습니다. 60초 뒤에 다시 봅니다. 그 뒤에도 그대로면 토큰과 경로를 확인합니다.
  3. 인증이 거부됩니다. 토큰 헤더 이름과 쿼리 이름이 맞는지 봅니다. 헤더와 쿼리 중 한쪽만 지원하는 경우도 있으니 구현을 확인합니다.
  4. 신호는 오는데 오경보가 납니다. 감시자를 감시 대상 안에 두었는지 확인합니다. 대상이 재시작할 때 감시자도 함께 멈추면 그 사이가 장애로 보입니다.
  5. 스케줄을 등록했는데 실행되지 않습니다. 플랫폼 쪽 문제일 수 있습니다. 재등록만으로 해결되지 않으면 대체 구동 경로를 두고, 감시자 생존 상태를 조회 결과에 노출합니다.

마무리

정리하면 이렇습니다.

  • 밀어 넣기로 바꾸면 포트를 열지 않아도 됩니다. 나가는 연결만 쓰므로 홈 네트워크로 들어오는 경로가 0개가 됩니다.
  • 신호의 부재를 감시하면, 죽은 서버가 조용해도 감지됩니다.
  • 저장소는 공용 우체통으로 충분합니다. 마지막 신호와 판정 결과만 있으면 됩니다.
  • 공개 표면은 집계 한 줄로 줄이고, 상세는 토큰 뒤에 둡니다.
  • 감시자는 감시 대상 밖에 둡니다. 두 번 꺼내야 합니다. 홈 네트워크 밖으로, 그리고 게이트웨이 밖으로.
  • 감시자 자신의 생존도 조회 결과에 드러냅니다.

돌아보면 이 구축에서 가장 값이 컸던 단계는 기능을 붙인 단계가 아니었습니다. 설계 전에 권한과 도달 범위를 먼저 재본 단계, 그리고 감시자 자신의 생존을 드러낸 단계였습니다. 앞의 것은 원안을 무너뜨렸지만 방향을 맞게 잡아 줬고, 뒤의 것은 조용한 실패를 눈에 보이게 만들었습니다.

확장 아이디어 하나를 덧붙입니다. 같은 구조에 대상을 더 얹는 것은 저장소 키를 하나 늘리는 일에 가깝습니다. 응답 시간을 함께 기록하면 느려지는 추세도 볼 수 있고, 야간에는 조용히 기록만 하도록 두면 아침에 밤사이 상황을 한 번에 확인할 수 있습니다.

댓글 쓰기 · 수정

0 댓글