如何用原始 API 在 k6 负载测试中识别验证码

k6 captcha - How to Solve CAPTCHA in k6 Load Tests With the Raw API

k6 里的验证码步骤必须走原始 HTTP API,因为 k6 不是 Node,SDK 没法装进去。这部分很简单:两次调用,提交和轮询。真正决定你这次测试有没有意义的,是识别放在哪里跑。放进 default 函数,每个虚拟用户在每次迭代里都要识别一次,这测的是识别工具而不是你的应用,还会把一台从来没打算扛这种量的机器打满。放进 setup 阶段,你能拿到干净的数字,但有一个必须老实交代的限制。

你需要什么

  • k6,任意较新的版本。没有包要装,因为根本没地方可装。
  • 你要测试的那个受保护端点的 sitekey 和页面 URL。
  • 如果 k6 跟识别工具跑在同一台机器上,让 CapSkip 以 Local 模式运行;如果跑在负载生成器上或者在 CI 里,则以 Server 模式运行。两者的说明都在 连接设置.

什么时候识别才是正确的选择

在写任何代码之前,先把这句话说清楚。如果被测站点是你自己的,更好的做法通常是直接放行负载生成器:把它的地址加入白名单,或者发一个你的预发布环境能识别的请求头,彻底跳过这个小组件。你想测的是你的应用,而每识别一次挑战,加上去的延迟都不属于你的应用。

有两种情况下识别才是对的。要么你要打的端点受到保护,而这个保护不是你能控制的;要么受保护的路径本身就是被测对象,跳过它就等于测了一条真实流量根本不会走的路线。这两种情况都是真实存在的,本文剩下的内容就是为它们写的。

为什么 SDK 在这里用不了

k6 脚本看起来像 JavaScript,但跑的并不是 Node。require 的实现是 k6 自己写的,文档上把这个限制说得很直白:它只加载 k6 内置模块、本地文件和远程脚本,不支持 Node 的模块解析算法。没有 npm,没有 node_modules,没有 fs,也没有 crypto。所以 CapSkip 的包没法被导入,你能想到的其他包同样不行。

这没有听起来那么麻烦。这个 API 兼容 2captcha,只有两个端点,k6 自带的 http module 用大约十五行代码就能搞定。如果你的负载生成器其实是个普通的 Node 进程, Node.js 验证码识别页面 讲的是有 SDK 可用的那个客户端。

第一步:把识别逻辑写成一个普通函数

把请求提交到 in.php,拿到一个 ID,然后轮询 res.php 直到结果返回。要求返回 JSON,这样你读的是字段,而不是靠一个竖线字符去拆字符串。

// No install step. Both modules are built into k6.
import http from 'k6/http';
import { sleep } from 'k6';

const SOLVER = 'http://127.0.0.1:8080';
const KEY = 'YOUR_API_KEY';

function solve(sitekey, pageurl) {
  const submitted = http.post(SOLVER + '/in.php', {
    key: KEY,
    method: 'userrecaptcha',
    googlekey: sitekey,
    pageurl: pageurl,
    json: '1',
  });

  return poll(submitted.json('request'));   // the captcha ID
}

注意参数名。reCAPTCHA 要的是 googlekey,而 Turnstile 配合 turnstile 方法要的是 sitekey。传错参数名通常就是 ERROR_GOOGLEKEY 响应的成因。

function poll(id) {
  const url = SOLVER + '/res.php?key=' + KEY +
              '&action=get&json=1&id=' + id;

  // Roughly three minutes of headroom at five seconds a try.
  for (let i = 0; i < 36; i++) {
    sleep(5);
    const res = http.get(url);
    if (res.json('status') === 1) {
      return res.json('request');   // the token
    }
  }
  throw new Error('solve did not finish in time');
}

还没出结果时,返回的是状态为零的 CAPCHA_NOT_READY,这就是为什么循环要检查 status 字段,而不是把任何响应都当成已完成。结果只能读取一次,所以拿到 token 之后要保存好。

第二步:在 setup 阶段完成识别

k6 会在任何虚拟用户启动之前先跑一次 setup,并把它的返回值传给 default 函数。这正是这里需要的结构。在这一步完成识别,把 token 分发出去,就没有哪个 VU 需要在自己的迭代里为识别买单。

const PAGE = 'https://example.com/page-with-recaptcha';
const SITEKEY = 'YOUR_SITEKEY';

export const options = {
  vus: 10,
  duration: '90s',
  setupTimeout: '5m',   // the 60s default expires mid solve
};

export function setup() {
  // One token per VU. They are single use.
  const tokens = [];
  for (let i = 0; i < 10; i++) {
    tokens.push(solve(SITEKEY, PAGE));
  }
  return { tokens };
}

setupTimeout 这一行比看起来更重要。k6 默认只给 setup 60 秒,而单单一次 reCAPTCHA 识别就可能用掉其中的大部分。十次识别根本塞不进去,这个阶段会被强制终止,报错指向的是 setup,而不是你会想到去查的地方。

在这上面继续搭之前,有个限制值得先知道

token 只能用一次,有效期大约两分钟,这两点在这里都会咬人。只能用一次意味着你至少需要跟受保护请求同样多的 token,所以一个要打受保护端点一千次的测试就需要一千次识别,这已经不太像一次负载测试了。两分钟意味着你在 setup 里识别出来的 token,等第一个 VU 启动时就已经开始老化,所以一次长时间的压测,大部分时间提交的都会是过期的 token。

