-
Notifications
You must be signed in to change notification settings - Fork 0
Troubleshooting
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。
按顺序排除:
-
[SERVER].host是不是127.0.0.1而客户端在别的机器上 - 防火墙
- TLS 一边开一边没开
-
http_proxy/HTTPS_PROXY环境变量把 gRPC 连接也劫持了
第 4 条特别隐蔽:机器上设了代理时,gRPC 会尝试用它连内网地址然后失败。
IPClick 的内部 channel 已经带了 grpc.enable_http_proxy = 0,
但你自己建的 channel 要自己加。
传输层:未启用(明文)
未配置鉴权令牌,任何能连到本端口的调用方都可以使用本服务。
这是有意的,不是 bug。 只监听本机的话可以忽略;监听在别的地址上请看安全。
先看 ipclick config-info:它会逐项显示每个机密来自哪里(环境变量 / 配置文件 /
未配置)。如果它说"未配置"而你确信写了,按这三条查:
-
.env只从启动服务的那个工作目录读,不向上递归。 在别的目录ipclick run, 仓库根目录那份.env就不参与,见配置体系 · 优先级。 -
值留空等于没配。 空串和只有空白的值都按"未配置"处理(机密值读进来会先
strip())。 -
同名的真实环境变量压过
.env,所以config-info显示"环境变量"而值不对时, 要改的是那个变量而不是.env。(反过来,环境变量是空串时不算已设置,.env照样生效。)
BOM 不是原因:.env 按 utf-8-sig 读取,Windows 记事本另存为 "UTF-8"、
PowerShell 的 Set-Content 默认加上的那个 BOM 会被吃掉,第一个变量不会因此变成
IPCLICK_AUTH_TOKEN 这种带不可见前缀的名字而静默失效。所以带 BOM 的文件可以直接用,
不必特意另存为"UTF-8(无 BOM)"。
[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 发信号。
传输层的问题:DNS、连接被拒、TLS 握手失败、超时。resp.error 里有具体原因。
对方封 IP 或封指纹的话,试试换适配器:
d.get(url, adapter="browser") # 真浏览器
d.get(url, impersonate="chrome124") # curl_cffi 指定指纹[SECURITY].allowed_schemes 只放行 http / https。这是 SSRF 防护的一部分,
见安全。
注意拦的位置:这条是服务端的策略,所以异常要等服务端回话才抛。客户端只拦
URL 前缀,d.get("file:///etc/passwd") 压根出不了本机,抛的是 ValidationError。
两条都想接住就抓 ValidationError——URLNotAllowedError 是它的子类。
按 host 限流没等到额度。要么放宽:
[DOWNLOADER.concurrency]
per_host_max_concurrent = 20
per_host_wait_timeout = 60要么就是这个限流本来就该生效——你确实在打同一个 host 太猛。
依次看:
-
resp.trace.attempts—— 是不是在反复重试 -
resp.trace.queued_ms—— 是不是卡在按 host 限流的队列里 - 浏览器适配器 → 看下一节
-
timeout是单次尝试的上限,总时长要乘上重试次数,见 性能
DEADLINE_EXCEEDED 刻意不在客户端重试——请求可能已经在服务端执行了,
只是回复没赶上,重发一个 POST 就是重复下单。
服务端这边有对应处理:调用方已经放弃时不再继续执行,避免白干。
pip 包装了,浏览器本体没下。这是两步安装:
python -m camoufox fetch # camoufox
patchright install chromium # patchright
playwright install chromium # playwright刻意不自动下载,见安装。
包本身没装:
pip install "ipclick[camoufox]"按可能性排序:
-
内存不够在换页。 camoufox 尤其吃内存。把
[BROWSER].max_pages调到 1~2。free -m看 swap 有没有在动——在动就是这个原因。 -
wait_until = "networkidle"碰上了轮询页面。 网络永远不会静默, 每个请求都耗满page_load。改成load。 -
没拦资源。 确认
block_resources至少拦了image/media/font。 - 每次都在冷启动。 浏览器实例应该复用;如果日志里频繁出现启动浏览器, 说明实例在被反复丢弃——通常是进程被 OOM killer 杀了,还是回到第 1 条。
正常热路径是 200~300 ms。慢到分钟级的话,基本就是上面四条之一。
[BROWSER]
no_sandbox = true # 同时会带上 --disable-dev-shm-usage容器默认 /dev/shm 只有 64 MB,Chromium 会崩;容器里通常也没有 user namespace。
两种可能:
- 服务端
[BROWSER].allow_scripts = false(默认) - 脚本有语法错误 —— 被判为参数错误,不重试(语法错重试三次还是语法错)
-
[GENERAL].mode是不是还是standalone -
forward = "on"时nodes里有没有列上本机自己 - 看
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)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 不会——目标站点慢是任务的问题,不是节点的问题。
把它算成节点故障的话,抓一个慢站点就能把整个集群摘空。
机器上设了 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 会显示密钥是否配了、来自哪里。
- 起服务时带了
-w或[WEB].enabled = true吗 - 默认只监听
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_errors、record_url
是热生效的;max_workers、监听地址、引擎这些要重启。
节点列表是例外:集群设置页保存即时生效,新节点马上参与转发轮询,不用重启。
探测是每次现查的(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/,或者先把那个目录建出来。
方括号被当成 glob 了:
pip install "ipclick[niquests]"httpx 和 requests 这两个适配器不存在,用 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的输出(先把机密涂掉——虽然它本来就不打印机密值) - 相关日志片段
- 一段能复现的最小代码