一台机器上用 nginx 托管多个站点,容易踩的三个坑
server_name 的匹配优先级、conf.d 按字母序加载导致的隐式默认站漂移,以及 ACME 验证路径被 301 跳转吃掉。都是新增一个站点时才暴露出来的问题。
在同一台机器上跑多个站点,nginx 的配置本身不难写,难的是新加一个站点时不影响已有的。下面三件事,都是”配置看起来完全正确、但行为变了”的典型。
server_name 的匹配是有优先级的
请求进来,nginx 先按 listen 找到对应的套接字,再在这个套接字上的所有 server 块里挑一个。挑选顺序是固定的:
- 精确名字,
api.example.com - 前导通配,
*.example.com - 尾部通配,
api.example.* - 正则,
~^api\d+\.example\.com$ - 都没有匹配上,用默认 server
关键在第 1 条和第 5 条的关系:精确匹配永远赢过默认 server。所以只要你的新站点写的是确切的域名,它就不会抢走别的站点的流量——反过来,别的站点用了 server_name _ 这种兜底写法,也抢不走你的精确匹配。
server_name _ 本身没有任何特殊含义,它只是一个永远不会和真实域名相等的无效名字,被约定俗成地用来表示”我不打算匹配任何具体域名”。真正让它成为兜底的,是它所在的 server 块恰好是默认 server。
坑一:新增站点悄悄改变了默认 server
默认 server 是谁?在没有任何 default_server 标记的情况下,是该 listen 套接字上第一个被加载的 server 块。
而 include /etc/nginx/conf.d/*.conf; 是按文件名字母序展开的。这就意味着:谁排在前面,谁就是默认站。
假设现在 conf.d/ 里是:
app.conf listen 443 ssl; server_name 39.0.0.1; # 第一个,443 的默认站
gateway.conf listen 443 ssl; server_name api.example.com;
IP 直连 https://39.0.0.1/ 会落到 app.conf,符合预期。
现在新增一个博客,文件名叫 blog.conf:
app.conf
blog.conf listen 443 ssl; server_name example.com; # 变成了第一个!
gateway.conf
blog.conf 按字母序排到了 app.conf 前面,于是它成了 443 的新默认站。所有 Host 不匹配的请求——IP 直连、扫描器、写错域名的客户端——现在全部落到博客上。app.conf 的 IP 访问就这么断了,而且没有任何报错。
blog.conf 自己一点问题都没有,server_name example.com 写得清清楚楚,精确匹配的流量完全正常。问题出在它顺带继承了默认站的身份。
解决办法是把隐式变成显式。给本来就该做兜底的那个块加上标记:
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
server_name 39.0.0.1;
...
}
加完之后,默认站由声明决定,不再受文件名排序影响。以后再新增多少个 a-blog.conf、00-first.conf 都不会漂移。
注意两点:default_server 每个 listen 地址端口组合只能有一个,重复声明会让 nginx -t 直接报错;IPv4 和 IPv6 是两个独立套接字,需要分别标记。
坑二:ACME 验证路径被 301 吃掉了
用 HTTP-01 签证书,Let’s Encrypt 会访问 http://example.com/.well-known/acme-challenge/<token>,走的是 80 端口的明文 HTTP。
而 80 端口的 server 块通常长这样:
server {
listen 80;
server_name example.com;
return 301 https://example.com$request_uri;
}
如果验证目录没有单独声明,这个请求会被 301 跳到 HTTPS。LE 的验证器会跟随跳转,所以有时也能过——但这就白白依赖了一个不受你控制的细节,而且当 HTTPS 那侧证书还没签出来时,跳转本身就是坏的,形成死循环。
正确写法是给验证路径一个优先级更高的 location:
server {
listen 80;
server_name example.com;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type text/plain;
try_files $uri =404;
}
location / {
return 301 https://example.com$request_uri;
}
}
^~ 的作用是:一旦这个前缀匹配成功,就不再去看后面的正则 location,直接用它。它的优先级高于普通前缀匹配,也高于正则。签发请求因此永远命中验证目录,不会被跳转拦截。
顺便,try_files $uri =404 比让请求掉进 SPA 的 index.html 兜底要好——验证失败时返回真实的 404,acme.sh 的错误信息才有参考价值。
坑三:改了配置不验证就 reload
nginx -s reload 是平滑重载,但如果新配置有语法错误,reload 会失败、老进程继续跑,看起来”没事”,实际上你的改动根本没生效。更糟的情况是配置语法正确但语义错误,比如证书文件路径写错——这时 reload 会让 worker 起不来。
所以顺序永远是:
nginx -t && nginx -s reload
这个习惯也值得写进证书的 reloadcmd 里,让自动续期在重载前先自检:
--reloadcmd "nginx -t && nginx -s reload"
一个可以照着抄的检查方式
改完 nginx,别只看新站点通不通,要把所有站点都验一遍,特别是那些你没打算动的:
# 新站点
curl -sI https://example.com/
# 已有的精确匹配站点
curl -s -o /dev/null -w '%{http_code}\n' https://api.example.com/health
# 默认站(IP 直连)—— 最容易在新配置之后悄悄变化
curl -sk -o /dev/null -w '%{http_code}\n' https://<服务器IP>/
curl -s -o /dev/null -w '%{http_code}\n' http://<服务器IP>/
第三条最容易被忽略,也正好是坑一的受害者。养成”改前先跑一遍记录基线、改后再跑一遍对比”的习惯,能把这类无声的回归挡在上线之前。