说在前面

配置了很多次 Nginx,一直没有搞明白,什么时候需要在路径中加上斜杠(/),什么时候不需要,以及与之相关的种种现象。在网络上找到了一些文章:

写的都不错,能够从具体例子的角度来解释是否带斜杠,但我觉得有几个不足:

  1. 缺乏简单易用的实验(第三篇有);
  2. 分析较为碎片化(alias, root, proxy_pass 分开讨论),缺少宏观的抓手,不方便记忆。
  3. 没有结合源代码来解释,欠缺说服力。

本文总体讨论 aliasrootproxy_pass 的共性与个性,并给出 docker-compose 的容器例子,以及在 AI 的帮助下结合 Nginx 的源代码做机制分析。

本文适合像笔者一样喜欢钻牛角尖以及探索 corner cases 的读者,只关注结论的读者可以仅阅读 TL;DR 小节。本文不是 Nginx 的入门文章,假设读者进行过简单Nginx 配置。

TL;DR

建议是:

  1. location 块中配置的路径永远以 / 结尾;
  2. 对静态的 alias 来说,alias 后的路径结尾情况和 location 的应该保持一致;
  3. proxy_pass 来说,要么只有主机名和端口号,没有 URI;但如果有 URI 部分,要和 location 块中的保持一致(要么都以 / 结尾,要么都不);
  4. root 的路径后有无 / 没区别,Nginx 会自动去掉最后一个 /nginx_http_core_module.c:4622。要是故意多写,因为只涉及静态目录访问,有多个 / 似乎问题不大,看 OS 的行为了,cd /etc////nginx)。

举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
server {
host www.example.com;
listen 80;

root /var/www; # 符合 4
# root /var/www/; # 和上方等价

location /api/ { # 符合 1
proxy_pass localhost:8000;
}

location /app { # 不符合 1, 用户访问 www.example.com/app.abc123.js 会定向给后端,污染静态文件目录
proxy_pass localhost:6000;
}

location /app/ {
alias /usr/share/nginx/; # 符合 2
alias /usr/share/nginx; # 不符合 2,拼接出意外目录
}

location /api/ {
proxy_pass foo:5000; # 符合 2, 用户访问 www.example.com/api/foo 会访问后端的 foo:5000/api/foo
proxy_pass foo:5000/api/; # 符合 2, 用户访问 www.example.com/api/foo 会访问后端的 foo:5000/api/foo
proxy_pass foo:5000/; # 符合 2, 用户访问 www.example.com/api/foo 会访问后端的 foo:5000/foo
proxy_pass foo:5000/api; # 不符合 2, 用户访问 www.example.com/api/foo 会访问后端的 foo:5000/apifoo
# 更多细节,后文探讨
}
}

伪算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// 1. 用户传入 URL(e.g http://www.example.com:8080/api/foo)
// 2. 根据主机名和端口号匹配 server 块
// 3. URL := 去掉主机名和端口后的部分(e.g 得到 /api/foo)
// 4. 根据 URL 的前缀匹配 location 块
// 5. 根据路径的关键字(kw == alias, root, proxy_pass) 匹配 handler

// 6. 先进行路径映射。下面是两个记号约定
// loc := location 后的路径,e.g location **/api/**
// base := 关键字后面的路径,e.g root **/var/www**; proxy_pass **localhost:8000**

if (kw == proxy_pass) { // 动态, 需要访问后端
if (base.contains_url()) { // base 包含 URL(e.g localhost:8000/, localhost:8000/api...)
mapped_path := base + URL[loc.length():];
} else { // 只有主机名,可选是否有端口号(e.g localhost:1234)
mapped_path := base + URL;
}
} else if (kw == root) {
mapped_path := base + URL;
} else if (kw == alias) {
mapped_path := base + URL[loc.length():];
} else {
// other situations...
}

