Nginx 解析 URL 中 斜杠(/) 的机制
说在前面
配置了很多次 Nginx,一直没有搞明白,什么时候需要在路径中加上斜杠(/),什么时候不需要,以及与之相关的种种现象。在网络上找到了一些文章:
写的都不错,能够从具体例子的角度来解释是否带斜杠,但我觉得有几个不足:
- 缺乏简单易用的实验(第三篇有);
- 分析较为碎片化(
alias,root,proxy_pass分开讨论),缺少宏观的抓手,不方便记忆。 - 没有结合源代码来解释,欠缺说服力。
本文总体讨论 alias、root 与 proxy_pass 的共性与个性,并给出 docker-compose 的容器例子,以及在 AI 的帮助下结合 Nginx 的源代码做机制分析。
本文适合像笔者一样喜欢钻牛角尖以及探索 corner cases 的读者,只关注结论的读者可以仅阅读 TL;DR 小节。本文不是 Nginx 的入门文章,假设读者进行过简单的 Nginx 配置。
TL;DR
建议是:
- 在
location块中配置的路径永远以/结尾; - 对静态的
alias来说,alias后的路径结尾情况和location的应该保持一致; - 对
proxy_pass来说,要么只有主机名和端口号,没有 URI;但如果有 URI 部分,要和location块中的保持一致(要么都以/结尾,要么都不); root的路径后有无/没区别,Nginx会自动去掉最后一个/(nginx_http_core_module.c:4622。要是故意多写,因为只涉及静态目录访问,有多个/似乎问题不大,看 OS 的行为了,cd /etc////nginx)。
举例:
1 | |
伪算法:
1 | |
Remark: 伪代码和源代码并不完全一致,源代码采用模块来先后处理 URL,顺序是 STATIC、INDEX 和 AUTO INDEX,分别在其内部进行路径处理与访问决策。然而伪代码先统一处理路径,再分别处理访问逻辑。在逻辑上等价,但和源代码并不是一一对应。个人认为伪代码在分析现象时比较方便。
实验环境
Docker Compose 配置:Github
采用 Nginx 进行反向代理与静态页面托管,使用 Flask 运行后端。
Nginx 设定
共有 8 个 Nginx 容器,分别命名为 nginx_a,nginx_b,…,nginx_h,它们有不同的配置。
在反向代理实验中,它们的配置如下:
| 容器名 | location | proxy_pass |
|---|---|---|
| nginx_a | /api/ | http://flask:5000 |
| nginx_b | /api | http://flask:5000 |
| nginx_c | /api/ | http://flask:5000/ |
| nginx_d | /api | http://flask:5000/ |
| nginx_e | /api/ | http://flask:5000/api |
| nginx_f | /api | http://flask:5000/api |
| nginx_g | /api/ | http://flask:5000/api/ |
| nginx_h | /api | http://flask:5000/api/ |
主要需要关注是否有斜杠,这是我们研究的重点。
在 alias 实验中,有下面的 4 种不同配置:
| 容器名 | location | alias |
|---|---|---|
| nginx_a | /code/ | /usr/share/nginx/files |
| nginx_c | /code | /usr/share/nginx/files |
| nginx_e | /code/ | /usr/share/nginx/files/ |
| nginx_g | /code | /usr/share/nginx/files/ |
b 和 a 该配置相同,d 和 c 该配置相同,以此类推,所以只有 4 种不同配置。
具体的 server 块参见源代码。
每个 nginx 容器内的目录结构相同,如下所示:
1 | |
和文件目录相对应,静态页面托管的配置模板如下:
1 | |
Flask 设定
只需要一个 Flask 容器,在 app.py 文件中定义 /api/foo、/foo、/apifoo 等路由,用来接受请求:
1 | |
docker-compose 文件配置
1 | |
完整的配置参见源码,需要注意的地方是,不同 flask 容器的 container_name 不同,不同 nginx 容器的 context、container_name 以及暴露给主机的端口号不同:
| 容器名 | 暴露端口 |
|---|---|
| nginx_a | 23333 |
| nginx_b | 23334 |
| nginx_c | 23335 |
| nginx_d | 23336 |
| nginx_e | 23337 |
| nginx_f | 23338 |
| nginx_g | 23339 |
| nginx_h | 23340 |
这样,我们可以在主机中对不同端口进行 curl 测试,或者在浏览器中直接输入地址,能够访问到不同的容器。
开始之前
Nginx 进行 URL 解析,分为三个步骤:
- 根据前缀,找到匹配的
location块; - 根据关键字(
alias、root或proxy_pass) 进行地址转换; - 根据静态映射还是后端转发,做相应的文件访问或请求转发逻辑。
location 块中指明的前缀只负责匹配,可以理解为 C 语言中的 case,具体地址转换的逻辑在第二步。
在第二步中,我把 Nginx 对 URL 的处理分成两派:
- 直接 concat;
- 先替换再 concat。
读者可以回顾
TL:DR一节的算法,看看是不是只有这两派。
假如用户输入的 URL 是 http://www.baidu.com/foo/bar,当到达目标服务器时,其实有用的信息是 /foo/bar,是不是就像在本地访问一样?cd /foo/bar?Nginx 要做的事情是把这个路径翻译为本地路径。
假如 root 是 /var/www,末尾有没有 / 没区别。
根据
Nginx的源码,如果root的路径末尾有/,会被删去最后一个/。既然用户的请求路径肯定从/开始(如http://www.baidu.com/foo中去掉协议和主机名后得到的/foo),为了拼接字符串,root没必要再在末尾保留/了。要是写了超过一个/怎么办?看看具体的 OS 实现吧。
1 | |
地址映射规则解析
下面具体解释这两种流派:
直接 concat 的意思是,根据某个前缀,如(/foo) 找到了匹配的 location 块,最终的地址就是 /var/www + /foo/bar。
而先替换再 concat 的意思是,根据前缀(如 /foo) 找到了匹配的 location 块后,先把用户传入的 URL 进行删减,把 location 块中的内容删去。如,/foo/bar 变成 /bar。随后,再和 root 拼接,得到 /var/www + /bar。也可以理解为切片。
当然,不只可以和 root 拼接,也可以和 alias 或者 proxy_pass 中的路径拼接,但具体的处理逻辑就这两种。
- 如果和 URL 拼接的是
root,则是直接 concat 的逻辑; - 如果是
alias则是先替换再 concat 的逻辑; - 如果是
proxy_pass:- 如果主机名(和端口号)后有 URL(如
/,/api,/api/),则按照alias的方式先替换再 concat; - 如果只有主机名,则按照
root的方式直接 concat。
- 如果主机名(和端口号)后有 URL(如
实验一:proxy_pass 路径测试
先给一个表格,总结所有的实验结果(为了简略,用后端代替http://flask:5000):
| 容器名 | location | proxy_pass | URL | 映射后的路径 | 原因分析 |
|---|---|---|---|---|---|
| nginx_a | /api/ | http://flask:5000 |
/api/foo | 后端/api/foo |
主机名,直接 concat |
| nginx_a | /api/ | http://flask:5000 |
/apifoo | 指向静态目录 | 没有命中 /api/,命中 / |
| nginx_b | /api | http://flask:5000 |
/api/foo | 后端/api/foo |
主机名,直接 concat |
| nginx_b | /api | http://flask:5000 |
/apifoo | 后端/apifoo |
前缀匹配命中,主机名,直接 concat |
| nginx_c | /api/ | http://flask:5000/ |
/api/foo | 后端/foo |
有 URL,先删除 /api/,再 concat |
| nginx_c | /api/ | http://flask:5000/ |
/apifoo | 指向静态目录 | 没有命中 /api/,命中 / |
| nginx_d | /api | http://flask:5000/ |
/api/foo | 后端//foo(没有打错) |
有 URL,先删除 /api,再 concat |
| nginx_d | /api | http://flask:5000/ |
/apifoo | 后端/foo |
有 URL,先删除 /api,再 concat |
| nginx_e | /api/ | http://flask:5000/api |
/api/foo | 后端/apifoo |
有 URL,先删除 /api/,再 concat |
| nginx_e | /api/ | http://flask:5000/api |
/apifoo | 指向静态目录 | 没有命中 /api/,命中 / |
| nginx_f | /api | http://flask:5000/api |
/api/foo | 后端/foo |
有 URL,先删除 /api,再 concat |
| nginx_f | /api | http://flask:5000/api |
/apifoo | 后端/apifoo |
有 URL,先删除 /api,再 concat |
| nginx_g | /api/ | http://flask:5000/api/ |
/api/foo | 后端/api/foo |
有 URL,先删除 /api/,再 concat |
| nginx_g | /api/ | http://flask:5000/api/ |
/apifoo | 指向静态目录 | 没有命中 /api/,命中 / |
| nginx_h | /api | http://flask:5000/api/ |
/api/foo | 后端/api//foo(没有打错) |
有 URL,先删除 /api,再 concat |
| nginx_h | /api | http://flask:5000/api/ |
/apifoo | 后端/api/foo |
有 URL,先删除 /api,再 concat |
感觉眼花缭乱的,分析分析:
- 不管情况怎样复杂,只需要记住,如果
proxy_pass后只有主机名,无脑 concat;主机名后有 URL,先再用户传入的 URL 中删除 location 的路径,再和proxy_pass中的路径 concat; - A 和 G 的表现相同;
- D 和 H 出现了双斜杠(
//); - B、D、F、H 实质上劫持了静态目录的内容;
- E 可能导致后端 API 路径错误。
再给一个表格,拓展上面的分析,给出每种配置是否推荐:
| 容器名 | location | proxy_pass | 是否推荐 | 理由 |
|---|---|---|---|---|
| nginx_a | /api/ | http://flask:5000 |
✅ | 适合前端托管静态文件,后端路由都以 /api 起始的情形 |
| nginx_b | /api | http://flask:5000 |
❌ | 污染前端 |
| nginx_c | /api/ | http://flask:5000/ |
✅ | 适合前端托管静态文件,后端路由直接从 / 开始的情形 |
| nginx_d | /api | http://flask:5000/ |
❌ | 污染前端,且后端出现 // |
| nginx_e | /api/ | http://flask:5000/api |
❌ | 后端路径拼接错误 |
| nginx_f | /api | http://flask:5000/api |
❌ | 污染前端 |
| nginx_g | /api/ | http://flask:5000/api/ |
✅ | 本质和 A 相同 |
| nginx_h | /api | http://flask:5000/api/ |
❌ | 污染前端,且后端访问出现 // |
做几个解释:
后端的路由格式
从我个人开发后端的角度来看,经历过两种路由格式,一种格式中,全部的路由都从 /api 开始;另一种,全部的路由直接从 / 开始。我用下面的 Flask 路由来表示:
1 | |
上方这种路由格式,如果要访问 API 1,格式是 后端/api/api1。
1 | |
而第二种路由格式中,访问 API 1,直接 后端/api1。
哪种更好呢?第一种好像更有逻辑,如果前后端挂在同一个域名下,不会和前端目录冲突,后端接口都放在 /api 目录下;而第二种则更简练,把前后端冲突的解决留给 Nginx。
怎么理解污染前端
在上面的表格中,所有出现污染前端的配置里,location 块中的路径都不以 / 结尾。
什么叫污染前端?直观的体现是,访问 /apifoo 会被发送给后端,这会有问题吗?好像我们在前端托管中一般不会有 api 开头的文件名或者目录。似乎是这样,但,如果前端的文件以 app 开头,而为了版本控制,后端也以 /app 开头呢?
如,后端的路由是 /app/v1/foobar,前端有一个经过 Vue 混淆的文件 app.asdfgh123.js。当客户端请求 http://www.example.com/app.asdfgh.js 时,经过前缀匹配,命中 location /app 块.
经过转换,转发给 后端/.asdfgh123.js、后端/app.asdfgh123.js 或者 后端/app/.asdfgh123.js。这会导致浏览器加载前端界面异常,是很严重的问题。
双斜杠(//) 会导致什么
经过实验,如果 // 出现在主机名后,会被合并为一个 / 可以正常访问合并后的链接;而如果 // 出现在 URL 中,如 /api//foo,不会被合并,会报错不存在该路径。
如,访问 localhost:23336/api/foo(即 D) 得到的响应是:
1 | |
而访问 localhost:23340/api/foo(即 H) 得到的响应是:
1 | |
AI 告诉我,这涉及到 WSGI 层(Werkzeug)对 URL 的规范化,和 Flask 无关。流程是:
1 | |
原始 URL 中可能包含 //,根据 WSGI 的不同表现,可能被合并,可能不被合并,经过 WSGI 处理后的 URL 到达 Flask,进行解析。
所以结论是,为了确保稳定性,尽量不要让 URL 中出现双斜杠(//)。
对于静态文件访问中出现的
//情况,参见后面的小节。
实验二:alias 路径测试
读者可能会想,为什么没有 root 的测试,因为 root 的现象相对好理解。Nginx 已经帮我们确保了 root 末尾不含 /,而用户给定的 URL 肯定以 / 开始,无脑拼接,很容易得到映射后的地址。就算有多个 //////,我试了试,在 WSL 上,cd /etc////nginx 是等价于 cd /etc/nginx 的,所以猜测静态目录访问不会有影响。
而 alias 则大大不同了,因为 Nginx 不会处理 alias 路径末尾的 /,所以形成了四种配置情况,有时候会有很有意思的结果。再重复一遍 alias 的逻辑:先删除 URL 中location 的路径,再 concat。
读者可能会想,为什么没有 root 的测试,这是因为 root 就是无脑连接。如果用户访问的是 /api/foo,那就是预期访问文件;如果是 /api/foo/ 那就是预期访问目录。不会出现原始 URL 期待访问文件,而映射后的 URL 变成访问目录的情况,反之亦然。
而 alias 大大不同,原始 URL 的访问预期经过映射后,可能会是另一层意思。
例如,原始 URL 是
/foo,如果location /foo {alias /var/www/},映射后的 URL/var/www/变成了目录访问。另一个例子是,原始 URL 是
/foo/,如果location /foo/ {alias /var/www},映射后的 URL 是/var/www,变成了文件访问。而 Nginx 会按照原始 URL 的预期来进行 URL 解析。你以为这会访问www目录嘛?实际上会访问/var/wwwindex.html文件,或者访问/var/ww目录!
| 容器名 | location | alias | URL | 映射文件 | 解释 |
|---|---|---|---|---|---|
| nginx_a | /code/ | /usr/share/nginx/files | /code | 404 | 命中 /,不存在 root + /code/ 目录 |
| nginx_a | /code/ | /usr/share/nginx/files | /code/ | 404/500/file 目录 | 原始 URL 按目录访问,拼接得到 files,走 INDEX-AUTO INDEX 路径,不存在配置的索引,进入 AUTO INDEX,访问 file |
| nginx_a | /code/ | /usr/share/nginx/files | /code/123.html | /usr/share/nginx/files123.html | 命中 /code/,删除再 concat |
| nginx_c | /code | /usr/share/nginx/files | /code | 301 到 /files/ | files 目录存在, 但当前是文件访问方式, 补上 / 让客户端再按目录方式访问一次 |
| nginx_c | /code | /usr/share/nginx/files | /code/ | 命中 /files/ | 拼后得到 files/,原始 URL 以 / 结尾,进入 INDEX 模块,不存在 files/index.html。进入 AUTO INDEX 模块,访问 files 目录成功 |
| nginx_c | /code | /usr/share/nginx/files | /code123.html | /usr/share/nginx/files123.html | 注意和 a 的路径不一样,道理相同,简单拼接 |
| nginx_e | /code/ | /usr/share/nginx/files/ | /code | 404 | 没有命中 /files/,访问别的 location 块 |
| nginx_e | /code/ | /usr/share/nginx/files/ | /code/ | 命中 /files/ | 原始 URL 以 / 结尾,走 INDEX-AUTO INDEX 路径,拼接得到 files/,访问 files 成功 |
| nginx_g | /code | /usr/share/nginx/files/ | /code | 301 到 /files/ | 原始路径是文件访问,拼凑后得到 files/,走 STATIC 模块,发现文件类型是目录,301 再次访问 |
| nginx_g | /code | /usr/share/nginx/files/ | /code/ | 命中 /files/ | 原始路径是目录访问,拼凑后得到 files//,走 INDEX-AUTO INDEX 路径,在 AUTO INDEX 中访问 files/,成功 |
展开讲讲 nginx_a 的现象,它驱动我写完了文章开头的整个伪代码。这里就不讲伪算法了,而是讲源代码的处理逻辑,读者读完后可能会发现和伪算法的思路是一致的。
静态内容访问,根据源代码,需要连续经过三个模块的处理:STATIC、INDEX 和 AUTO INDEX。
先经过 STATIC。如果原始 URL 以 / 结尾,STATIC 直接返回 NGX_DECLINE,表示不处理,丢给后续模块。这就说明,STATIC 模块只负责处理文件路径的访问,和另外两个模块不在同一个分支。
在 STATIC 模块中,先尝试打开文件,如果不存在文件,直接 404 返回。如果存在文件,进行后续处理。如果文件类型是文件夹,则 301 重定向到原始 URL + / 的地址;如果是文件,返回文件内容。
对于目录的访问,会先进入 INDEX 模块,也就是检查有没有手工配置的 index.html、index.htm 等索引文件。这符合直觉:个人配置的在 AUTO 之前。逐个检查文件。在每个文件的循环中,先判断能否打开文件,如果能打开,则返回索引。不能打开,则看下一个。不过在看下一个之前,先验证父目录是否存在,如果不存在或者不是目录,不必浪费时间访问其他索引文件了。
INDEX 处理完毕后,如果是 NGX_DECLINE,则交给 AUTO INDEX 模块做收尾。AUTO INDEX 模块把路径无脑地去除最后一个字符。可能的原因是它认为路径都会以 / 结尾。随后,它试图打开这个目录,如果能打开,返回自动构建的索引,否则,如果目录不存在,返回 404;如果没有设定启用自动索引,返回 403。
读者如果有些混乱,我们可以看看 nginx_a 中访问 /code/ 的情形。
我们设定了 3 个情景,仓库中的容器配置默认是情景 2,剩下的需要手动进入容器修改。在 nginx_a 容器的 /usr/share/nginx 目录下,一直存在 file/ 目录。而 files 有 3 种情形:
- files 是普通文件;
- files/ 是目录;
- files 不存在。
经过拼接,得到的路径是 /usr/share/nginx/files。原始 URL(/code/)是目录访问,所以走 INDEX-AUTO INDEX 这个分支。
在 INDEX 模块中,nginx 试图访问自动构建的索引文件,但是拼接是机械的。如,访问 index.html 时,本地路径是 /usr/share/nginx/filesindex.html。显然不存在。随后,进入到检查父目录的环节。如果 files 目录存在,则继续后续的检索,否则早停,返回错误。
如果在
/usr/share/nginx/下放一个filesindex.html文件,想必是可以访问的。
返回什么错误呢?如果 files 不存在,返回 404,如果存在却是普通文件,返回 500。情形 1 得到 500,情形 3 得到 404。
如果 files/ 目录存在,继续 INDEX 模块的循环,直到遍历完所有的默认索引文件,发现都无法打开,因为我们没有手动配置。模块给出 NGX_DECLINE,让 AUTO INDEX 模块处理。
AUTO INDEX 模块无脑地将最后一个字符删去,得到 /usr/share/nginx/file,随后尝试打开这个目录。如果这个目录存在,则返回这个目录的自动构建索引;否则,返回 404。
这个现象非常有意思,解释了为什么目录是 files 却访问到了 file 目录的情况。让 AI 烧了好久的 token。
最后,考考读者,为什么 files 不存在时,Nginx 不会访问 file 目录?
因为按照执行顺序,先执行 INDEX 模块,发现 files 目录不存在,直接返回错误,不会进入 AUTO INDEX 模块。
计算机的世界没有魔法。
结论与展望
具体要说的收获,在 TL;DR 部分已经提过了。这次实验是在 AI 的指导下开展的,帮我搭建 Docker 配置文件很快速,但是在阅读源代码方面,走了一些弯路。用的是 kimi k2.5 + claude code。现在 Coding Plan 非常火爆,真是抢不到其他的模型。
本次实验,是对于钻牛角尖人好奇心的满足。想一想,在这种不符合文档建议的访问情况下,对于 nginx_a 容器,怎样才能让访问 /code/ 时映射到 files 而不是 file 呢?好像把「无脑删除最后一个字符」改为「判断是否为 / 结尾再删」就好一些?我试着让 AI 帮忙改一改,但是没能成功,说是涉及到内存越界或者其他的遗留问题。感兴趣的读者可以自行尝试。
