前言
最典型的症状是这样的:服务器上配了a.com和b.com两个站点,浏览器访问b.com,返回的却是a.com的首页;或者访问任何一个域名,都落到同一个目录里。还有一种更隐蔽的症状——页面能打开,但一执行$_SERVER['HTTP_HOST']相关的逻辑就串站,A 站的用户会话跑到了 B 站。
这类问题几乎都不是 PHP 本身的问题,而是 Web 服务器(Web Server)的虚拟主机(Virtual Host)匹配规则没配好。虚拟主机分两类:基于名称的(name-based,靠Host请求头区分)和基于 IP 的(IP-based)。现在绝大多数场景用的都是基于名称的,而基于名称的虚拟主机有一条铁律——请求头里的Host决定一切,匹配不上就走"默认主机"。
本文以 PHP 8.5 为例(PHP 8.5 已于 2025 年 11 月发布,是当前的稳定版本),分别给出 Nginx 与 Apache 的多站点配置,并重点讲 PHP-FPM(FastCGI Process Manager)进程池的隔离,最后列出几个最容易踩的坑。文中所有配置都按"能直接抄进生产"的标准写。
一、基于名称的虚拟主机是怎么匹配的
无论 Nginx 还是 Apache,匹配流程都是同一个逻辑:
- 取出 HTTP 请求头里的
Host(不含端口号); - 在所有虚拟主机里找
server_name/ServerName与它相等的; - 找不到就找
server_name里带通配符(如*.example.com)能匹配上的; - 还找不到,就用第一个被加载的虚拟主机作为默认主机。
第 4 条就是所有"串站"问题的根因。很多人以为"没匹配到会报 404",实际不会,它会静默地把第一个站点的内容吐出去。
| 匹配方式 | Nginx 写法 | Apache 写法 | 优先级 |
|---|---|---|---|
| 精确匹配 | server_name a.com; | ServerName a.com | 最高 |
| 前置通配 | server_name *.a.com; | ServerAlias *.a.com | 中 |
| 后置通配 | server_name a.*; | 不常用 | 低 |
| 默认主机 | default_server | 第一个 VirtualHost 段 | 兜底 |
因此,规范的做法是显式声明一个 default_server,把所有未匹配的域名统一拒绝掉,而不是让它落到某个业务站点上。
server { listen 80 default_server; server_name _; return 444; }return 444是 Nginx 独有的非标准状态码,效果是直接断开连接,不返回任何响应体——对扫描器和错误域名最省资源。
二、Nginx + PHP 8.5 的完整配置
先装 PHP 8.5。Ubuntu 上通常用 ondrej 的 PPA:
sudo add-apt-repository ppa:ondrej/php sudo apt update sudo apt install -y php8.5-fpm php8.5-cli php8.5-mysql php8.5-mbstring php8.5-curl sudo systemctl enable --now php8.5-fpm装完之后 socket 一般在/run/php/php8.5-fpm.sock。接着为两个站点各写一份 server 块。注意这里刻意不共用 root,root指向各自的public目录:
# /etc/nginx/sites-available/a.com server { listen 80; server_name a.com www.a.com; root /var/www/a.com/public; index index.php index.html; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { include snippets/fastcgi-php.conf; fastcgi_pass unix:/run/php/php8.5-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } location ~ /\.(?!well-known) { deny all; } }b.com的配置同理,只换server_name、root和 socket。关键点是fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;这一行——如果漏掉,Nginx 会把 PHP-FPM 的默认脚本路径传过去,表现为"Nginx 显示 File not found"、日志里写Primary script unknown。
启用站点并检查语法:
sudo ln -s /etc/nginx/sites-available/a.com /etc/nginx/sites-enabled/ sudo ln -s /etc/nginx/sites-available/b.com /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl reload nginx三、Apache + PHP 8.5 的配置方式
Apache 下用mod_proxy_fcgi转发给 PHP-FPM,比老式的mod_php更可控,也能让多个站点跑在不同 FPM 池里。
sudo apt install -y apache2 libapache2-mod-fcgid sudo a2enmod proxy proxy_fcgi setenvif rewrite headers虚拟主机文件/etc/apache2/sites-available/a.com.conf:
<VirtualHost *:80> ServerName a.com ServerAlias www.a.com DocumentRoot /var/www/a.com/public <Directory /var/www/a.com/public> Options -Indexes +FollowSymLinks AllowOverride All Require all granted </Directory> <FilesMatch "\.php$"> SetHandler "proxy:unix:/run/php/php8.5-fpm-a.sock|fcgi://localhost/" </FilesMatch> ErrorLog ${APACHE_LOG_DIR}/a.com-error.log CustomLog ${APACHE_LOG_DIR}/a.com-access.log combined </VirtualHost>然后a2ensite a.com b.com,并确认/etc/apache2/ports.conf里只有一个Listen 80(写两次会让 Apache 把请求路由到第一个 vhost)。Apache 的默认主机是配置文件中第一个 VirtualHost 段,所以务必把拒绝用的兜底 vhost 放在最前面,比如新建000-default-deny.conf。
四、PHP-FPM 进程池隔离
多站点共用一个 FPM 池是隐患之源:一个站点的memory_limit调大、open_basedir设宽,会影响所有站点;某个站点打满pm.max_children,其他站点全部排队 502。
正确做法是每个站点一个池。/etc/php/8.5/fpm/pool.d/a.com.conf:
[a.com] user = www-a group = www-a listen = /run/php/php8.5-fpm-a.sock listen.owner = www-data listen.group = www-data listen.mode = 0660 pm = dynamic pm.max_children = 20 pm.start_servers = 4 pm.min_spare_servers = 2 pm.max_spare_servers = 6 pm.max_requests = 500 php_admin_value[open_basedir] = /var/www/a.com:/tmp php_admin_value[upload_tmp_dir] = /var/www/a.com/tmp php_admin_value[error_log] = /var/log/php/a.com-error.log php_admin_flag[log_errors] = on php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen改完sudo systemctl reload php8.5-fpm。注意php_admin_value在 PHP 里无法被 ini_set() 覆盖,这正是隔离的意义所在;如果写成php_value,业务代码一句ini_set('open_basedir', ...)就能绕过。
五、一次性验证脚本
把下面这段存成/var/www/a.com/public/whoami.php,两个站点各放一份,然后分别访问,能立刻确认落在了哪个站、跑了哪个池:
<?php declare(strict_types=1); // 需要 PHP 8.0+(使用了 str_contains 与 match) $host = $_SERVER['HTTP_HOST'] ?? 'unknown'; $root = $_SERVER['DOCUMENT_ROOT'] ?? 'unknown'; $socket = $_SERVER['SCRIPT_FILENAME'] ?? ''; $fpmUser = function_exists('posix_getpwuid') ? (posix_getpwuid(posix_geteuid())['name'] ?? 'n/a') : 'n/a'; header('Content-Type: text/plain; charset=utf-8'); echo "host : {$host}\n"; echo "document_root: {$root}\n"; echo "script : {$socket}\n"; echo "php version : " . PHP_VERSION . "\n"; echo "fpm user : {$fpmUser}\n"; echo "open_basedir: " . (ini_get('open_basedir') ?: '(empty)') . "\n"; echo "sapi : " . PHP_SAPI . "\n"; $flag = match (true) { str_contains($root, '/a.com/') => 'A 站', str_contains($root, '/b.com/') => 'B 站', default => '未识别(可能命中了默认虚拟主机)', }; echo "判定 : {$flag}\n";访问a.com/whoami.php输出判定: A 站、fpm user: www-a,访问b.com/whoami.php输出判定: B 站,说明名称匹配和池隔离都生效了。如果两个域名的document_root一模一样,那就是典型的"所有请求落到默认主机",回到第一节检查server_name。
常见坑点
1. 只配了server_name,没配ServerAlias,www子域串站
❌ 只写server_name a.com;,结果www.a.com落到默认站点 ✅ 同时列出:server_name a.com www.a.com;
2. 用了$document_root却没在 location 里重设
❌fastcgi_param SCRIPT_FILENAME $fastcgi_script_name;(缺$document_root前缀,FPM 拿到的是相对路径) ✅fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
3. Nginx 的location ~ \.php$被location /的 try_files 抢走
❌ 正则 location 写成了\.php之外的形式,导致.php请求被当静态文件 ✅ 保持正则location ~ \.php$,并在location /里用try_files $uri $uri/ /index.php?$query_string;
4. PHP-FPM 池的用户对站点目录没有读权限,报 502/403
❌ 只改了pool.d里的user,没同步改目录属主 ✅ 池里的user/group必须与chown -R后的站点目录属主一致,否则 FPM 读不到脚本
5.htdocs目录下把源码和可访问目录混在一起
❌root /var/www/a.com;,.env和vendor/直接暴露在公网 ✅root /var/www/a.com/public;,业务代码放在public的上一级
6. 改完配置不reload,或者只reload了 Nginx 没reloadPHP-FPM
❌nginx -s reload之后发现 FPM 池配置没生效 ✅ 两个都要:sudo systemctl reload nginx && sudo systemctl reload php8.5-fpm
7.listen 80 default_server写了两次导致 Nginx 启动失败
❌ 两个 server 块都带default_server,nginx -t报a duplicate default server✅ 整个 Nginx 里default_server只能出现一次,且只给兜底站点
总结
| 环节 | 关键点 | 出错后的典型症状 |
|---|---|---|
| 域名匹配 | 显式 default_server,server_name列全别名 | 访问 B 站出现 A 站内容 |
| 脚本路径 | SCRIPT_FILENAME必须带$document_root | File not found/Primary script unknown |
| 站点根目录 | root指向public,而非项目根 | 配置、源码文件可被直接下载 |
| 进程隔离 | 每站一个 FPM 池 +php_admin_value | 单站崩溃拖垮全部站点,502 频发 |
| 生效方式 | 改完必须 reload 两边服务 | 配置"看起来改了但没用" |
多站点虚拟主机的核心只有两句话:域名靠Host头精确匹配,匹配不上必须显式拒绝;每个站点要有独立的文档根和独立的 FPM 池。把这两条落地,绝大部分串站、串会话、互相拖垮的问题都会消失。