Skip to content

Troubleshooting

Hades edited this page Aug 23, 2026 · 7 revisions

故障排查

第一步永远是这个

ipclick config-info

它显示的是「什么真生效了」,不是配置文件里写了什么——环境变量会覆盖、 有些项写错了会被忽略、有些有默认值。一大半配置问题在这里一眼能看出来。

读懂报错

IPClick 刻意把这几类问题分开报,因为处理动作完全不同:

症状 类别 该做什么
status_code == -1 瞬时传输故障 resp.error,多半是网络/目标站点
ValidationError 参数写错 改代码
URLNotAllowedError URL 被服务端 SSRF 准入拒绝 改 URL 或 [SECURITY]
AdapterError 服务端能力缺失 装依赖 / 下浏览器本体 / 改配置
AuthenticationError 鉴权 对令牌
ConfigError 配置写错 改配置文件

只有瞬时传输故障返回 -1 把用法错误伪装成网络故障会让人查错方向—— 这是刻意的设计。被 SSRF 策略拒绝尤其容易混进来,所以它会作为 URLNotAllowedError 抛出来而不是变成一条 -1 的响应:这两者的排查方向完全相反。

有一处不对称值得知道:d.get() / d.request() 会把 TransportError 吞掉、转成 status_code == -1 的响应,但 d.download() / d.stream() / d.batch() 会把它原样抛出来

启动

端口被占用

Failed to bind to address [::]:9528

(前半段取决于 [SERVER].host,默认是 [::]。)

单进程模式(默认)下 IPClick 显式关掉了 SO_REUSEPORT,所以端口撞了会直接起不来。 gRPC 默认是开着它的,那样两个进程都能"成功"监听,请求被内核随机分给其中一个—— 症状是"改了配置只有一半生效",极难定位。宁可起不来。

[SERVER].processes > 1 时反过来:多个 worker 就是靠 SO_REUSEPORT 共享同一个端口, 必须开着。这时端口冲突改由 fork 之前的一次独占试绑来拦,结论仍然是起不来,但机制不同。

找出占用者:

ss -lptn "sport = :9528"

别用 pkill -f ipclick —— pkill -f 会匹配到它自己的命令行。 用上面的命令拿到 PID 再 kill

起来了但客户端连不上

按顺序排除:

  1. [SERVER].host 是不是 127.0.0.1 而客户端在别的机器上
  2. 防火墙
  3. TLS 一边开一边没开
  4. http_proxy / HTTPS_PROXY 环境变量把 gRPC 连接也劫持了

第 4 条特别隐蔽:机器上设了代理时,gRPC 会尝试用它连内网地址然后失败。 IPClick 的内部 channel 已经带了 grpc.enable_http_proxy = 0, 但你自己建的 channel 要自己加。

启动就打两条告警

传输层:未启用(明文)
未配置鉴权令牌,任何能连到本端口的调用方都可以使用本服务。

这是有意的,不是 bug。 只监听本机的话可以忽略;监听在别的地址上请看安全

.env 里明明配了令牌,却报"未配置鉴权令牌"

先看 ipclick config-info:它会逐项显示每个机密来自哪里(环境变量 / 配置文件 / 未配置)。如果它说"未配置"而你确信写了,按这三条查:

  • .env 只从启动服务的那个工作目录读,不向上递归。 在别的目录 ipclick run, 仓库根目录那份 .env 就不参与,见配置体系 · 优先级
  • 值留空等于没配。 空串和只有空白的值都按"未配置"处理(机密值读进来会先 strip())。
  • 同名的真实环境变量压过 .env,所以 config-info 显示"环境变量"而值不对时, 要改的是那个变量而不是 .env。(反过来,环境变量是空串时不算已设置,.env 照样生效。)

BOM 不是原因.envutf-8-sig 读取,Windows 记事本另存为 "UTF-8"、 PowerShell 的 Set-Content 默认加上的那个 BOM 会被吃掉,第一个变量不会因此变成 IPCLICK_AUTH_TOKEN 这种带不可见前缀的名字而静默失效。所以带 BOM 的文件可以直接用, 不必特意另存为"UTF-8(无 BOM)"。

对单个 worker 发 SIGTERM,整个集群都停了

[SERVER].processes > 1 时对某一个 worker 发 SIGTERM(滚动重启、supervisor 停单个进程) 会连带停掉整队,这是按设计的,不要指望它消失:父进程的监控循环只有在它自己 收到 SIGINT/SIGTERM 时,才会把随后发生的子进程退出当成"预期之内"并正常广播停机; 如果你直接对某一个 worker 子进程的 PID 发 SIGTERM 而不是发给主/父进程,那个 worker 会正常退出(退出码 0),但父进程因为自己没收到信号,仍会把这次退出判定为"意外", 进而摘掉并终止其余所有 worker、抛出异常——这是"一台不对就全队一起停"的故障处理设计, 模块里也没有单个 worker 重启的逻辑,不是待修的 bug。

被信号打掉的那个 worker 不会自己再去牵连别人:worker 子进程有一道硬边界,它自己的 SystemExit 只让自己退出。所以日志里出现 启动第 N 个 worker 失败,正在收掉已启动的 M 个 时,说明是真的 fork 失败在收尾,不要往这个症状上套。

