多个网站如何部署在同一台服务器?虚拟主机与反向代理方案

一台 4 核 8 GB 的服务器上只跑一个日访问几百的企业官网,资源浪费是显而易见的。所以在一台机器上托管多个站点几乎是必然选择——个人站长有几个不同主题的站点,企业需要官网、后台管理、接口服务共存,外包公司要给多个客户做演示环境。

但"放多个"不等于"简单地放多个"。核心要考虑三件事:流量怎么路由到正确的站点、不同站点之间怎么隔离、以及一个站点出问题时会不会连带搞垮其他站点。这篇文章从最常见的虚拟主机方案讲起,逐层递进到多 PHP 版本共存、反向代理多服务、Docker 容器化,最后讲资源隔离与交叉影响的风险控制。

方案总览与选择

先给一张对比表,按自己的情况对号入座:

方案 路由依据 优点 缺点 适合场景
server_name 虚拟主机 请求头里的 Host 一个 IP 承载无数站点,路径干净 站点共享同一套 Nginx 进程和 PHP 环境 绝大多数常规场景,首选
端口区分 不同监听端口 配置简单 用户要记端口号,不利于传播,常被防火墙拦 内部管理后台、临时演示
反向代理多后端 域名或路径 可以用不同语言、不同运行时 多一跳,需要管理多个后端服务 微服务、混合技术栈
Docker 多容器 + Nginx 反代 域名 环境完全隔离,互不干扰,便于迁移 需要容器知识,镜像占用磁盘 多版本需求强烈或要标准化交付
多公网 IP 不同 IP 隔离最彻底 成本高,IP 资源需要申请 邮件服务等对 IP 独立性有要求的场景

结论先给出:绝大多数情况下用 server_name 虚拟主机;当一个服务器需要跑不同的 PHP 版本或不同语言的服务时,采用虚拟主机加反向代理的组合;当环境差异大到难以在同一系统内调和,或者需要把站点作为整体交付迁移时,用 Docker。

方案一:基于 server_name 的虚拟主机

这是最主流、也是最应该优先掌握的方案。

原理

HTTP 请求头里有一个 Host 字段,指明客户端要访问的域名(例如 Host: www.example.com)。所有指向同一个 IP 的域名,请求到达时源 IP 和端口都一样,唯一的区分依据就是 Host。Nginx 接收到请求后,用 Host 的值与各个 server 块里的 server_name 做匹配,命中哪个就用哪个的配置。

这意味着:只要 DNS 把多个域名解析到同一个 IP,Nginx 就能把它们区分开,用户完全感知不到它们在同一台机器上。

理解匹配顺序能避免很多奇怪的现象。Nginx 按以下优先级选择 server:

  1. 精确匹配 server_name(如 server_name example.com;)
  2. 以通配符开头的匹配(如 server_name *.example.com;)
  3. 以通配符结尾的匹配(如 server_name www.example.*;)
  4. 正则表达式匹配(如 server_name ~^www\d+\.example\.com$;)
  5. 都没有命中,则使用监听端口上的 default_server

没有 default_server 时,Nginx 使用该端口上第一个定义的 server 块作为兜底。这就是为什么直接访问服务器 IP 有时会打开某个意料之外的站点——它落到了第一个 server 块。

因此一个好习惯是:显式定义一个 default_server,让它直接返回 403 或 444,这样未配置的域名和直接用 IP 访问的请求不会意外落到某个真实站点上:

server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name _;
    return 444;      # 444 是 Nginx 特有状态码,直接关闭连接不返回内容
}

return 444 比 403 更适合这种场景,因为它不发送任何响应体,能减少被扫描探测时的信息暴露。

目录规划

提前规划目录能省掉后面很多麻烦。推荐结构:

/var/www/
├── site-a.com/
│   ├── public/          # Nginx root 指向这里,唯一对外暴露的目录
│   ├── storage/         # 应用可写:上传、缓存、会话
│   └── backup/          # 本地临时备份,正式备份应异地
├── site-b.com/
│   ├── public/
│   └── storage/
└── logs -> /var/log/nginx/   # 可选软链,方便查看

核心原则:每个站点一个顶层目录,root 永远指向站点下的 public 子目录,而不是 /var/www 或站点根目录本身。 这样即使有人把 .env、数据库备份、Git 目录放到了站点根,它们也不在 Web 可达路径内。

日志建议按站点分离,方便定位:

