这是《Nginx 从入门到精通》系列的第 3 篇(全系列 15 篇 + 4 个实战项目)。目标:把 Nginx 配置当成「一门有作用域、有继承、有惰性求值的语言」来读------不靠猜、不靠试错,只用
nginx -t+nginx -T就能回答「这条指令到底在哪一层生效、值最终来自谁」。前置 :第 2 篇(安装、升级与目录结构)的目录约定与
-t/-s用法;实验复用lab/00-env(宿主 8080 → 容器 80,上游组app_pool)。 本文所有输出与结论均为 nginx 1.30.6(stable)实测,文末附 5 道自测与延伸阅读。
1. 先跑起来(5 分钟实验)
一行配置都不改,先把「当前真正生效的配置」打印出来。这是本章唯一必须养成肌肉记忆的动作:动手前 -T,改完后 -t。
bash
cd nginx/lab/00-env
docker compose up -d
docker compose exec -T gateway nginx -t -c /etc/nginx/lab/nginx.conf
docker compose exec -T gateway nginx -T -c /etc/nginx/lab/nginx.conf | grep -n '^# configuration file'
预期输出(前两行是 -t 的结论,它写在 stderr ;其余是 -T 的 stdout,行号随镜像里的 mime.types 行数浮动):
text
nginx: the configuration file /etc/nginx/lab/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/lab/nginx.conf test is successful
1:# configuration file /etc/nginx/lab/nginx.conf:
51:# configuration file /etc/nginx/mime.types:
152:# configuration file /etc/nginx/lab/conf.d/default.conf:
对着这两条命令能读出两件事,本章剩下的内容都是在把它们讲透:
| 观察 | 结论 |
|---|---|
-t 只吐 syntax is ok / test is successful 两行 |
-t 校验语法 + 语义 :文件能否打开、指令是否允许出现在这个上下文、参数个数与类型、upstream 组名是否真的存在。它不校验业务行为 ------能过 -t 不代表路由是对的,第 4/5/6 篇的坑全在这条缝里 |
-T 输出里每个文件前有 # configuration file <路径>: 标记,且递归展开 (连 mime.types 都在,第 51 行) |
-T = -t + 把所有 include 原地粘贴成一片平铺文本。「谁覆盖谁」可以肉眼比对,不用来回跳文件;顺序就是真实加载顺序,注释也原样保留 |
-t 必须带 -c /etc/nginx/lab/nginx.conf:主配置被 command 的 -c 改到了挂载目录,不带 -c 校验的是镜像自带那份默认配置,等于没校验 (第 2 篇的坑 1,本章所有命令都带 -c)。
2. 核心概念
2.1 两种指令,与一棵上下文树
| 类型 | 写法 | 例子 | 说明 |
|---|---|---|---|
| 简单指令 | 名字 参数... ; |
worker_processes 1; |
以分号结束,一行可以放多条 |
| 块指令 | 名字 { ... } |
server { ... } |
花括号成对,块内可再放简单指令或块指令 |
上下文(context)就是「这句话允许写在哪一对花括号里」。写错位置不是警告,而是启动失败:
text
main(顶层,没有花括号,只出现一次)
├── events { } 连接模型:worker_connections / use
├── http { } HTTP 服务的一切
│ ├── upstream { } 后端组:块级独立,不参与继承
│ ├── map / geo { } 变量映射:块级独立,请求期惰性求值
│ └── server { } 一个站点(端口 + 域名)
│ ├── if { } 隐式 location,官方称为危险区
│ ├── location { } 路由单元,可再套 limit_except { }(按方法限权)
│ └── location @name { } 命名 location,只能由内部跳转到达
├── stream { } 四层代理(第 13 篇)
└── mail { } 邮件代理(本教程不用)
为什么这样分层:http 放「所有站点共享的东西」(MIME、日志格式、upstream、gzip),server 放「这个站点的东西」(端口、域名、root),location 放「这个路径的东西」。层级就是复用边界------写在高一层 = 所有低层默认继承;写在低一层 = 只影响那一个分支。后面所有「改了没反应」的问题,根子都在这里:你改的那一层可能根本没被匹配到。
2.2 继承的四种形态(本章最重要的一张表)
| 形态 | 代表指令 | 规则 | 实测后果 |
|---|---|---|---|
| 单值覆盖 | root、index、expires、proxy_read_timeout |
子级没写 → 用父级的值;子级写了 → 就地覆盖,父级对该分支不再有意义 | root 在 server 里,location /static/ 改用 alias,两者互不干扰 |
| 整体替换(最容易踩) | add_header、proxy_set_header 这类「可重复」指令 |
子级一旦出现 该指令,父级同组指令全部失效 ;同一块内写多条 = 追加 | server 级的 add_header X-Gateway 在写了 add_header Cache-Control 的 /static/ 里消失 |
| 块级独立 | upstream、map、geo |
不参与继承:upstream 靠组名 被 proxy_pass 引用,map 靠变量名被引用 |
upstream app_pool 只在 proxy_pass http://app_pool 处生效;写进 server 里直接报错 |
| 上下文独占 | worker_connections(仅 events)、worker_processes(仅 main)、proxy_pass(不能在 http) |
位置错了就是 directive is not allowed here |
见实验 1 |
把「整体替换」记成一句话:同名指令在子级出现 = 父级那一组被整体丢掉,不是相加 。「可重复」只意味着同一层内能写多行,不意味着跨层累加。这个语义是第 10 篇「线上安全头只剩一个」的直接原因。
时间维度上还有第二层:add_header 默认只对 200 201 204 206 301 302 303 304 307 308 生效,4xx/5xx 响应一条 add_header 都不带 ;要覆盖错误响应必须加 always(≥1.7.5,见 ngx_http_headers_module)。
2.3 变量:名字、来源与求值时机
变量是配置里唯一「运行时才有值」的东西,也是 -T 看不到的部分(它只显示 $uri 这种名字,不显示值)。
| 变量 | 是什么 | 常见用途 |
|---|---|---|
$uri |
当前请求归一化后 的路径:已解码 %20、已消掉 ../、不含参数;rewrite 会改它 |
location / try_files / 拼 root |
$request_uri |
客户端发来的原始 URI(含参数、保留编码),永不变 | 日志、跳转时保住原始参数 |
$args / $query_string |
问号后面的部分(两者同值);set $args "" 可清空 |
拼后端 URL |
$host |
主机名:小写、去端口 ;取值顺序 = 请求行主机 → Host 头 → 匹配到的 server_name |
绝大多数场景用它 |
$http_host |
原样 的 Host 头(含端口、保留大小写,可能为空) |
排障用,不要直接拼业务逻辑 |
$http_<名字> |
任意请求头,- 换成 _,如 $http_user_agent |
map 分类、限流、灰度 |
$remote_addr / $scheme |
直连客户端 IP(不是 XFF 里的真实 IP)/ http 或 https |
访问控制、拼重定向 |
$request_time / $upstream_response_time / $upstream_addr |
总耗时 / 上游耗时 / 本次碰过的上游地址,重试链上是逗号分隔的多值 | 日志排障、摘流、串号排查(第 9 篇) |
三类求值时机,决定了「能不能用 map 替掉 if」:
| 时机 | 机制 | 例子 |
|---|---|---|
| 配置加载期(启动时一次) | 普通指令的参数 | root /usr/share/nginx/html;、listen 80; |
| 请求期惰性求值(用到才算) | map、geo、set、if 的条件 |
map $host $backend_pool { ... } |
| 特例 | init_by_lua(第 11 篇,OpenResty)在配置加载期执行 |
预热字典、预处理配置 |
set 是 rewrite 模块的指令,在 server/location/if 里按书写顺序执行 ,会真的改掉变量值;map 声明在 http 里,只在被引用时求值并缓存 ,压根没有「执行顺序」这回事。所以 map 天然比 if 安全:无副作用、不会被跳过、也不影响 location 的匹配结果。
2.4 include 的语义与加载顺序
include 只做一件事:在该位置把目标文件内容粘贴进来。它不引入作用域、不隔离配置、不改变继承关系------粘贴点的位置决定了被包含内容的上下文。
| 事实 | 说明 |
|---|---|
| glob 按文件名字典序展开 | 00-a.conf → 05-b.conf → 10-c.conf,与创建时间无关 |
| 匹配不到任何文件不报错 | include /etc/nginx/lab/conf.d/*.conf; 在空目录下照样 syntax is ok------「少了一个站点却没人发现」的经典原因 |
相对路径以编译期 prefix 为基准 |
include mime.types; = /etc/nginx/mime.types(官方镜像 prefix 为 /etc/nginx,nginx -V 可查) |
| 同一文件被包含两次 = 内容出现两次 | 表现为 duplicate upstream "app_pool" / duplicate listen |
sites-available + sites-enabled 软链约定(Debian/Ubuntu 包管理器引入)的利弊:启停站点只需建/删软链,不用注释掉配置;代价是软链与真实文件分居两地、ls 看不到全貌,误留的备份文件(default.conf.bak 同样匹配 *.conf)会被加载,-t 报出的路径又是软链目标、容易看错。本库因此约定「目录 + 数字前缀 」:conf.d/00-default.conf、conf.d/10-api.conf,前缀数字控制字典序,可读、有序、不依赖软链。
3. 指令详解
下面 14 条是本章配置里真正用到的(默认值以官方文档为准,页面见第 8 节);resolver、log_format、stub_status、try_files 留给第 7 / 4 / 9 / 4 篇。
| 指令 | 默认值 | 上下文 | 说明与坑 |
|---|---|---|---|
include |
--- | 任意 | 文本展开;glob 字典序;匹配不到不报错;被包含内容继承粘贴点的上下文 |
worker_processes |
1 |
main | 只能出现一次,写两次 → directive is duplicate;生产用 auto,本实验固定 1 让轮询可预测 |
worker_connections |
512 |
events | 只在 events 内合法,写进 http 直接 not allowed here;单 worker 最大连接数,还受 fd 上限约束 |
map |
--- | http | map $源 $新 { 键 值; default 值; };请求期求值、无副作用;键支持字符串与正则(~、~*) |
set |
--- | server/location/if | rewrite 模块;按书写顺序执行,真的改变变量;别拿它当「配置开关」滥用 |
if |
--- | server/location | 隐式 location,只该放 return 与 rewrite;能过 -t 却可能不生效(官方 IfIsEvil) |
add_header |
--- | http/server/location/if | 整体替换 父级同组头;默认只作用于 2xx/3xx(精确列表见 2.2);加 always 覆盖 4xx/5xx(≥1.7.5) |
limit_except |
--- | location | 按方法限权:limit_except GET HEAD { deny all; } = 只有 GET/HEAD 放行,其余 403 |
auth_basic / auth_basic_user_file |
off / --- |
http/server/location/limit_except | auth_basic 触发 401;两条要成对写 ,只写 auth_basic 不写文件路径 → -t 报参数不足 |
error_page |
--- | http/server/location/if | error_page 404 = @named; 带 = 时状态码由内部跳转结果决定;不带 = 则保留原状态码、只换响应体 |
internal |
--- | location | 标记「只能由内部跳转访问」,外部直连 404;但能被 error_page 404 = /该路径 间接打开(坑 6) |
return |
--- | server/location/if | return 200 "text"; 直接结束请求;带响应体的 return 不进入 error_page 处理(实测) |
proxy_pass |
--- | location/if/limit_except | 不能写在 http/main ;不带 URI 原样透传路径,带 URI(结尾 /)替换匹配前缀;用变量时 URI 规则不同(坑 5) |
proxy_set_header |
Host $proxy_host、Connection close |
http/server/location | 与 add_header 同属「整体替换」:/api/ 里写了 4 条,server 级同组全部失效 |
4. 完整配置示例
下面是本章结束时的 lab 配置。相对 lab/00-env 只新增 5 处(标了「新增 ①...⑤」),其余逐字保持原样,保证后续各章的实验基线不变。为省版面,配置里不留空行,注释即分块标记。
4.1 主配置 nginx/nginx.conf
nginx
# main 上下文
user nginx;
worker_processes 1; # 实验固定 1 worker,轮询可预测
error_log /var/log/nginx/lab_error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024; # 只能在 events 内,写进 http 会启动失败
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user "$request" $status $body_bytes_sent '
'rt=$request_time urt=$upstream_response_time up=$upstream_addr '
'xff="$http_x_forwarded_for"';
access_log /var/log/nginx/lab_access.log main;
sendfile on;
keepalive_timeout 65;
gzip on;
gzip_types text/css application/javascript application/json text/plain;
resolver 127.0.0.11 valid=5s ipv6=off; # 容器 DNS;变量 proxy_pass 要靠它
upstream app_pool {
zone app_pool 64k;
server app1:8080 resolve max_fails=3 fail_timeout=10s;
server app2:8080 resolve max_fails=3 fail_timeout=10s;
server app3:8080 resolve max_fails=3 fail_timeout=10s;
}
# 新增 ①:$host -> 上游,请求期惰性求值;值可以是组名,也可以是 host:port
map $host $backend_pool {
default app_pool; # 走轮询组
debug.lab.local app1:8080; # 排查用:钉死单台
}
include /etc/nginx/lab/conf.d/*.conf; # 展开顺序 = 文件名字典序
}
4.2 站点配置 nginx/conf.d/default.conf
nginx
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# 新增 ②:server 级响应头------只对"自己没有 add_header"的 location 生效
add_header X-Gateway "lab" always;
location = /healthz {
access_log off;
return 200 "gateway ok\n";
}
location /nginx_status {
stub_status;
allow 127.0.0.1;
deny all;
}
location /static/ {
alias /usr/share/nginx/html/;
expires 7d;
# lab 原本就有这一行:它让 server 级的 X-Gateway 在本 location 整体失效
add_header Cache-Control "public";
}
location /slow/ {
proxy_pass http://app_pool;
proxy_set_header Host $host;
proxy_read_timeout 5s;
}
location /api/ {
proxy_pass http://app_pool/; # 带 URI:/api/json -> 后端 /json
proxy_connect_timeout 2s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 新增 ③:变量形式的 proxy_pass,上游由 map 决定
location /lane/ {
proxy_pass http://$backend_pool; # 不带 URI:路径原样透传
}
# 新增 ④:按方法限权 + Basic 认证
location /private/ {
limit_except GET HEAD {
deny all;
}
auth_basic "lab";
auth_basic_user_file /etc/nginx/lab/conf.d/htpasswd;
}
# 新增 ⑤:错误页交给命名 location;$uri 保持原值
location = /boom {
error_page 404 = @fallback;
return 404;
}
location @fallback {
return 200 "fallback uri=$uri\n";
}
location = /internal-metrics {
internal;
return 200 "internal-only\n";
}
location / {
try_files $uri $uri/ /index.html;
}
}
4.3 相对 lab/00-env 改了哪几行
| # | 文件 | 位置 | 改动 | 本章用途 |
|---|---|---|---|---|
| ① | nginx.conf |
http 内、include conf.d 之前 |
map $host $backend_pool { ... } |
惰性求值 + map 替 if(实验 4) |
| ② | default.conf |
server 顶部 |
add_header X-Gateway "lab" always; |
继承与整体替换(实验 2) |
| ③ | default.conf |
新增 location /lane/ |
proxy_pass http://$backend_pool; |
变量形式 proxy_pass 的 URI 差异(实验 4) |
| ④ | default.conf |
新增 location /private/ |
limit_except + auth_basic* |
按方法限权、认证放置位置(实验 5) |
| ⑤ | default.conf |
新增 /boom、@fallback、/internal-metrics |
error_page + internal |
内部跳转与「只许内部访问」(实验 5) |
落盘(改完必须 -t,再 reload)------实验 2 到实验 5 都要先做这一步:
bash
cd nginx/lab/00-env
# 手工编辑上两个文件之后:
docker compose exec -T gateway nginx -t -c /etc/nginx/lab/nginx.conf \
&& docker compose exec -T gateway nginx -s reload
docker compose exec -T gateway curl -sI localhost/static/index.html \
| grep -E 'HTTP/|Cache-Control|X-Gateway|Expires'
预期:HTTP/1.1 200 OK、Cache-Control: public、Expires: ...、没有 X-Gateway------这三行的组合就是 2.2 那张表第 2 行的现场照片。
5. 必做实验
实验 1:把指令丢进错误上下文,看 emerg 长什么样
bash
cd nginx/lab/00-env
# 用编辑器在 nginx/nginx.conf 的 http { 之后插入一行(故意写错):
# proxy_pass http://app_pool;
docker compose exec -T gateway nginx -t -c /etc/nginx/lab/nginx.conf
预期(nginx 1.30.6 实测,行号随插入位置变化):
text
nginx: [emerg] "proxy_pass" directive is not allowed here in /etc/nginx/lab/nginx.conf:17
nginx: configuration file /etc/nginx/lab/nginx.conf test failed
记住这四种固定句式,能定位 80% 的启动失败:
| 报错片段 | 含义 | 典型原因 |
|---|---|---|
unknown directive "xxx" |
没有这个指令名 | 拼错(worker_connection 少个 s);或该模块没编译进来 |
"xxx" directive is not allowed here |
指令存在,但当前上下文不允许 | worker_connections 写到 http;proxy_pass 写到 http |
"xxx" directive is duplicate |
同一上下文重复声明 | 同文件写两次 worker_processes;两条软链指向同一站点文件 |
duplicate upstream "app_pool" |
upstream 组名重复 | 复制站点文件时把带 upstream 的文件也复制了一份 |
为什么能在启动前发现:-t 会真的走一遍解析器(词法 → 块结构 → 每个指令的参数校验),所以不启动、不 reload 就能拿到与启动时完全一致的第一条错误。验完记得把那一行删掉并 -t 确认恢复。
实验 2:add_header 到底是继承还是覆盖
bash
docker compose exec -T gateway nginx -T -c /etc/nginx/lab/nginx.conf | grep -n add_header
docker compose exec -T gateway curl -sI localhost/healthz
docker compose exec -T gateway curl -sI localhost/static/index.html
docker compose exec -T gateway curl -sI localhost/definitely-404
预期(实测):
| 请求 | 响应头里有 X-Gateway |
为什么 |
|---|---|---|
/healthz |
有 | 该 location 内没有任何 add_header,整体继承 server 级那一条 |
/static/index.html |
没有 | 该 location 写了 add_header Cache-Control,server 级整组被替换 |
/definitely-404(try_files 内部 404) |
有 | server 级那条带 always,4xx 也发;去掉 always 就只有 2xx/3xx 有 |
结论:add_header 的语义是「当前层这一组头 」,不是「往响应里加一条」。想让所有 location 都带安全头只有两条路:每个 location 各写一遍,或抽成统一片段 include conf.d/security-headers.conf;(第 10 篇的做法)。顺带记住:同一 location 里写多条 add_header 是累加的------这也是你能在 /static/ 同时看到 Cache-Control: public 与 Expires 的原因(后者由 expires 生成,属于另一组)。
实验 3:只用 -T 回答「这个 location 继承了哪个 root」
bash
docker compose exec -T gateway nginx -T -c /etc/nginx/lab/nginx.conf \
| sed -n '/configuration file .*default.conf/,$p' | grep -nE 'root|alias|location|try_files'
docker compose exec -T gateway nginx -T -c /etc/nginx/lab/nginx.conf | grep -n '^# configuration file'
预期看到 root /usr/share/nginx/html; 在 server 级,location /static/ 里是 alias;# configuration file 的顺序是 nginx.conf → conf.d/default.conf。判断方法三句话:
- 只有
server级声明了root,所以location = /healthz、location /继承到/usr/share/nginx/html; location /static/自己用alias,不参与root继承 ------alias与root是两个不同的指令名,不构成覆盖关系(拼接规则差异见第 4 篇);- 任何 location 只要自己写了
root/alias,父级那条对它无效;同理新增conf.d/05-api.conf时,它按文件名字典序 排在00-default.conf之后,不是按创建时间。
实验 4:用 map 把 $host 映射到上游,并实测变量版 proxy_pass 的 URI 规则
bash
# 默认 Host:轮询三台
for i in $(seq 3); do docker compose exec -T gateway curl -s localhost/lane/json | grep -o '"app": "[^"]*"'; done
# 指定 Host:钉死 app1
for i in $(seq 3); do docker compose exec -T gateway curl -s -H 'Host: debug.lab.local' localhost/lane/json | grep -o '"app": "[^"]*"'; done
预期:第一组 app1 / app2 / app3 依次出现;第二组全是 app1。
为什么:proxy_pass http://$backend_pool 里出现变量,nginx 在每个请求 上求值,得到组名 app_pool(走轮询)或 app1:8080(单机地址)。值是组名时仍复用 upstream 配置(含 zone/resolve);值是 host:port 时按地址直连------app1 这种容器名要靠 http 里的 resolver 127.0.0.11 才能解析。
再验证变量形式与静态写法最大的区别------URI 拼接:
bash
docker compose exec -T gateway curl -s localhost/lane/json # 上游收到 /json
sed -i '' 's|proxy_pass http://$backend_pool;|proxy_pass http://$backend_pool/;|' nginx/conf.d/default.conf
docker compose exec -T gateway nginx -t -c /etc/nginx/lab/nginx.conf \
&& docker compose exec -T gateway nginx -s reload
docker compose exec -T gateway curl -s 'localhost/lane/a/b?q=1' # 上游只会收到 /
实测:多了一个结尾斜杠后,/lane/a/b?q=1 到上游变成 uri=/ args=------路径和参数全部丢失 。带变量时若写了 URI,nginx 就把该 URI 当最终 URI、不再拼接原请求;要用变量又不丢路径,必须显式拼 proxy_pass http://$backend_pool$request_uri;。实验完改回来(git checkout 或手改),再 -t + reload。
实验 5:按方法限权、错误页与「只许内部访问」
bash
docker compose exec -T gateway curl -s -o /dev/null -w 'GET /private/x -> %{http_code}\n' localhost/private/x
docker compose exec -T gateway curl -s -o /dev/null -w 'POST /private/x -> %{http_code}\n' -X POST localhost/private/x
docker compose exec -T gateway curl -si localhost/boom | sed -n '1p;/fallback/p'
docker compose exec -T gateway curl -s -o /dev/null -w 'external metrics -> %{http_code}\n' localhost/internal-metrics
预期(实测;htpasswd 文件没创建,认证必然失败):
| 请求 | 状态码 | 为什么 |
|---|---|---|
GET /private/x |
401 |
auth_basic 生效且无凭据(用户文件不存在也是 401,不是 500) |
POST /private/x |
403 |
limit_except GET HEAD { deny all; } 先于认证生效,非 GET/HEAD 直接拒 |
GET /boom |
200 + fallback uri=/boom |
error_page 404 = @fallback 把 404 内部改写成 @fallback 的 200,$uri 保持原值 |
直连 /internal-metrics |
404 |
internal 生效,非内部请求一律 404(对外表现为「不存在」,不泄露路径存在性) |
@fallback 是命名 location :URI 永远匹配不到它,即便不写 internal 也打不开。internal 是给「有真实 URI 的内部端点」(如 /metrics、/404.html)用的。
6. 验收自测(5 题)
Q1. 手上只有 nginx -T 的输出、没有任何文档:怎么判断 /api/ 这个 location 最终用的 root(或 alias)是哪一个?
答案要点:①在
-T里定位/api/这个 location 块(include已平铺,不存在「文件没加载」的情况);②块内没有root/alias→ 向上找最近的server、再找http级的root,那就是它的值;③块内有 → 就是块内这条,父级无效;④若该 location 有proxy_pass且实际命中上游,root根本不参与。判断「最近」看的是文本嵌套层级,不是文件顺序。
Q2. 客户端原样发来 GET /a%20b/../c?x=1&y=2(不做归一化),$uri、$request_uri、$args 各是什么?
答案要点:
$uri=/c(已解码、已消掉../、不含参数);$request_uri=/a%20b/../c?x=1&y=2(原始,连编码都保留);$args=x=1&y=2。延伸:../若真穿透到站点根之外,nginx 在归一化阶段直接回 400,所以「用$uri拼文件路径」比用$request_uri安全得多。想现场复现,在 lab 里加一条location = /vars { return 200 "uri=$uri|request_uri=$request_uri|args=$args|host=$host|http_host=$http_host\n"; },再用curl --path-as-is打过去。
Q3. Host: LAB.LOCAL:8080 与 Host: EVIL.COM 两种请求下,$host 和 $http_host 分别是什么?为什么业务里该用 $host?
答案要点:
LAB.LOCAL:8080→$host=lab.local(小写、去端口)、$http_host=LAB.LOCAL:8080(原样);EVIL.COM→$host=evil.com、$http_host=EVIL.COM。$host的取值顺序是「请求行主机 →Host头 → 匹配的server_name」,所以没有Host头(HTTP/1.0 或畸形请求)时它仍有值,而$http_host为空;它还统一了小写与端口,适合做map的键、拼重定向、与server_name比对。$http_host只适合原样透传或排障。
Q4. server 里写了 add_header X-Frame-Options DENY;,但线上 /static/ 的响应里没有它,/healthz 里有。为什么?怎么改?
答案要点:
add_header是「整体替换」型------/static/里写了add_header Cache-Control ...,该 location 只发自己这一组头,父级server的X-Frame-Options整组失效。三种改法:①在/static/里把安全头再写一遍;②抽出include conf.d/security-headers.conf;片段,在每个需要的 location 里包含(推荐,第 10 篇采用);③若只是 4xx/5xx 上丢头,那是另一个坑,需要always(≥1.7.5)。补充:同层写多条是累加的,「覆盖」只发生在跨层。
Q5. if ($http_user_agent ~* bot) { proxy_pass http://app_pool; } 能通过 nginx -t,但「看起来对、跑起来不对」。为什么?正解是什么?
答案要点:
if在 location 里被实现为隐式 location (官方 IfIsEvil 明确说明),块内只保证return/rewrite的语义,其它指令不受承诺;-t只校验语法与上下文,所以必过。更麻烦的是它静默生效,线上表现为「某些 UA 走了另一个上游」。正解:用map把 UA 折算成变量,再在静态的proxy_pass/proxy_set_header/add_header里使用------map无副作用、请求期求值、可被-T完整审计;规则复杂时拆成独立 location(第 6 篇用map做灰度)。
7. 常见坑
- 线上安全头只剩一个,
/static/与/api/表现还不一样 → 根因:add_header整体替换,子级一出现就丢掉父级整组。正解:每个 location 各写一遍,或include统一片段;验收用curl -sI逐条路径看。 - 4xx/5xx 响应上安全头全没了 → 根因:
add_header默认只作用于 2xx/3xx(精确列表见 2.2)。正解:加always(≥1.7.5),并且错误页要单独验一遍。 if里写了proxy_pass,-t通过、行为不可预测 → 根因:if是隐式 location,语义不受承诺(IfIsEvil)。正解:用map+ 变量;或拆 location;if里只留return/rewrite。- 新增了站点文件,
-t通过但访问不到 → 根因:includeglob 匹配不到文件不报错 (空目录也syntax is ok);或前缀数字导致顺序不对。正解:加完文件先nginx -T,在输出里搜configuration file确认它被加载。 - 变量形式
proxy_pass后端只收到/,参数全丢 → 根因:带变量且写了 URI 时,nginx 把该 URI 当最终 URI、不拼原请求。正解:proxy_pass http://$backend_pool$request_uri;,或干脆不写结尾/。 - 加了
internal的端点仍被外部访问 → 根因:error_page 404 = /该路径;会把 404 内部改写到它(实测外部返回 200)。正解:错误页指向@命名 location;internal只保护「没有error_page指过来」的路径。 error_page配了不生效,响应体还是原来的 → 根因:return 404 "body";带响应体时不进入error_page处理(实测);或状态码不在error_page列出范围。正解:用不带 body 的return 404;,再核对状态码。-t报duplicate upstream/duplicate listen→ 根因:同一份内容被加载两次------复制站点文件时带上了upstream、软链指向同一文件、*.conf匹配到备份文件。正解:备份文件放conf.d之外;upstream只留一处;在-T输出里搜组名确认只出现一次。
判断顺序建议:先 -T 看真实生效文本(是不是我以为的那份)→ 再 -t 看语义(上下文 / 参数 / 重复)→ 最后 curl -sI 看结果(头 / 状态码),三步覆盖「改错层、写错位、期望错」三类问题。
8. 延伸阅读
ngx_http_core_module(root/alias/location/internal/error_page,上下文与继承的权威定义):nginx.org/en/docs/htt...ngx_http_map_module(map语法、hostnames≥1.11.3、volatile):nginx.org/en/docs/htt...ngx_http_rewrite_module(set/if/return/rewrite与$uri、$request_uri、$args):nginx.org/en/docs/htt...ngx_http_headers_module(add_header的生效状态码与always≥1.7.5):nginx.org/en/docs/htt...- 命令行开关(
-t、-T;-T自 1.9.0 起):nginx.org/en/docs/swi... - 「If is evil」(
if在 location 内的官方立场与反例):www.nginx.com/resources/w...
系列导航:第 1 篇进程模型与能力边界 → 第 2 篇安装、升级与目录结构 → 第 3 篇(本篇)配置语法与上下文 → 第 4 篇静态站与 location → 第 5 篇反向代理 → 第 6 篇 rewrite 与 server_name ... 第 15 篇故障案例库。