为什么必须拆分 include
Nginx 的主配置只适合承载「全局骨架」:worker 进程数、事件模型、日志格式、以及指向各个功能片段的 include。把业务站点直接写进主配置的后果是任何改动都要全量 diff,评审成本随行数线性上升,回滚粒度也粗得可怕。
合理的切分单位是「按职责」而不是「按行数」:HTTP 通用设置一份、upstream 池一份、每个 server 站点一份、location 级别再按功能(限流、静态资源缓存、真实 IP)拆分。这样新增站点只需新增文件并加一行 include。
要注意 include 的路径是相对于 `prefix`(通常是 `/etc/nginx`)而非当前配置文件所在目录,这是最常见的踩坑点。规范的做法是全仓库统一用绝对路径,并在 README 里写清目录约定。
# /etc/nginx/nginx.conf 主配置只留骨架
user nginx;
worker_processes auto;
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main "$remote_addr - $request_uri $status $body_bytes_sent $request_time";
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
# map 必须定义在 http 层级,才能被 server/location 引用
include /etc/nginx/conf.d/maps/*.conf;
include /etc/nginx/conf.d/upstreams/*.conf;
include /etc/nginx/conf.d/servers/*.conf;
}location 匹配优先级必须写进文档
location 的匹配顺序是最容易出错也最难凭直觉确认的部分:先精确匹配 `=` 的 uri,再处理最长前缀匹配(同一最长前缀内,正则按声明顺序优先,`^~` 则直接终止正则匹配),最后才是正则与前缀兜底。这套规则必须以表格形式固化在文档里。
实践中的常见错误是同时使用 `^~` 和正则。对静态资源目录用 `^~` 是正确的(避免正则误伤),但如果同一层又写了 `location ~ \.php$` 之类的正则,命中顺序就会变得难以预测。稳妥做法是:静态资源一律 `^~`,动态请求才进正则。
验证方式是把优先级写成可执行的断言。用 `curl -I` 对每类路径做一次探测,断言响应头是否符合预期;再配合 `nginx -T` 打印展开后的完整配置,确认 include 全部生效且没有重复定义。
# 静态资源:^~ 终止正则匹配,避免被 location ~ .* 抢走
location ^~ /static/ {
root /var/www/app;
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
# 精确匹配:优先级最高
location = /healthz {
default_type application/json;
return 200 "{\"status\":\"ok\"}";
}
# 正则匹配:只处理动态请求
location ~ \.php$ {
fastcgi_pass unix:/run/php-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
# 兜底反代:只有上面都没命中才会到这里
location / {
proxy_pass http://app_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}map 与 geo:把重复 if 变成声明式
`map` 在 http 层级建立「输入 → 输出」的映射表,常用于把 User-Agent 转成限流键、把域名规范化为上游名、或把老参数名映射到新名字。它替代的是一长串 `if ($http_user_agent ~ ...)`,而且支持默认值 `default`,不会出现某个分支没命中就静默落空。
`geo` 专门处理 IP 归属,`geo $variable { default 0; 1.0.0.0/8 1; ... }` 之后可以在 server 里直接 `if ($is_cn)` 做判断。两者都支持 `include`,所以 IP 段列表和 UA 规则都可以独立维护,甚至从 GeoIP 数据库自动生成。
注意事项:`map` 的第一个参数可以是变量、字符串或正则,但**不能是**组合了捕获的复杂变量(历史上对 `$http_x_forwarded_for` 之类的处理有限制);`geo` 的 CIDR 列表很大时应放在独立文件里,避免主配置膨胀。
# conf.d/maps/limit-key.conf —— http 层级
map $http_user_agent $limit_key {
default "other";
"~*bot|crawler|spider" "crawler";
"~*mobile|android|iphone" "mobile";
"" "nobot";
}
map $host $upstream_name {
default "app_backend";
api.example.com "api_backend";
admin.example.com "admin_backend";
}
# conf.d/maps/geo.conf —— CIDR 列表放独立文件
geo $is_blocked_region {
default 0;
include /etc/nginx/geo/blocked.cidr;
}
server {
listen 80;
server_name example.com;
if ($is_blocked_region) {
return 403;
}
location /api/ {
limit_req zone=perip burst=20 nodelay;
proxy_pass http://$upstream_name;
}
}配置校验与安全基线
任何配置改动都必须先过 `nginx -t`,再 reload,绝不直接 `restart`。`-t` 会解析所有 include 并做语法与指令合法性检查;配合 `-T` 可以打印展开后的完整配置,便于 diff 审查实际生效内容。注意 `-t` 只做静态检查,不验证上游主机可达性。
安全基线里最常被漏掉的两项:一是隐藏版本号 `server_tokens off;`,二是 `limit_req`/`limit_conn` 与 `client_max_body_size` 的显式设置——默认 1MB 上传限制经常在接入新功能时突然冒出来。TLS 层面建议启用 HSTS 并关闭弱协议(`ssl_protocols TLSv1.2 TLSv1.3`)。
工程上把校验写进 CI:仓库里维护渲染模板(模板 + 环境变量 → 最终配置),CI 渲染后跑 `nginx -t`,通过才允许部署。这样环境差异不会漂移到线上,也避免了「本地 nginx 版本与线上不一致」导致的隐性兼容问题。
http {
server_tokens off;
client_max_body_size 20m;
limit_req_zone $binary_remote_addr zone=perip:10m rate=20r/s;
limit_conn_zone $binary_remote_addr zone=connperip:10m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
}
# CI 校验脚本片段
set -euo pipefail
envsubst < templates/nginx.conf.tpl > build/nginx.conf
nginx -t -c "$PWD/build/nginx.conf"
nginx -T -c "$PWD/build/nginx.conf" | grep -E "server_name|location" > build/nginx.effective.txt