access_log /var/log/nginx/site-a.com.access.log;
error_log  /var/log/nginx/site-a.com.error.log;

完整配置示例

Debian、Ubuntu 通常在 /etc/nginx/sites-available/ 下建文件,再用符号链接启用:

sudo tee /etc/nginx/sites-available/site-a.com >/dev/null <<'EOF'
server {
    listen 80;
    listen [::]:80;
    server_name site-a.com www.site-a.com;

    root /var/www/site-a.com/public;
    index index.php index.html;

    access_log /var/log/nginx/site-a.com.access.log;
    error_log  /var/log/nginx/site-a.com.error.log;

    client_max_body_size 32m;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        try_files $uri =404;
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.2-a.sock;
    }

    location ~ /\. {
        deny all;
    }
}
EOF

sudo ln -s /etc/nginx/sites-available/site-a.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Rocky、RHEL、CentOS 一般直接放到 /etc/nginx/conf.d/site-b.com.conf,PHP 转发部分改用实际的 listen 值(该系常见为 TCP 回环):

server {
    listen 80;
    listen [::]:80;
    server_name site-b.com www.site-b.com;

    root /var/www/site-b.com/public;
    index index.php index.html;

    access_log /var/log/nginx/site-b.com.access.log;
    error_log  /var/log/nginx/site-b.com.error.log;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        try_files $uri =404;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_pass 127.0.0.1:9001;
    }

    location ~ /\. {
        deny all;
    }
}

多站点时强烈建议用 include 拆分公共片段。 每个站点的 PHP 转发块大同小异,重复写十遍容易出错且难以统一修改。可以抽成一个片段文件:

sudo tee /etc/nginx/snippets/php-a.conf >/dev/null <<'EOF'
location ~ \.php$ {
    try_files $uri =404;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_pass unix:/run/php/php8.2-a.sock;
}
EOF

然后站点配置里写 include snippets/php-a.conf; 即可。这是可选的工程优化,站点数量少于五个时也可以直接重复写。

Apache 上对应的是 VirtualHost,Debian、Ubuntu 放在 /etc/apache2/sites-available/,Rocky、RHEL、CentOS 放在 /etc/httpd/conf.d/:


    ServerName site-a.com
    ServerAlias www.site-a.com
    DocumentRoot /var/www/site-a.com/public
    ErrorLog  /var/log/httpd/site-a.com-error.log
    CustomLog /var/log/httpd/site-a.com-access.log combined
    
        AllowOverride All
        Require all granted
    

方案二:多个 PHP 版本共存

现实场景中这是刚需:一个老的程序只能在 PHP 7.4 上跑,新的站点要用 PHP 8.2。两个版本的 PHP-FPM 可以同时装在一台机器上,通过不同的进程池和不同的套接字区分。

思路

每一个 PHP-FPM 池监听一个独立的套接字(或端口),每个站点的 Nginx 配置 fastcgi_pass 指向自己对应的套接字。这样不同站点自然就用了不同版本。

Debian、Ubuntu 上可以同时安装多个版本:

sudo apt install -y php8.1-fpm php8.2-fpm php8.1-mysql php8.2-mysql php8.1-gd php8.2-gd
sudo systemctl list-units --type=service 'php*-fpm.service'
ls /run/php/

Rocky、RHEL、CentOS 默认仓库通常只提供一个主版本,多版本需要借助第三方仓库。使用第三方仓库要谨慎评估来源可靠性与系统版本兼容性,并先在测试机上验证,不要直接在生产机上替换系统的 PHP 包。

为每个站点创建独立进程池

不要直接改 www.conf,更好的做法是每个站点一个 pool 文件。

Debian、Ubuntu 的 pool 目录是 /etc/php/8.2/fpm/pool.d/,Rocky、RHEL、CentOS 是 /etc/php-fpm.d/。创建站点专属配置:

sudo tee /etc/php/8.2/fpm/pool.d/site-a.conf >/dev/null <<'EOF'
[site-a]
user = site-a
group = site-a

listen = /run/php/php8.2-site-a.sock
listen.owner = nginx-data
listen.group = nginx-data
listen.mode = 0660

pm = dynamic
pm.max_children = 10
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
pm.max_requests = 500

slowlog = /var/log/php-fpm/site-a.slow.log
request_slowlog_timeout = 5s

php_admin_value[open_basedir] = /var/www/site-a.com/:/tmp/
php_admin_value[upload_max_filesize] = 32M
EOF