规避办法:只对主/父进程(或整个进程组)发信号,它会正常广播给所有 worker; 不要直接对某个 worker 子进程的 PID 发信号。

请求

-1 加一句连接错误

传输层的问题:DNS、连接被拒、TLS 握手失败、超时。resp.error 里有具体原因。

对方封 IP 或封指纹的话,试试换适配器:

d.get(url, adapter="browser")      # 真浏览器
d.get(url, impersonate="chrome124")  # curl_cffi 指定指纹

URLNotAllowedError: 不允许的协议

[SECURITY].allowed_schemes 只放行 http / https。这是 SSRF 防护的一部分, 见安全

注意拦的位置:这条是服务端的策略,所以异常要等服务端回话才抛。客户端只拦 URL 前缀,d.get("file:///etc/passwd") 压根出不了本机,抛的是 ValidationError。 两条都想接住就抓 ValidationError——URLNotAllowedError 是它的子类。

RESOURCE_EXHAUSTED

按 host 限流没等到额度。要么放宽:

[DOWNLOADER.concurrency]
per_host_max_concurrent = 20
per_host_wait_timeout = 60

要么就是这个限流本来就该生效——你确实在打同一个 host 太猛。

请求特别慢或直接超时

依次看:

  1. resp.trace.attempts —— 是不是在反复重试
  2. resp.trace.queued_ms —— 是不是卡在按 host 限流的队列里
  3. 浏览器适配器 → 看下一节
  4. timeout单次尝试的上限,总时长要乘上重试次数,见 性能

客户端超时了但服务端还在跑

DEADLINE_EXCEEDED 刻意不在客户端重试——请求可能已经在服务端执行了, 只是回复没赶上,重发一个 POST 就是重复下单。

服务端这边有对应处理:调用方已经放弃时不再继续执行,避免白干。

浏览器渲染

FAILED_PRECONDITION: 浏览器引擎 'X' 不可用(包已装,浏览器本体未就绪)

pip 包装了,浏览器本体没下。这是两步安装

python -m camoufox fetch        # camoufox
patchright install chromium     # patchright
playwright install chromium     # playwright

刻意不自动下载,见安装

AdapterError: 适配器 'camoufox' 需要额外依赖

包本身没装:

pip install "ipclick[camoufox]"

请求慢到像卡死(几分钟)

按可能性排序:

  1. 内存不够在换页。 camoufox 尤其吃内存。把 [BROWSER].max_pages 调到 1~2。 free -m 看 swap 有没有在动——在动就是这个原因。
  2. wait_until = "networkidle" 碰上了轮询页面。 网络永远不会静默, 每个请求都耗满 page_load。改成 load
  3. 没拦资源。 确认 block_resources 至少拦了 image / media / font
  4. 每次都在冷启动。 浏览器实例应该复用;如果日志里频繁出现启动浏览器, 说明实例在被反复丢弃——通常是进程被 OOM killer 杀了,还是回到第 1 条。

正常热路径是 200~300 ms。慢到分钟级的话,基本就是上面四条之一。

容器里 Chromium 起不来

[BROWSER]
no_sandbox = true      # 同时会带上 --disable-dev-shm-usage

容器默认 /dev/shm 只有 64 MB,Chromium 会崩;容器里通常也没有 user namespace。

automation_scriptINVALID_ARGUMENT

两种可能:

  1. 服务端 [BROWSER].allow_scripts = false(默认)
  2. 脚本有语法错误 —— 被判为参数错误,不重试(语法错重试三次还是语法错)

集群

请求全打在一台上

  1. [GENERAL].mode 是不是还是 standalone
  2. forward = "on"nodes有没有列上本机自己
  3. resp.trace.node_id —— 它是实际执行的节点
with Downloader(host="10.0.0.1") as d:
    for _ in range(6):
        print(d.get("https://example.com").trace.node_id)

日志说"未能在 nodes 里识别出本节点"

self_id 没配又自动识别失败。后果是本节点只转发不执行任务——安全但浪费一台机器。

相邻还有两条告警,处理动作各不相同:

  • [CLUSTER].self_id = ... 不在 nodes 列表里 —— 配了但拼错了,或 nodes 里漏了这台。
  • 有多个节点匹配本机监听地址(...),请显式设置 [CLUSTER].self_id —— nodes 里有多条 指向同一个地址,自动识别不敢猜,只能显式指定。

顺带一条同源的坑:配了 IPCLICK_CLUSTER_SECRET 但 self_id 识别不出来时,本节点 不接受任何集群内部令牌(其他节点转发过来全是 UNAUTHENTICATED)。这种情况会 单独打一条点名 [CLUSTER].self_id 的告警。

IPCLICK_CLUSTER_SELF_ID=n1

节点被反复摘除又加回

  • probe_timeout 太短(默认 3 秒),网络抖动就判失败
  • failure_threshold / recovery_threshold 太小

注意什么才算节点故障:只有 UNAVAILABLE(连接压根没建起来)会摘节点。 DEADLINE_EXCEEDED 不会——目标站点慢是任务的问题,不是节点的问题。 把它算成节点故障的话,抓一个慢站点就能把整个集群摘空。

