Snapshot 多节点动态路由
本场景在多个 ScootGate 节点上集中管理 backends/models。节点本地只保存 Provider 凭据引用的实际值,控制面通过版本化快照下发路由数据。
1. 拓扑与职责
┌────────────────────────────────┐
│ 控制面 │
│ auth snapshot / routing snapshot│
└──────────────┬─────────────────┘
│ HTTPS(Bearer)
┌───────────────────┴───────────────────┐
▼ ▼
ScootGate prod-a ScootGate prod-b
local credentials local credentials
│ │
└──────────────► egress proxy ◄─────────┘
│
▼
Providers
MQTT routing/notify:只提示版本,不携带路由内容
控制面负责:
- 生成完整、严格递增的路由快照;
- 只下发
credential_ref,不持有或下发 Provider key; - 为
since查询返回最新版本或 304; - 快照发布后可发送 MQTT 通知加速拉取。
节点负责:
- 从本地环境/文件解析 credential;
- 校验并原子替换完整路由表;
- 拒绝旧版本、未知引用与非法协议组合;
- 控制面超期不可达时转 unready,但继续服务最后有效表。
2. 节点完整配置
instance_id = "scootgate-prod-b"
[server]
listen = "0.0.0.0:8443"
max_concurrent_requests = 2048
request_head_timeout_ms = 5000
[server.tls]
cert = "/etc/scootgate/tls/fullchain.pem"
key = "/etc/scootgate/tls/privkey.pem"
[health]
listen = "127.0.0.1:9090"
max_concurrent_requests = 64
request_head_timeout_ms = 2000
[auth]
snapshot_url = "https://control.example.com/v1/scootgate/auth-snapshot"
token = "REPLACE_WITH_AUTH_CONTROL_PLANE_TOKEN"
cache_path = "/var/lib/scootgate/auth-snapshot.json"
poll_interval_secs = 30
[egress]
kind = "http_connect"
addr = "egress-proxy.internal.example.com:8443"
username = "scootgate-prod-b"
password = "REPLACE_WITH_EGRESS_SERVICE_PASSWORD"
tls = true
connect_timeout_ms = 5000
[api]
max_request_body_bytes = 1048576
response_head_timeout_ms = 30000
stream_idle_timeout_ms = 300000
[routing]
max_pre_send_attempts = 2
[routing.load_balancing]
algorithm = "weighted_p2c"
ewma_alpha = 0.2
initial_first_byte_ms = 1000
slow_start_secs = 30
[routing.circuit_breaker]
failure_threshold = 3
failure_window_secs = 30
cooldown_secs = 30
half_open_max_requests = 1
throttle_cooldown_secs = 10
[routing_source]
mode = "snapshot"
snapshot_url = "https://control.example.com/v1/scootgate/routing-snapshot"
token_env = "SCOOTGATE_ROUTING_TOKEN"
cache_path = "/var/lib/scootgate/routing-snapshot.json"
poll_interval_secs = 30
max_stale_secs = 300
[credentials.openai-primary]
file = "/run/secrets/openai-primary-api-key"
[credentials.compatible-backup]
file = "/run/secrets/compatible-backup-api-key"
[credentials.anthropic-primary]
file = "/run/secrets/anthropic-primary-api-key"
[usage]
schema = 2
delivery = "mqtt_outbox"
authoritative = false
outbox_path = "/var/lib/scootgate/usage-outbox"
max_outbox_bytes = 1073741824
flush_interval_ms = 500
on_full = "drop_newest"
[mqtt]
broker = "mqtts://mqtt.internal.example.com:8883"
username = "scootgate-prod-b"
password = "REPLACE_WITH_MQTT_SERVICE_PASSWORD"
topic_prefix = "scootgate/prod"
client_id = "scootgate-prod-b"
queue_capacity = 8192
[shutdown]
grace_period_secs = 60
[log]
level = "info"
注意此配置没有 [backends] 与 [models]。snapshot 模式声明任何本地
backend/model 都会启动失败,不存在本地与远端合并。
在进程管理器中注入路由控制面 token:
export SCOOTGATE_ROUTING_TOKEN='REPLACE_WITH_ROUTING_CONTROL_PLANE_TOKEN'
scootgate --config /etc/scootgate/scootgate.toml
生产环境不要在交互式 shell 中导出真实 token;应由 secret manager 注入进程环境。
3. 初始路由快照
版本 41 提供两个 OpenAI-compatible target 和一个 Anthropic target:
{
"schema": 1,
"version": 41,
"backends": {
"openai-primary": {
"protocol": "openai_compatible",
"upstream": "https://api.openai.com",
"credential_ref": "openai-primary",
"max_inflight_per_node": 64,
"enabled": true
},
"compatible-backup": {
"protocol": "openai_compatible",
"upstream": "https://compatible.example.net",
"credential_ref": "compatible-backup",
"max_inflight_per_node": 32,
"enabled": true
},
"anthropic-primary": {
"protocol": "anthropic_messages",
"upstream": "https://api.anthropic.com",
"credential_ref": "anthropic-primary",
"max_inflight_per_node": 64,
"enabled": true
}
},
"models": {
"chat-fast": {
"protocol": "openai_compatible",
"enabled": true,
"targets": [
{
"backend": "openai-primary",
"model": "gpt-4o-mini",
"weight": 100,
"supports_stream": true,
"supports_tools": true,
"supports_json_schema": true
},
{
"backend": "compatible-backup",
"model": "vendor-chat-fast",
"weight": 40,
"supports_stream": true,
"supports_tools": false,
"supports_json_schema": false
}
]
},
"claude-fast": {
"protocol": "anthropic_messages",
"enabled": true,
"targets": [
{
"backend": "anthropic-primary",
"model": "claude-3-5-haiku-latest",
"weight": 100,
"supports_stream": true,
"supports_tools": true,
"supports_json_schema": true
}
]
}
}
}
控制面接口:
GET /v1/scootgate/routing-snapshot?since=0
Authorization: Bearer REPLACE_WITH_ROUTING_CONTROL_PLANE_TOKEN
响应:
HTTP/1.1 200 OK
Content-Type: application/json
{完整版本 41 快照}
若节点请求 since=41 且没有更新,返回:
HTTP/1.1 304 Not Modified
Content-Length: 0
成功的 200 或 304 都刷新 control-plane freshness。网络错误、401、非法 JSON 或编译失败都不会刷新版本,也不会覆盖旧表。
4. 冷启动顺序
- 节点加载授权缓存;没有则保持 unready;
- 节点加载路由缓存;没有则 API 返回 503
not_ready; - auth 与 routing poller 分别拉取控制面;
- 两份有效快照都加载后
/readyz返回 200; - 流量入口才把节点加入服务集合。
示例健康响应:
{
"service": "scootgate",
"instance_id": "scootgate-prod-b",
"version": "0.1.0",
"status": "ready",
"ready": true,
"auth_snapshot_version": 7,
"routing_snapshot_version": 41,
"usage_outbox": {
"pending_events": 0,
"pending_bytes": 0,
"dropped_events": 0,
"oldest_event_age_secs": null
}
}
5. 热更新权重
版本 42 把备用 OpenAI-compatible target 从权重 40 提高到 100:
{
"schema": 1,
"version": 42,
"backends": {
"openai-primary": {
"protocol": "openai_compatible",
"upstream": "https://api.openai.com",
"credential_ref": "openai-primary",
"max_inflight_per_node": 64,
"enabled": true
},
"compatible-backup": {
"protocol": "openai_compatible",
"upstream": "https://compatible.example.net",
"credential_ref": "compatible-backup",
"max_inflight_per_node": 32,
"enabled": true
},
"anthropic-primary": {
"protocol": "anthropic_messages",
"upstream": "https://api.anthropic.com",
"credential_ref": "anthropic-primary",
"max_inflight_per_node": 64,
"enabled": true
}
},
"models": {
"chat-fast": {
"protocol": "openai_compatible",
"enabled": true,
"targets": [
{
"backend": "openai-primary",
"model": "gpt-4o-mini",
"weight": 100,
"supports_stream": true,
"supports_tools": true,
"supports_json_schema": true
},
{
"backend": "compatible-backup",
"model": "vendor-chat-fast",
"weight": 100,
"supports_stream": true,
"supports_tools": false,
"supports_json_schema": false
}
]
},
"claude-fast": {
"protocol": "anthropic_messages",
"enabled": true,
"targets": [
{
"backend": "anthropic-primary",
"model": "claude-3-5-haiku-latest",
"weight": 100,
"supports_stream": true,
"supports_tools": true,
"supports_json_schema": true
}
]
}
}
}
推荐发布顺序:
-
控制面先把版本 42 设为当前完整快照;
-
再向
scootgate/prod/routing/notify发布 QoS 1 消息:{"version": 42} -
节点收到通知后等待 500ms 合并突发,再用 bearer token 拉取完整快照;
-
/healthz中routing_snapshot_version变为 42; -
观察两个 target 的选择量、EWMA、429 与熔断状态。
通知只是一种加速。消息丢失时,30 秒轮询仍会获取新版本;通知不能直接改状态。
6. 状态继承与请求一致性
版本 41 → 42 只修改 target 权重,三个 backend 的 id、协议、origin、 credential_ref 与容量不变,因此节点保留其:
- 在途计数;
- EWMA 首包延迟;
- 熔断与 half-open 状态;
- 429 冷却;
- 因 401/403 产生的 disabled 状态。
若上述 backend 身份字段任一变化,则创建新 runtime 状态。
每个请求在开始时固定一个 Arc<RoutingTable>。更新后:
- 新请求使用版本 42;
- 进行中的请求继续使用版本 41;
- 不会在一次流式响应中跨版本换 backend。
7. 回滚
版本号必须严格递增,不能重新发布 41。回滚版本 42 的内容时:
- 复制版本 41 的 backends/models 内容;
- 把版本号设为 43;
- 先发布完整版本 43;
- 再发送
{"version":43}通知; - 确认所有节点健康面已切换。
这保证乱序、重复或延迟消息不会让节点状态倒退。
8. 故障与恢复
| 故障 | 节点行为 | 恢复 |
|---|---|---|
| 快照引用未知 credential | 整份拒绝,继续版本 41 | 发布更高版本的有效完整快照 |
| schema 不是 1 | 整份拒绝 | 修正 schema 并提高版本 |
| 收到版本 40/41 通知 | 忽略,不拉取旧状态 | 无需操作 |
| MQTT 断线 | 轮询继续,数据面不受阻 | 重连后恢复通知 |
| 控制面短暂不可达 | 旧表继续服务,仍 ready | 成功 200/304 后刷新 freshness |
| 超过 300 秒不可达 | 旧表继续服务,/readyz 非 200 | 拉取成功后自动恢复 ready |
| 新版本编译失败后发布 43 | 版本 41 保留直到 43 成功 | 原子切到 43 |
9. 多节点验收
对每个节点分别确认:
auth_snapshot_version与routing_snapshot_version达到目标;/readyz为 200 后才加入入口;- 版本更新期间持续请求无中断;
- 坏版本从未出现在健康面;
- 控制面超期时节点被入口摘除;
- MQTT/outbox 不包含 Provider key、客户端 key 或请求正文;
- 任一节点退出只损失容量,不影响其他节点路由。