VPN 网关运维
VPN 网关运维
内嵌 VPN 网关的容量、租约、拒绝分类、指标现状与故障处理。
部署与放行见 VPN 网关部署; 配置键的取值范围见 配置说明; 用户侧现象与自助排查见 VPN 使用者帮助。
运行时形态
网关是 Server 进程内的一组 goroutine 加一个进程内内存 TUN 设备,没有内核网络接口、
没有 /dev/net/tun、不需要 CAP_NET_ADMIN 或 CAP_NET_RAW。这决定了运维上的几件事:
ip link/wg show在网关宿主机上看不到任何接口。要确认网关活着只能看日志、指标和/api/v1/vpn-nodes。- 宿主机重启或进程重启后数据面状态全部丢失,但这是无损的:流表、监听器注册表、peer 内存视图、
拒绝聚合器全在内存,重建靠数据库里的
vpn_peers与vpn_ip_leases。 - 不存在“接口卡死需要 down/up”这类操作。止损手段是重启进程或关开关。
启动日志(顺序即装配顺序):
vpn_gateway_started node_id=server-node-1 listen=0.0.0.0:51820 port=51820 mtu=1420 peers=N
vpn_gateway_enabled node_id=server-node-1 listen=0.0.0.0:51820 endpoint_host=gw-1.mesh.example.com
vpn_peers_loaded node_id=server-node-1 peers=N refused=M
refused 非 0 时要看紧邻的 vpn_peer_load_refused 行,它给出行不能服务的原因
(典型是 node_id 不属于本节点)。加载 peer 集有 30s 上限:一个等不到数据库的网关
会占着 UDP 端口却不应答任何握手,那是从外部最难诊断的故障形态,所以宁可启动失败。
IP 池容量与耗尽
每个节点从 server.vpn.ip_pool 里租约一个 node_subnet_size 前缀的子网,
peer 的 /32 从本节点子网里分配。子网第一个可用地址留给节点自己的 VPN 接口,所以:
node_subnet_size |
可分配 peer 数 |
|---|---|
/24 |
253 |
/23 |
509 |
/22 |
1021 |
/30 |
1 |
耗尽的两级,别混:
- 节点子网耗尽 → 签发返回 409
vpn_ip_pool_exhausted。这是本节点的地址用完了, 其它节点可能还有余量。扩池只能靠改node_subnet_size(需要全集群一致 + 重启)或扩ip_pool。 max_peers触顶 → 签发返回 503vpn_capacity_exhausted。这是策略上限,不是地址用完, 调大server.vpn.max_peers即可。
水位从 /api/v1/vpn-nodes 的 allocated / capacity 读。这两个计数器读自子网租约与 peer 表,
不是从前缀推算的——推算出来的容量是签发方未必能兑现的承诺(第一个地址被保留、租约可能丢失)。
后台在这两个值缺失时渲染成 em dash,而不是显示“已分配 0”。
建议告警阈值:allocated / capacity > 0.8 预警,> 0.95 严重。地址池扩不动,
所以这个告警要有能落地的处置动作(清理过期 peer、吊销长期不握手的 peer)。
吊销不回收地址:公钥与地址都不复用,这是刻意的,避免一个新 peer 继承旧 peer 在内网侧留下的 ACL 或日志归因。所以长期运行的节点子网会被历史 peer 逐渐占满,规划时按“累计签发数” 而不是“并发在线数”估容量。
子网租约与 stale epoch
租约 TTL 1 分钟(vpnSubnetLeaseTTL),续租间隔 TTL/3 = 20 秒,单次读写超时 10 秒。
续租是 compare-and-set,携带行里的 lease_holder 与 epoch 原样回写——续租循环自己发明 epoch
会在第一轮就把本节点从自己的子网里 fence 出去。
续租结果打点 tunnelmesh_registry_lease_total{operation="vpn_subnet_renew",result,error_class}:
result / error_class |
含义 | 处置 |
|---|---|---|
success |
正常 | — |
failed / list_failed |
连本节点持有的租约都列不出来(数据库不可达) | 看 vpn_subnet_list_failed;已加载的 peer 继续服务,这是降级不是停机 |
failed / stale_epoch |
别的节点现在持有这个子网 | 看 vpn_subnet_lease_lost;见下 |
failed / error |
其它续租失败 | 看 vpn_subnet_renew_failed |
stale epoch 的处理是刻意的:续租循环放弃这个子网(再续也只会同样失败), 但已经在服务的 peer 与流一律不动。一次数据库分歧不是把正在工作的流量打成黑洞的理由; 新签发由 repository 层 fence,不由这个循环 fence。
后果是:一个失去租约的节点会继续服务存量 peer,直到进程重启。要判断节点是否还持有子网,
看 vpn_subnet_lease_lost 是否出现过,以及 /api/v1/vpn-nodes 的 subnet 字段。
两个节点同时认为自己持有同一子网时,双方都能服务存量,但只有一方能新签发。
drain 与 5 分钟止损
shutdown_timeout(默认 15s)是退出时等待在途流排空的上限,可为 0(不等待)。
等待期间每 20ms 看一次流表——用轮询而不是信号,因为流可以因四种原因结束
(peer 关闭、agent 关闭、idle reaper、吊销),一个要被逐一通知的计数器就是第四个能把 teardown 写错的地方。
超时则强制关闭在途流,并在退出日志里报告:
vpn_gateway_stopped node_id=... port=51820 waited=15s in_flight_at_timeout=3 flows_forced=3 \
steps=drain>listeners>await_flows>flows>endpoint>peers>stack>background>denials
steps 是实际执行顺序,卡住时看它停在哪一步。waited=0s 且 in_flight_at_timeout=0 是干净退出。
止损两条路,都不需要回滚代码:
server.vpn.enabled: false+ 重启 → 进程不创建任何 VPN 资源,行为与旧版本完全一致; 管理 API 写操作返回 409vpn_node_disabled,读操作返回空列表。- 换回不带
-tags vpn的二进制 → 数据面根本不在进程里,最强止损。
数据面无持久化状态,回滚不需要数据补偿、迁移或清理。
拒绝原因对照
IP 层没有响应通道,网关无法把错误码回传给用户。所有拒绝在用户侧都表现为“连不通”或
“ping 不通”,没有 RST、没有 ICMP 错误、没有提示页。这是刻意的:回一个
Destination Unreachable 等于告诉对端“这个地址存在,只是不让你访问”,而那正是出口策略要隐藏的事实。
排障因此只能在服务端做。error_class 全集(16 个,已发布即契约,不新增):
error_class |
触发条件 | 用户侧现象 |
|---|---|---|
peer_unknown |
公钥不在本节点的 peer 内存视图里 | 握手不成功 |
peer_revoked |
peer 已吊销 | 握手不成功,在途流被切断 |
peer_expired |
peer 已过期 | 握手不成功 |
target_denied |
目标不在 peer 的 allowed_ips 内 |
连不通 |
metadata_denied |
目标是云 metadata 地址(169.254.169.254) | 连不通 |
port_denied |
目标端口不在 allowed_ports 内 |
连不通 |
protocol_unsupported |
IP 协议不是 TCP/UDP/ICMP-echo(GRE、SCTP、组播等) | 连不通,且静默 |
fragment_dropped |
非首片分片包 | 大包/分片场景连不通 |
oversize_dropped |
超过隧道 MTU | UDP 大包丢失 |
capacity_exhausted |
达到 max_flows_per_peer / max_flows_total / icmp_max_concurrent,或出向队列满 |
新连接失败,存量不受影响 |
rate_limited |
超过 packet_rate_per_peer |
间歇性丢包 |
egress_unavailable |
出口 Agent 不可达或连接失败 | 连不通 |
egress_timeout |
connect_timeout 内没建成到目标的连接 |
连接超时 |
icmp_unsupported |
出口 Agent 未协商 stream_icmp_echo.v1 |
ping 不通但 TCP 通 |
icmp_timeout |
icmp_timeout 内没有 echo 应答 |
ping 超时 |
stack_error |
网关内部不变量破裂(监听器注册冲突、agent 串了 correlation 等) | 不定,必须查日志 |
stack_error 与其它类不同:它不是策略决定的结果,而是网关自己出了问题。出现即告警,
并按紧邻的 vpn_stack_* 日志定位。
审计
拒绝写审计事件 vpn_packet_denied,含 reason、peerId、目标地址与协议,不含任何密钥与包内容。
为避免审计洪泛,同一 peer + reason + 目标 /24 在窗口内聚合计数后再落库:窗口 1 分钟
(vpnDenialWindow),活跃桶上限 4096(vpnDenialMaxKeys)。所以审计里的 count
是一分钟内的次数,不是一次一条;桶数触顶时新的桶会被丢弃(而不是无限增长),
这时指标仍然是准的,只有审计的粒度会退化。
目标地址按 /24 聚合是刻意的:一个扫描 256 个地址的 peer 产生的是一条审计而不是 256 条,
而运维需要知道的正是“这个 peer 在扫一个网段”。
指标现状
这一版复用既有向量,没有 VPN 专用指标。 规格 §13 列的 6 个
tunnelmesh_vpn_* 向量属阶段 8,尚未实现。当前实际能看到的是:
| 指标 | 标签 | VPN 下的含义 |
|---|---|---|
tunnelmesh_bytes_total |
component="vpn", direction, protocol |
隧道载荷字节。direction 为 ingress(peer→内网)/egress(内网→peer);protocol 为 tcp/udp/icmp-echo |
tunnelmesh_streams_total |
protocol, result, error_class |
流的结果计数。丢包也走这里,result="rejected" |
tunnelmesh_streams_active |
protocol |
活跃流 gauge |
tunnelmesh_stream_errors_total |
protocol, error_class |
流错误 |
tunnelmesh_registry_lease_total |
operation="vpn_subnet_renew", result, error_class |
子网租约续租 |
一个必须知道的精度损失
丢包复用 tunnelmesh_streams_total{result="rejected"},而 ObserveStream 同时维护活跃流 gauge,
在 rejected 上会减一次却没有对应的加一次。所以一阵密集拒绝会把 tcp 的
tunnelmesh_streams_active 拉到低于真实活跃流数,直到下一次 accept 把它校正回来。
counter 本身是精确的,告警读的是 counter,所以告警不受影响。
受影响的是 gauge——不要用 tunnelmesh_streams_active 做容量判断或告警,
活跃流的权威来源是 /api/v1/vpn-peers/{peerId}/flows 与 /api/v1/vpn-nodes 的 peers。
阶段 8 会用专用的 tunnelmesh_vpn_packets_dropped_total 替换这个 body,包路径不变
(网关依赖的是 vpnMetrics 小接口而不是 *observability.Metrics,就是为了这次替换)。
在那之前,Grafana 的 VPN Row 与告警规则应按 counter 写。
标签基数
error_class 是 16 个已发布常量之一,这正是它能做 Prometheus 标签的原因。
peer ID、VPN IP、目标地址一律不是标签——它们是审计字段。任何用请求内容拼标签的改动
都会让 registry 无界增长。
结构化事件
按用途分组,全部带 node_id:
生命周期:vpn_gateway_started、vpn_gateway_enabled、vpn_gateway_stopped、
vpn_gateway_close_failed、vpn_peers_loaded、vpn_peer_load_refused、vpn_handler_missing、
vpn_background_loop_stop_timeout
peer 热更新:vpn_peer_apply_failed、vpn_peer_device_install_failed、
vpn_peer_device_remove_failed、vpn_peer_device_remove_render_failed、vpn_peer_key_rotated、
vpn_peer_flows_stopped
子网与租约:vpn_node_address_learned、vpn_node_address_unavailable、
vpn_node_address_pool_invalid、vpn_subnet_list_failed、vpn_subnet_renew_failed、
vpn_subnet_lease_lost
数据面:vpn_packet_denied_audit_failed、vpn_egress_open_failed、vpn_egress_open_timeout、
vpn_tcp_accept_failed、vpn_tcp_unusable_peer_address、vpn_udp_emit_failed、
vpn_udp_stream_ended、vpn_icmp_emit_failed、vpn_icmp_capability_probe_failed、
vpn_flows_reaped、vpn_listeners_reclaimed、vpn_device_error
协议栈:vpn_stack_unmatched_tcp、vpn_stack_address_conflict、
vpn_stack_listener_close_failed、vpn_stack_release_failed、vpn_stack_remove_nic_failed
两个刻意限流的:
vpn_stack_unmatched_tcp每进程每网关只打一行。一个探测端口的 peer 否则会每段一行; 量在 counter 里,形状在日志里。所以看到一行不代表只发生了一次,看它的count字段。- wireguard-go 自己的错误文本经
vpnDeviceLogger限流到每秒最多一行(vpnDeviceLogInterval)。
vpn_stack_unmatched_tcp 的 count 非 0 且持续增长,说明有 TCP 段找不到监听器:
要么是对端在探测,要么是监听器注册顺序出了问题(后者是 D4 要保证的不变量,出现即 bug)。
常见故障
| 现象 | 先看什么 | 典型根因 |
|---|---|---|
| 所有用户握手失败 | ss -lunp 有没有那个 UDP 端口;启动日志有没有 vpn_gateway_started |
安全组没放行;listen 写了内网地址;二进制没带 -tags vpn(此时进程根本起不来) |
| 部分用户握手失败 | tunnelmesh_streams_total{error_class="peer_unknown"\|"peer_revoked"\|"peer_expired"} |
密钥轮换后没重新下发;peer 过期 |
| 握手成功但全部连不通 | error_class="egress_unavailable" / egress_timeout |
出口 Agent 掉线;Agent 宿主机到内网服务不通 |
| 个别目标连不通 | error_class="target_denied" / port_denied + vpn_packet_denied 审计 |
peer 策略没放开;用户误以为 AllowedIPs 是全流量 |
ping 不通但 TCP 通 |
error_class="icmp_unsupported" |
Agent 没协商 stream_icmp_echo.v1;宿主机 net.ipv4.ping_group_range 没放开 |
| 大包/UDP 应用异常 | error_class="fragment_dropped" / oversize_dropped" |
不支持分片;UDP 报文超过路径 MTU |
| 新连接失败、存量正常 | error_class="capacity_exhausted" |
max_flows_per_peer / max_flows_total / icmp_max_concurrent 触顶 |
| 间歇性丢包 | error_class="rate_limited" |
packet_rate_per_peer 偏低 |
签发返回 409 vpn_node_disabled |
/api/v1/vpn-nodes 的 enabled;启动日志 |
server.vpn.enabled: false;或 TUNNELMESH_VPN_NODE_PRIVATE_KEY 不可用(此时数据面启动会失败,管理面降级) |
签发返回 409 vpn_ip_pool_exhausted |
/api/v1/vpn-nodes 的 allocated/capacity |
本节点子网用完;累计签发数(含已吊销)占满 |
flows 接口返回 501 |
该 peer 的 node_id 是否是本节点 |
集群里 peer 由别的节点服务;或本进程无数据面。501 不是空列表,空列表才表示“在本节点且空闲” |
| 退出很慢 / 强制断流 | vpn_gateway_stopped 的 waited、flows_forced、steps |
shutdown_timeout 内有长连接未结束;调大超时或接受强制断流 |
排障路径遵循 AGENTS.md 的约定:access_log → app_log → trace_id 链路 → 根因。
VPN 流的 Metadata 携带 traceparent,所以一条 VPN 流可以和它经 Agent 出去的那一腿串在同一个 trace 里。
逻辑 traceroute(POST /api/v1/agents/{agentId}/trace)可以验证 Agent 到内网目标那一腿,
但不能验证用户到网关那一腿——那是 WireGuard,不是本产品的逻辑链路。