一次很隐蔽的 Cloudflare API 故障排查:不是 WAF,不是回源,而是 Minimum TLS Version
最近排查了一个让我印象非常深的故障。
现象表面上看,是一个很普通的 API 接入问题:sub2api 在不开 Cloudflare CDN 时,一切正常;一旦给域名加上 Cloudflare 代理,来自 codex 的请求就会失败。失败时,客户端报错类似:
|
|
如果只看这句报错,第一反应通常会是:
- 是不是
sub2api对流式请求支持有问题? - 是不是 Cloudflare 把请求拦了?
- 是不是回源失败了?
- 是不是接口路径被改写了?
- 是不是 CDN 不适合拿来代理这类 API?
但这次真正的根因,和这些猜测都不完全一样。
最后定位下来,问题既不在应用本身,也不在 WAF 或回源,而是在一个很容易被忽略的 Cloudflare 配置项上:
Minimum TLS Version 被设成了 TLS 1.3。
这个问题最“阴”的地方就在于,它表现得像应用层故障,但实际上请求根本还没有进入 HTTP 层,死在了客户端和 Cloudflare 之间的 TLS 握手阶段。
这篇文章想把整个排查过程、思路转折、最终证据链,以及从中得到的经验完整记录下来。以后自己再遇到类似问题,或者别人遇到“开 CDN 后源站完全没日志”的诡异现象时,至少能少走一些弯路。
一、背景:sub2api 接入 codex,没开 CDN 时一切正常
我的服务基于 sub2api,平时作为一层接口转换和聚合入口使用。不开 Cloudflare 代理时,来自 codex 的请求可以正常打到接口,整体行为稳定,没有明显异常。
后来为了统一流量入口、顺便利用 Cloudflare 的代理能力和安全能力,我把 API 域名接到了 Cloudflare 上。原本以为这只是把链路从:
codex -> sub2api
变成:
codex -> Cloudflare -> sub2api
在这个过程中,应用逻辑本身并没有变化,所以按经验来说,最多是补一些 WAF 跳过规则、缓存绕过规则,就应该可以正常跑起来。
结果实际一开代理,问题就来了。
二、事故现象:开 CDN 前正常,开 CDN 后 codex 失败,源站无日志
故障现象非常集中,也非常具有误导性。
问题域名这里匿名化为 api-a.example.com。
另外我还有一个行为正常的对照域名,匿名化为 api-b.example.com。
表现如下:
api-a.example.com开启 Cloudflare 代理后,codex请求失败- 失败报错为:
|
|
sub2api日志中没有对应请求记录- 源站应用也几乎看不到这次调用痕迹
- 我已经针对
/responses、/api/等路径配置了 CloudflareSkip规则 - 但问题依然存在
- 另一个同样在 Cloudflare 后面的域名
api-b.example.com却能正常工作
这几条现象叠在一起时,真的会让人陷入一种很典型的误区:
“是不是 Cloudflare 拦了,但我没找到规则?” “是不是源站没收到,所以没有日志?” “是不是 codex 的流式请求和 CDN 不兼容?”
这些猜测都很自然,也都很像真正的原因。
但问题恰恰在于,这次故障看上去“像”很多常见问题,实际上却不是其中任何一个最直观的版本。
三、最开始最容易怀疑的方向
回头看,这次排障一开始最容易被带偏到这些方向:
1. WAF / Bot Fight / Browser Integrity Check
因为只要开了 Cloudflare 代理,大家都会本能地先怀疑:
- WAF Managed Rules
- Super Bot Fight Mode
- Browser Integrity Check
- 自定义防火墙规则
- Rate Limiting
而且 codex 本身不是浏览器,很容易让人联想到“机器人识别”“非浏览器请求被拦”之类的问题。
2. Cloudflare 回源失败
另一个很自然的怀疑是:
- Cloudflare 边缘收到请求了
- 但没法回源到
sub2api - 所以源站没日志
这类问题在 Cloudflare 场景下也很常见,比如:
- 源站端口问题
- 源站 TLS 证书问题
- 安全组没放行 Cloudflare IP
- 回源协议不匹配
3. sub2api 或流式接口不兼容 CDN
因为 codex 的错误里有 stream disconnected before completion,这很容易让人怀疑:
- 是不是
/responses这种流式接口和 Cloudflare 的某些行为有冲突 - 是不是 HTTP/2、buffering、连接保持之类出了问题
- 是不是流在中间层被断了
4. Full / Full (strict) 相关 SSL 配置
很多人也会下意识把“开 Cloudflare 后 HTTPS 不正常”和“源站没加密/加密有问题”联系起来,怀疑是:
FlexibleFullFull (strict)
这些模式和源站证书之间存在配置冲突。
这些怀疑都不是没道理,但如果你从一开始就一头扎进这些方向,反而可能会错过真正更关键的线索。
四、这次问题为什么特别迷惑
这次故障最迷惑的地方,不在于问题本身多复杂,而在于它非常擅长伪装。
它伪装成了一个应用层故障:
- 客户端看到的是请求失败
- 像是接口服务异常
它又伪装成了一个回源层故障:
- 源站没有日志
- 像是请求根本没到服务器
它还伪装成了一个 Cloudflare 规则问题:
- 开 CDN 才出问题
- 关 CDN 就恢复
- 像是某个安全策略命中了请求
更麻烦的是,它还有一个“正常对照组”:
- 另一个 Cloudflare 账户下的类似域名却是正常的
这就让整个问题更像一种“配置玄学”或者“平台偶发现象”。
但如果从网络层的角度重新看,就会发现有一种可能性恰好能解释所有现象:
请求根本不是被应用拒绝了,而是在变成 HTTP 请求之前就已经失败了。
也就是说,问题发生的位置可能比 WAF、更比回源、更比应用都更早:
客户端与 Cloudflare 边缘的 TLS 握手阶段。
一旦你把视角移到这里,很多原本看起来矛盾的现象就 suddenly 变得合理了。
五、第一步:先验证 Cloudflare 边缘本身是不是正常
遇到这种问题,我后来觉得第一步不应该急着看应用日志,也不应该先怀疑业务代码,而应该先回答一个更基础的问题:
这个域名在 Cloudflare 边缘上到底能不能正常完成最基础的访问?
最方便的测试路径就是 cdn-cgi/trace。
于是我分别对两个域名做了测试:
|
|
结果两个域名都返回正常。
这一步虽然简单,但价值非常高。它至少说明:
- Cloudflare 边缘证书不是完全失效
- 这个域名不是整体不可用
- DNS 不存在致命错误
- 客户端至少有机会和 Cloudflare 建立某种 HTTPS 连接
也就是说,问题不是“这个域名一挂代理就彻底死掉”。
这一步把排查范围从“整个链路不可用”缩小到了“特定客户端行为或特定协商方式下不可用”。
这是一个非常重要的缩圈。
六、第二步:验证 /responses 路径本身是不是能到应用
既然边缘没彻底坏,下一步自然是看 API 路径是否可达。
我对两个域名都发了一个最基础的 POST /responses 请求,不带 API Key,只是想看是否能至少走到应用层并拿到一个明确的 HTTP 响应:
|
|
结果这两个域名都返回了类似的业务层响应:
|
|
状态码是 401 Unauthorized。
这个结果对排查思路影响很大,因为它说明:
- Cloudflare 并不是完全无法把请求转给源站
/responses路径并没有因为代理而失效sub2api不是“一加 CDN 就彻底坏了”- 应用至少在某些请求形态下是完全能收到流量的
换句话说,问题已经不再是“路径有没有”“接口是不是挂了”,而变成了:
为什么同样一个入口,普通 curl 可以请求,codex 却不行?
到这里,问题就从“服务坏了”转变为“客户端行为差异导致的兼容性问题”。
七、开始怀疑真正的差异:TLS、HTTP/2、ALPN、流式行为
当普通 curl 能成功拿到 HTTP 响应,而真实客户端 codex 却失败时,一个很关键的判断是:
codex 和我手工测试的 curl,并不是在完全相同的条件下访问这个域名。
这个“不同条件”可能来自很多地方,比如:
- TLS 版本不同
- 是否走 HTTP/2
- ALPN 协商不同
- 是否启用了 HTTP/3 / QUIC
- 是否采用不同的连接复用策略
- 流式请求和普通短请求行为不同
- 运行时、代理层、托管环境对 TLS 1.3 的支持程度不同
在很多场景里,我们很容易把“都叫 HTTPS 请求”当成“行为完全一样”。 但实际上,对 CDN、代理、边缘 TLS、连接复用这些东西来说,不同客户端之间差异很大。
这时我重新回去看了两个 Cloudflare 账户的配置差异,终于注意到一个很关键的不同:
- 正常的对照域名所在账户,最低 TLS 版本比较宽松
- 出问题的域名所在账户,把
Minimum TLS Version设成了 TLS 1.3
这一下,整个问题突然有了一个非常有力的怀疑方向。
八、决定性证据:强制指定 TLS 版本测试
仅仅看到配置差异还不够,必须有实验结果来支撑。
于是我分别对两个域名强制指定 TLS 版本进行测试:
|
|
结果非常干脆。
对照域名 api-b.example.com
强制使用 TLS 1.2,返回成功:
|
|
说明这个域名允许客户端以 TLS 1.2 接入。
问题域名 api-a.example.com
强制使用 TLS 1.2,直接握手失败:
|
|
说明这个域名不接受 TLS 1.2。
同一个问题域名再强制 TLS 1.3
结果恢复正常:
|
|
这三条实验结果组合起来,几乎已经是完整证据链了。
它们共同说明:
api-a.example.com实际上被配置成了 TLS 1.3 onlyapi-b.example.com则允许 TLS 1.2- 所以真正的问题不在
sub2api,也不在/responses路径,而在于客户端与 Cloudflare 边缘之间的 TLS 协商条件不同
换句话说:
codex 至少在访问 api-a.example.com 时,没有稳定地以 TLS 1.3 完成握手。
于是,连接在握手阶段就被 Cloudflare 边缘拒绝,请求根本没机会进入 HTTP 层。
九、根因:问题发生在“客户端 -> Cloudflare”这一段,不是“Cloudflare -> 源站”
这次故障里最容易让人混淆的一点,是很多人会把下面两件事混在一起:
1. Cloudflare 到源站的加密模式
这个通常受以下配置影响:
FlexibleFullFull (strict)
它决定的是 Cloudflare 往回源那一段怎么和你的服务器建立连接。
2. 客户端到 Cloudflare 边缘的 TLS 最低版本
这个由 Minimum TLS Version 控制。
它决定的是:客户端至少得支持哪个 TLS 版本,Cloudflare 才愿意让它接入。
这次问题完全是第二种。
不是因为源站没开 HTTPS。
不是因为回源证书无效。
不是因为 Full 模式不对。
而是因为 Cloudflare 边缘入口门槛设得过高了。
这也是为什么它会表现成:
- 源站没日志
- 应用没日志
- WAF 跳过规则看起来也没用
- 但基础域名某些请求又似乎是通的
因为入口层的协商条件本来就会导致这种“部分请求可达、部分请求根本还没开始”的现象。
十、为什么源站完全没日志
这次事故里,一个非常有价值的经验就是:
没有源站日志,并不一定意味着 Cloudflare 没有回源,也不一定意味着请求被 WAF 吞了。
还有一种更前置的可能性:
请求在建立 HTTPS 的过程中就已经失败了。
具体到这次,链路实际上是这样的:
codex尝试访问https://api-a.example.com/responses- 首先需要和 Cloudflare 边缘建立 TLS 连接
- 但由于
api-a.example.com被配置为最低 TLS 1.3,而客户端没有满足这一条件 - 所以 TLS 握手阶段直接失败
- HTTP 请求根本没有真正发出去
/responses路径根本还没有被请求sub2api当然不会有日志- 源站 access log 也不会有日志
这就是为什么它看起来像“请求被吞了”,但实际上是“请求还没开始”。
这是一个很值得记住的结论:
只要失败发生在 TLS 握手阶段,业务系统通常是什么都看不到的。
十一、为什么 WAF Skip 规则没能解决问题
我一开始其实已经做了不少 Cloudflare 层面的规避工作,比如针对 API 路径配置了 Skip 规则,跳过:
- Managed Rules
- Custom Rules
- Rate Limiting
- Super Bot Fight
- Browser Integrity Check
- 一些旧版防护项
直觉上,这似乎已经足够宽松了。
但问题仍然存在。
后来想明白的关键在于:
WAF 处理的是已经成为 HTTP 请求的流量。
而 TLS 握手发生在 HTTP 之前。 如果客户端连 HTTPS 连接都没建成,那 WAF 根本没有机会“看到”这个请求。
这也是一个非常典型的误区:
“我已经配置 Skip 了,为什么还是不行?”
因为你跳过的是安全规则,不是 TLS 协议门槛。 两者根本不是同一层的东西。
十二、修复:把 Minimum TLS Version 从 1.3 调回 1.2
既然根因已经明确,修复就很直接了。
最终的调整方案是:
- 把问题域名的
Minimum TLS Version从TLS 1.3改为TLS 1.2 - 同时保留
TLS 1.3开启
这点非常重要,因为“改成最低 1.2”并不等于“只能使用 1.2”。
真正的含义是:
- 支持 TLS 1.3 的客户端,依然会优先协商到 TLS 1.3
- 只能稳定使用 TLS 1.2 的客户端,也可以正常接入
这是一个典型的“兼容性优先但不放弃现代协议”的配置方式。
而对于面向公网、面对多种第三方客户端和运行时的 API 域名来说,这通常也是最稳妥的方案。
十三、为什么不是继续坚持 TLS 1.3 only
从纯“新协议更先进”的角度看,TLS 1.3 确实更现代:
- 握手更快
- 协议更简洁
- 默认更安全
- 移除了很多旧的算法和协商方式
但问题在于,“更先进”不等于“更适合作为公网 API 的唯一门槛”。
只要你的 API 需要面向:
- 第三方 SDK
- 不同语言运行时
- 各种代理环境
- 托管平台
- CLI 工具
- 远端执行环境
就很难保证所有客户端都始终稳定地使用 TLS 1.3。
有些环境名义上支持 TLS 1.3,实际上:
- 会回退到 TLS 1.2
- 在某些网络或中间层下协商不稳定
- 对 ALPN、HTTP/2、连接复用行为存在差异
于是结果就是: 你以为只是把门槛提高了一点,实际上却是把一部分正常客户端直接拦在门外。
这次 codex 的行为,基本就是一个很典型的例子。
十四、这次事故让我真正学会区分的两件事
这次排查之后,我脑子里对 Cloudflare 的两个概念终于被彻底分开了。
概念一:客户端怎么连 Cloudflare
关键配置项是:
Minimum TLS VersionTLS 1.3- 可能还有 HTTP/2、HTTP/3、ALPN 相关行为
这决定的是:入口能不能建立起来。
概念二:Cloudflare 怎么连我的源站
关键配置项是:
FlexibleFullFull (strict)- 回源证书
- 回源端口
- 源站放行策略
这决定的是:Cloudflare 收到请求之后,能不能继续把请求转回我的服务。
这次故障,完全是第一段的问题。 如果一直盯着第二段看,只会越看越困惑。
十五、我最后总结出的排查方法
这次之后,我给自己整理了一套更可靠的排查顺序,尤其适合“开 CDN 后 API 异常,但源站没日志”的场景。
第一步:先打 cdn-cgi/trace
|
|
这一步用来确认:
- Cloudflare 边缘是否可访问
- 是否至少进入了 HTTP 层
- 当前实际协商的 TLS 版本是什么
第二步:再打真实 API 路径
|
|
这一步用来确认:
- API 路径能不能到应用
- 是否能至少拿到业务层响应
- 问题是“接口整体不可达”还是“特定客户端不兼容”
第三步:强制指定 TLS 版本做控制变量实验
|
|
如果一个通、一个不通,基本就能把问题定位到边缘 TLS 配置。
第四步:再去看 WAF、回源、源站日志
只有当前面确认基础握手和基础 HTTP 都没问题时,再去深入看:
- WAF
- 回源证书
- 源站反代
- 应用日志
- SSE / buffering / timeout
这样排查会快很多,不容易在错误层级上耗时间。
十六、面向公网 API 的 Cloudflare 配置建议
经过这次踩坑,我现在对这类 API 域名的推荐配置会更保守也更务实一些。
如果是一个要面对多种客户端的公网 API,我会优先这样配:
Minimum TLS Version = 1.2TLS 1.3 = OnSSL/TLS mode = Full或Full (strict)- API 路径明确
Bypass Cache - 需要的话再对 API 路径配置 WAF Skip
- 非必要不强制
TLS 1.3 only
原因很简单:
- 兼容性足够好
- 安全性依然在现代基线之上
- 不会因为过度追求“最新协议”而把一部分正常客户端挡掉
对于内部系统、完全受控的客户端环境,当然可以更激进。 但对公网入口来说,很多时候最好的安全策略不是“设得最高”,而是“设得合理”。
十七、这次故障最值得记住的一句话
如果要用一句话总结这次事故,我会写成:
这不是 sub2api 挂了,也不是 Cloudflare WAF 拦了,而是 Cloudflare 边缘把 API 域名配置成了 TLS 1.3 only,导致 codex 请求在 TLS 握手阶段就失败了,请求根本没有进入 HTTP 层。
而如果再进一步提炼经验,那就是:
当一个请求“看起来像没到源站”时,别只盯着源站和 WAF,有时候它甚至还没有成为一个真正的 HTTP 请求。
十八、结语
这次问题让我印象最深的,不是它技术上有多复杂,而是它有多会“伪装”。
它伪装成 API 问题。
它伪装成 Cloudflare 规则问题。
它伪装成回源问题。
它伪装成 sub2api 或流式接口兼容性问题。
但最后,真正的原因只是一个在控制台里看起来并不起眼的配置项。
也正是这种“不起眼”,才最容易让人忽略它。
以后如果再遇到类似现象:
- 开 CDN 前正常
- 开 CDN 后失败
- 源站没日志
- WAF 没明显拦截
- 普通请求好像又能通
我会第一时间去看:
Minimum TLS Version- 客户端实际协商的 TLS 版本
- 客户端与 Cloudflare 边缘之间的握手是否成功
有些问题,真的不是“请求被拦了”,而是“请求还没开始”。
附录:文中关键命令
|
|
|
|
|
|