HTTP转HTTPS的一次经历

6 阅读12分钟

可以,下面给你一版 全脱敏博客版
我把域名、路径、IP、邮箱、任务 ID、文件名、项目名这些都换成了通用占位符,适合你直接发博客、发内部复盘、发朋友圈装一下也行。


我是怎么把一个“真机看不到证件照”的小程序,硬生生救活的

有时候程序出问题,不是它坏了。
是它在用一种非常委婉、非常含蓄、非常东方哲学的方式告诉你:

“哥,我这条链路,从头到尾就没通。”

这次就是个标准案例。
表面症状很简单:

  • 小程序真机没有图
  • 页面提示网络请求失败
  • 开发工具里又好像有点能用
  • 后端日志看起来还挺努力

整个系统给人的感觉就是:

每一层都觉得不是自己的锅。

于是我开始了一场很经典的排查之旅:
从“图片根本读不到”,一路打到“HTTPS 正式跑通”,最后顺手揪出了前端一个额外 404。

下面这篇,就是完整脱敏复盘。


一、起点:它不是“生成失败”,而是“连输入图都没读到”

最开始看到的关键报错,长这样:

Image path does not exist: /path/to/server/uploads/original/xxxx.jpg

翻译成人话:

处理服务想读图片,但这个路径在它眼里根本不存在。

这个时候如果你情绪上头,很容易开始怀疑:

  • 是不是前端没传图
  • 是不是参数错了
  • 是不是识别模型炸了
  • 是不是微信真机抽风
  • 是不是今天不宜上线

但日志其实非常诚实:

  • 收到了上传文件
  • 参数校验通过了
  • 真正挂在检测阶段
  • 报错非常直白:找不到图片路径

也就是说:

不是用户没上传,是后端把一条“对自己有效、对别人无效”的路径传出去了。


二、第一个坑:路径长得很像对的,实际上不是同一份文件系统

最开始后端传给处理服务的路径是这种:

/path/to/server/uploads/original/xxxx.jpg

乍一看很合理。
但 Docker 世界里有个巨坑:

路径一样,不代表底下真的是同一份东西。

于是开始查容器挂载。

结果发现:

  • 服务 A 共享挂载目录其实是:/shared/uploads
  • 服务 B 共享挂载目录其实也是:/shared/uploads
  • 但服务 A 自己把文件存到了:/path/to/server/uploads

这就像两个人约好“文件都放前台”,结果其中一个人偷偷把文件塞进自己抽屉,然后跟另一个人说:

你去前台拿啊。

对方去前台一看:空的。

于是优雅报错:

Image path does not exist

三、第一轮修复:统一共享目录,不要各玩各的

修法其实不复杂,核心就一句话:

所有和上传、原图、处理图有关的路径,都统一到共享目录。

例如统一成:

  • 原图:/shared/uploads/original
  • 结果图:/shared/uploads/processed
  • 临时目录:/shared/uploads/temp

然后服务 A 调服务 B 的时候,不再传这种:

/path/to/server/uploads/original/xxxx.jpg

而是传这种:

/shared/uploads/original/xxxx.jpg

修完之后,日志一下就顺眼了:

  • detect 成功
  • generate 成功
  • 候选图组装成功
  • 接口返回 200

到这里说明一件大事:

生成链路通了。

也就是说,图终于不是“压根打不开”了。


四、第二个坑:后端已经生成成功,但真机还是没图

生成成功之后,后端开始返回候选图地址,类似这样:

{
  "previewUrl": "http://image-gateway.example.com/path/to/preview.jpg",
  "hdUrl": "http://image-gateway.example.com/path/to/hd.png"
}

服务端看到这里,往往会露出满足的笑容:

“我都把图给你了,你还想怎样?”

结果真机还是:

  • 图片不显示
  • 页面提示网络请求失败

这时候要冷静。
因为“后端成功”只代表服务端世界很开心。
微信真机认不认,是另一套宇宙法则。


五、第三个坑:前端还在调一个根本不存在的确认接口

继续往日志里看,发现还有一条很扎眼:

POST /api/task/{taskId}/confirm -> 404

这个很要命。

它意味着前端在拿到候选图之后,又去调了一个“确认结果”的接口。
但后端根本没实现它。

于是前端把 404 翻译成一句非常笼统的话:

网络请求失败

所以你肉眼看到的是一个问题:

  • “没图”
  • “网络请求失败”

但实际上是两个问题叠在一起:

问题一:图片地址不合规

问题二:前端多调了一个不存在的接口


六、第四个坑:微信小程序不爱 HTTP,只爱 HTTPS

后端返回给小程序的候选图地址,一开始是:

http://image-gateway.example.com/uploads/...

而微信真机环境,对资源访问有个很朴素的原则:

你不给我 HTTPS,我就不给你好脸色。

开发工具有时候会放你一马,真机通常不会。

