把第二个站放进同一台服务器的时候,大多数人第一反应是「再开一个虚拟机」,或者「再买一台」。但如果你手上是两三台小站、流量都不大,一台机器上跑多个站点其实是更务实的选择——成本是一方面,更主要的是升级、备份、监控这些日常动作可以一次做完全部站点,而不是重复三遍。
麻烦的地方在于 URL 层:一台机器上有多个域名,请求进来时凭什么决定走哪个站?路径冲突怎么办?其中一个站要换结构、要做 301、要被临时挂起,怎么做到不影响另一个?这篇讲的是这套东西怎么规划,以及在 URL 重写这个层面最容易埋雷的几个地方。
一、先决定「域名怎么分」,再决定「路径怎么分」
多站点架构的第一步不是写配置,而是决定隔离的粒度。常见的三种形态,代价完全不同:
第一种是一个域名一个站点,各自有独立的根目录和独立的配置文件。这是最清晰的形态,也是我推荐的默认形态。它的代价是域名数量上去了管理成本线性增长,但只要每个站一个 include 文件,实际维护并不难。
第二种是主域名 + 子域名,比如主站 www.example.com,论坛在 bbs.example.com,静态资源在 static.example.com。这类的好处是 Cookie 可以按子域隔离,坏处是搜索引擎会把子域名当成相对独立的站点,权重不共享(虽然同主域有相关性,但和同域子目录不完全一样)。
第三种是一个域名下的子目录,example.com/blog/、example.com/wiki/。这一种对 SEO 最友好(权重全在同一个域上),但技术上最麻烦——因为子目录里的应用往往默认自己占着根路径,把它挪进去要改不少地方,而这正是本篇后面要重点讲的。
选哪种没有绝对答案,但有一条判断标准很好用:这些内容是不是同一个站的组成部分?是的话优先用子目录(权重共享);是独立产品、独立运营、独立团队的,用子域名或独立域名(隔离清晰)。
二、域名到站点的映射,写清楚比写简短重要
Nginx 靠 server_name 匹配 Host 头来决定用哪个 server 块。规则本身不复杂,但有几处默认行为会让人踩坑,值得提前写死。
# /etc/nginx/conf.d/main.conf
server {
listen 80;
server_name example.com www.example.com;
return 301 https://www.example.com$request_uri;
}
# /etc/nginx/conf.d/www.conf
server {
listen 443 ssl http2;
server_name www.example.com;
root /srv/www/www.example.com;
include /etc/nginx/snippets/ssl-example.conf;
include /etc/nginx/sites-enabled/www.example.com/*.conf;
}
# 兜底:所有没匹配上的 Host 直接拒绝,不给默认站点机会
server {
listen 443 ssl http2 default_server;
server_name _;
ssl_reject_handshake on; # nginx 1.19.4+
}三个细节值得说明。第一,server_name _ 加 default_server 是必须的:如果没有显式指定默认服务器,Nginx 会把第一个加载的 server 块当作默认,于是任何指向这台机器 IP 的、或者随机 Host 头的请求,都会落到你的第一个站上。这既是信息泄露(别人能看到你的站),也是缓存污染和 SEO 隐患(搜索引擎可能把这个 IP 当镜像站)。
第二,ssl_reject_handshake on 比返回 444 更彻底——它在 TLS 握手阶段就拒绝,连证书都不给,对方根本看不出你上面有哪些站点。老版本 Nginx 不支持这条指令,退而求其次用 return 444;。
第三,同一份证书里包含多个域名(SAN 证书)时,证书配置要抽成 snippet 复用,不要在每个 server 块里复制一遍。复制出来的问题是:证书续期后你只改了其中一个文件的路径,另一个还在指旧证书,表现是「某个域名偶发证书过期」——而且因为大部分浏览器接受已缓存的证书,很难当场发现。
三、子目录部署:应用不认自己不在根上
这部分是多站点里返工最多的地方。把一个本来跑在根路径的应用(WordPress、Typecho、各类 CMS、以及自己写的框架)挪到 /blog/ 下面,会同时遇到三类问题:应用自己生成的链接、静态资源的路径、以及重写规则的前缀。
先说重写规则。很多教程给的 WordPress 伪静态是这样的:
location / {
try_files $uri $uri/ /index.php?$args;
}放进子目录站点后会变成这样(假设应用在 /blog):
location /blog/ {
alias /srv/www/example.com/blog/;
index index.php;
try_files $uri $uri/ /blog/index.php?$args;
}
location ~ ^/blog/.+\.php$ {
include /etc/nginx/fastcgi_params;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME /srv/www/example.com/blog$fastcgi_script_name;
}这里有两处必须注意。用 alias 而不是 root:root 会把 location 前缀拼进文件路径,alias 则是直接替换,两者在子目录场景下行为不同,混用会导致 404 或路径穿透。另一个是 fastcgi_param SCRIPT_FILENAME 必须显式写全,因为 include fastcgi_params 里的默认值依赖 $document_root,而 alias 场景下 $document_root 的值不是你以为的那个。
然后是应用自己生成的链接。这一步没有通用解,只能改应用配置:WordPress 要在 wp-config.php 里把 WP_HOME 和 WP_SITEURL 写成带 /blog 的完整地址;Typecho 要在后台把站点地址改掉,并注意有些主题里硬编码了根路径;自研框架则要找到生成 URL 的那个函数,统一加前缀。判断有没有改干净的方法很直接:打开页面,看源码里有没有以 / 开头却指向应用资源的链接。
# 一眼找出页面里没带子目录前缀的站内链接
curl -s https://www.example.com/blog/ | \
grep -oE '(href|src)="/[^"]*"' | grep -v '^.*="/blog/' | head -20这条命令列出来的每一条都是漏网的绝对路径。数量多到吓人很正常,因为很多主题连 CSS 里的背景图都写死成 /wp-content/...。
四、跳转与规范化:一个站只能有一个「正身」
多站点环境里,重复内容的风险比单站高得多,因为同一个页面往往可以通过多种地址访问到:带 www 和不带 www、http 和 https、带尾斜杠和不带、大写和小写、加了 index.php 和不加。搜索引擎会把这些当成不同 URL,权重被摊薄,还可能被判重复内容。
规范化的原则是:选定一个形态,其余全部 301 过去,且只跳一次。「只跳一次」很重要——如果 http→https 跳一次、非 www→www 再跳一次,就是两跳,实测中两跳会损失一部分爬虫预算,也会让部分用户的浏览器显示跳转较慢。
一次跳完的写法是把所有条件写在一个 server 块里:
server {
listen 80;
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# 80 和 443 都在这里接,直接 301 到最终形态
return 301 https://www.example.com$request_uri;
}
这个写法的关键是:example.com 这个 server 块只负责跳转,不提供任何内容。所以不管来的是 http 还是 https,都只跳一次到 https://www.example.com/...。证书需要覆盖 example.com(因为 https 的裸域请求也要能握手成功才能跳)。
尾斜杠的统一要谨慎。对目录型 URL 加尾斜杠是合理规范,但对已经是 .html 结尾的页面加尾斜杠就多余了,还可能和应用的规则打架。我的做法是:只统一协议和 www,尾斜杠交给应用或者干脆不做——try_files $uri $uri/ 本身就会对目录做一次内部跳转。
五、临时挂起与替换:别让一个站拖累全站
多站点架构有个天然优势,也有个天然风险:任何一个站出问题,同一台机器上的其它站都可能受牵连。所以规划时要预留两条「切断」通道。
通道一:单站临时挂起。某个站要改版、要迁移、或者因为内容问题要临时下线,最快的做法是把它整个 server 块换成一个返回固定页面的版本,而不是去停 PHP 或者挪目录:
server {
listen 443 ssl http2;
server_name old.example.com;
include /etc/nginx/snippets/ssl-example.conf;
root /srv/www/maintenance;
location / { try_files /maintenance.html =503; }
add_header Retry-After 86400 always;
add_header Cache-Control "no-store" always;
}=503 加 Retry-After 是告诉搜索引擎「暂时不可用,过一天再来」,这比返回 404 友好得多——404 会让搜索引擎把索引清掉,恢复后要重新等收录。Cache-Control: no-store 是为了防止 CDN 或者浏览器把「维护中」缓存下来,恢复后用户还看到维护页。
通道二:路径级转移。如果只是某个子目录要换应用(比如论坛从 A 换成 B),用前缀精确匹配,避免新旧规则同时生效:
location ^~ /bbs/ {
alias /srv/www/example.com/bbs-new/;
}^~ 的作用是:一旦匹配到这个前缀,就不再尝试后面的正则 location。这一点在多站点里非常关键——正则 location(~ 或 ~*)的优先级比普通前缀高,如果不写 ^~,你写在别处的 location ~ \.php$ 会抢走 /bbs/ 下的 PHP 请求,把它交给错误的 FPM 池或者错误的 SCRIPT_FILENAME,表现是「页面打开是另一个站的内容」或者「空白页」。
六、改了重写规则之后,怎么验证没搞坏别的站
多站点的返工成本高,是因为改一个站的重写规则,很容易顺带影响另一个。所以每次改完都要跑一组回归,而不是「打开首页看看能显示」就结束。我固定做四类检查:
# 1) 只跳一次:最多 1 次 301,最终 200
for h in example.com www.example.com; do
for s in http https; do
curl -s -o /dev/null -L -w "final=%{http_code} hops=%{num_redirects} url=%{url_effective} $s://$h" "$s://$h/"
done
done
# 2) 每个站的首页 + 一篇详情页 + 一个静态资源,三项都 200
for u in https://www.example.com/ https://www.example.com/blog/ \
https://bbs.example.com/ https://www.example.com/sitemap.xml; do
printf "%s %s |" "$(curl -s -o /dev/null -w '%{http_code}' "$u")" "$u"
done
# 3) 随机 Host 不该命中任何站
curl -s -o /dev/null -w "code=%{http_code} |" -H "Host: random.invalid" https://$(hostname -I | awk '{print $1}')/
# 4) 跨站串门:A 站的路径在 B 站的域名下应 404
curl -s -o /dev/null -w "code=%{http_code} |" https://bbs.example.com/blog/第 3 条和第 4 条是最容易漏的。第 3 条验证兜底 server 生效;第 4 条验证没有哪条正则规则写得太宽,把 A 站的路径也接走了。我遇到过最典型的一次事故,是一个 location ~ \.php$ 写在了 server 块外面(放进了 http 块级别的 include 里),结果所有站的 PHP 请求都走了同一个 FPM 池、同一个 SCRIPT_FILENAME,表现是 B 站的页面偶尔返回 A 站的内容——因为文件路径拼错了但那个路径恰好也存在。
七、日志分开,出问题才查得动
最后一个容易偷懒的地方:日志。多站点如果共用一份 access.log,出问题时你得先按 Host 过滤才能看,而且几小时就能堆到几个 G,grep 一次要等很久。正确做法是每个站单独一套日志,日志文件名里带站名:
# 放在每个站点的 include 里
access_log /var/log/nginx/www.example.com.access.log main;
error_log /var/log/nginx/www.example.com.error.log warn;配合 logrotate 的时候要注意:一个站一个 logrotate 配置容易漏掉新增的站,所以直接在主配置里用通配符覆盖全部:
/var/log/nginx/*.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
sharedscripts
postrotate
[ -f /run/nginx.pid ] && kill -USR1 $(cat /run/nginx.pid)
endscript
}kill -USR1 是让 Nginx 重新打开日志文件(不是 reload 配置),比 nginx -s reload 轻得多,也不会影响连接。sharedscripts 保证一批日志轮转完只执行一次 postrotate,而不是每个文件执行一次。
八、小结
一台机器跑多个站,技术上不难,难在隔离边界要划清楚:域名到站点一一对应并显式指定默认服务器、子目录部署时把 alias 和 SCRIPT_FILENAME 写对、规范化只跳一次、用 ^~ 防止正则规则串站、每个站一套独立日志。这几条做到,多个站就是互不干扰的邻居;少做任何一条,它们就会在某个深夜悄悄互相污染,而你要花一整天才能定位。
上线前花十分钟跑一遍上面那四条检查,比事后排查一周要划算得多。