所以这个模式适合针对受保护端点的一次短时突发测试,而不是三十分钟的持续压测。 关于 reCAPTCHA token 有效期有多长的那篇文章 讲清了这些时间。如果你需要通过 widget 施加持续负载,前面提到的白名单方案才是唯一靠谱的办法。

第三步:别让识别工具混进你的指标

凡是你用 k6 的 http module 发出去的请求,都会算进 http_req_duration,识别调用也不例外。一个悄悄混进了十五秒轮询的 p95,不是一个你能拿来做决策的数字。给你关心的请求打上标签,把阈值指向那个标签。

export const options = {
  vus: 10,
  duration: '90s',
  setupTimeout: '5m',
  thresholds: {
    // Measure the app, not the solve.
    'http_req_duration{target:app}': ['p(95)<500'],
  },
};

export default function (data) {
  const token = data.tokens[__VU - 1];

  http.post(PAGE, { 'g-recaptcha-response': token }, {
    tags: { target: 'app' },
  });
}

__VU 变量给虚拟用户编号是从一开始数的,用它去索引 token 数组,就能让每个 VU 拿到自己的那个。相比把识别藏到 k6 看不见的地方,打标签是更干净的解法,因为等你想知道识别到底花了多久时,这些识别请求依然会出现在输出里。

识别工具必须放在哪里

负载生成器很少是你的桌面机。k6 跑在 CI 里、一台专用机器上,或者一个托管服务上,而不管是哪一种,它们的回环地址都不是你识别工具所在的那台机器。

模式监听地址适用场景
本地127.0.0.1,仅限该设备k6 和识别工具在同一台机器上
服务器你的内网地址或公网 IPCI、一组负载生成器、托管的 runner

Server 模式就是第二行里所有情况的答案:在应用里换掉监听地址,把脚本指向那台机器的地址,每个生成器就能共用同一个识别工具。如果调用方位于你自己网络之外,建议使用静态公网 IP。它依然是你自己的硬件,依然不按量计费,所以这只是改变了识别工具运行的位置,别的什么都没变。CapSkip 是 Windows 应用,所以那就是一台 Windows 机器,供你的生成器调用。有一点需要注意:不要把它放在你正在测试的那个负载均衡器后面,否则你测的就是自己的瓶颈,还测了两遍。

常见错误

你所看到的原因修复
找不到模块 ‘capskip’k6 不会解析 npm 包改用 k6 自带的 http module 去调用 API
setup() 执行超时识别耗时超过了默认的 60 秒把 setupTimeout 调高,覆盖所有识别
p95 高得离谱,但明明没有哪里慢识别调用被算进了 http_req_duration给应用请求打标签,把阈值过滤到那个标签上
后面的迭代被拒绝,前面的没事token 老化超过了有效期缩短运行时长,或者晚一点再少识别几次
每个 VU 都遭到同样的拒绝同一个 token 在多个 VU 之间被重复使用每个 VU 各自识别一个,用 __VU 做索引

完整的错误码列表,以及每种方法所需的参数,都在 CapSkip API 文档.

常见问题

我能把 CapSkip 的 SDK 装进 k6 吗?

不能。k6 有自己的模块加载器,只处理内置模块、本地文件和远程脚本,而且是故意不遵循 Node 的解析算法的,所以 npm 包用不了。这个 API 兼容 2captcha,两个端点就能顶上 SDK 原本要帮你做的所有事情,用 k6 自带的 http module 写大约十五行就够了。

我应该改成在 default 函数里识别吗?

只有在识别本身就是你的测试对象时才应该这样做。default 函数是每个 VU 每次迭代跑一次,二十个 VU 跑两分钟就是几百次识别,你的延迟数字就变成了对轮询的测量。在 setup 里识别,把 token 分发出去,让迭代只保留你真正关心的那个请求。

我的 k6 跑在一个托管服务上,它能连到识别工具吗?

能,用 Server 模式。识别工具监听的是一个网络地址而不是回环地址,生成器像调用任何其他内部服务一样,通过 API 调它。当 runner 位于你自己网络之外时,静态公网 IP 能让这一切稳定下来。打开密钥校验,给每个环境配一个自己的密钥,这样某一个泄露了,撤销它也不会影响其他环境。

我要怎么才能通过一个小组件跑一个小时的负载测试?

你做不到,至少没法靠真实 token 做到。只能用一次加上两分钟的有效期,意味着一个小时的持续负载需要源源不断的识别,而到了那个地步,被测系统实际上就变成了识别工具本身。对于你自己应用上的长时间压测,应该让负载生成器免于挑战,去测挑战背后的那条路径。

简短版结论

把 setupTimeout 调高,在 setup 阶段用 k6 自带的 http module 调用 in.php 和 res.php 来识别,给你的应用请求打上标签,让阈值仍然有意义,并把运行时长控制在 token 还没过期之前。想看爬虫那一侧的做法,请看 面向网络爬虫的验证码识别页面。负载测试是按次计费显得最荒谬的地方,因为一次突发测试就可能需要几百个 token,却创造不了任何商业价值。定价模式的差别就在这里: 验证码绕过 跑在你已经拥有的硬件上,无论你跑一次测试还是四十次,成本都一样。