-
Notifications
You must be signed in to change notification settings - Fork 0
Cluster
多台机器分担请求,通常是为了多个出口 IP,其次才是吞吐。IPClick 有两种集群形态,
用 [CLUSTER].forward 选。不需要 Redis 之类的中间件。
┌─→ 节点 A
调用方 ──┼─→ 节点 B
└─→ 节点 C
调用方(ClusterDownloader)自己持有全部节点地址,按策略挑一台直连。
- ✅ 少一跳,不占任何节点的入口带宽
- ✅ 故障转移在客户端做,最快
- ❌ 调用方必须能连到所有节点
- ❌ 批量是整批打给同一台。
batch()一次挑一个节点,故障转移的粒度是整批而不是 单个任务,中途换节点会把整批重发——所以别指望用一个巨型批次来分散负载 - ❌ 流式只有建流这一步会转移,而且只对幂等方法(
GET/HEAD/OPTIONS)转移——stream()照样接受method=POST,而结果未知时换节点重投等于把那个 POST 执行两次, 所以非幂等方法建流失败会直接把错误抛给调用方。流开始之后断掉不会跨节点续传(那需要 Range 请求才能不重复数据),要断点续传用ipclick.resume.download_to_file
┌─→ 节点 B
调用方 ──→ 节点 A ──┼─→ 节点 C
└─→ 自己执行
调用方只需要知道一个地址。A 按策略挑节点:挑到自己就本地干,挑到别人就把请求 原样转过去,拿到结果再回给调用方。
- ✅ 调用方只需一个地址,防火墙规则简单
- ✅ 子节点可以完全不对调用方暴露
- ❌ 多一跳;响应体要经过入口节点,占它的带宽
六条性质,配之前值得知道:
-
只转一跳。 转发时带
ipclick-forwarded标记,收到带标记的请求一律本地执行。 环路在协议层就不可能出现,不靠 TTL 计数之类的兜底。 -
任意节点都能当入口。 五台机器可以用完全相同的配置 + 相同的
.env, 谁被访问谁就是入口。平时只把流量打给 A,A 挂了直接改指向 B。 -
入口自己也干活(只要它在
nodes里,且self_id识别成功)。子节点全挂时 入口会自己兜底——但仅限GET/HEAD/OPTIONS,写请求宁可报错也不重投, 见健康检查与故障转移。 -
入口节点的 SSRF 准入对被转发的请求同样生效——本地执行和转发出去的请求都要过
入口这一层准入,所以"入口开了
block_private_networks、工作节点用默认配置"这个 再常见不过的组合下,策略对全部流量都管得住。下游节点自己也会再校验一次。 - 入口节点的按 host 限流对被转发的请求同样生效——本地执行和转发出去的请求都过 入口节点这同一个闸。开了转发不做配额分片,配额由入口这一个点统一把关:配 10 QPS 部三台,目标站点挨的还是 10 QPS。见性能与容量。
-
流式下载(
stream=True)不转发,永远由收到请求的节点自己执行。 把每个分片再中转一次会让入口带宽翻倍。要让子节点出流量就用客户端分发模式直连它。
[GENERAL]
mode = "cluster" # standalone / cluster / auto
[CLUSTER]
forward = "on" # off = 客户端分发;on = 服务端转发
self_id = "" # 本节点在 nodes 里的 id;留空则自动识别
forward_timeout = 0 # 0 = 自动推算
load_balancer = "round_robin" # round_robin / random / weight
failure_threshold = 2 # 连续失败几次摘除
recovery_threshold = 2 # 连续成功几次加回
probe_interval = 10 # 探活间隔(秒)
probe_timeout = 3
max_failover = 2 # 一次请求最多换几个节点(只对 GET/HEAD/OPTIONS 生效)
allow_remote_install = false # 允许主控远程装 / 卸本机的可选组件
nodes = [
{ id = "node-101", address = "192.168.1.101:9528", region = "us-east-1", zone = "1a", weight = 100 },
{ id = "node-102", address = "192.168.1.102:9528" },
{ id = "node-103", address = "192.168.1.103:9528", token = "..." },
]forward = "on" 时这份列表就是集群全貌,包括本机自己——本机也要列进来才会分到活。
节点要知道"我是谁",否则会把请求转给自己、或者永远不给自己派活。
解析顺序(环境变量优先):
- 环境变量
IPCLICK_CLUSTER_SELF_ID← 多台机器共用一份配置时用这个。 它会盖掉文件里的值,这正是"五台机器一份配置"能成立的原因 [CLUSTER].self_id- 按
[SERVER]的监听端口 + 本机地址自动识别
识别不出来会打警告,后果有两个,第二个更要紧:
- 本节点只转发不执行任务——这是安全的降级(不会转给自己造成环路),但白白浪费 一台机器的算力;
-
同时丢掉了兜底能力。子节点全挂时入口不会自己执行,而是直接返回
UNAVAILABLE, 错误里写着「入口节点未加入执行池」。
self_id 写了但不在 nodes 里也是同一个下场,同样只有一条 warning,别忽略。
还有一条同源的坑:配了 IPCLICK_CLUSTER_SECRET 而 self_id 识别失败时,本节点
不接受任何集群内部令牌,其他节点转发过来全是 UNAUTHENTICATED。
所有节点放同一个共享密钥,每台的令牌由它派生:
token = base64url(HMAC-SHA256(secret, "ipclick-node:" + node_id)[:24]) # 去掉 = 补齐,32 个字符
取前 24 字节再 base64url 编码,所以结果是固定 32 个字符。没有 CLI 能直接打印它, 要拿某个节点的令牌就跑:
python -c "from ipclick.cluster.tokens import derive_token; print(derive_token('<secret>', 'n1'))"# .env(所有节点相同)
IPCLICK_CLUSTER_SECRET=<一串随机>这样安排的原因:
- 每台机器的令牌各不相同——拿到 B 的令牌不能调 C
- 但谁都不用抄谁的令牌,加机器不用发新凭据
- 密钥只有一个,轮换只改一处
想给某台单独指定令牌(比如是别人维护的节点),在它的 nodes 条目里写 token = "...",
会覆盖派生值。
ipclick config-info 会显示集群密钥是否配了、来自哪里。密钥缺失时会告警——
无鉴权的转发集群等于任何人都能拿它当跳板。
[CLUSTER.discovery]
mode = "static" # static / dns
dns_name = "" # 例如 "ipclick.default.svc.cluster.local"
port = 9528
refresh_interval = 30 # 0 = 只在启动时解析一次-
static(默认)—— 用上面的nodes。扩缩容要改配置重启。 -
dns—— 解析一个域名,每条 A/AAAA 记录就是一个节点,后台定期重解析。 K8s 的 headless Service、Consul DNS、云内网负载均衡域名都能直接用。
DNS 模式下扩缩容不用改配置,也不用重启——新 Pod 起来,下一轮解析就带上了。
⚠️ dns只对客户端分发生效。 服务端转发用的节点池是从静态nodes建起来的, 不会重解析域名;DNS 发现在服务端那一侧只用于 Web 管理端的节点观测和限流分片。 想在服务端转发下用 DNS 发现,目前只能把节点列表显式写出来。另外 DNS 模式下节点 id 是解析出来的
host:port,不是你能自己命名的。
| 策略 | 行为 |
|---|---|
round_robin(默认) |
轮询,最均匀 |
random |
随机 |
weight |
按 nodes 里的 weight 加权,机器配置不一致时用 |
- 探活走
grpc.health.v1(免鉴权),间隔probe_interval -
连续
failure_threshold次失败才摘除,连续recovery_threshold次成功才加回。 用连续计数而不是单次判定,是为了避免一次网络抖动就让流量反复横跳。 注意这个迟滞只作用于后台探活——真实请求失败是一次就摘,客户端分发与服务端转发口径一致: 都只在UNAVAILABLE时立刻摘除,其余传输错误只计入failure_threshold的连续失败计数,然后靠recovery_threshold次连续探活成功再加回。 取舍是:探活是旁路,抖一下无所谓;真实请求失败说明这条路当下真的不通,让下一个请求 继续踩上去没有意义 - 一次请求最多换
max_failover个节点(max_failover = 2就是最多试 3 台)。 但只有GET/HEAD/OPTIONS会换
写请求不做故障转移。 POST / PUT / PATCH / DELETE 碰上节点故障时既不换节点也不本地 兜底,直接把错误返回给调用方——因为下游可能已经执行完了、只是回复没赶上,重投一次 就是重复下单。要让写请求也有冗余,得由调用方自己判断幂等性后重试。
⚠️ 例外:节点池被榨干时不算。 上面这条规则只覆盖"选中的节点自己报故障"这一支; 如果NodePool.acquire()本身就选不出节点(比如所有节点都被手动摘除),入口节点会 退回本地执行兜底——不分方法,写请求也一样。方法白名单只挡在"换节点重试"这一步, 挡不住这条池耗尽路径。
"节点故障"分两层,别混起来:
-
摘除(标记不健康、后续不再派活)只由
UNAVAILABLE触发——连接压根没建起来。 -
换节点重试的范围宽一些:
UNAVAILABLE、DEADLINE_EXCEEDED、RESOURCE_EXHAUSTED、INTERNAL、UNKNOWN都会换一台再试(仍受上面的方法白名单约束)。
INVALID_ARGUMENT、PERMISSION_DENIED 两者都不触发——参数写错或被策略拒绝是任务的
问题,不是节点的问题。把它们算成节点故障的话,抓一个慢站点就能把整个集群摘空。
forward_timeout = 0 # 0 = 自动推算自动推算的公式:任务自身的 timeout × (重试次数 + 1) + 15s 余量。
浏览器适配器另算——冷启动要拉起浏览器进程,会额外给出宽限。写死一个值的话, 要么普通请求等太久,要么浏览器请求还没热起来就被掐断。
# ipclick.toml —— 五台完全一样
[GENERAL]
mode = "cluster"
[CLUSTER]
forward = "on"
nodes = [
{ id = "n1", address = "10.0.0.1:9528" },
{ id = "n2", address = "10.0.0.2:9528" },
{ id = "n3", address = "10.0.0.3:9528" },
{ id = "n4", address = "10.0.0.4:9528" },
{ id = "n5", address = "10.0.0.5:9528" },
]# 每台机器的 .env 只差一行
IPCLICK_CLUSTER_SECRET=<同一串>
IPCLICK_CLUSTER_SELF_ID=n1 # n2 / n3 / n4 / n5调用方:
Downloader(host="10.0.0.1", port=9528, token="<n1 的派生令牌>")A 挂了就把 host 改成 10.0.0.2,不用动任何节点的配置。
K8s 里用客户端分发:[CLUSTER.discovery] 写在调用方那一侧,由
ClusterDownloader 解析 headless Service 直连每个 Pod。Pod 里的服务端什么集群配置都不用。
# 调用方(不是 Pod 里的服务端)
[GENERAL]
mode = "cluster"
[CLUSTER]
forward = "off" # 客户端分发
[CLUSTER.discovery]
mode = "dns"
dns_name = "ipclick.default.svc.cluster.local" # headless Service
port = 9528
refresh_interval = 30
⚠️ 别把forward = "on"和纯 DNS 发现配在一起。 服务端转发要求静态nodes非空, 只配 discovery 的话转发会静默退化成本地执行——日志里连"服务端转发已启用"那一行 都不会有。想在 K8s 里用转发,就得把节点列表显式写出来。
Pod 里的服务端仍然建议注入 IPCLICK_CLUSTER_SELF_ID(downward API,一般用 Pod IP
或 Pod 名),这样链路记录里能看出是哪个 Pod 执行的。
没有配置节,它是库 API:
from ipclick import create_client
from ipclick.cluster.status_page import StatusPageServer
with create_client() as d:
StatusPageServer(d.snapshot).start(9529) # 第二个参数是 host,默认 127.0.0.1port 是必填的位置参数(没有默认值),host 默认 127.0.0.1。
只读页面:节点列表、健康状态、请求统计。不提供任何变更操作——运维变更请改配置文件, 网页改配置等于再开一个高价值攻击面。它会暴露内网节点地址与拓扑,所以默认只监听本机; 要远程访问请自行加反向代理并做鉴权。
(这跟 Web 管理端 是两个东西:状态页只读、无登录、只看集群; Web 管理端有登录、能试请求、能改白名单内的配置。)
推荐用 create_client(),让配置决定形态,代码里不写死单机还是集群:
from ipclick import create_client
with create_client() as d: # 按 [GENERAL].mode 给单机或集群客户端
resp = d.get("https://example.com")
print(resp.trace.node_id) # 实际哪台执行的要显式指定就从子模块导入——ClusterDownloader 不在 ipclick 顶层导出:
from ipclick.cluster.client import ClusterDownloader
with ClusterDownloader() as d:
resp = d.get("https://example.com")
print(resp.trace.node_id)mode = "cluster" 却没配任何节点会直接报错,不会静默退回单机——静默退回会让你以为
集群生效了,实际所有流量都打在一台上,也没有故障转移。
Web 管理端的 /nodes 页可以增删改节点,写回
[CLUSTER].nodes。有三处直接影响"加一台机器"这件事:
保存完会原地重建 ClusterConfig 与 NodePool:新节点立刻参与转发轮询,被移除或改了
地址的节点连接会被关掉。重建时按 id 复用已有的节点状态——直接重建会把健康计数清零,那样
"连续 N 次才切状态"的判定永远达不到,熔断与恢复双双失效。
热更新覆盖节点列表、权重、策略、阈值;监听端口这类要重建 gRPC server 的项不在范围内 (但改节点列表本来也不会动到端口)。DNS 发现模式下节点是解析出来的,热更新只改策略 与阈值、不动节点列表。
每行一个按钮,只验连通性与集群内部鉴权,不发业务请求。
否则加完节点只能等真实流量转过去才发现连不上,而那时错误已经混在业务失败里了。
| 结果 | 去查什么 |
|---|---|
| 连不上 | 进程、防火墙、地址写没写对 |
| 鉴权不通过 | 各节点 .env 里的 IPCLICK_CLUSTER_SECRET 是否完全一致 |
| 通过(对方未设防) | 那台没启用鉴权,任何人都能调它 |
为什么不能只用健康检查:grpc.health.v1 刻意免鉴权(编排系统的探针通常拿不到
密钥),所以在它眼里"那台机器没起来"和"起来了但我的令牌不对"长得一模一样——而这两件
事的排查方向完全相反。
所以探两层:健康检查回答"连得上吗",Ping RPC(走鉴权、不做任何业务动作)
回答"令牌对吗"。第二层的返回码就是结论:
| 返回 | 结论 |
|---|---|
OK |
鉴权通过(响应里还带对端的 id、版本、是否启用鉴权、是否开着转发、在途数) |
UNAUTHENTICATED |
令牌不匹配 |
UNIMPLEMENTED |
鉴权是通的,只是对端版本过旧、不提供 Ping 这个 RPC |
最后一种必须单独说,否则滚动升级期间会被误报成鉴权失败。
对端还会自报 auth_required——探测成功本身分不清"我的令牌对"和"它根本不验"。
如果对方自报的 id 和你列表里写的对不上,也会提示:转发的路由与链路记录里的 "谁执行的"都以列表里那个为准,对不上两边的记录就拼不到一起。
试一试页多一个"目标节点"下拉,选中后跳过负载均衡 强制打到那一台——验证新加的机器配对没有,不用再反复点、靠轮询碰运气命中。
resp.trace.node_id 是实际执行的那台。连着发几个请求,node_id 应该在节点之间轮转:
with Downloader(host="10.0.0.1") as d:
for _ in range(6):
print(d.get("https://example.com").trace.node_id)
# n1 n2 n3 n4 n5 n1Web 管理端的请求流页也能实时看到分发情况,每条记录都带执行节点。
想确定性地验证某一台,用试一试的"目标节点"下拉点名, 比连点几次靠轮询命中可靠得多。