Skip to content

Cluster

Hades edited this page Aug 23, 2026 · 8 revisions

集群

多台机器分担请求,通常是为了多个出口 IP,其次才是吞吐。IPClick 有两种集群形态, 用 [CLUSTER].forward 选。不需要 Redis 之类的中间件。

两种形态

客户端分发(forward = "off",默认)

        ┌─→ 节点 A
调用方 ──┼─→ 节点 B
        └─→ 节点 C

调用方(ClusterDownloader)自己持有全部节点地址,按策略挑一台直连

  • ✅ 少一跳,不占任何节点的入口带宽
  • ✅ 故障转移在客户端做,最快
  • ❌ 调用方必须能连到所有节点
  • 批量是整批打给同一台batch() 一次挑一个节点,故障转移的粒度是整批而不是 单个任务,中途换节点会把整批重发——所以别指望用一个巨型批次来分散负载
  • 流式只有建流这一步会转移,而且只对幂等方法(GET / HEAD / OPTIONS)转移—— stream() 照样接受 method=POST,而结果未知时换节点重投等于把那个 POST 执行两次, 所以非幂等方法建流失败会直接把错误抛给调用方。流开始之后断掉不会跨节点续传(那需要 Range 请求才能不重复数据),要断点续传用 ipclick.resume.download_to_file

服务端转发(forward = "on"

                    ┌─→ 节点 B
调用方 ──→ 节点 A ──┼─→ 节点 C
                    └─→ 自己执行

调用方只需要知道一个地址。A 按策略挑节点:挑到自己就本地干,挑到别人就把请求 原样转过去,拿到结果再回给调用方。

  • ✅ 调用方只需一个地址,防火墙规则简单
  • ✅ 子节点可以完全不对调用方暴露
  • ❌ 多一跳;响应体要经过入口节点,占它的带宽

六条性质,配之前值得知道:

  1. 只转一跳。 转发时带 ipclick-forwarded 标记,收到带标记的请求一律本地执行。 环路在协议层就不可能出现,不靠 TTL 计数之类的兜底。
  2. 任意节点都能当入口。 五台机器可以用完全相同的配置 + 相同的 .env, 谁被访问谁就是入口。平时只把流量打给 A,A 挂了直接改指向 B。
  3. 入口自己也干活(只要它在 nodes 里,且 self_id 识别成功)。子节点全挂时 入口会自己兜底——但仅限 GET / HEAD / OPTIONS,写请求宁可报错也不重投, 见健康检查与故障转移
  4. 入口节点的 SSRF 准入对被转发的请求同样生效——本地执行和转发出去的请求都要过 入口这一层准入,所以"入口开了 block_private_networks、工作节点用默认配置"这个 再常见不过的组合下,策略对全部流量都管得住。下游节点自己也会再校验一次。
  5. 入口节点的按 host 限流对被转发的请求同样生效——本地执行和转发出去的请求都过 入口节点这同一个闸。开了转发不做配额分片,配额由入口这一个点统一把关:配 10 QPS 部三台,目标站点挨的还是 10 QPS。见性能与容量
  6. 流式下载(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" 时这份列表就是集群全貌,包括本机自己——本机也要列进来才会分到活。

节点身份(self_id

节点要知道"我是谁",否则会把请求转给自己、或者永远不给自己派活。

解析顺序(环境变量优先):

  1. 环境变量 IPCLICK_CLUSTER_SELF_ID多台机器共用一份配置时用这个。 它会盖掉文件里的值,这正是"五台机器一份配置"能成立的原因
  2. [CLUSTER].self_id
  3. [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 触发——连接压根没建起来。
  • 换节点重试的范围宽一些:UNAVAILABLEDEADLINE_EXCEEDEDRESOURCE_EXHAUSTEDINTERNALUNKNOWN 都会换一台再试(仍受上面的方法白名单约束)。

INVALID_ARGUMENTPERMISSION_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

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.1

port 是必填的位置参数(没有默认值),host 默认 127.0.0.1

只读页面:节点列表、健康状态、请求统计。不提供任何变更操作——运维变更请改配置文件, 网页改配置等于再开一个高价值攻击面。它会暴露内网节点地址与拓扑,所以默认只监听本机; 要远程访问请自行加反向代理并做鉴权。

(这跟 Web 管理端 是两个东西:状态页只读、无登录、只看集群; Web 管理端有登录、能试请求、能改白名单内的配置。)

客户端 API

推荐用 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 管理端管节点

Web 管理端/nodes 页可以增删改节点,写回 [CLUSTER].nodes。有三处直接影响"加一台机器"这件事:

1. 保存即生效,不用重启

保存完会原地重建 ClusterConfigNodePool:新节点立刻参与转发轮询,被移除或改了 地址的节点连接会被关掉。重建时按 id 复用已有的节点状态——直接重建会把健康计数清零,那样 "连续 N 次才切状态"的判定永远达不到,熔断与恢复双双失效。

热更新覆盖节点列表、权重、策略、阈值;监听端口这类要重建 gRPC server 的项不在范围内 (但改节点列表本来也不会动到端口)。DNS 发现模式下节点是解析出来的,热更新只改策略 与阈值、不动节点列表。

2. 「测试连接」分得清"连不上"和"鉴权不通过"

每行一个按钮,只验连通性与集群内部鉴权,不发业务请求

否则加完节点只能等真实流量转过去才发现连不上,而那时错误已经混在业务失败里了。

结果 去查什么
连不上 进程、防火墙、地址写没写对
鉴权不通过 各节点 .env 里的 IPCLICK_CLUSTER_SECRET 是否完全一致
通过(对方未设防) 那台没启用鉴权,任何人都能调它

为什么不能只用健康检查grpc.health.v1 刻意免鉴权(编排系统的探针通常拿不到 密钥),所以在它眼里"那台机器没起来"和"起来了但我的令牌不对"长得一模一样——而这两件 事的排查方向完全相反。

所以探两层:健康检查回答"连得上吗",Ping RPC(走鉴权、不做任何业务动作) 回答"令牌对吗"。第二层的返回码就是结论:

返回 结论
OK 鉴权通过(响应里还带对端的 id、版本、是否启用鉴权、是否开着转发、在途数)
UNAUTHENTICATED 令牌不匹配
UNIMPLEMENTED 鉴权是通的,只是对端版本过旧、不提供 Ping 这个 RPC

最后一种必须单独说,否则滚动升级期间会被误报成鉴权失败。

对端还会自报 auth_required——探测成功本身分不清"我的令牌对"和"它根本不验"。

如果对方自报的 id 和你列表里写的对不上,也会提示:转发的路由与链路记录里的 "谁执行的"都以列表里那个为准,对不上两边的记录就拼不到一起。

3. 「试一试」可以点名目标节点

试一试页多一个"目标节点"下拉,选中后跳过负载均衡 强制打到那一台——验证新加的机器配对没有,不用再反复点、靠轮询碰运气命中。

怎么确认转发真的生效了

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 n1

Web 管理端的请求流页也能实时看到分发情况,每条记录都带执行节点。

想确定性地验证某一台,用试一试的"目标节点"下拉点名, 比连点几次靠轮询命中可靠得多。

下一步

Clone this wiki locally