再创建另一个版本的池给老站点:

sudo tee /etc/php/8.1/fpm/pool.d/site-b.conf >/dev/null <<'EOF'
[site-b]
user = site-b
group = site-b
listen = /run/php/php8.1-site-b.sock
listen.owner = nginx-data
listen.group = nginx-data
listen.mode = 0660
pm = dynamic
pm.max_children = 8
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
pm.max_requests = 500
php_admin_value[open_basedir] = /var/www/site-b.com/:/tmp/
EOF

注意几点:

listen.owner 和 listen.group 要填 Nginx 运行用户(Debian、Ubuntu 一般是 www-data,Rocky、RHEL、CentOS 一般是 nginx),否则 Nginx 连不上套接字会返回 502。上面示例里的 nginx-data 只是占位符,请替换为实际的用户名。

pool 名、user、listen 路径三者区分不同的站点,这样进程隔离、慢日志文件、资源限制都是分开的。

open_basedir 限制 PHP 只能访问指定目录,这是防止站点被入侵后横向读取其他站点文件的重要手段(后面会展开)。

重启并检查:

sudo php-fpm8.2 -t
sudo systemctl restart php-fpm8.2 php-fpm8.1
ls -la /run/php/

然后在对应站点的 Nginx 配置里使用各自的 socket:

# site-a:PHP 8.2
location ~ \.php$ {
    try_files $uri =404;
    include snippets/fastcgi-php.conf;
    fastcgi_pass unix:/run/php/php8.2-site-a.sock;
}
# site-b:PHP 8.1
location ~ \.php$ {
    try_files $uri =404;
    include snippets/fastcgi-php.conf;
    fastcgi_pass unix:/run/php/php8.1-site-b.sock;
}

注意 include snippets/fastcgi-php.conf 这类片段是按 PHP 版本无关的,它只负责设置 SCRIPT_FILENAME 等参数,版本差异全部体现在 fastcgi_pass 上。

方案三:反向代理托管多个不同服务

当站点不再是同一种技术栈时——比如官网是 PHP,官网后台是 Node.js,还有一个 Python 的接口服务——虚拟主机就不够了,需要 Nginx 统一在外层做反向代理。

upstream node_admin {
    server 127.0.0.1:3000 max_fails=3 fail_timeout=10s;
    keepalive 16;
}

upstream python_api {
    server 127.0.0.1:8000 max_fails=3 fail_timeout=10s;
    keepalive 16;
}

server {
    listen 80;
    server_name admin.site-a.com;

    location / {
        proxy_pass http://node_admin;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        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;
    }
}

server {
    listen 80;
    server_name api.site-a.com;

    location / {
        proxy_pass http://python_api;
        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;
    }
}

关键安全要求:内部服务只监听回环地址,不要监听 0.0.0.0。 例如 Node 服务监听 127.0.0.1:3000 而非 0.0.0.0:3000,Python 服务同理。这样即便没有防火墙规则,外部也无法直接访问到这些端口,只有 Nginx 能通过本机回环访问。

验证所有对外暴露情况:

sudo ss -lntup

理想的监听清单应该只有:80、443、SSH 端口,以及少量必须对外且刻意开放的服务。任何显示 0.0.0.0:8080 或 0.0.0.0:3000 的行都应仔细确认是否有意为之。

对于内部服务,还要考虑进程托管。用 systemd 管理它们,保证开机自启和崩溃重启:

sudo tee /etc/systemd/system/node-admin.service >/dev/null <<'EOF'
[Unit]
Description=Admin Node Service
After=network.target

[Service]
Type=simple
User=nodeapp
WorkingDirectory=/opt/admin
ExecStart=/usr/bin/node /opt/admin/server.js
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now node-admin
sudo systemctl status node-admin --no-pager

方案四:Docker 多容器加 Nginx 反向代理

当环境差异大到难以调和(一个要特定版本的数据库客户端库,一个要完全不同的系统依赖),或者需要把整套站点作为一个单元交付、迁移时,Docker 是更合适的选择。

基本架构是:每个站点跑在自己的容器里,容器不直接对外暴露端口,由宿主机上一个统一的 Nginx 容器把它们反代出去。