于是问题开始浮出水面:

  • 小程序请求的是业务接口域名
  • 业务接口再去调用图像处理域名
  • 这两层中间用了内网穿透
  • 但最外层对外暴露出来的其实还是 HTTP

这就像你穿着拖鞋去见客户。
理论上也算“穿鞋了”,
但大家都知道你不太正式。


七、内网穿透没错,但它不是微信真机的最终答案

这里要替内网穿透工具说句公道话:

它没错。
它很适合干这些事:

  • 把内网服务打到跳板机
  • 根据 Host 做域名分发
  • 把多个服务统一暴露出去

但它当前只是这一层:

公网 -> 穿透服务(http) -> 内网业务

而微信小程序真机想要的是:

公网 -> HTTPS 域名 -> 合法域名 -> 真正可访问资源

所以正确思路不是:

“让内网穿透直接承担完整 HTTPS 正式入口。”

而是:

内网穿透继续做内层转发,外层加 Nginx 做 HTTPS 终止。


八、确认跳板机现状:Nginx 到底活着没有

接下来开始查跳板机上的 Nginx。

一开始看服务状态,发现:

Active: failed

也就是说,Nginx 根本没起来。

再看端口监听,发现:

  • 80 端口被穿透服务占着
  • 443 根本没人监听

这意味着:

  • HTTP 是有入口的
  • HTTPS 压根不存在

在这种状态下,真机加载图片失败,完全合理。


九、第一步架构调整:让 80/443 归 Nginx,穿透服务退到内部端口

于是开始调整结构。

原来

外部 -> 穿透服务:80 -> 内网服务

改成

外部 -> Nginx:80/443 -> 穿透服务:内部端口 -> 内网服务

也就是说:

  • 80 和 443 给 Nginx
  • 穿透服务退到一个内部端口,例如 8087
  • Nginx 再把请求带着 Host 头转发给这个内部端口

这样一来,分工就清晰了:

  • Nginx 负责 HTTPS
  • 穿透服务负责域名分发
  • 内网服务负责业务逻辑

十、验证穿透服务是否真的会分发:一手 Express,一手 Uvicorn

为了确认穿透层到底好不好使,我们直接本机带 Host 测。

测处理服务

curl -I http://127.0.0.1:8087 -H "Host: image-gateway.example.com"

返回类似:

HTTP/1.1 405 Method Not Allowed
Server: uvicorn

说明:

  • 请求确实到了 Python 服务
  • 分发没问题

测业务服务

curl -I http://127.0.0.1:8087 -H "Host: api-gateway.example.com"

返回类似:

HTTP/1.1 404 Not Found
X-Powered-By: Express

说明:

  • 请求也确实到了 Node/Express 服务
  • 分发同样没问题

到这一步,穿透层基本可以摘锅了。

它没问题,它甚至还挺靠谱。


十一、Nginx 443 还没配,因为证书文件压根不存在

然后开始写 Nginx 配置,把两个域名都挂上去。

一测配置,报错大概是这种:

cannot load certificate ".../fullchain.pem": No such file or directory

翻译一下:

你把 443 写出来了,但证书文件还没生成。

属于一种非常典型的“人还没到,先给他安排工位”的操作失误。

所以正确顺序应该是:

  1. 先让 80 能工作
  2. 先把证书申请下来
  3. 再启用 443

十二、证书申请第一回合:HTTP-01 验证,看起来都通,结果 Let’s Encrypt 还是说超时

于是开始用 webroot 模式申请证书。

为了验证 challenge 路径,先在 Nginx 静态目录里放了一个测试文件:

mkdir -p /usr/share/nginx/html/.well-known/acme-challenge
echo hello > /usr/share/nginx/html/.well-known/acme-challenge/test.txt

本机测试 challenge 路径能访问。
外部浏览器访问 challenge 路径,也能看到 hello

按理说,这时候你会觉得:

“行了,这把稳了。”

结果 Let’s Encrypt 还是报:

Timeout during connect

这一步特别像:

你把门打开了,对方还在门外大喊:

里面没人!


十三、进一步验证:真实 challenge 文件外网都能访问,LE 还是 timeout

不服,继续查。

用调试模式让 Certbot 暂停,拿到它真正生成的 challenge 文件名。
再从外部访问这些真实文件,Nginx 日志里清清楚楚出现:

GET /.well-known/acme-challenge/... 200 87

也就是说:

  • challenge 文件真实存在
  • 外网真实能访问
  • Nginx 真实返回了 200
  • 返回大小也对

但 Let’s Encrypt 还是说超时。

这个时候,工程上最理智的选择,不是继续赌。

而是:

换验证方式。


十四、终结者登场:DNS-01 验证

于是直接切 DNS 验证:

certbot certonly --manual --preferred-challenges dns -d api-gateway.example.com -d image-gateway.example.com

然后 Certbot 会给你两条 TXT challenge:

第一条

_acme-challenge.api-gateway.example.com
<一串 token>

第二条

