Nginx 配置语法与上下文:一次搞懂继承、变量与用 map 替掉 if

这是《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。判断方法三句话:

  1. 只有 server 级声明了 root,所以 location = /healthz、location / 继承到 /usr/share/nginx/html;
  2. location /static/ 自己用 alias,不参与 root 继承 ------alias 与 root 是两个不同的指令名,不构成覆盖关系(拼接规则差异见第 4 篇);
  3. 任何 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. 常见坑

  1. 线上安全头只剩一个,/static/ 与 /api/ 表现还不一样 → 根因:add_header 整体替换,子级一出现就丢掉父级整组。正解:每个 location 各写一遍,或 include 统一片段;验收用 curl -sI 逐条路径看。
  2. 4xx/5xx 响应上安全头全没了 → 根因:add_header 默认只作用于 2xx/3xx(精确列表见 2.2)。正解:加 always(≥1.7.5),并且错误页要单独验一遍。
  3. if 里写了 proxy_pass,-t 通过、行为不可预测 → 根因:if 是隐式 location,语义不受承诺(IfIsEvil)。正解:用 map + 变量;或拆 location;if 里只留 return/rewrite。
  4. 新增了站点文件,-t 通过但访问不到 → 根因:include glob 匹配不到文件不报错 (空目录也 syntax is ok);或前缀数字导致顺序不对。正解:加完文件先 nginx -T,在输出里搜 configuration file 确认它被加载。
  5. 变量形式 proxy_pass 后端只收到 /,参数全丢 → 根因:带变量且写了 URI 时,nginx 把该 URI 当最终 URI、不拼原请求。正解:proxy_pass http://$backend_pool$request_uri;,或干脆不写结尾 /。
  6. 加了 internal 的端点仍被外部访问 → 根因:error_page 404 = /该路径; 会把 404 内部改写到它(实测外部返回 200)。正解:错误页指向 @命名 location;internal 只保护「没有 error_page 指过来」的路径。
  7. error_page 配了不生效,响应体还是原来的 → 根因:return 404 "body"; 带响应体时不进入 error_page 处理(实测);或状态码不在 error_page 列出范围。正解:用不带 body 的 return 404;,再核对状态码。
  8. -t 报 duplicate upstream / duplicate listen → 根因:同一份内容被加载两次------复制站点文件时带上了 upstream、软链指向同一文件、*.conf 匹配到备份文件。正解:备份文件放 conf.d 之外;upstream 只留一处;在 -T 输出里搜组名确认只出现一次。

判断顺序建议:先 -T 看真实生效文本(是不是我以为的那份)→ 再 -t 看语义(上下文 / 参数 / 重复)→ 最后 curl -sI 看结果(头 / 状态码),三步覆盖「改错层、写错位、期望错」三类问题。


8. 延伸阅读

  1. ngx_http_core_module(root/alias/location/internal/error_page,上下文与继承的权威定义):nginx.org/en/docs/htt...
  2. ngx_http_map_module(map 语法、hostnames ≥1.11.3、volatile):nginx.org/en/docs/htt...
  3. ngx_http_rewrite_module(set/if/return/rewrite 与 $uri、$request_uri、$args):nginx.org/en/docs/htt...
  4. ngx_http_headers_module(add_header 的生效状态码与 always ≥1.7.5):nginx.org/en/docs/htt...
  5. 命令行开关(-t、-T;-T 自 1.9.0 起):nginx.org/en/docs/swi...
  6. 「If is evil」(if 在 location 内的官方立场与反例):www.nginx.com/resources/w...

相关章节:Ch2 · Ch4 · Ch5 · Ch6 · Ch10 · Ch13


系列导航:第 1 篇进程模型与能力边界 → 第 2 篇安装、升级与目录结构 → 第 3 篇(本篇)配置语法与上下文 → 第 4 篇静态站与 location → 第 5 篇反向代理 → 第 6 篇 rewrite 与 server_name ... 第 15 篇故障案例库。

相关推荐
Arone聊架构15 分钟前
把 WeKnora v0.9 + 多站点客服工单塞进 1.9G 小鸡:248MB 内存压测、纯 Go 解析桥与 SQLite 写锁踩坑实录
后端
泡海椒23 分钟前
jquick-pdf 饼图(JPie)实战:Java 生成贷款五级分类资产质量报告
后端
学长毕设1 小时前
基于SpringBoot的景点管理系统-附源码
java·spring boot·后端
生锈的键盘3 小时前
从实战搞懂 Docker:一个 Python 项目的开发环境容器化笔记
后端
九零HTTP3 小时前
RFC 7230 把请求行分了四种,网关只用得上两种
后端
SomeOtherTime4 小时前
Rust生命周期介绍(AI回答)
开发语言·后端·rust
无责任此方_修行中4 小时前
「不止技术」AIE 2026 会后感悟
人工智能·后端·ai编程
SimonKing5 小时前
Workbuddy太占C盘空间了,写个迁移工具迁走10G的数据
java·后端·程序员
喵了几个咪6 小时前
GoWind Flow 拆解:一个 Go 工作流引擎与它的双模式设计器
开发语言·人工智能·后端·golang