// 7. 再进行目标访问
if (kw == proxy_pass) {
transfer_to_proxy(mapped_path); // 直接发给后端
} else { // 静态
if (URL.endsWith('/')) { // 原始 URL(不是 mapped_path) 期待访问目录
// 7.1 INDEX 模块, 对应 ngx_http_index_module.c:97. ngx_http_index_handler()
for index in index_path { // 对每个候选 index 文件
// 7.1.1 先尝试直接打开文件
if (fileExist(mapped_path + index)) {
return index_file, 200;
}
// 7.1.2 尝试父目录(即 mapped_path)是否存在,必须是目录
status := open_dir(mapped_path)

// 如果父目录有问题, 不再查看后续 index 文件, 节省时间
if (status == normal_file) { // 文件存在, 但不是目录
return None, 500;
} else if (status == not_found) { // 文件不存在
return None, 404;
}
}

// 遍历完所有 index 文件, 都不满足, 进入 AUTO INDEX 模块

// 7.2 AUTO INDEX 模块, 对应 ngx_http_autoindex_module.c:53. ngx_http_autoindex_handler
local_path := mapped_path[:-1]; // 永远去掉最后一个字符, 对应 L196-200
status := open_dir(local_path) // 预期是 local_path 是 dir

if (status == turn_off_autoindex) { // 没设定 autoindex on
return None, 403;
} else if (status == no_such_file_or_dir) { // 找不到目录/文件
return None, 404;
} else if (status == not_a_dir) { // 是文件,不是目录
return None, 404;
} else if (status == ok) { // 找到了目录
return directory_content, 200; // 返回目录内容
}
} else { // 原始 URL 是文件访问
// 7.0 STATIC 模块, ngx_http_static_module.c:49. ngx_http_static_handler
status := open_file(mapped_path);

if (status == dir) {
return redirect_to_added_slash, 301; // 命中目录, 重定向到带斜杠访问
} else if (status == not_found) {
return None, 404; // 不存在文件
} else if (status == not_regular_file) {
return None, 404; // 不是常规文件, 可能是设备...
} // other situations...

return file_content, 200; // 返回常规文件内容
}
}

Remark: 伪代码和源代码并不完全一致,源代码采用模块来先后处理 URL,顺序是 STATIC、INDEX 和 AUTO INDEX,分别在其内部进行路径处理与访问决策。然而伪代码先统一处理路径,再分别处理访问逻辑。在逻辑上等价,但和源代码并不是一一对应。个人认为伪代码在分析现象时比较方便。

实验环境

Docker Compose 配置:Github

采用 Nginx 进行反向代理与静态页面托管,使用 Flask 运行后端。

Nginx 设定

共有 8 个 Nginx 容器,分别命名为 nginx_anginx_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/

ba 该配置相同,dc 该配置相同,以此类推,所以只有 4 种不同配置。

具体的 server 块参见源代码。


每个 nginx 容器内的目录结构相同,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/
├─ etc/
│ └─ nginx/
│ └─ nginx.conf
├─ usr/
│ └─ share/
│ └─ nginx/
│ ├─ files123.html
│ ├─ html/
│ │ ├─ index.html
│ │ └─ apifoo/
│ │ └─ index.html
│ ├─ files/
│ │ ├─ counter.py
│ │ ├─ hello.c
│ │ └─ README.md
│ ├─ file/
│ │ ├─ counter1.py
│ │ ├─ hello1.c
│ │ └─ README1.md
│ └─ images/
│ └─ demo1.jpg
└─ var/
└─ log/
└─ nginx/

和文件目录相对应,静态页面托管的配置模板如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
server {
listen 80;
server_name localhost;

autoindex on;
absolute_redirect off; # 后文会讲它的作用,301 相对重定向

# 根目录
location / {
root /usr/share/nginx/html;
index index.html index.htm;
}

# Code(不同的容器配置不同,当前是 nginx_a 容器的配置)
location /code/ {
alias /usr/share/nginx/files;
expires 30d;
}

# 图片文件
location /images/ {
root /usr/share/nginx;
expires 30d;
}
}

Flask 设定

只需要一个 Flask 容器,在 app.py 文件中定义 /api/foo/foo/apifoo 等路由,用来接受请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@app.route('/api/foo', methods=['GET'])
def foo():
"""URL 前有 api 前缀"""
return jsonify({
"message": "Flask 配置的路由是 /api/foo",
})

@app.route('/foo', methods=['GET'])
def bar():
"""另一个路由"""
return jsonify({
"message": "Flask 配置的路由是 /foo"
})

@app.route('/apifoo', methods=['GET'])
def apifoo():
"""API 路由"""
return jsonify({
"message": "Flask 配置的路由是 /apifoo"
})

@app.route('/health', methods=['GET'])
def health():
"""健康检查端点"""
return jsonify({
"status": "healthy",
"timestamp": datetime.now().isoformat()
})

# catch all
@app.route('/<path:path>', methods=['GET'])
def catch_all(path):
"""捕获所有未定义的路由"""
return jsonify({
"message": f"未找到路径: /{path}"
}), 404

docker-compose 文件配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
services:
# Flask 后端服务
flask:
build:
context: ./flask
dockerfile: Dockerfile
container_name: flask
restart: unless-stopped
networks:
- microservice-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
interval: 30s
timeout: 10s
retries: 3

