Nginx多域名多PHP版本共存配置实战与避坑指南
1. 从一个真实运维场景说起为什么一台机器要跑多个PHP版本手里维护着一台测试服务器上面同时跑着三四个项目一个老掉牙的WordPress站点还在用PHP 7.4一个新起的Laravel项目要求PHP 8.1起步还有一个内部工具因为某个扩展只兼容PHP 8.0。如果给每个项目单独开一台机器成本高不说光是环境同步就够折腾的。最省事的办法就是让这些站点共用一台Nginx通过不同的域名把请求转发到不同版本的PHP-FPM进程上。这个需求听起来简单但真正动手的时候坑比想象中多。Nginx本身不处理PHP它只是把.php文件的请求通过FastCGI协议丢给PHP-FPM。所以“根据域名解析多个PHP版本”的本质是在Nginx的server块里针对不同的server_name配置不同的fastcgi_pass指向不同的PHP-FPM监听地址。听起来就一句话但实际配置中涉及PHP-FPM的安装方式、监听方式的选择、Nginx的location匹配优先级、以及各种路径和权限问题。这篇文章适合两类人看一类是刚接手多项目服务器的运维新手另一类是想在自己开发机上模拟多版本共存环境的开发者。我会从PHP-FPM的安装和隔离讲起一步步拆到Nginx的配置细节最后把我在实际操作中踩过的坑和排查思路完整地摆出来。你跟着走一遍基本能把这套环境搭起来而且知道每一步为什么这么做。2. 多版本PHP-FPM的安装与隔离别让它们互相打架2.1 安装源的选择与版本共存的基本逻辑在Linux上装多个PHP版本最怕的就是包管理器把不同版本的依赖搞混。以常见的RPM系发行版为例系统自带的仓库通常只提供一个默认版本想要7.4、8.0、8.1共存必须引入第三方软件源。这里我不提具体源的名字只说选择原则找那种把每个PHP版本的所有包都加上版本号后缀的源比如php74-php-fpm、php81-php-fpm这种命名方式。这样安装的时候不同版本的二进制文件、配置文件、扩展目录都是完全独立的不会出现覆盖的情况。安装的时候有一个关键点不要装php这个元包因为它会拉取默认版本并可能产生冲突。要精确安装php74-php-fpm、php81-php-fpm这样的具体包。每个版本的配置文件通常放在/etc/opt/remi/php74/这类路径下不同源路径不同而不是统一的/etc/php.ini。这一点很重要因为后面配置PHP-FPM池的时候你需要知道每个版本的配置文件到底在哪。装完之后用systemctl status php74-php-fpm和systemctl status php81-php-fpm分别检查服务状态。如果两个都能正常启动说明基础隔离已经做好了。这时候它们默认都会监听127.0.0.1:9000这就会冲突。所以下一步必须改监听方式。2.2 监听方式的选择Unix Socket还是TCP端口PHP-FPM有两种监听方式Unix Socket和TCP端口。很多人图省事直接用TCP端口比如9000、9001、9002这样排下去。但我在实际使用中更推荐Unix Socket原因有三个。第一是性能。Unix Socket不走网络协议栈少了TCP握手和拆包的开销在高并发下响应更快。第二是安全。Unix Socket是文件系统层面的通信只有有权限的进程才能访问不像TCP端口那样暴露在网络层面哪怕绑定了127.0.0.1也存在被本地其他用户扫描到的风险。第三是管理清晰。每个版本的Socket文件放在自己的目录下一看路径就知道是哪个版本。具体配置在PHP-FPM的池配置文件里通常叫www.conf。找到listen 这一行改成类似这样的路径; PHP 7.4 的池配置 listen /var/opt/remi/php74/run/php-fpm/www.sock listen.owner nginx listen.group nginx listen.mode 0660; PHP 8.1 的池配置 listen /var/opt/remi/php81/run/php-fpm/www.sock listen.owner nginx listen.group nginx listen.mode 0660这里有几个细节要注意。listen.owner和listen.group必须设置成Nginx工作进程运行的用户通常是nginx或www-data。如果不设置Socket文件的属主是rootNginx没有权限连接就会报502错误。listen.mode设为0660保证只有属主和同组用户能读写。另外Socket文件所在的目录必须存在而且PHP-FPM要有写入权限。有些发行版的包会自动创建/run/php-fpm/目录但如果你自定义了路径需要手动mkdir并chown。改完配置后用systemctl restart php74-php-fpm重启服务。然后用ls -l看一下Socket文件是否生成权限是否正确。这一步确认无误后再去动Nginx否则后面排查起来会分不清是PHP-FPM的问题还是Nginx的问题。2.3 验证PHP-FPM是否真正独立运行在配置Nginx之前我习惯先用cgi-fcgi这个工具直接测试Socket是否可用。命令大概是这样cgi-fcgi -bind -connect /var/opt/remi/php74/run/php-fpm/www.sock如果连接成功会返回一些环境变量信息。如果报“Permission denied”说明Socket权限不对如果报“No such file or directory”说明PHP-FPM没启动或者路径写错了。这个测试能帮你把PHP-FPM层面的问题提前排除掉不至于到Nginx那边再回头查。还有一个更直观的办法在每个版本的Web目录下放一个phpinfo.php文件内容就是?php phpinfo();。然后用php74 -f /path/to/phpinfo.php在命令行跑一下确认版本和扩展加载正常。虽然命令行和FPM的配置不完全一样但至少能验证基础环境没问题。3. Nginx配置的核心server_name与fastcgi_pass的绑定关系3.1 最简配置模型一个域名对应一个PHP版本Nginx的配置文件结构是http块里包含多个server块每个server块通过server_name来区分域名。所以最直接的做法就是为每个域名写一个独立的server块在location ~ \.php$里指定对应的fastcgi_pass。假设我们有两个域名old.example.com对应PHP 7.4new.example.com对应PHP 8.1。配置大概长这样server { listen 80; server_name old.example.com; root /var/www/old-site; index index.php index.html; location ~ \.php$ { fastcgi_pass unix:/var/opt/remi/php74/run/php-fpm/www.sock; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } } server { listen 80; server_name new.example.com; root /var/www/new-site; index index.php index.html; location ~ \.php$ { fastcgi_pass unix:/var/opt/remi/php81/run/php-fpm/www.sock; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } }这个模型清晰明了每个站点的PHP版本一目了然。但实际项目中往往还有静态资源处理、URL重写、HTTPS跳转等需求配置会复杂一些。不过核心逻辑不变server_name决定哪个域名进来fastcgi_pass决定交给哪个PHP-FPM处理。这里有一个容易忽略的点fastcgi_param SCRIPT_FILENAME这一行。很多教程直接写include fastcgi_params;就完事了但fastcgi_params文件里通常没有SCRIPT_FILENAME的定义或者定义成了/scripts$fastcgi_script_name这种默认值。如果不显式指定$document_root$fastcgi_script_namePHP-FPM会找不到文件返回“File not found”错误。这个坑我踩过不止一次排查的时候看Nginx错误日志会提示“Primary script unknown”其实就是路径没对上。3.2 当域名很多时用map指令做优雅映射如果服务器上跑了十几个站点每个都写一个完整的server块配置文件会变得很长而且修改PHP版本的时候要找到对应的块去改容易出错。这时候可以用Nginx的map指令把域名和PHP-FPM的Socket路径做一个映射表然后在server块里统一引用。具体做法是在http块里定义一个mapmap $host $php_fpm_sock { default unix:/var/opt/remi/php81/run/php-fpm/www.sock; old.example.com unix:/var/opt/remi/php74/run/php-fpm/www.sock; legacy.example.com unix:/var/opt/remi/php74/run/php-fpm/www.sock; tool.example.com unix:/var/opt/remi/php80/run/php-fpm/www.sock; }然后在server块里fastcgi_pass直接写$php_fpm_sockserver { listen 80; server_name old.example.com legacy.example.com new.example.com tool.example.com; root /var/www/$host; index index.php index.html; location ~ \.php$ { fastcgi_pass $php_fpm_sock; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } }这样配置的好处是新增站点只需要在map里加一行不用复制整个server块调整某个站点的PHP版本也只需要改map里对应的那一行。而且root指令用了$host变量如果目录结构和域名一致连root都不用单独写。不过map方案有一个限制fastcgi_pass使用变量时Nginx在启动时不会解析Unix Socket路径而是在请求到达时才解析。这意味着如果Socket文件不存在Nginx启动不会报错但请求会返回502。所以用map方案的时候一定要确保所有映射的Socket文件都真实存在。我一般会在Nginx配置检查通过后再用nginx -T把最终配置打出来逐个核对Socket路径。3.3 location匹配优先级对PHP处理的影响Nginx的location匹配有优先级规则精确匹配最高其次是前缀匹配^~然后是正则匹配~和~*最后是普通前缀匹配。这个规则在配置PHP处理时非常关键。最常见的错误是写了一个location / { try_files $uri $uri/ /index.php?$query_string; }来做URL重写然后又写了一个location ~ \.php$ { ... }来处理PHP。当请求/index.php时正则location会匹配没问题。但当请求一个不存在的路径比如/article/123时try_files会把请求内部重定向到/index.php这时候Nginx会重新走一遍location匹配最终还是会落到location ~ \.php$上。这个流程是正常的。但如果你把location ~ \.php$写在了location /的前面或者用了^~修饰符就可能出现静态文件被错误地交给PHP处理的情况。我的建议是PHP处理的location永远用正则匹配~ \.php$不要加^~URL重写的try_files放在location /里如果有一些目录需要禁止访问PHP比如上传目录可以单独写一个location ^~ /uploads/ { }来阻断。还有一个细节fastcgi_split_path_info这个指令。当URL是/index.php/foo/bar这种形式时需要用它把SCRIPT_FILENAME和PATH_INFO分开。配置大概是location ~ \.php$ { fastcgi_split_path_info ^(.\.php)(/.)$; fastcgi_pass $php_fpm_sock; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param PATH_INFO $fastcgi_path_info; include fastcgi_params; }不加这个的话/index.php/foo/bar这种URL会报404。很多现代框架的路由都依赖PATH_INFO所以这一行最好加上。4. 实操中容易翻车的几个细节与排查链路4.1 Socket权限问题502错误的头号嫌疑犯配置写完之后访问站点返回502 Bad Gateway这是最常见的情况。Nginx错误日志里通常会写connect() to unix:/var/opt/remi/php74/run/php-fpm/www.sock failed (13: Permission denied)这就是Socket权限问题。原因通常是PHP-FPM的listen.owner和listen.group没有设置成Nginx的用户或者Socket文件所在目录的权限不对。排查步骤是这样的第一步用ps aux | grep nginx确认Nginx工作进程的运行用户。通常是nginx或www-data。第二步用ls -l /var/opt/remi/php74/run/php-fpm/www.sock看Socket文件的属主和权限。如果属主是root权限是rwx------那Nginx肯定连不上。第三步检查PHP-FPM池配置里的listen.owner、listen.group、listen.mode三项。改完之后必须重启PHP-FPM服务因为Socket文件是在服务启动时创建的。第四步如果Socket文件属主已经对了但还是连不上检查Socket文件所在目录的权限。比如/var/opt/remi/php74/run/php-fpm/这个目录Nginx用户需要有执行权限才能进入。用namei -l /var/opt/remi/php74/run/php-fpm/www.sock可以逐级查看路径权限哪一级断了补哪一级。我遇到过一次特别隐蔽的情况SELinux开着Socket文件的上下文类型不对Nginx被拒绝访问。用ls -Z看上下文然后用restorecon修复。如果不想折腾SELinux可以临时用setenforce 0测试确认是SELinux的问题后再决定是加策略还是关掉。生产环境不建议直接关但测试环境可以这么排查。4.2 文件路径与open_basedir的限制有时候Socket权限没问题Nginx也连上了PHP-FPM但PHP返回“File not found”。这时候要看PHP-FPM的错误日志通常在/var/opt/remi/php74/var/log/php-fpm/www-error.log。如果日志里写“Unable to open primary script”那就是SCRIPT_FILENAME传过去的路径PHP-FPM访问不了。原因可能有两个一是Nginx的root指令和PHP-FPM的open_basedir限制冲突。open_basedir是PHP的安全机制限制PHP只能访问指定目录。如果Nginx传过去的路径不在open_basedir范围内PHP就会拒绝执行。检查PHP-FPM池配置里的php_admin_value[open_basedir]确保它包含了站点的根目录。二是文件系统权限。PHP-FPM的运行用户通常是apache或nginx取决于配置需要对站点目录有读取权限。如果站点目录的属主是某个开发用户权限是700PHP-FPM读不了。用namei -l /var/www/old-site/index.php逐级检查确保每一级目录都有执行权限文件本身有读取权限。4.3 缓存与配置重载的坑Nginx的配置修改后用nginx -t测试语法然后nginx -s reload重载。但有时候重载了发现配置没生效原因可能是第一nginx -t只检查语法不检查逻辑。比如map里写的Socket路径不存在语法没问题但请求会502。所以重载后一定要实际访问测试。第二PHP-FPM的配置修改后systemctl reload php74-php-fpm和restart的行为不一样。reload是平滑重启会重新读取配置并创建新的Socket文件但旧进程可能还在处理请求。如果Socket路径变了reload可能不会删除旧的Socket文件导致新旧文件并存。我一般直接restart虽然会中断正在处理的请求但测试环境无所谓生产环境要在低峰期操作。第三浏览器缓存。有时候配置已经生效了但浏览器缓存了之前的502页面。用curl -I http://old.example.com在命令行测试能排除浏览器缓存的干扰。4.4 日志分级与快速定位排查这类问题日志是最重要的线索。我习惯同时开三个终端窗口一个tail -fNginx的error.log一个tail -fPHP-FPM的error.log一个用来执行curl命令。这样请求一发出去两边日志同时滚动能立刻看出请求走到了哪一步、在哪一步断掉的。Nginx的error.log里connect() failed是Socket连接问题Primary script unknown是路径问题upstream timed out是PHP执行超时。PHP-FPM的error.log里Unable to open primary script是路径问题Allowed memory size exhausted是内存限制Maximum execution time exceeded是超时。根据关键词定位比盲目猜测快得多。5. 进阶玩法同一域名下按路径区分PHP版本5.1 用location前缀匹配做路径级分流有些项目是单体应用但不同模块对PHP版本要求不同。比如/legacy/路径下的代码是旧版PHP写的/api/路径下的代码需要新版本。这时候就不能只靠server_name来区分了需要在同一个server块里根据location的前缀来指定不同的fastcgi_pass。配置思路是这样的server { listen 80; server_name app.example.com; root /var/www/app; index index.php index.html; location ^~ /legacy/ { location ~ \.php$ { fastcgi_pass unix:/var/opt/remi/php74/run/php-fpm/www.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } } location ^~ /api/ { location ~ \.php$ { fastcgi_pass unix:/var/opt/remi/php81/run/php-fpm/www.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } } location / { try_files $uri $uri/ /index.php?$query_string; } }这里用了嵌套location。外层用^~做前缀匹配内层用正则匹配PHP文件。Nginx允许在location里嵌套location但嵌套的location只能匹配外层location已经匹配的URI部分。这个写法在Nginx里是合法的但要注意^~修饰符会阻止后续的正则匹配所以内层的正则location必须写在外层里面才能生效。这种配置的维护成本比多域名方案高因为PHP版本的映射关系散落在各个location里。如果路径规则经常变建议还是用多域名或者map方案。5.2 用map结合request_uri做更细粒度的控制如果路径规则比较复杂比如/v1/用PHP 7.4/v2/用PHP 8.0/v3/用PHP 8.1用嵌套location写起来很啰嗦。这时候可以用map指令把$request_uri的前缀映射到Socket路径map $request_uri $php_fpm_sock_by_path { default unix:/var/opt/remi/php81/run/php-fpm/www.sock; ~^/v1/ unix:/var/opt/remi/php74/run/php-fpm/www.sock; ~^/v2/ unix:/var/opt/remi/php80/run/php-fpm/www.sock; }然后在location ~ \.php$里用fastcgi_pass $php_fpm_sock_by_path;。map支持正则匹配~^/v1/表示以/v1/开头的URI。这样配置比嵌套location清晰得多而且修改规则只需要改map。不过要注意map里的正则匹配是按顺序来的第一个匹配成功的就生效。所以要把更具体的规则放在前面默认规则放在最后。另外$request_uri包含查询字符串如果路径里带参数正则要写得宽松一些比如~^/v1/就能匹配/v1/index.php?foobar。5.3 这种方案的适用边界与性能考量按路径区分PHP版本本质上是在同一个server块里做请求分流。Nginx处理map指令的性能开销很小基本可以忽略。但有一个隐患如果不同版本的PHP-FPM对同一个文件的处理结果不同比如缓存格式、session存储方式不一样可能会导致数据混乱。所以这种方案更适合那些路径之间完全独立、不共享状态的项目。另外如果用了try_files做URL重写重写后的URI会变成/index.php这时候map里的路径规则可能就不匹配了。比如/v1/article/123重写成/index.php后map看到的是/index.php会落到默认规则上。解决办法是在重写的时候保留原始路径信息或者用$request_uri而不是$uri来做map。$request_uri是原始请求的URI不会被重写改变。6. 我踩过的三个真实坑与最终稳定方案6.1 坑一PHP-FPM的默认池配置覆盖了自定义配置有一次我在www.conf里改了listen路径重启PHP-FPM后Socket文件确实生成了但Nginx还是连不上。查了半天发现PHP-FPM的配置文件目录里还有一个zz-docker.conf或者类似的覆盖文件里面的listen配置把我改的值又覆盖回去了。PHP-FPM加载配置的顺序是按文件名字母序后面的覆盖前面的。所以改配置的时候要么直接改主配置文件要么确保覆盖文件的加载顺序在最后。这个坑的教训是改完PHP-FPM配置后用php-fpm -tt如果支持或者查看启动日志确认最终生效的listen路径是什么。不要假设你改的文件就是最终生效的文件。6.2 坑二Nginx的fastcgi_params文件里缺少必要参数不同发行版的Nginxfastcgi_params文件内容不一样。有的版本里没有SCRIPT_FILENAME有的版本里SCRIPT_FILENAME写的是/scripts$fastcgi_script_name。如果直接include fastcgi_params;而不显式覆盖SCRIPT_FILENAMEPHP-FPM就会去/scripts目录找文件当然找不到。我的做法是永远在location ~ \.php$里显式写一行fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;放在include fastcgi_params;的后面。这样即使fastcgi_params里有定义也会被后面的覆盖。Nginx的指令是后面的覆盖前面的所以顺序很重要。6.3 坑三系统升级后PHP-FPM的Socket路径变了有一次系统自动升级PHP-FPM的包更新后Socket文件的默认路径从/var/run/php-fpm/www.sock变成了/run/php-fpm/www.sock。虽然/var/run通常是指向/run的软链接但Nginx配置里写的是旧路径升级后软链接可能被重建或者权限变了导致连接失败。这个坑的教训是Nginx配置里引用Socket路径时尽量用/run而不是/var/run因为/run是标准的运行时目录更稳定。另外系统升级后要重新检查一遍Nginx和PHP-FPM的配置确认路径没有变化。6.4 最终稳定方案map加Unix Socket加独立日志经过几次折腾我现在的标准配置是这样的用map做域名到Socket的映射所有PHP-FPM都用Unix Socket监听每个版本的PHP-FPM配置独立的错误日志和慢日志。Nginx的每个server块里location ~ \.php$统一引用map变量。新增站点只需要在map里加一行然后在DNS里加一条解析Nginx配置基本不用动。这套方案跑了半年多没再出现过502或者File not found的问题。唯一需要注意的是每次新增PHP版本的时候要记得在map里加默认规则否则新域名的请求会落到默认的Socket上可能版本不对。我一般会在map的default里指向一个最常用的版本然后在注释里写清楚每个域名的版本对应关系方便后面接手的人看。如果你也在维护多版本PHP共存的服务器建议先把PHP-FPM的Socket权限和路径确认清楚再去配Nginx。顺序反了的话排查起来会多花很多时间。另外每次改完配置用curl -I加tail -f日志的方式验证比在浏览器里刷新靠谱得多。