转发全部失败,报 UNAVAILABLE

机器上设了 http_proxy 之类的环境变量,gRPC 拿它去连内网节点然后失败。 IPClick 的转发 channel 已经带了 grpc.enable_http_proxy = 0,所以这条通常不会再出现; 真遇上就先把这些环境变量清掉复现一次,确认是不是它。

集群令牌对不上

先用集群设置页的「测试连接」。 它能直接告诉你是"连不上"还是 "连上了但鉴权不通过"——这两种的排查方向完全相反,而只看健康检查是分不出来的 (它免鉴权)。见 Web 管理端 · 测试连接

每台的令牌是派生的,各不相同:

token = HMAC-SHA256(secret, "ipclick-node:" + node_id)
  • 所有节点的 IPCLICK_CLUSTER_SECRET 必须完全一致
  • 每台的 self_id 必须和 nodes 里的 id 对得上(大小写敏感

ipclick config-info 会显示密钥是否配了、来自哪里。

Web 管理端

打不开

  1. 起服务时带了 -w[WEB].enabled = true
  2. 默认只监听 127.0.0.1,远程访问要走 SSH 隧道: ssh -L 9527:127.0.0.1:9527 user@server

密码每次重启都变

没配 IPCLICK_WEB_PASSWORD,所以每次随机生成。写进 .env 就固定了。

请求流页面空的

[TRACE]
memory_size = 500        # 别设成 0
sqlite_enabled = true    # 要查历史必须开

不开 SQLite 的话只有内存里最近 500 条,进程重启即丢。

日志刷"链路记录队列已满"

写盘跟不上请求速率。三个方向:

[TRACE]
queue_size = 20000       # 加大队列(治标)
only_errors = true       # 只记失败(最有效)
sqlite_enabled = false   # 干脆关掉落盘

或者把 db 放到更快的盘上。记录丢弃永远不会反压业务请求——这条日志是提醒你 数据不完整,不是服务出问题了。

改了配置没生效

配置页每项都标了是否需要重启。日志级别、调试模式、only_errorsrecord_url 是热生效的;max_workers、监听地址、引擎这些要重启。

节点列表是例外:集群设置页保存即时生效,新节点马上参与转发轮询,不用重启。

装了依赖,Web 端还显示"未装"

探测是每次现查的(importlib.util.find_spec),但结论在一次"刷新"之间是缓存的 ——去 /components 页点一下「刷新状态」即可,不用重启。装 / 卸完成后也会自动刷。

如果点了刷新还是"未装",多半是装到别的环境去了。核对一下:

ipclick config-info      # 看它跑在哪个解释器上

/components 页点按钮装则不会有这个问题——那条命令绑定当前解释器。

同一目录起了多个实例,链路记录 / 日志混在一起

多进程写同一个 SQLite 库或同一个日志文件不报错(WAL 模式下多写者是合法的), 所以你看到的是多个实例合并后的数据,而界面上完全看不出来。

给两个路径加 {port} 占位符:

[TRACE]
sqlite_path = "ipclick-trace.{port}.db"

[LOG]
output = "logs/{port}/app.log"

再用 --port / --web-port 把两个端口都岔开:

ipclick run --port 9528 --web-port 9531 -w

检测到库被别的进程占用时会打一条明确告警,但那是兜底,不是解法。 详见配置体系

日志文件没出现在我指定的目录里

/ 结尾、或指向一个已存在的目录,都会被正确当成目录处理(文件名用 ipclick.log)。 两者都不满足时,logs 会被当成"缺扩展名的文件名",改写成同级logs.log

所以先去同级目录找一下 logs.log;要写进目录就把路径写成 logs/,或者先把那个目录建出来。

依赖与安装

zsh 报 no matches found: ipclick[niquests]

方括号被当成 glob 了:

pip install "ipclick[niquests]"

适配器 'httpx' 已移除

httpxrequests 这两个适配器不存在,用 niquests 代替:

d.get(url, adapter="niquests")   # 能力覆盖 httpx,还多支持 HTTP/3
d.get(url)                       # 要指纹伪装就用默认的 curl_cffi

还是定位不了

打开 debug 日志(会打完整请求内容,包括头和 cookie):

ipclick run -v                   # 最省事的一次性开法

或者写进配置 / 环境变量(要长期开着就用这两个):

[GENERAL]
debug = true

或者 IPCLICK_LOG_LEVEL=debug

run -v 对整个进程生效:verbose 标志会一路传到 IPClickServer (fork 出的每个 worker 各自初始化时也带着这个值),并参与服务端真正生效的那次 日志初始化,把级别强制为 DEBUG——服务端构造时会按同一个 logger 名重新初始化一次日志, -v 参与的正是这一次,所以级别不会在建完 server 之后又退回 [LOG].level

开 issue 时请附上:

  • ipclick --version
  • ipclick config-info 的输出(先把机密涂掉——虽然它本来就不打印机密值)
  • 相关日志片段
  • 一段能复现的最小代码

下一步

Clone this wiki locally