services:
  site-a:
    image: wordpress:php8.2-fpm
    container_name: site-a
    restart: unless-stopped
    volumes:
      - /srv/site-a:/var/www/html
    environment:
      WORDPRESS_DB_HOST: site-a-db
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_DB_USER: wpuser
      WORDPRESS_DB_PASSWORD: 这里换成强密码
    networks:
      - site-a-net
    depends_on:
      - site-a-db

  site-a-db:
    image: mariadb:10.11
    container_name: site-a-db
    restart: unless-stopped
    volumes:
      - /srv/site-a-db:/var/lib/mysql
    environment:
      MARIADB_DATABASE: wordpress
      MARIADB_USER: wpuser
      MARIADB_PASSWORD: 这里换成强密码
      MARIADB_ROOT_PASSWORD: 这里换成强密码
    networks:
      - site-a-net
    mem_limit: 1g

  nginx-proxy:
    image: nginx:1.27-alpine
    container_name: nginx-proxy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./nginx/certs:/etc/nginx/certs:ro
      - /srv/site-a:/var/www/site-a:ro
    networks:
      - site-a-net
    depends_on:
      - site-a

networks:
  site-a-net:
    driver: bridge

注意这段 compose 里的几个要点:

容器端口不要对外映射。 上面的 site-a 没有 ports 段,它只在内部网络 site-a-net 中可达。对外只用 Nginx 容器的 80 和 443。这是容器方案里的关键安全原则。

mem_limit 用于限制单个容器的内存使用,防止一个容器把主机内存耗尽。

Nginx 容器同时挂载了站点目录(/srv/site-a:/var/www/site-a:ro),这样静态资源可以由 Nginx 直接读取,不必都转发给 PHP 容器。

对应的 Nginx 配置:

