#!/bin/bash # 看门狗第四版:**双实例**。 # # 为什么必须改: # 现在跑两个 cloudflared 实例,连**同一个具名隧道** lanremote: # lanremote-tunnel --protocol quic --ha-connections 5 metrics:20241 # lanremote-tunnel-h2 --protocol http2 --ha-connections 5 metrics:20242 # Cloudflare 会把请求分摊到全部 10 条连接上。 # # 第三版看门狗有两个致命盲点: # 1. METRICS 写死 20241 —— **只看得到 QUIC 那个实例**。 # http2 实例整个死掉,它照样认为"有 5 条连接,正常"。 # 2. restart_tunnel 只重启 lanremote-tunnel, # 副实例哪怕僵死也永远不会被拉起来。 # # 这一版: # * 分别读两个 metrics 端口,**分实例**判断 # * 某个实例端口不响应 / 连接数归零 -> 只重启那一个 # * 两个都垮 / 公网不通 -> 重启两个 # * BUILT 的"建齐"阈值改成按**总连接数**算(双实例期望 10 条) # # 保留第三版的判定逻辑(它踩过坑,注释都留着): # 信号 A:连接数降到危险区 信号 B:公网打不通 信号 C:日志僵死 set -u RELAY_DIR=/root/lanremote-relay URL_FILE="$RELAY_DIR/public_url.txt" STATE_FILE="$RELAY_DIR/watchdog.state" LAST_RESTART_FILE="$RELAY_DIR/watchdog.last_restart" LOG_TAG=lanremote-watchdog # ---- 双实例定义 ---- # 端口**从服务配置里读**,不写死。 # # 为什么:这两个服务原来都没写 --metrics,都用 cloudflared 的默认 20241。 # 正常情况下先起的那个拿到 20241、后起的退到 20242 —— 看起来是稳的, # 但那只是启动顺序的巧合。实测把主实例 stop 掉、再重启副实例, # 副实例就抢到了 20241,于是"20241 是谁"翻转了, # 按端口读指标的判断全部读错实例。 # # 现在服务里已显式写了 --metrics,这里再自动读一遍: # 以后谁改了端口,watchdog 也跟着走,不会再对不上。 INSTANCES=() log_port_hint="" for pair in "lanremote-tunnel:20241" "lanremote-tunnel-h2:20242"; do _svc="${pair%%:*}" _fallback="${pair##*:}" _port=$(systemctl cat "$_svc" 2>/dev/null \ | grep -oE '\-\-metrics[[:space:]]+[0-9.]+:[0-9]+' | head -1 \ | grep -oE '[0-9]+$') INSTANCES+=("${_svc}:${_port:-$_fallback}") done # 日志文件(判"僵死"时都看) TUNNEL_LOGS=("$RELAY_DIR/tunnel.log" "$RELAY_DIR/tunnel-h2.log") # 连续几次公网不通才重启。 # 设 3(约 60 秒)而不是 2:短抖动让 cloudflared 自己重连就好, # 频繁重启反而让它一直停在"正在重连"的状态。 THRESHOLD=3 # 连接**健康**时用的阈值,放宽很多。 # # 为什么需要两个阈值(实测踩到): # 17:30:59 / 17:31:19 / 17:31:40 连着三次 HTTP 000, # 当时连接数是 7+2=9 条,完全健康 —— 结果看门狗按 THRESHOLD=3 # 把两个实例都重启了。而重启会**主动切断全部连接**, # 接下来 60 秒真的不可用(17:32:35 才恢复到 6+1)。 # 也就是说:这次重启把一次"抖一下就好"的故障,变成了确定的 1 分钟中断。 # # 连接健康的情况下,公网偶尔打不通几乎都是链路丢包或边缘抖动, # 重启本地隧道解决不了,只会自伤。给足时间让它自己恢复。 # 连接真的掉下去了(降级),才用 THRESHOLD=3 快速重启。 THRESHOLD_HEALTHY=8 STALE_MINUTES=10 # 日志多久没动静算卡死 # 两次重启至少隔这么久。 # 150 秒不是拍脑袋:隧道在差的 4G 上重建 5 条连接要 30~60 秒, # 原来设 45 秒会导致**隧道还没建齐就被下一次重启打断**, # 永远停在只剩 1 条连接的状态(实测死循环,每 50 秒重启一次)。 MIN_RESTART_GAP=150 GRACE=150 # 总连接数到多少算"建齐过"。双实例期望 5+5=10, # 保守取 6(够证明链路能撑住)。 BUILT_MIN=6 log() { logger -t "$LOG_TAG" "$1" echo "[$(date '+%m-%d %H:%M:%S')] $1" } read_state() { local key="$1" default="$2" value="" if [ -f "$STATE_FILE" ]; then value=$(grep "^$key=" "$STATE_FILE" 2>/dev/null | cut -d= -f2) fi [ -n "$value" ] && echo "$value" || echo "$default" } # 状态文件: # zero —— 连续降级次数 # fail —— 连续失败次数 # built —— 本次重启后总连接数有没有到过 BUILT_MIN write_state() { printf 'zero=%s\nfail=%s\nbuilt=%s\n' "$1" "$2" "${3:-0}" > "$STATE_FILE" } # 读某个 metrics 端口的连接数。 # 端口不通(实例死了)→ 输出 "X"(区分于 "0":0 是活着但没连接) conns_of() { local port="$1" out out=$(timeout 5 curl -s --max-time 4 "http://127.0.0.1:$port/metrics" 2>/dev/null \ | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}') if [ -z "$out" ]; then echo "X" else echo "$out" fi } # 重启指定的一组服务(带最小间隔保护) # $1 = 原因 # $2... = 要重启的服务名 # # 设 DRY_RUN=1 时只打印不真重启 —— 用来验证判定逻辑是否正确 # (双实例的判定分支不少,直接上生产风险大)。 restart_services() { local why="$1"; shift local now last gap total_now now=$(date +%s) last=0 [ -f "$LAST_RESTART_FILE" ] && last=$(cat "$LAST_RESTART_FILE" 2>/dev/null || echo 0) gap=$((now - last)) # 总连接数为 0 = 真的断了,不是抖动 —— 这种不受间隔限制 total_now=$TOTAL if [ "${total_now:-1}" != "0" ] && [ "$gap" -lt "$MIN_RESTART_GAP" ]; then # 刚重启过还没到间隔:这次先不重启, # 让 cloudflared 自己把连接建起来(它本来就在重试)。 log "$why —— 但距上次重启才 ${gap} 秒(下限 ${MIN_RESTART_GAP} 秒),先不重启" write_state 0 0 "${BUILT:-0}" exit 0 fi if [ "${DRY_RUN:-0}" = "1" ]; then log "[DRY_RUN] $why —— 本应重启 [$*](距上次 ${gap} 秒)" exit 0 fi log "$why —— 重启 [$*](距上次 ${gap} 秒)" echo "$now" > "$LAST_RESTART_FILE" for svc in "$@"; do systemctl restart "$svc" done write_state 0 0 0 sleep 25 log "重启后地址: $(cat "$URL_FILE" 2>/dev/null || echo '(还没拿到)')" exit 0 } ZERO=$(read_state zero 0) FAIL=$(read_state fail 0) BUILT=$(read_state built 0) # ---------- 信号 A:分别读两个实例 ---------- TOTAL=0 DEAD_SVCS=() LOW_SVCS=() DETAIL_PARTS=() NOW_TS=$(date +%s) LAST_R_NOW=$(cat "$LAST_RESTART_FILE" 2>/dev/null || echo 0) SINCE_RESTART=$(( NOW_TS - LAST_R_NOW )) for entry in "${INSTANCES[@]}"; do svc="${entry%%:*}" port="${entry##*:}" c=$(conns_of "$port") if [ "$c" = "X" ]; then # metrics 端口不通:要么进程没了,要么卡死到不响应 DEAD_SVCS+=("$svc") if systemctl is-active --quiet "$svc"; then DETAIL_PARTS+=("$svc=无响应") else DETAIL_PARTS+=("$svc=服务停") fi continue fi TOTAL=$((TOTAL + c)) DETAIL_PARTS+=("$svc=$c") # 连接数为 0 也要拉起来 —— 端口响应但一条连接都没有, # 说明这个实例根本没在工作(另一个实例可能还在扛着, # 所以公网看起来是通的,光看公网永远发现不了)。 # # 但要给宽限期:刚重启的实例连接数本来就是 0, # 这时候重启会把它按回去,陷入"永远建不起来"的死循环。 if [ "$c" = "0" ] && [ "$SINCE_RESTART" -gt "$GRACE" ]; then DEAD_SVCS+=("$svc") continue fi if [ "$c" -le 1 ] 2>/dev/null; then LOW_SVCS+=("$svc") fi done DETAIL_CONNS=$(IFS=, ; echo "${DETAIL_PARTS[*]}") # 总连接数到过 BUILT_MIN 就记下来(跨多次检查保留) if [ "$TOTAL" -ge "$BUILT_MIN" ] 2>/dev/null; then if [ "$BUILT" != "1" ]; then log "本次已建齐(合计 ${TOTAL} 条:${DETAIL_CONNS})" fi BUILT=1 fi # 某个实例整个没响应 -> 直接把它拉起来(这不受公网状态影响, # 因为另一个实例可能还在扛着,公网看起来是通的) if [ "${#DEAD_SVCS[@]}" -gt 0 ]; then restart_services "实例无响应(${DETAIL_CONNS})" "${DEAD_SVCS[@]}" fi # ---------- 信号 B:公网真的通吗 ---------- URL=$(cat "$URL_FILE" 2>/dev/null | tr -d '\r\n') CODE="000" if [ -n "$URL" ]; then CODE=$(timeout 20 curl -4 -s -o /dev/null -w '%{http_code}' \ -H 'User-Agent: LanRemote/1.8.2' "$URL/v1/health" 2>/dev/null) CODE=${CODE:-000} fi PUBLIC_OK=0 [ "$CODE" = "200" ] && PUBLIC_OK=1 # ---------- 信号 C:日志僵死了吗 ---------- # 两个日志都超过 STALE_MINUTES 没动静才算僵死 LOG_STALE=1 NOW=$(date +%s) for lg in "${TUNNEL_LOGS[@]}"; do if [ -f "$lg" ]; then LAST=$(stat -c %Y "$lg" 2>/dev/null || echo 0) if [ "$LAST" -gt 0 ] && [ $((NOW - LAST)) -le $((STALE_MINUTES * 60)) ]; then LOG_STALE=0 break fi fi done # ---------- 判断 ---------- if [ "$PUBLIC_OK" = "1" ]; then # 公网通就是真的通,计数器清零 if [ "$ZERO" != "0" ] || [ "$FAIL" != "0" ]; then log "公网恢复(HTTP 200),计数清零(之前 zero=$ZERO fail=$FAIL)" fi write_state 0 0 "${BUILT:-0}" exit 0 fi # 公网不通 —— 看是哪种原因 DETAIL="HTTP $CODE,${DETAIL_CONNS}" ALL_SVCS=() for entry in "${INSTANCES[@]}"; do ALL_SVCS+=("${entry%%:*}"); done if [ "$LOG_STALE" = "1" ] && [ "$FAIL" -ge 1 ]; then restart_services "两个日志都 ${STALE_MINUTES} 分钟没动静加上公网不通(进程卡死)" \ "${ALL_SVCS[@]}" fi FAIL=$((FAIL + 1)) # 总连接数 <= 1 也算"降级"。 # # 原来是 == 0 才算,但实测**只剩 1 条连接时跨机房回源就会失败** # (用户撞上的 530 就是这种:请求落 SEA,隧道只剩 LAX 一条)。 # 所以 1 和 0 一样危险,都计入降级计数。 DEGRADED=0 if [ "$TOTAL" -le 1 ] 2>/dev/null; then ZERO=$((ZERO + 1)) DEGRADED=1 fi log "公网不通($DETAIL),连续第 $FAIL 次(降级 $ZERO 次)" if [ "$DEGRADED" = "1" ]; then LAST_R=$(cat "$LAST_RESTART_FILE" 2>/dev/null || echo 0) SINCE=$(( NOW - LAST_R )) # 建齐过(>=BUILT_MIN)又垮下来 = 链路本来能撑住,现在是真故障 # -> 不用再等宽限期,立刻重启 # 从没建齐过 = 它还在努力建 # -> 继续等,别打断它 if [ "${BUILT:-0}" = "1" ]; then restart_services "公网不通,且本次重启后曾建齐过连接(确认故障,立即重启)" \ "${ALL_SVCS[@]}" elif [ "$SINCE" -lt "$GRACE" ]; then log "公网不通且只剩 ${TOTAL} 条连接,但本次重启还没建齐过(${SINCE}/${GRACE} 秒)—— 继续等它建" else restart_services "公网不通且 ${SINCE} 秒都没建齐(链路或边缘有问题,重启试试)" \ "${ALL_SVCS[@]}" fi fi if [ "$FAIL" -ge "$THRESHOLD" ] || [ "$ZERO" -ge "$THRESHOLD" ]; then # 连接还健康的话,用更宽的阈值 —— 别把"抖一下"升级成 1 分钟中断 if [ "$TOTAL" -ge "$BUILT_MIN" ] 2>/dev/null && [ "$ZERO" -lt "$THRESHOLD" ]; then if [ "$FAIL" -lt "$THRESHOLD_HEALTHY" ]; then log "公网连续 $FAIL 次不通,但连接健康(合计 $TOTAL 条)—— 再等等,不重启" write_state "$ZERO" "$FAIL" "$BUILT" exit 0 fi fi # 有实例只是"低连接"(不是完全没响应)时,只重启那些实例; # 全都低就重启全部。 if [ "${#LOW_SVCS[@]}" -gt 0 ] && [ "${#LOW_SVCS[@]}" -lt "${#ALL_SVCS[@]}" ]; then restart_services "连续 $FAIL 次公网不通(含 $ZERO 次降级,低连接实例)" \ "${LOW_SVCS[@]}" else restart_services "连续 $FAIL 次公网不通(含 $ZERO 次降级)" "${ALL_SVCS[@]}" fi fi write_state "$ZERO" "$FAIL" "$BUILT"