_acme-challenge.image-gateway.example.com
<另一串 token>

接着去 DNS 控制台添加两条 TXT 记录。

这里特别容易犯两个错:

错误一:主机记录填完整域名

很多 DNS 面板本身已经在某个主域管理页里了,所以主机记录通常只填前半部分。

比如应填:

  • _acme-challenge.api-gateway
  • _acme-challenge.image-gateway

而不是把整个完整域名全塞进去。

错误二:TXT 值抄错一个字符

DNS 验证没有容错。

差一个字母,它都当你没配。

最终等 TXT 记录生效,nslookup -type=TXT 查到一字不差之后,再回到 Certbot 按回车,终于成功拿到证书。

那一刻的日志非常治愈:

Congratulations! Your certificate and chain have been saved at:
...

十五、Nginx 443 正式上线

拿到证书之后,把 443 配置正式补上。

证书路径换成实际生成的那套:

ssl_certificate     /path/to/live/example.com/fullchain.pem;
ssl_certificate_key /path/to/live/example.com/privkey.pem;

然后两个域名都通过 Nginx 443 反代到内部端口,再由穿透服务继续分流到各自内网服务。

最终看端口状态:

  • 80:Nginx
  • 443:Nginx
  • 内部端口:穿透服务

这时候整套 HTTPS 链路终于像个正经生产环境了。


十六、微信小程序后台最终怎么配

服务端通了,小程序还得认。

所以微信公众平台里,最终填的是:

request 合法域名

https://api-gateway.example.com

uploadFile 合法域名

https://api-gateway.example.com

downloadFile 合法域名

https://image-gateway.example.com

并且,后端返回给小程序的图片 URL,必须从:

http://image-gateway.example.com/...

改成:

https://image-gateway.example.com/...

否则你后台配得再标准,真机还是会继续甩脸子。


十七、最后还有一个独立问题:confirm 接口 404

整个过程中,还有一个单独的问题一直存在:

POST /api/task/{taskId}/confirm -> 404

这个和 HTTPS 不是一回事。

就算图片加载问题解决了,如果前端还继续调用这个不存在的确认接口,点确认时仍然会报错。

所以这个属于后续收尾:

  • 要么后端补上 confirm 接口
  • 要么前端别调它

十八、这一整套排查,到底做了什么

这里给一份完整排障清单。

1. 看日志,不靠猜

先看报错是路径不存在,不是先怪微信。

2. 进容器看文件

确认文件真的保存到了服务 A 的容器里。

3. 查 Docker 挂载

确认两边真正共享的目录是什么。

4. 统一共享路径

把所有原图、结果图、临时文件都放到同一个挂载目录下。

5. 验证生成链路

看到 detect 成功、generate 成功,确认算法链路已通。

6. 查前端额外接口

发现前端还调了一个不存在的 confirm。

7. 查协议

确认返回给小程序的图片地址是 HTTP,而不是 HTTPS。

8. 查穿透配置

确认穿透层只是内层转发,不是正式 HTTPS 门面。

9. 查跳板机 Nginx 状态

确认 80/443 到底谁在监听。

10. 测穿透分发

用带 Host 的 curl 验证请求是否打到各自服务。

11. 调整外层结构

让 Nginx 接 80/443,穿透服务退到内部端口。

12. 申请证书

先尝试 webroot,再切换到 DNS 验证。

13. 验证 DNS TXT

用 nslookup 核对 challenge 值是否完全正确。

14. 启用 HTTPS

确认 443 已被 Nginx 成功监听。

15. 回到小程序后台

填合法域名,并把返回地址全部改成 HTTPS。


十九、最后的结论:这不是一次修 bug,这是一次链路通灵

表面问题是:

真机没图,网络请求失败。

真实情况其实是连环套:

  1. 图片最开始存错目录
  2. 处理服务根本读不到原图
  3. 生成成功后前端还调了一个不存在的确认接口
  4. 返回给真机的是 HTTP 图片地址
  5. 跳板机没有完整 HTTPS 入口
  6. HTTP 验证还遇到公网对某些验证节点不稳定
  7. 最终用 DNS 验证把证书拿下

这次最大的经验不是某条命令,而是一个排障原则:

不要问“哪个地方可能有问题”,要问“这条链路每一段,我有没有证据证明它正常”。

也就是从:

  • 文件有没有落盘
  • 容器有没有共享
  • 路径对不对
  • 服务有没有返回
  • 穿透有没有分发
  • Nginx 有没有监听
  • 80/443 有没有开放
  • 证书有没有签下来
  • URL 是不是 HTTPS
  • 小程序后台是不是合法域名

一段一段拿证据。

程序世界里,很多“玄学问题”,最后都只是因为:

某一层你以为它应该对,但你从来没验证过。

而这次,我们把它一层层验证到了底。

最后它终于恢复正常。
不是因为运气来了。
是因为每一个“我以为”,都被替换成了“我确认”。