server {
    listen 80;
    server_name site-a.com www.site-a.com;

    root /var/www/site-a;
    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        # 这里的 host 是 compose service 名,Docker 内部 DNS 会解析
        fastcgi_pass site-a:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

这里的 fastcgi_pass site-a:9000 用的是 compose service 名,Docker 内置 DNS 会自动解析到对应容器。同样因为带了变量,需要 resolver 或依赖服务名解析——实际部署时如果发现解析失败,可以改用固定 IP 或确认 Nginx 容器是否在同一个 Docker 网络中。

容器方案的额外好处是迁移极其简单:整个 compose 目录加数据卷打包,在另一台机器上解包启动即可,环境完全一致。

资源隔离与互相影响的风险

多站点共存最大的隐患不是技术上做不到,而是一个站点出问题拖垮全部。

常见的连带故障场景

资源耗尽型。 某个站点被攻击或代码有内存泄漏,吃光了服务器的内存或 CPU,其他站点全部变慢或 502。这是最常见的连带故障。

进程池耗尽型。 一个站点突发流量占满了所有 PHP-FPM 子进程,其他站点的请求排队等待,表现为整体响应变慢。解决办法就是前面讲的:为每个站点配置独立的进程池并限制各自的 pm.max_children。 共享一个 www.conf 是多站点部署中最典型的结构性缺陷。

磁盘写满型。 某个站点的日志没做切割,或者上传目录没人管,把磁盘写满,导致数据库无法写入,所有站点一起挂。

安全交叉型。 一个站点存在文件上传漏洞被植入 Webshell,攻击者以该 PHP-FPM 进程的运行用户身份获得了本机执行权限。如果所有站点共用同一个用户(比如都是 www-data 或 nginx),攻击者就能直接读取其他站点的配置文件和数据库凭据——这是最严重的连带风险,也是分用户隔离的核心理由。

权限隔离:每个站点一个 Systemd 用户

前面 pool 配置里的 user = site-a 不是随便写的。为不同站点创建不同的系统用户:

sudo useradd -r -s /sbin/nologin site-a
sudo useradd -r -s /sbin/nologin site-b
sudo install -d -o site-a -g site-a -m 750 /var/www/site-a.com/public
sudo install -d -o site-b -g site-b -m 750 /var/www/site-b.com/public

sbin/nologin 阻止这些用户交互登录,-r 创建系统用户。然后把每个 Pool 的 user 和 group 设为各自的用户,把 Nginx 运行用户加入各站点组以便读取静态文件:

sudo usermod -aG site-a www-data    # Debian、Ubuntu
sudo usermod -aG site-a nginx       # Rocky、RHEL、CentOS

再叠加 open_basedir 限制,双保险确保即使被入侵,PHP 也只能访问自己那一片目录。

资源配额

对 systemd 管理的服务,可以用资源控制指令限制用量:

[Service]
MemoryMax=1G
CPUQuota=80%
TasksMax=200

这样单个服务的失控不会波及整机。对于 PHP-FPM,pm.max_children 本身就是天然的进程上限;对于 Docker,mem_limit 和 cpus 参数同样能达到目的。

日志与磁盘管理

多站点时日志量会成倍增长,必须配置 logrotate。Nginx 与 PHP-FPM 通常自带 logrotate 配置,检查 /etc/logrotate.d/。自定义服务要自己加:

sudo tee /etc/logrotate.d/site-a >/dev/null <<'EOF'
/var/log/nginx/site-a.com.*.log {
    daily
    rotate 14
    compress
    delaycompress
    missingok
    notifempty
    create 0640 nginx adm
    sharedscripts
    postrotate
        [ -f /run/nginx.pid ] && kill -USR1 $(cat /run/nginx.pid)
    endscript
}
EOF
sudo logrotate -d /etc/logrotate.d/site-a    # 调试模式,不实际执行

Rocky、RHEL、CentOS 上的运行用户一般是 nginx,Debian、Ubuntu 是 www-data,create 行要相应调整。

另外建议给磁盘使用设置告警阈值,防止写满的突发故障。

常见问题

多个域名解析到同一 IP,为什么访问 A 却打开了 B?

Host 没有精确匹配到对应的 server 块,落到了兜底的 server 上。检查该 server 块里 server_name 是否包含了你访问的完整域名(注意 www 子域要单独列出),并确认配置了 default_server 让它返回 444 或 403,这样问题会立刻显现而不是悄悄打开错误的站点。

直接用服务器 IP 访问会打开某个站点,正常吗?

这是默认行为(落到第一个 server 块),但不推荐。设置 default_server 返回 444 即可避免,同时也避免未备案域名或恶意解析的域名指向你的 IP 后被搜索引擎收录。

一个站点遭到攻击,其他站点会受影响吗?

取决于隔离程度。如果共用 PHP-FPM 池,大概率会受影响;如果按本文为每个站点配置了独立 pool、独立用户和资源限制,影响范围就局限在该站点自己的进程池内。但网络和磁盘层面的争抢无法完全消除,所以高价值业务仍建议独立部署。

需要为每个站点申请独立的 SSL 证书吗?

可以为每个站点单独签一张,也可以在一张证书里用 SAN 包含多个域名(Certbot 用多个 -d 参数即可)。后者管理更方便,但要注意:这张证书的所有域名都列在同一份证书里,任何人访问该站点查看证书都能看到你有哪些域名。 如果这在意,就分开签发。

多站点时 Nginx worker 进程数要不要增加?

通常需要。worker_processes 默认 auto 会按 CPU 核心数设置,这个值在多站点场景下依然适用。真正需要调整的是每个后端的处理能力,而不是盲目增大 Nginx worker 数量——过多 worker 反而增加上下文切换开销。

同一个站点能不能既用 PHP 又用 Python 服务?

可以,用 location 区分路径:静态和 PHP 交给本地处理,特定路径(如 /ai/)反向代理给 Python 服务。这也是反向代理方案最常见的混合用例。

总结

  • 多站点部署首选基于 server_name 的虚拟主机,依赖 HTTP 请求头里的 Host 字段路由;务必显式配置 default_server 返回 444,避免未匹配域名意外落到真实站点。
  • 目录按"每站一个顶层目录、root 指向 public 子目录、日志按站分离"规划,从结构上减少配置项误暴露和日志混杂。
  • 多 PHP 版本共存靠"一套 PHP-FPM 版本一个服务、一个站点一个 pool、一个 pool 一个套接字",再在 Nginx 各站点配置里写不同的 fastcgi_pass;不要所有站点共用一个 www.conf。
  • 反向代理方案下内部服务必须只监听 127.0.0.1,对外只开放 80 和 443;Docker 方案下容器不映射对外端口,由统一的 Nginx 容器反代并用 mem_limit 限制资源。
  • 隔离包含三个层面:每个站点独立系统用户加 open_basedir 防止交叉读取、每个进程池独立的 pm.max_children 防止资源抢占、logrotate 加磁盘告警防止日志超额写满磁盘。
  • 多站点共存的连带风险来自资源争抢和安全交叉,站点数量多或有高价值业务时应通过资源配额、分用户隔离逐步加固,必要时拆分到独立机器。