# Nginx 前端/网关服务
nginx_a:
build:
context: ./nginx_a
dockerfile: Dockerfile
container_name: nginx_a
restart: unless-stopped
ports:
- "23333:80"
depends_on:
- flask
networks:
- microservice-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/"]
interval: 30s
timeout: 10s
retries: 3

# 略去 7 个 nginx 微服务

networks:
microservice-network:
driver: bridge

完整的配置参见源码,需要注意的地方是,不同 flask 容器的 container_name 不同,不同 nginx 容器的 contextcontainer_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 解析,分为三个步骤:

  1. 根据前缀,找到匹配的 location 块;
  2. 根据关键字(aliasrootproxy_pass) 进行地址转换;
  3. 根据静态映射还是后端转发,做相应的文件访问或请求转发逻辑。

location 块中指明的前缀只负责匹配,可以理解为 C 语言中的 case,具体地址转换的逻辑在第二步。

在第二步中,我把 Nginx 对 URL 的处理分成两派:

  • 直接 concat;
  • 先替换再 concat。

读者可以回顾 TL:DR 一节的算法,看看是不是只有这两派。

假如用户输入的 URL 是 http://www.baidu.com/foo/bar,当到达目标服务器时,其实有用的信息是 /foo/bar,是不是就像在本地访问一样?cd /foo/barNginx 要做的事情是把这个路径翻译为本地路径。

假如 root/var/www,末尾有没有 / 没区别。

根据 Nginx 的源码,如果 root 的路径末尾有 /,会被删去最后一个 /。既然用户的请求路径肯定从 / 开始(如 http://www.baidu.com/foo 中去掉协议和主机名后得到的 /foo),为了拼接字符串,root 没必要再在末尾保留 / 了。要是写了超过一个 / 怎么办?看看具体的 OS 实现吧。

1
2
3
4
5
6
// src/http/ngx_http_core_module.c:4622
if (!alias && clcf->root.len > 0 // 没有 alias 且 root 长度 > 0
&& clcf->root.data[clcf->root.len - 1] == '/') // 而且 root 的最后一个字符是 /
{
clcf->root.len--;
}

地址映射规则解析

下面具体解释这两种流派:

直接 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。

实验一: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
2
3
4
5
6
7
8
9
@app.route('/api/api1', methods=['GET'])
def api1():
pass
@app.route('/api/api2', methods=['GET'])
def api2():
pass
@app.route('/api/api3', methods=['GET'])
def api3():
pass

上方这种路由格式,如果要访问 API 1,格式是 后端/api/api1

1
2
3
4
5
6
7
8
9
@app.route('/api1', methods=['GET'])
def api1():
pass
@app.route('/api2', methods=['GET'])
def api2():
pass
@app.route('/api3', methods=['GET'])
def api3():
pass

而第二种路由格式中,访问 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
2
3
{
"message": "Flask 配置的路由是 /foo"
}

而访问 localhost:23340/api/foo(即 H) 得到的响应是:

1
2
3
{
"message": "未找到路径: /api//foo"
}

AI 告诉我,这涉及到 WSGI 层(Werkzeug)对 URL 的规范化,和 Flask 无关。流程是:

1
原始 URL => WSGI => Flask

原始 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.htmlindex.htm 等索引文件。这符合直觉:个人配置的在 AUTO 之前。逐个检查文件。在每个文件的循环中,先判断能否打开文件,如果能打开,则返回索引。不能打开,则看下一个。不过在看下一个之前,先验证父目录是否存在,如果不存在或者不是目录,不必浪费时间访问其他索引文件了。

INDEX 处理完毕后,如果是 NGX_DECLINE,则交给 AUTO INDEX 模块做收尾。AUTO INDEX 模块把路径无脑地去除最后一个字符。可能的原因是它认为路径都会以 / 结尾。随后,它试图打开这个目录,如果能打开,返回自动构建的索引,否则,如果目录不存在,返回 404;如果没有设定启用自动索引,返回 403。


读者如果有些混乱,我们可以看看 nginx_a 中访问 /code/ 的情形。

我们设定了 3 个情景,仓库中的容器配置默认是情景 2,剩下的需要手动进入容器修改。在 nginx_a 容器的 /usr/share/nginx 目录下,一直存在 file/ 目录。而 files 有 3 种情形:

  1. files 是普通文件;
  2. files/ 是目录;
  3. 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 帮忙改一改,但是没能成功,说是涉及到内存越界或者其他的遗留问题。感兴趣的读者可以自行尝试。