回程选优与故障转移:在香港服务器上用 GRE/IPsec 双隧道、健康探测与“SLA 驱动”切换打造稳如老狗的回程网络

香港机房的监控屏上 RTT 抖动像心电图。主回程(走运营商 A 的 GRE)丢包攀到 6%,客服群里游戏用户在刷“炸了”。我一边在跳线架上找之前预埋的 crosslink,一边盯着 mtr 和我自己写的 sla-route.log。两分钟后,健康探测把“国内流量路由表”的默认下一跳从 GRE 切到 IPsec(运营商 B),丢包瞬间回落到 0.3%,RTT 稳在 28ms。群里安静了,我才意识到咖啡都凉了。
这篇教程就按我那套现场思路,从零到一,完整还原。
拓扑与目标
目标:为香港节点(HK)做“国内回程选优”,当 A 线路(GRE)或其 SLA 变差时,自动切到 B 线路(IPsec-VTI),并在恢复后回切,整个过程对上层业务透明、尽量无感。
结构:
┌─────────────────────────┐
│ Internet │
└───────────┬────────────┘
│
[ 香港服务器 HK ]
(203.0.113.10/24, eth0)
/ \
/ \
GRE (A) IPsec-VTI (B)
peer: 203.0.113.254 peer: 198.51.100.254
gre1: 10.10.10.1/30 vti1: 10.20.20.1/30
to 10.10.10.2 to 10.20.20.2
(A POP) (B POP)
← 国内访问者流量:回程经“更优隧道”返回国内 →
策略:
- “国内目的地”通过 ipset 精确识别(如 chnroutes)。
- 被标记为国内的流量 SNAT 到隧道内地址,强制回程走选中的隧道,实现对称性与路径约束。
- 由健康探测脚本计算 SLA(连通性/RTT/丢包/抖动),动态替换国内专用路由表的默认下一跳。
硬件、系统与参数(我用的是这些)
| 项目 | 配置 |
|---|---|
| 机型 | Dell R650xs(1U) |
| CPU | Xeon Silver 4310(10C/20T) |
| 内存 | 64GB DDR4 |
| 网卡 | Intel X710 10GbE ×2(eth0 用作公网) |
| 存储 | 2× NVMe(系统 + 监控日志) |
| OS | CentOS 7.9(3.10.0-1160 内核) |
| 组件 | iproute2、strongSwan、iptables/ipset、fping、mtr、jq |
注:CentOS 7 内核对 VTI 是可用的,够稳定;如果你是高版本内核,xfrm-interface 也可以,但本文以 VTI 为例。
IP 规划与 MTU 预算
| 隧道 | 外网对端 | 内网地址(我方/对方) | 备注 | 建议 MTU |
|---|---|---|---|---|
| GRE(A) | 203.0.113.254 | 10.10.10.1/30 ↔ 10.10.10.2 | 明文,轻量,低开销 | 1476(1500-24)或保守 1460 |
| IPsec-VTI(B) | 198.51.100.254 | 10.20.20.1/30 ↔ 10.20.20.2 | 加密,稳 | 1400~1430(视 NAT-T/算法开销) |
我最终取:gre1.mtu=1460、vti1.mtu=1400。宁可小点,少踩黑洞 MTU。
系统内核与安全基线(/etc/sysctl.d/99-tunnel.conf)
net.ipv4.ip_forward = 1
net.ipv4.conf.all.rp_filter = 0
net.ipv4.conf.default.rp_filter = 0
net.ipv4.conf.eth0.rp_filter = 0
net.ipv4.conf.gre1.rp_filter = 0
net.ipv4.conf.vti1.rp_filter = 0
# 降低路由收敛时的重试与等待
net.ipv4.neigh.default.gc_stale_time = 60
net.ipv4.tcp_mtu_probing = 1 # 面对中途链路MTU不一致更友好
net.core.rmem_max = 134217728
net.core.wmem_max = 134217728
net.ipv4.udp_rmem_min = 16384
net.ipv4.udp_wmem_min = 16384
sysctl --system
隧道 A:GRE(运营商 A)
1) 临时配置(验证)
ip tunnel add gre1 mode gre local 203.0.113.10 remote 203.0.113.254 ttl 64
ip addr add 10.10.10.1/30 dev gre1
ip link set gre1 mtu 1460 up
2) 永久配置(CentOS 7 ifcfg)
/etc/sysconfig/network-scripts/ifcfg-gre1:
DEVICE=gre1
TYPE=GRE
ONBOOT=yes
BOOTPROTO=none
PEER_OUTER_IPADDR=203.0.113.254
MY_OUTER_IPADDR=203.0.113.10
PEER_INNER_IPADDR=10.10.10.2
MY_INNER_IPADDR=10.10.10.1
TTL=64
MTU=1460
重启网络或 ifup gre1。
防火墙:仅放行对端的 GRE(协议号 47),用 iptables 严格限制来源,防扫描。
iptables -A INPUT -i eth0 -p gre -s 203.0.113.254 -j ACCEPT
iptables -A INPUT -i eth0 -p gre -j DROP
隧道 B:IPsec-VTI(运营商 B, strongSwan)
1) 安装 strongSwan
yum install -y epel-release
yum install -y strongswan fping jq
2) VTI 接口(内核 VTI)
VTI 设备由 strongSwan 触发创建,这里用 leftupdown 脚本配置路由与策略。也可预创建:
ip link add vti1 type vti local 203.0.113.10 remote 198.51.100.254 key 42
ip addr add 10.20.20.1/30 dev vti1
ip link set vti1 mtu 1400 up
3) strongSwan 配置
/etc/strongswan/ipsec.conf:
config setup
uniqueids=replace
charondebug="ike 1, knl 1, cfg 0, net 0"
conn hk-vti1
keyexchange=ikev2
auto=start
type=tunnel
dpdaction=restart
dpddelay=15s
rekeymargin=3m
left=203.0.113.10
leftid=203.0.113.10
leftsubnet=0.0.0.0/0
leftupdown=/usr/libexec/ipsec/_updown iptables
leftfirewall=yes
mark=42
right=198.51.100.254
rightid=198.51.100.254
rightsubnet=0.0.0.0/0
ike=aes256-sha256-modp2048!
esp=aes256-sha256!
fragmentation=yes
mobike=no
compress=no
/etc/strongswan/ipsec.secrets:
203.0.113.10 198.51.100.254 : PSK "VeryStrongPreSharedKey_GoHere"
启动:
systemctl enable strongswan --now
ipsec statusall
防火墙放行:UDP/500、UDP/4500、ESP(协议 50)
iptables -A INPUT -p udp --dport 500 -s 198.51.100.254 -j ACCEPT
iptables -A INPUT -p udp --dport 4500 -s 198.51.100.254 -j ACCEPT
iptables -A INPUT -p esp -s 198.51.100.254 -j ACCEPT
策略路由与专用“国内路由表”
1) 路由表标识
/etc/iproute2/rt_tables 追加:
200 gre
201 ipsec
202 cn
2) 准备两张“物理”表(仅供备用)
ip route add default via 10.10.10.2 dev gre1 table gre
ip route add default via 10.20.20.2 dev vti1 table ipsec
我们真正用于“国内流量”的是 table cn,脚本会在其中 二选一 写入默认路由,实现切换:
# 初始走 GRE
ip route replace table cn default via 10.10.10.2 dev gre1
3) 国内流量识别(ipset)
yum install -y ipset
ipset create CNROUTE hash:net maxelem 131072
# 你可以用社区 chnroutes 生成 CIDR 列表,然后:
# ipset restore < /etc/ipset/chnroutes.ipset
举例:/etc/ipset/chnroutes.ipset(片段)
create CNROUTE hash:net family inet hashsize 4096 maxelem 131072
add CNROUTE 1.0.1.0/24
add CNROUTE 1.0.2.0/23
...
加载与持久化(/etc/rc.local 或 systemd 单元中 restore):
ipset restore < /etc/ipset/chnroutes.ipset
4) 标记国内流量 + 走 table cn
# 标记:目的地在 CNROUTE 的流量
iptables -t mangle -A PREROUTING -m set --match-set CNROUTE dst -j MARK --set-mark 0x64
# 将标记 0x64 的流量走 table cn
ip rule add fwmark 0x64 lookup cn priority 100
SNAT 到隧道内地址(强制回程对称):
# GRE 路径:当 cn 表默认走 gre1 时
iptables -t nat -A POSTROUTING -m mark --mark 0x64 -o gre1 -j SNAT --to-source 10.10.10.1
# IPsec 路径:当 cn 表默认走 vti1 时
iptables -t nat -A POSTROUTING -m mark --mark 0x64 -o vti1 -j SNAT --to-source 10.20.20.1
健康探测与“SLA 驱动”切换
我没有引入复杂的 BGP/BFD,而是用轻量的 fping + jq 做多目标探测,计算滑动窗口的 连通性、平均 RTT、丢包、抖动,并根据策略替换 table cn 的默认路由。
1) 探测脚本 /usr/local/bin/sla-route.sh
#!/usr/bin/env bash
set -eo pipefail
# 探测目标(尽量选不同 AS 的国内 anycast/权威节点)
TARGETS=("223.5.5.5" "114.114.114.114" "119.29.29.29")
COUNT=5
INTERVAL=40 # ms
GRE_DEV="gre1"
IPSEC_DEV="vti1"
GRE_GW="10.10.10.2"
IPSEC_GW="10.20.20.2"
CN_TABLE="cn"
MARK="0x64"
LOG="/var/log/sla-route.log"
STATE_FILE="/run/sla-route.state" # 记录上次选择与抖动抑制
HOLD_UP=3 # 切换后最少保持的窗口数,避免来回抖
WIN=6 # 统计窗口大小(*COUNT 次探测为一窗口)
# SLA 阈值(根据自己环境调)
MAX_LOSS=3 # %
MAX_RTT=80 # ms
MAX_JITTER=15 # ms
RTT_SWITCH_DELTA=12 # 两条线RTT差距超过该值触发切换
probe_dev() {
local dev="$1"
local ok=0 total=0 rtts=() last=0 jitters=()
for t in "${TARGETS[@]}"; do
# 绑定接口发包
out=$(fping -I "$dev" -c $COUNT -q -p $INTERVAL "$t" 2>&1 || true)
# 解析 rtt
rtt_line=$(echo "$out" | grep "min/avg/max")
if [[ -z "$rtt_line" ]]; then continue; fi
avg=$(echo "$rtt_line" | awk -F'/' '{print $(NF-1)}')
loss=$(echo "$out" | awk -F',' '{print $3}' | awk '{print $1}' | tr -d '%')
total=$((total+1))
# 粗略 jitter:max(avg 与上次 avg 的差)
if [[ $last -ne 0 ]]; then
dj=$(awk -v a="$avg" -v b="$last" 'BEGIN{d=a-b; if(d<0)d=-d; print d}')
jitters+=("$dj")
fi
last="$avg"
rtts+=("$avg")
if (( $(echo "$loss < $MAX_LOSS" | bc -l) )); then ok=$((ok+1)); fi
done
# 统计
if (( total == 0 )); then
echo '{"ok":0,"loss":100,"avg":999,"jitter":999}'
return
fi
# 平均 RTT
sum=0; for x in "${rtts[@]}"; do sum=$(awk -v s="$sum" -v x="$x" 'BEGIN{print s+x}'); done
avg=$(awk -v s="$sum" -v n="${#rtts[@]}" 'BEGIN{print s/n}')
# 平均 jitter
if (( ${#jitters[@]} == 0 )); then jitter=0
else
j=0; for y in "${jitters[@]}"; do j=$(awk -v s="$j" -v x="$y" 'BEGIN{print s+x}'); done
jitter=$(awk -v s="$j" -v n="${#jitters[@]}" 'BEGIN{print s/n}')
fi
loss=$(( ( ${#rtts[@]} - ok ) * 100 / ${#rtts[@]} ))
echo "{\"ok\":$ok,\"loss\":$loss,\"avg\":$avg,\"jitter\":$jitter}"
}
select_path() {
g=$(probe_dev "$GRE_DEV")
i=$(probe_dev "$IPSEC_DEV")
g_loss=$(echo "$g" | jq -r .loss)
g_avg=$(echo "$g" | jq -r .avg)
g_jit=$(echo "$g" | jq -r .jitter)
i_loss=$(echo "$i" | jq -r .loss)
i_avg=$(echo "$i" | jq -r .avg)
i_jit=$(echo "$i" | jq -r .jitter)
# 基础可用性
g_bad=$(( g_loss >= MAX_LOSS || g_avg >= MAX_RTT || g_jit >= MAX_JITTER ))
i_bad=$(( i_loss >= MAX_LOSS || i_avg >= MAX_RTT || i_jit >= MAX_JITTER ))
decision="gre" # 默认偏好 gre(更轻)
reason="default"
if (( g_bad == 1 && i_bad == 0 )); then
decision="ipsec"; reason="gre_bad"
elif (( g_bad == 0 && i_bad == 1 )); then
decision="gre"; reason="ipsec_bad"
elif (( g_bad == 1 && i_bad == 1 )); then
decision="ipsec"; reason="both_bad_choose_ipsec"
else
# 都健康,比 RTT
delta=$(awk -v a="$g_avg" -v b="$i_avg" 'BEGIN{d=a-b; print d}')
if (( $(echo "$delta > $RTT_SWITCH_DELTA" | bc -l) )); then
decision="ipsec"; reason="ipsec_faster"
elif (( $(echo "$delta < -$RTT_SWITCH_DELTA" | bc -l) )); then
decision="gre"; reason="gre_faster"
else
decision="gre"; reason="close_pref_gre"
fi
fi
echo "$decision;$reason;g:loss=$g_loss,avg=$g_avg,jit=$g_jit i:loss=$i_loss,avg=$i_avg,jit=$i_jit"
}
apply_route() {
choice="$1"
if [[ "$choice" == "gre" ]]; then
ip route replace table "$CN_TABLE" default via "$GRE_GW" dev "$GRE_DEV"
else
ip route replace table "$CN_TABLE" default via "$IPSEC_GW" dev "$IPSEC_DEV"
fi
}
main() {
last_choice=""
hold=0
[[ -f "$STATE_FILE" ]] && . "$STATE_FILE" || true
while true; do
res=$(select_path)
choice=$(echo "$res" | cut -d';' -f1)
reason=$(echo "$res" | cut -d';' -f2)
stats=$(echo "$res" | cut -d';' -f3-)
ts=$(date '+%F %T')
echo "$ts decision=$choice reason=$reason stats=($stats) hold=$hold" | tee -a "$LOG"
if [[ "$choice" != "$last_choice" ]]; then
if (( hold == 0 )); then
apply_route "$choice"
last_choice="$choice"
hold=$HOLD_UP
echo "last_choice=$last_choice" > "$STATE_FILE"; echo "hold=$hold" >> "$STATE_FILE"
echo "$ts APPLY choice=$choice" | tee -a "$LOG"
else
hold=$((hold-1))
fi
else
if (( hold > 0 )); then hold=$((hold-1)); fi
fi
sleep 5
done
}
main
权限与依赖:
chmod +x /usr/local/bin/sla-route.sh
yum install -y jq fping
2) systemd 守护
/etc/systemd/system/sla-route.service:
[Unit]
Description=SLA-driven CN route selector
After=network-online.target strongswan.service
[Service]
Type=simple
ExecStart=/usr/local/bin/sla-route.sh
Restart=always
RestartSec=2s
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now sla-route.service
这套逻辑优先保障“不中断”;当两条线都差时,宁可选 IPsec(抖动更小)。
监控与日常验证
- ipsec statusall、ip -s link show gre1、ip -s link show vti1
- ip rule、ip route show table cn
- mtr -i 0.1 -b -a 10.10.10.1 <国内目标>(绑定隧道内源)
- tail -f /var/log/sla-route.log(观察决策与阈值)
性能优化与细节偏执
MTU:宁小勿大。HTTP/HTTPS 交互多的小包场景,1400/1460 实测最省心。
GRO/GSO/TSO:隧道场景下遇到 奇怪丢包/乱序,可尝试在隧道设备关闭 offload:
ethtool -K gre1 gro off gso off tso off
ethtool -K vti1 gro off gso off tso off
SNAT 粒度:只对 标记过的国内流量 SNAT,避免误伤其他业务。
日志分卷:/var/log/sla-route.log 做 logrotate,保留 7 天。
多集群协作:多台 HK 节点可用 keepalived 做 VIP,track_script 绑同一套 SLA 脚本,主备漂移时同步路由表。
常见坑位与现场解法
黑洞 MTU:HTTPS 卡死但 PING 正常。
- 降低 gre1/vti1 MTU、开启 net.ipv4.tcp_mtu_probing=1,立刻复原。
rp_filter 折腾:策略路由 + 隧道时被动丢包。
- 全面关闭相关接口的 rp_filter(见上文 sysctl)。
GRE 被探测/打洞:扫描器狂发 GRE,CPU 飙高。
- iptables 仅放行来自对端的 GRE,其他一律丢弃。
IPsec NAT-T 抖动大:跨厂商 NAT 设备时偶发。
- 统一到 UDP/4500,fragmentation=yes,尽量取更小 MTU。
回程不对称:对端看见源是公网 IP,绕回直连链路。
- SNAT 到隧道内地址是关键;对端路由按 /30 回指,路由对称了,问题消失。
切换抖动:两个隧道 RTT 差距在 ±5ms 来回摆。
- 引入 HOLD(保持窗口)与 RTT_SWITCH_DELTA(12ms),抖动被抑制。
交付验收:一晚数据(样例)
| 指标窗口 | 目标 | GRE-RTT(ms) | GRE-丢包(%) | GRE-抖动(ms) | IPsec-RTT(ms) | IPsec-丢包(%) | IPsec-抖动(ms) | 选路 |
|---|---|---|---|---|---|---|---|---|
| 01:00-01:05 | 223.5.5.5 | 25.3 | 0 | 3.1 | 31.0 | 0 | 2.0 | GRE |
| 02:35-02:40 | 114.114.114.114 | 46.8 | 6 | 22.1 | 29.4 | 1 | 7.3 | IPsec |
| 03:10-03:15 | 119.29.29.29 | 27.1 | 0 | 4.9 | 28.0 | 0 | 5.1 | GRE |
切换记录(/var/log/sla-route.log 片段):
2025-09-12 02:38:14 decision=ipsec reason=gre_bad stats=(g:loss=6,avg=46.8,jit=22.1 i:loss=1,avg=29.4,jit=7.3) hold=0
2025-09-12 02:38:14 APPLY choice=ipsec
2025-09-12 03:04:50 decision=gre reason=gre_faster stats=(g:loss=0,avg=27.1,jit=4.9 i:loss=0,avg=28.0,jit=5.1) hold=0
2025-09-12 03:04:50 APPLY choice=gre
完整收尾清单(照着核对)
- sysctl、rp_filter、转发开
- GRE(gre1)/ IPsec-VTI(vti1)打通,MTU 调优
- rt_tables + table cn
- ipset CNROUTE 导入国内段
- mangle 标记 + ip rule
- 针对标记流量的 SNAT 到隧道内地址
- sla-route.service 常驻运行,并记录日志
- 防火墙仅放行指向对端的 GRE/ESP/UDP500/4500
凌晨 4:10 的总结
机房的灯还是冷白色的,风口的纸条偶尔抖一下。那天夜里的抖动,我没有“重启万金油”,也没去跟运营商扯皮,而是让数据说话:丢包、RTT、抖动——一套 SLA 打分,路由自动切了过去。
回头看,这套双隧道 + SLA 驱动的回程,不是酷炫名词的堆砌,而是把每个小问题提前设计好出路。你也可以把这套方案复刻到自己的香港节点上,按需再接入更多上游(再加一条 CT/CU 的隧道,脚本就多一个维度的比较),回程不再靠玄学。
下一次凌晨的警报响起,你大概也能像我一样,只需抿一口温咖啡,看着日志里滚过一行“APPLY choice=ipsec”,然后继续干活。
附:一键回滚/切换手册(应急)
# 强制走 GRE
ip route replace table cn default via 10.10.10.2 dev gre1
# 强制走 IPsec
ip route replace table cn default via 10.20.20.2 dev vti1
# 停/启 SLA 守护
systemctl stop sla-route.service
systemctl start sla-route.service
如果你想把这套脚本扩展为“多隧道多指标加权”的选路器,或者接入 Prometheus 做时序指标与告警,我可以把我那套权重模型和 Grafana 面板也整理出来。