深夜十一点半,CI 流水线卡在最后一步,日志窗口里滚动着同一行字:Retrying (Retry(total=4, connect=None, read=None, redirect=None, status=None)) after connection broken by 'ReadTimeoutError'。五分钟过去,total从 4 降到 0,然后报错退出,第二天早上重新跑一次,又莫名其妙地过了。这种"重跑就好、不重跑就废"的体验,几乎每个写过 Python 包安装脚本的人都遇到过。这篇就来把 Python 包安装里那个最烦人的Retrying拆开看:它到底在重试什么、5 种最常见的触发原因分别长什么样、每一种该怎么用参数和配置真正解决掉,而不是靠反复重跑来赌运气。无论你是刚学会pip install的新手,还是天天维护 CI 流水线的老手,下面这套排查顺序和配置模板都可以直接拿去用。
1. 从一条卡住的日志说起:Retrying 究竟在重试什么
1.1 这行字不是 pip 在摆烂,是 urllib3 在礼貌地报点
很多人看到Retrying的第一反应是"pip 又抽风了",其实这行字根本不是 pip 自己打印的,而是它底层的 HTTP 客户端库在执行重试策略时输出的过程日志。完整的形态通常是这样:
WARNING: Retrying (Retry(total=4, connect=None, read=None, redirect=None, status=None)) after connection broken by 'ReadTimeoutError("HTTPSConnectionPool(host='...', port=443): Read timed out. (read timeout=15)")': /simple/somepackage/拆开看几个关键信息:total=4说明还剩 4 次机会(默认总共 5 次),read timeout=15说明这次是读取超时,触发阈值是 15 秒,最后的/simple/somepackage/是这次请求的路径。这段日志的信息量其实非常大——它同时告诉了你剩余重试次数、失败类型、超时阈值、请求目标。可惜绝大多数人直接把它当成噪声划过去了,然后去网上搜"pip 安装失败怎么办",得到一堆互相矛盾的答案。
我自己的习惯是:看到Retrying先不要动,把这一整段连同下面的Traceback一起复制出来,先定位是ReadTimeoutError、ConnectionResetError还是SSLError。这三种对应的处理路径完全不同,混着治只会浪费时间。
1.2 下载阶段的卡顿和解析阶段的卡顿,长得完全不一样
一个容易混淆的点:pip install其实分成两段完全独立的过程,卡在不同位置,病因天差地别。
第一段是索引查询和元数据获取。pip 会去索引源请求/simple/<包名>/这个页面,拿到所有可用版本列表,再逐个下载候选版本的元数据(.metadata文件或整个 wheel 的头部)来判断依赖关系。这一段的特点是请求次数多、单次数据量小、但极度依赖往返延迟。如果卡在这里,你会看到大量针对同一个包名的短请求反复重试。
第二段是实际下载与安装。选定版本后,pip 去拉完整的 wheel 或源码包,这一段单次请求数据量大、耗时长,容易撞上读取超时或者连接被中途掐断。
区分方法很简单:在命令后面加-v,日志里会出现Looking in indexes:和Collecting xxx这样的行。如果卡在Collecting之前反复重试,是索引查询问题;如果Collecting之后开始出现百分比进度然后中断,是下载问题。这两类问题的解法在后面的章节里会分别展开。
1.3 先学会读那三行关键日志
真正需要盯住的其实只有三行:
| 日志片段 | 含义 | 指向的排查方向 |
|---|---|---|
Read timed out. (read timeout=15) | 连接建立了但数据迟迟不来 | 超时阈值太小、链路抖动、包太大 |
ConnectionResetError(104)/Connection aborted | 连接被对端或中间设备掐断 | 出口网关空闲连接回收、镜像站限流 |
SSLError(...CERTIFICATE_VERIFY_FAILED...) | 证书校验没过 | 系统时间、CA 证书包、内网自签证书 |
Could not find a version that satisfies the requirement | 索引里确实没有这个版本 | 源不对、版本号写错、包被撤下 |
把这张表记住,你已经能在 30 秒内判断出该往哪个方向查了。剩下四节,就是逐条把每种原因讲到能落地操作的程度。
2. 原因一:timeout 和 retries 在最不该省的地方被默认值绑住了
2.1 15 秒 × 5 次背后的时间账
pip 的--timeout默认值是 15 秒,--retries默认值是 5 次。这个组合在十年前的网络环境下是合理的,放到今天就是灾难——尤其是在拉取体积几十兆甚至上百兆的 wheel 包时。算一笔账:一次下载如果稳定速度只有 200KB/s,一个 40MB 的包需要 200 秒,而读取超时的判定是"连续 15 秒没有收到任何数据"。只要中间有一段网络抖动导致数据停顿超过 15 秒,这次下载就作废,从头再来。5 次重试全部失败的概率,在跨地域链路上其实相当高。
更麻烦的是,重试是从头开始的,没有断点续传的保证。所以你会看到进度条走到 70% 突然归零重来,反复几次之后报错。用户感知就是"pip 一直在 Retrying",实际上每一次重试都真的在下载,只是每次都功亏一篑。
提示:把超时理解成"数据停顿容忍时间"而不是"总下载时间",很多困惑就解开了。一个 500MB 的包正常下载 20 分钟完全没问题,只要中间不出现连续 15 秒的空窗。
2.2 参数该落在哪一层:命令行、环境变量还是配置文件
调这两个参数有三个落点,优先级从高到低是命令行、环境变量、配置文件。临时排查用命令行,长期生效走配置文件,CI 环境里用环境变量最方便:
# 临时单次生效 pip install somepackage --timeout 60 --retries 10 # 环境变量(CI 里最常用) export PIP_DEFAULT_TIMEOUT=60 export PIP_RETRIES=10 # 配置文件里写死 pip config set global.timeout 60 pip config set global.retries 10具体调成多少,我一般的经验值是:普通开发机 60 秒超时 / 10 次重试,CI 环境 30 秒超时 / 3 次重试。方向是反的,原因是 CI 环境最怕的不是失败而是慢——一个卡住 15 分钟的构建比一个 2 分钟就失败退出的构建糟糕得多。CI 里重试次数少一点,快速失败,把重试交给流水线本身去做,反而更划算。
2.3 大包和 CI 应该用完全不同的策略
对于已知体积大的包(带完整运行时的那类),单独给它一套参数更省心。可以先把包下载到本地,再离线安装,这样即使后面安装环节出问题也不用重新下载:
pip download somepackage -d ./wheels --timeout 120 --retries 20 pip install --no-index --find-links ./wheels somepackage--no-index加--find-links这个组合是我在弱网环境里的常用招数:彻底跳过索引查询阶段,所有包都从本地目录找,把网络依赖压到最低。顺带一个小技巧,pip download出来的 wheel 可以塞进构建缓存,下次构建的命中率会高很多。
3. 原因二:索引源配置的层层叠加与互相打架
3.1 一条 pip config list 能省下两个小时
我见过太多"pip 行为诡异"的案例,最后发现是配置文件里躺着一行半年前加的index-url。诊断第一步永远是:
pip config list pip config debugpip config list输出的是当前实际生效的所有配置项,pip config debug会额外告诉你它读了哪些文件、每个文件是否存在。这两条命令加起来不到两秒,但能直接排除掉一大半"玄学问题"。有一次某位同学反馈说他本地怎么都装不上某个包,跑完pip config debug发现配置文件里写的是一个早就下线的内网地址,他自己完全不记得改过。
3.2 四个配置层级和它们的覆盖顺序
pip 的配置不是只有一份,而是四个层级依次叠加,后读的覆盖先读的:
| 层级 | Linux / macOS 位置 | Windows 位置 | 说明 |
|---|---|---|---|
| 全局 | /etc/pip.conf | C:\ProgramData\pip\pip.ini | 系统级,装了多个 Python 都会读到 |
| 用户 | ~/.config/pip/pip.conf | %APPDATA%\pip\pip.ini | 最常见,pip config set默认写这里 |
| 虚拟环境 | $VIRTUAL_ENV/pip.conf | %VIRTUAL_ENV%\pip.ini | 只在这个 venv 里生效,优先级最高 |
| 环境变量 | PIP_* | PIP_* | 覆盖上面所有文件 |
这个顺序解释了很多"为什么我在 A 环境能装、B 环境不能装"的问题——两边读到的配置文件根本不是同一份。虚拟环境那一层特别值得利用:给某个项目单独放一份pip.conf在 venv 目录下,就能做到项目级的源隔离,不污染全局。
3.3 extra-index-url 的隐性代价
--extra-index-url看着很美:主源加一个补充源,两边都能找。但它的实现方式是同时向所有索引源发请求,然后把结果合并。这意味着每个包的查询开销直接翻倍,在往返延迟高的链路上,感知上就是"变慢了一倍"。
更隐蔽的坑是,如果两个源里存在同名但内容不同的包,pip 会按版本号选择,你可能装到一个不是你以为的那个。我在内部项目里见过因为这个问题引入了来源不明的同版本包,排查了大半天。
我的建议很明确:能用单一源解决的问题,就不要用 extra-index-url。确实需要多源时,明确指定哪个包从哪个源来:
pip install somepackage --index-url https://mirrors.example.internal/simple pip install otherpackage --index-url https://pypi.example.org/simple多写几行命令,换来的是行为可预期。这笔买卖划算。
4. 原因三:证书校验失败被伪装成了网络超时
4.1 CERTIFICATE_VERIFY_FAILED 的三种常见面孔
证书问题最坑的地方在于,它有时候并不会老老实实报出SSLError,而是表现为握手阶段反复超时,最后给你一个Retrying加ReadTimeoutError。真正的原因藏在更上面几行日志里。
三种典型场景:
- 系统时间不对。虚拟机快照恢复、容器镜像时间戳异常,都会让系统时间偏离真实时间。证书有生效区间,时间不对直接校验失败,报错里会出现
certificate is not yet valid或certificate has expired。 - CA 证书包过旧。
certifi这个包就是专门管证书的,版本太老会导致新的根证书不被认识。 - 内网源使用自签证书。公司自建的镜像服务用内部 CA 签的证书,本机没有把这个 CA 加进信任链。
排查顺序建议:先date看一眼时间对不对,一秒钟的事,但能解决相当比例的问题。然后升级certifi:
python -m pip install --upgrade certifi python -c "import certifi; print(certifi.where())"第二条命令会打印出当前使用的证书包路径,对于排查"到底读的是哪个证书包"非常有用。
4.2 让 pip 认识你的内部 CA
如果是内网自签证书,正确的做法是把内部 CA 证书导入信任链,而不是直接关掉校验。有两种做法:
# 方式一:通过环境变量指定额外的 CA 包 export PIP_CERT=/path/to/internal-ca-bundle.pem # 方式二:直接把 CA 追加到 certifi 的包里(注意升级 certifi 后要重做) cat internal-ca.crt >> $(python -c "import certifi; print(certifi.where())")方式一更干净,推荐优先用。
4.3 trusted-host 的边界在哪里
--trusted-host这个参数很多人当万能药在用,一遇到证书问题就加上去。它确实能绕过校验让安装继续,但代价是这个域名下的所有内容都不再验证来源。在受控的内网环境里,为了效率这么做可以接受;在公网环境里,这是在拿依赖安全换便利。
我的底线是:trusted-host只写内网镜像的具体域名,绝不写通配,也绝不在公网源上用它。配置写成这样:
[global] index-url = https://mirrors.example.internal/simple trusted-host = mirrors.example.internal域名写死,范围可控。真出了事,至少知道影响面在哪里。
5. 原因四:DNS 解析与 IPv6 优先级带来的首包黑洞
5.1 解析慢和连接慢,症状很像但根因不同
Retrying还有一种容易被误判的成因:DNS 解析阶段就卡住了。表现是 pip 启动之后长时间没有任何输出,然后突然开始刷重试。这时候看超时日志会发现connect timeout而不是read timeout——注意区分这两个词,connect是连都连不上,read是连上了但拿不到数据。
验证方法很直接:
# 看域名解析耗时 time getent hosts mirrors.example.internal # 更细致的解析过程 dig +stats mirrors.example.internal如果解析本身就花了好几秒,问题就不在 pip 而在 DNS。常见原因是配置了多个 DNS 服务器,第一个不响应,要等超时才轮询到第二个。
5.2 IPv6 优先策略在只有 IPv4 出口时的表现
这是一个非常经典的坑:系统默认优先使用 IPv6,但实际网络环境只有 IPv4 出口,或者 IPv6 路由不通。表现就是每次连接都先尝试 IPv6,等超时(通常几秒到几十秒)再回落到 IPv4。单个请求看不出来,但 pip 一次安装要发几十上百个请求,累积起来就是灾难级的慢。
判断方法:
# 强制走 IPv4 试试,如果速度立刻正常,基本就确认了 pip install somepackage --timeout 30 -v 2>&1 | head -50如果确认是这个原因,可以在系统层面调整地址优先级(/etc/gai.conf里把 IPv4 的优先级调高),或者在容器/CI 环境里直接禁用 IPv6。这属于系统配置层面的处理,改完对所有命令行工具都生效,比在 pip 上打补丁更彻底。
5.3 三条能立刻验证的命令
遇到"卡在连接阶段"的情况,我会按这个顺序敲三条命令,通常两分钟内能定性:
# 1. 通不通 ping -c 4 mirrors.example.internal # 2. 端口通不通(443 是否可达) curl -v --max-time 10 https://mirrors.example.internal/simple/ -o /dev/null # 3. HTTP 层面通不通(不带 TLS,看是否有中间拦截) curl -v --max-time 10 http://mirrors.example.internal/simple/ -o /dev/null第二条命令的-v输出里会明确写出 DNS 解析耗时、TCP 连接耗时、TLS 握手耗时,这三段哪一段慢一目了然。这比盲猜高效太多了,我现在基本不再用别的工具做这个判断。
6. 原因五:看着像网络问题,其实是包和元数据在作怪
6.1 版本根本不存在时,pip 会把所有源翻个遍
有一类"重试"其实和网络质量毫无关系。当你写了一个不存在的版本号,比如把2.1.0写成了2.1.0.0,pip 不会立刻告诉你"没有这个版本",而是会去索引里把所有版本列表拉一遍,尝试各种匹配,最后才报Could not find a version that satisfies the requirement。在源响应慢的情况下,这个过程本身就会产生大量重试日志,让你误以为是网络问题。
快速确认的办法是先看有哪些版本可用:
pip index versions somepackage如果这条命令本身也报错或超时,那确实是源的问题;如果它能正常列出所有版本,那问题就在你写的版本号上。另外注意--pre参数:不加上它,预发布版本是不会被选中的,有些人写了个 beta 版本号却忘了加--pre,然后对着"找不到版本"的报错发呆。
6.2 需要本地编译时的长时间"假死"
安装某些包含 C 扩展的包时,如果索引里没有匹配当前平台和 Python 版本的预编译 wheel,pip 会退回到源码包自己编译。这个过程的日志会长时间停住不动,看起来就像卡死了。
判断是不是这个情况,看日志里有没有出现Building wheel for xxx或者Running setup.py install。如果有,那它其实在正常工作,只是慢。
避免它的办法有两个。一是优先选预编译包:
pip install somepackage --only-binary :all:这条命令的意思是"只接受预编译包,装不上就报错"。听起来很严格,但好处是快速失败——你会立刻知道当前平台没有现成的 wheel,而不是等二十分钟编译完才发现缺了编译工具链。确认确实需要编译时,再去掉这个参数。
另一个是检查当前环境支持的 wheel 标签,确认是不是环境本身太特殊(比如很新的 Python 版本、非主流架构):
pip debug --verbose输出里的Compatible tags列表会列全当前环境能接受的所有 wheel 标签。对着这个列表去看某个包提供了哪些标签,匹配不上就是匹配不上,别硬等。
6.3 依赖解析回溯:pip 自己的另一种"重试"
还有一类耗时发生在依赖解析阶段,日志里会出现Collecting和Downloading交替,然后突然开始回溯——pip 发现版本组合冲突,回退到更早的候选版本重新尝试。依赖树复杂的时候,这个过程可能尝试几十上百种组合,表现出来的就是"安装特别慢,还一直重复下载同样的包"。
这个问题在较新的 pip 版本里改善了很多,所以第一条建议永远是升级 pip 本身:
python -m pip install --upgrade pip如果升级后依然慢,可以先用--no-deps只装主包,再单独处理依赖,把问题范围缩小:
pip install somepackage --no-deps -v顺带说一句,用python -m pip而不是直接敲pip,能避免多环境下的解释器错配——这个习惯我建议所有人在一开始就养成。
7. 我自己的排查顺序:从三分钟定位到十分钟解决
7.1 一张按成本排序的排查表
真遇到问题的时候,按成本从低到高逐条排除,比上来就改配置高效得多:
| 顺序 | 动作 | 耗时 | 能排除的问题 |
|---|---|---|---|
| 1 | pip config list+pip config debug | 2 秒 | 源配置错误、残留配置 |
| 2 | date看系统时间 | 1 秒 | 证书有效期校验失败 |
| 3 | curl -v --max-time 10 <源地址> | 10 秒 | DNS、连通性、TLS 握手 |
| 4 | 加-v重跑,看是 connect 还是 read 超时 | 1 分钟 | 连接问题 vs 下载问题 |
| 5 | pip index versions <包名> | 10 秒 | 版本号写错、包不存在 |
| 6 | --only-binary :all:试一次 | 30 秒 | 无预编译包导致的编译等待 |
| 7 | pip cache purge后重试 | 1 分钟 | 缓存损坏 |
| 8 | 调--timeout和--retries | 1 分钟 | 链路抖动、大包传输 |
注意第 7 条那个缓存问题。pip cache purge平时没人想起来,但缓存文件损坏确实会导致反复下载同一个包然后校验失败。缓存的默认位置:Linux 在~/.cache/pip,macOS 在~/Library/Caches/pip,Windows 在%LocalAppData%\pip\Cache。用pip cache dir可以直接问出来,用pip cache info能看到占用多少空间。磁盘满的时候 pip 的报错也是千奇百怪,顺手看一眼磁盘剩余空间不会错。
7.2 一份可以直接抄的配置模板
把我这几年稳定用下来的一套配置贴出来,按自己的镜像地址替换域名即可:
[global] index-url = https://mirrors.example.internal/simple trusted-host = mirrors.example.internal timeout = 60 retries = 10 disable-pip-version-check = true [install] prefer-binary = true几个字段的用意解释一下。prefer-binary让它优先挑预编译包,能绕开大量编译等待。disable-pip-version-check关掉那个烦人的升级提示,同时少一次额外的网络请求——在 CI 里这个请求偶尔也会卡。timeout和retries按前面第 2 节的经验值来。
写进配置文件有两种方式:直接编辑对应路径的文件,或者用pip config set命令。后者更安全,因为它会写到正确的用户级路径下,不会因为你记错路径而写了个永远不会被读到的文件。虚拟环境里想单独覆盖,就在$VIRTUAL_ENV/pip.conf里放一份精简版,只写要覆盖的那几项。
7.3 CI 环境里那几条容易踩的额外注意事项
CI 和本地开发机的排查思路要分开。CI 里我踩过的几个坑,列出来省得别人再踩:
第一,构建机没有持久化缓存。每次构建都从零下载全部依赖,命中不了任何缓存。解决办法是在流水线里显式缓存pip cache dir返回的目录,key 用requirements.txt的哈希值,这样依赖没变就能直接复用。
第二,构建机的时间和时区。容器镜像的时间戳偶尔会漂,导致证书校验失败,而报错信息又指向网络超时。在流水线开头加一行打印时间的命令,能省掉大量排查时间。
第三,不要在 CI 里用--trusted-host兜底。本地为了图快加上的参数,一旦进了 CI 配置就会一直留着,几个月后没人知道它为什么在那儿。如果确实需要,写在 CI 专用的配置文件里,并加注释说明原因。
第四,给安装步骤设置整体超时。pip自己的重心放在单次请求上,整个安装过程可能因为依赖解析而耗时极长。流水线层面加一个总时长限制,超时直接失败,比卡在那里占着构建机划算得多。
最后分享一个小习惯:我习惯在项目根目录留一个scripts/install.sh,把pip install -r requirements.txt加上常用的--timeout、--retries、--prefer-binary全部封装进去。团队里新人不看文档也能跑对参数,也不会因为某个人本地配了个奇怪的源而装出不一样的结果。这种把"正确做法"固化成脚本的思路,比在群里反复解释"你要加个参数"有效得多——踩过的坑,最好让它只被踩一次。