一个在笔记本电脑上能跑通的爬虫,接入 CI 之后通常会遇到两种情况之一。要么有人把代理密码直接粘贴进工作流文件里”先让它跑绿再说”,要么流水线在每次推送时都执行一次完整的实时抓取,悄无声息地在周三之前就把一个月的带宽额度用光。这两种情况都是可以避免的,解决办法主要在于提前想清楚:到底哪些运行真的需要一个实时代理。
本教程涵盖代理凭据在 CI 中应该存放在哪里、如何让它不出现在日志中、如何拆分测试使大多数运行根本不接触网络,以及如何在不慌乱的情况下轮换凭据。示例使用 GitHub Actions 和 GitLab CI,搭配 Shifter 的住宅网关,但其结构可以照搬到任何运行环境。
会出什么问题
四种失败模式几乎涵盖了爬虫在 CI 中遇到的所有凭据事故:
| 失败情形 | 是怎么发生的 |
|---|---|
| 凭据被提交进仓库 | 一个 .env 文件或硬编码的代理 URL 进入了代码仓库 |
| 凭据出现在日志中 | 一行调试信息打印出了代理 URL,或者 HTTP 客户端记录了请求头 |
| 凭据暴露给不受信任的代码 | 来自团队外部的 pull request 在有权访问密钥的情况下运行 |
| 带宽被耗尽 | 每次推送都对真实网站执行一次实时抓取 |
前三种是安全问题。第四种是成本问题,同时也会让流水线变得不稳定,因为真实网站会发生变化,而你的构建不应该因为别人的页面出问题而失败。
第一步:将凭据存储为密钥,绝不写入代码
你的住宅代理用户名和密码可以在面板的套餐页面上找到。将它们存储为两个 CI 密钥:
SHIFTER_USERNAME:显示的完整用户名,例如customer-USERNAMESHIFTER_PASSWORD:密码
GitHub Actions。 在仓库的 Settings、Secrets and variables、Actions 下添加这两项。GitHub 会在日志中隐去密钥值,并且除 GITHUB_TOKEN 之外,当工作流由一个 fork 出来的仓库触发时,密钥不会被传递给 runner。这第二条规则对公开仓库很重要:外部贡献者的 pull request 无法读取你的代理密码,但也因此无法运行你的实时测试,这一点将在第三步中处理。
GitLab CI。 将两者都添加为 CI/CD 变量,标记为masked(掩码),并标记为protected(受保护),使其仅对受保护分支或标签上的流水线可用。有一个坑值得专门指出:GitLab 只能对单行、长度 8 个字符以上的值进行掩码处理。Shifter 的住宅密码可能比这更短,GitLab 会拒绝对其掩码。解决办法是把这一对值存成一个被掩码的变量:
SHIFTER_PROXY_AUTH=customer-USERNAME:PASSWORD
这个值的长度轻松超过 8 个字符,并且只使用 GitLab 在掩码变量中允许的字符。在你的代码里按最后一个冒号拆分它。
在同一次提交中把 .env 加入 .gitignore,这样本地文件就永远不会跟着凭据一起进入仓库。
第二步:在代码中构建代理 URL,绝不打印它
在运行时根据环境变量在一个地方组装代理 URL,这样定位参数和会话标志能被一致地添加,除此之外没有任何其他代码会接触原始密码:
import os
GATEWAY = "p.shifter.io:443"
def shifter_credentials():
if "SHIFTER_PROXY_AUTH" in os.environ:
username, password = os.environ["SHIFTER_PROXY_AUTH"].rsplit(":", 1)
return username, password
return os.environ["SHIFTER_USERNAME"], os.environ["SHIFTER_PASSWORD"]
def proxy_url(country=None, session=None, ttl=None):
username, password = shifter_credentials()
if country:
username += f"-country-{country}"
if session:
username += f"-sid-{session}"
if ttl:
username += f"-ttl-{ttl}"
return f"http://{username}:{password}@{GATEWAY}"
def redact(url):
# Safe to log: keeps the flags, drops the password.
creds, host = url.rsplit("@", 1)
return f"{creds.rsplit(':', 1)[0]}:***@{host}"
如果你需要查看某次运行使用了哪些标志,就打印 redact(url)。绝不要打印 URL 本身。
密钥掩码有一个值得了解的盲点。它只匹配存储的值,因此一个经过转换的值会漏网。代理身份验证是通过 Proxy-Authorization 请求头发送的,其中包含 username:password 的 base64 编码,而调试日志中打印的请求头会显示这段编码,任何 CI 掩码机制都不会识别它。在 CI 中保持关闭请求头级别的调试日志。如果你必须组装一个本身不是密钥、但很敏感的字符串,在任何东西可能打印它之前,用 GitHub 的 ::add-mask:: 命令把它注册进去。
将密钥作为环境变量传递给你的爬虫,而不是命令行参数。GitHub 自己的指南也建议尽可能避免在命令行中把密钥在进程间传递。参数很容易在进程列表中被看到,也往往最终出现在 shell 跟踪记录里。
第三步:拆分测试,使大多数运行不需要代理
这一步能消除大部分成本和大部分不稳定性。将爬虫测试划分为三个层级:
| 层级 | 检查什么 | 是否需要代理 | 何时运行 |
|---|---|---|---|
| 解析器测试 | 针对已保存 HTML 固定样本的提取逻辑 | 否 | 每次推送和 pull request |
| 实时冒烟测试 | 通过网关发出的少量真实请求 | 是 | 主分支及定时任务 |
| 完整运行 | 实际的抓取 | 是 | 有自己的定时计划,或手动触发 |
解析器测试是覆盖率的主体部分。将真实响应保存为固定样本文件,并测试你的选择器是否能从中提取出正确的字段。它们运行只需几秒钟,不产生成本,不需要任何密钥,只有在你的代码出错时才会失败。当某个网站改版时,保存一份新的固定样本,并在同一个 pull request 中更新解析器。
实时冒烟测试验证的是凭据、定位参数和连通性是否正常,而不是验证每个页面都能被解析。把它限制在几次请求以内。当密钥不存在时,它应该跳过而不是失败,而这恰恰是 fork 出来的 pull request 所面临的情况:
import os
import pytest
import requests
from scraper.proxy import proxy_url
live = pytest.mark.skipif(
not (os.environ.get("SHIFTER_PASSWORD") or os.environ.get("SHIFTER_PROXY_AUTH")),
reason="no proxy credentials in this environment",
)
@live
def test_gateway_exits_in_requested_country():
url = proxy_url(country="de")
r = requests.get("https://ipinfo.io/json", proxies={"http": url, "https": url}, timeout=30)
assert r.status_code == 200
assert r.json()["country"] == "DE"
完整运行应该放在定时计划上,而不是放在推送触发上,这样一次合并就不会触发一次生产规模的抓取。
第四步:每个任务一个会话,绝不共享
粘性会话(sticky session)将一次运行固定到一个出口 IP,这正是像分页这样的多步骤流程所需要的。Shifter 的会话 ID 可以是你选择的任意字符串,默认生命周期为 120 秒,可以用 ttl 覆盖。文档中的警告直接适用于 CI:不要在并发的工作流之间复用同一个会话 ID,因为来自不同任务、落在同一个 IP 上的请求在大多数反爬系统看来是可疑的。
CI 已经为每次运行提供了一个唯一值。使用它,如果你运行矩阵作业,再加上任务索引:
import os
run = os.environ.get("GITHUB_RUN_ID") or os.environ.get("CI_PIPELINE_ID", "local")
job = os.environ.get("JOB_INDEX", "0")
url = proxy_url(country="us", session=f"ci{run}j{job}", ttl=600)
保持 ID 为字母数字组合,因为用户名使用短横线来分隔标志。对于彼此独立的请求,干脆不使用会话,让每个请求都轮换到一个新的 IP。
第五步:在大规模运行前检查配额
一次跑到一半就因带宽耗尽而中止的定时抓取,比一次根本没开始的抓取更糟。Shifter 的用量与配额 API会返回套餐剩余的额度,因此一个预检步骤可以干净地跳过这次运行:
import os
import sys
import requests
MIN_GB = float(os.environ.get("MIN_REMAINING_GB", "5"))
r = requests.get(
f"https://shifter.io/api/v1/memberships/{os.environ['SHIFTER_MEMBERSHIP']}/usage",
params={"api_token": os.environ["SHIFTER_API_TOKEN"]},
timeout=30,
)
r.raise_for_status()
plan = r.json()["data"]
if plan["metered"] and plan["remaining_gb"] < MIN_GB:
print(f"Only {plan['remaining_gb']} GB left, resets {plan['resets_at']}. Skipping run.")
sys.exit(78)
API 令牌可以在面板的 Account、API Tokens 下生成。像密码一样把它存储为密钥:它属于你的账户,而不是某一个工作区,因此它能读取你作为成员的每一个工作区的用量。该端点的速率限制是每分钟 60 次请求,远超一次预检所需。
如果某次运行在关闭了 Extra Traffic 的情况下确实耗尽了套餐,网关会返回 509 Bandwidth Limit Exceeded。把它当作一个停止条件,而不是需要重试的东西。
第六步:在身份验证错误上快速失败
重试对瞬时网络错误是合适的,对凭据错误则是不合适的。407 Proxy Authentication Required 意味着用户名、密码或某个标志出错了,下一次尝试同样会出错。在 CI 中,围绕 407 的重试循环会把一个一秒钟就能发现的失败拖成十分钟的失败,还带着一份令人困惑的日志。让你的客户端把 407 视为致命错误,打印经过脱敏的代理 URL,然后停止。常见原因以及排查顺序在修复 407 代理身份验证错误中有介绍。
一个完整的 GitHub Actions 工作流
name: scraper
on:
push:
pull_request:
schedule:
- cron: "0 6 * * *"
workflow_dispatch:
jobs:
parser-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: pytest tests/parsers
smoke-test:
if: github.ref == 'refs/heads/main'
needs: parser-tests
runs-on: ubuntu-latest
env:
SHIFTER_USERNAME: ${{ secrets.SHIFTER_USERNAME }}
SHIFTER_PASSWORD: ${{ secrets.SHIFTER_PASSWORD }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: pytest tests/live
full-run:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
needs: smoke-test
runs-on: ubuntu-latest
concurrency: scraper-full-run
env:
SHIFTER_USERNAME: ${{ secrets.SHIFTER_USERNAME }}
SHIFTER_PASSWORD: ${{ secrets.SHIFTER_PASSWORD }}
SHIFTER_API_TOKEN: ${{ secrets.SHIFTER_API_TOKEN }}
SHIFTER_MEMBERSHIP: ${{ vars.SHIFTER_MEMBERSHIP }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: python scripts/check_quota.py
- run: python -m scraper.run
解析器测试在任何地方运行,不需要任何密钥。冒烟测试和完整运行只存在于有密钥的地方。concurrency 分组阻止两次定时运行重叠并共享一份 IP 预算。工作区 ID 并非秘密,因此它存放在一个普通的仓库变量中。
有一处调整值得做出:按目前的写法,配额脚本的退出码 78 会让该任务失败。如果你希望看到的是一次被跳过的运行而不是一次标红的运行,给检查步骤加一个 id,让它把一个标志写入 $GITHUB_OUTPUT,再用这个输出去控制抓取步骤是否执行。
轮换凭据
按计划轮换,并且在密钥可能已经泄露的任何时刻立即轮换:一份公开的日志、一位离职的承包商、一个你不确定的被 fork 出来的工作流。
在 Shifter 上,账户所有者或工作区 Admin 可以在面板的套餐页面上生成一个新的住宅密码。Viewer 和 Billing 成员则不能。网关会立即启用新密码,而旧密码在同一时刻就会失效,因此要规划好顺序:
- 暂停定时工作流,或者接受一次正在进行的运行会因 407 而失败。
- 在面板中生成新密码。
- 立即更新 CI 密钥。
- 更新同一套餐的每一个其他使用方。密码属于套餐,而不属于某一条流水线,因此其他任何使用它的地方都会在同一时刻失效。
- 重新运行冒烟测试以确认。
第 4 点正是为什么在需要轮换之前就先弄清楚一个套餐的凭据在哪些地方被使用是值得的。如果有多条独立的流水线共享一个套餐,一次轮换会同时影响它们所有。
底线
在 CI 中运行爬虫,大部分工作在于让不需要代理的运行不去接触代理。针对固定样本的解析器测试在每次推送时覆盖逻辑本身,不需要密钥,也不消耗带宽。主分支上一次小规模的实时冒烟测试证明凭据依然有效。真正的抓取按计划运行,先检查配额,使用一个没有人共享的会话 ID,并在遇到第一个身份验证错误时立即停止,而不是重试它。
凭据本身存放在 CI 密钥存储中,在一个函数里被组装,并且绝不会被打印出来,包括以 base64 形式。关于长时间运行采集任务的运维层面内容,可参见监控网页抓取流水线;关于将其分散到多个地区,可参见面向多地区流水线的住宅代理故障转移。基于浏览器的爬虫在客户端的代理配置内容见在 Selenium 和 Playwright 中配置住宅代理。