ERR_SSL_PROTOCOL_ERROR:我如何把问题追到 Docker、Caddy 与宝塔
FIELD NOTE / INCIDENT REVIEW
浏览器报出 ERR_SSL_PROTOCOL_ERROR 时,语气很笃定,信息量却大约等于“坏了,你猜”。我接手 Hacksth 的第一场基础设施排障,就从这句不太热心的提示开始。
表面上看,这是一个证书问题;再靠近一点,它可能是 TLS 配置;继续往里走,又会遇到宿主机端口、Docker 映射、反向代理、WordPress 容器和控制面板。它们都在同一台服务器上,于是每一层都显得很可疑——这正是误判最容易发生的时候。
这篇文章不提供“重启一下试试”的玄学处方。我只记录这次真实排障里最有复用价值的一件事:先确认请求实际经过谁,再讨论应该修谁。
错误提示是症状,不是架构图
HTTPS 请求从浏览器到 WordPress,至少可能经过四层:
- 宿主机对外暴露的 443 端口;
- Docker 的端口映射,例如
443:443; - 真正终止 TLS 的 Caddy、Nginx 或其他反向代理;
- 只负责返回 HTTP 内容的 WordPress/PHP 应用。
浏览器只知道握手失败,不知道上面哪一层出了问题。如果一看到 SSL 报错就钻进 WordPress 容器找证书,相当于家里总闸跳了,先去责问台灯为什么不亮。台灯确实没亮,但它大概率不是主谋。
我先确认谁真正监听 443
第一步不是改配置,而是在宿主机上看监听者:
ss -lntp '( sport = :443 )'
如果服务器使用 Docker,再看哪个容器发布了 443:
docker ps --filter publish=443 \
--format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'
这里最关键的是端口映射的方向。443:443 表示宿主机收到的 443 流量被交给容器的 443;如果一个反向代理容器同时映射 80:80 和 443:443,那么公网 TLS 首先到它,而不是直接跳进 WordPress。容器很多并不等于它们都能抢 443,宿主机同一个地址和端口在同一时刻只有一个实际入口。
用握手证据缩小范围
当时主站 HTTP 能正常返回重定向,响应头里还能看到 Server: Caddy;TCP 连接到 443 也能建立,但 TLS 1.2 和 TLS 1.3 都在 ClientHello 之后返回内部错误,没有下发证书。这已经排除了“端口完全没开”,并把范围收窄到 TLS 终止层。
openssl s_client \
-connect www.hacksth.com:443 \
-servername www.hacksth.com \
-tls1_3
-servername 不能省。现代 HTTPS 通常依赖 SNI 选择证书;不带域名去握手,测试到的可能是默认站点,而不是浏览器实际访问的虚拟主机。
更有价值的对照是:同一台服务器上的 CoveChat 子域名可以完成 TLS 握手。于是问题不再像“整台服务器的 443 都坏了”,而更像“主域名和 www 的证书选择、加载或代理规则有问题”。对照实验往往比多看十遍错误页诚实。
caddy: command not found 证明不了 Caddy 不存在
排障过程中出现过一个很容易误导人的结果:
sudo: caddy: command not found
它只证明宿主机的命令路径里没有 Caddy,可没有证明 Docker 容器里没有 Caddy。进程住在容器里时,宿主机当然找不到它的二进制文件;这就像酒店前台说“这里没有这个人”,而那个人其实住在楼上的房间里,只是没在大堂站着。
正确顺序应该是先看容器和映射,再查看对应容器日志、挂载和配置:
docker logs <proxy-container>
docker inspect <proxy-container>
只有确定容器身份后,才有资格在容器里验证 Caddyfile、证书存储权限和配置加载结果。先删证书目录再说,通常属于把证据和退路一起删掉,排障效率会立刻变得很有戏剧性。
为什么我没有先改 WordPress
WordPress 容器负责的是应用内容。只要反向代理以 HTTP 把请求转发给它,证书就不必、也不应该塞进 WordPress 容器。TLS 在入口代理终止,容器内部走受控网络,这才是边界清楚的结构。
判断原则:谁在宿主机接住 443,谁先对公网证书和 TLS 握手负责;WordPress 只有在它自己直接监听 HTTPS 时,才是第一责任层。
把证书同时挂进多个业务容器,不会让 HTTPS 更可靠,只会让续期、权限和故障归属变得含糊。基础设施里最昂贵的东西往往不是组件,而是“大家好像都管一点”。
最终选择:让入口只剩一个主人
在明确请求链后,主站架构被收敛为:由宝塔管理的 Nginx 接管主站入口、证书与 HTTPS,WordPress 容器继续只承担应用服务,主站不再额外经过 Caddy。
这不是说 Caddy 不好。它的自动 HTTPS、配置简洁和容器化体验都很出色;只是当宝塔/Nginx 已经承担站点管理,再叠加一个同样想管理 80/443 的代理,收益不一定抵得过认知成本。架构不是组件选美,少一层含糊通常比多一个漂亮功能更可靠。
修复后的公开响应由 Nginx 提供,响应头可见 Server: nginx,主站 HTTPS 能正常完成握手。这里的“完成”不是看浏览器终于打开就鼓掌,而是重新检查了入口、证书、公开页面和容器职责是否一致。
一份可复用的排障顺序
- 确认 DNS:主域名和 www 是否指向预期服务器。
- 拆分 TCP 与 TLS:端口能连上,不等于握手和证书正常。
- 查宿主机监听:用
ss找到实际入口进程。 - 查 Docker 发布端口:确认是谁映射了 80/443,不凭容器名字猜。
- 带 SNI 测握手:用
openssl s_client看是否下发正确证书。 - 做同机对照:比较其他子域名,判断是全局故障还是单虚拟主机问题。
- 最后才改配置:先保存日志、挂载、证书和配置基线,再做单一变量修改。
- 修完重走请求链:验证 HTTP 重定向、TLS、页面响应和续期责任,而不只看一次成功加载。
我从这次故障里留下的规则
以后再遇到类似问题,我会坚持三条规则:
- 不把应用层的名字,当成网络入口的证据;
- 不把
command not found,当成容器里没有该组件的证据; - 不在找到真正责任层之前,通过重启和删文件制造新的变量。
一个错误提示没有义务替我们解释系统,但我们有义务把系统画清楚。那天浏览器没有告诉我答案,它只是用一句 ERR_SSL_PROTOCOL_ERROR,把我推到了正确的问题面前:公网流量到底先交给了谁?
问题问对之后,排障终于从玄学恢复成了工程。浏览器还是那副冷淡样子,不过这次我原谅它了。