Linux入门教程:Linux 下 Nginx + PHP 环境的配置,环境运维工程师就像之前我看到 火丁


从我开始折腾 WordPress、Typecho 博客至今,我折腾了无数次 Nginx 的安装、配置与 PHP 环境的搭建,看过各种各样的教程,它们往往都有一个共同点,就是仅仅是给你一些现成的命令复制粘贴,它们大多从操作的角度出发,并没有太多原理上的阐述。就像之前我看到 火丁笔记博客的一篇文章 所说:“如果大家不求甚解,一味的拷贝粘贴,早晚有一天会为此付出代价。”所以我希望通过这篇文章,能够在一个不一样的角度去描述这个过程,希望能对看到这篇文章的你有所帮助。

本文假定你对 Linux 的命令、程序的文件IO、HTTP 协议、PHP 有一些大致的了解。

服务器后端做的事情

首先我们应该对网站的结构有一个基本的认识,通常来说一个网站的分为前端与后端两部分。它们相互依存,前端在用户的浏览器中,负责内容的显示和交互。后端负责处理浏览器发来的请求,根据不同的请求生成对应的响应返回给前端展示给用户。

浏览器与服务器之间需要通信,必然得遵循一些标准,一个必备的标准就是 HTTP 协议啦。(可能有的网站会用WebSocket等协议,但这篇文章只讨论HTTP的内容)

从本质的角度来看,后端的主要工作就是处理浏览器发来的 HTTP 请求,并根据服务器程序的业务逻辑返回这些请求对应的 HTTP 响应给浏览器。后端返回的响应,可以是一段 HTML/CSS/JavaScript 代码、也可以是一张图片、一段音频、或者是一个个不同类型文件。

我们可以这么说,无论这个后端是什么程序编写的,只要它能依照 HTTP 协议返回符合协议规定格式的响应就可以满足要求。因此,与前端只能用 HTML/CSS/JavaScript 三件套不同,后端的语言,平台的选择可以有很多,例如 PHP, Python, Ruby, JavaScript(Node.js), Java, Go, C++, C 等等。PHP 只是其中的一种选择,由于 PHP 的部署简单,性能也不错,所以也十分流行。

Web服务器

后端处理 HTTP 请求,主要还是通过 Web 服务器来实现,Web 服务器返回响应的过程主要有两条途径:

返回请求的对应的磁盘文件 把请求分发给其他的程序处理,并把该程序处理的结果返回。

如图:

其中,这里的请求分发的过程,也叫作反向代理(Reverse Proxy)。

一般来说用的比较多的 Web 服务器软件有 Nginx、Apache 和 Lighttpd,它们都是 C 语言编写的,其中因为 Nginx 的配置较为简单,高性能且资源占用较少,在业界备受欢迎,这里我也选择了 Nginx 作为网站的 Web 服务器。

PHP的执行机制

PHP脚本的执行

PHP的全称是,PHP: Hypertext Preprocessor,中文名为“超文本预处理器”。也就是说,PHP主要还是为了处理文本而产生的,这从它的代码中也有体现,我们来尝试一个简单的例子:

新建一个文本文件,命名为 temp.php ,里面输入以下内容:

这是php标签外的内容
---------华丽丽的分割线---------
<?php
$a = 2333 + 6666;
echo "这是PHP标签内部的内容,将会返回一个运算结果\n";
echo "2333+6666=" . $a;
?> 
---------又是一条华丽丽的分割线---------
这是php标签外部的内容
---------下一条华丽丽的分割线---------
<?php
$b = 12345;
echo "变量\$b的值为" . $b;
?> 
---------最后一条华丽丽的分割线---------
php标签外部的内容

把 temp.php 交给PHP解释器执行,这里我以Linux命令行为例,下面是这段脚本运行后的结果:

也就是说,PHP 解释器在处理文本的时候,把 之外的内容直接输出到了标准输出流中,当遇到 之间的代码时,它运行了代码,再把代码中 echo 语句的结果输出到标准输出流中。

我们可以通过重定向的操作,把 PHP 解释器的标准输出流重定向到别的地方,例如,我这里把它的输出结果重定向到与代码同目录的 result.txt 里面。

这时候我们试一下把 result.txt cat出来,发现里面的内容也和之前的输出一样。

ps: 如果你对数据流的概念不熟悉,可以参考《鸟哥的 Linux 私房菜》关于流的描述

通过Web服务器运行PHP脚本

我们知道,PHP 这门语言主要应用在 Web 的领域中,所以一般 PHP 文件都是通过 Web 服务器来触发运行的。

为了方便测试,我们可以用下面的命令可以直接在 temp.php 所在的文件夹下启动一个临时的 PHP cli 开发服务器,就不用配环境那么麻烦了:

php -S 0.0.0.0:8888

在浏览器访问 http://0.0.0.0:8888/temp.php 或者 http://127.0.0.1:8888/temp.php ,查看源代码,我们可以看到如图所示的结果:

我们可以发现,浏览器中看到的内容,和我们之前用 PHP 运行这个脚本生成的输出结果是一样的。

忽略细节的话,在某种意义上我们也许也可以这么说,PHP 服务器程序在收到浏览器发过来的请求之后,运行脚本,把脚本的标准输出流重定向到了浏览器中,就像之前把命令行运行的结果重定向到了 result.txt 一般。

相比通过文件存储的静态网页,类似PHP每次接到请求后通过解释器执行,执行的结果来返回数据的页面,因为数据会根据实际情况而变化,通常也被称为“动态网页”。

CGI协议

随着动态网页的流行,这种服务器接收请求,执行程序,把执行程序的输出返回给浏览器的过程也逐渐成型。规范化这个过程也变得有必要起来,由此,CGI协议便诞生了。

CGI (Common Gateway Interface),中文名是“通用网关接口”,它定义了Web服务器与处理请求的程序之间传输数据需要遵循的标准。

一般来说,程序运行时,它与外界交互的途径是标准输入(stdin)、标准输出(stdout)和环境变量,有的可能涉及到其它的文件IO,CGI协议定义了HTTP请求、HTTP响应与程序运行的环境变量、输入流、输出流的对应关系,从而实现了通过后端程序来处理HTTP请求的功能,这个程序也被称为CGI中的“网关”(Gateway)。关于CGI协议的具体规定,我们可以参阅 RFC3875 的文档描述。 https://tools.ietf.org/html/rfc3875

CGI协议规定了,每一个HTTP请求交给一个网关程序的进程来处理。这个HTTP请求的请求头(Header),QueryString,以及其它关于客户端的信息,作为网关程序运行的环境变量,这个HTTP中的请求体(Body),作为网关程序运行的标准输入(stdin);网关程序执行过程中的标准输出(stdout),则作为这个HTTP请求的响应数据返回给Web服务器。

参考 http://www.php-internals.com/book/?p=chapt02/02-02-03-fastcgi 的介绍,引用一下:

CGI 的运行原理

客户端访问某个 URL 地址之后,通过 GET/POST/PUT 等方式提交数据,并通过 HTTP 协议向 Web 服务器发出请求。

服务器端的 HTTP Daemon(守护进程)启动一个子进程。然后在子进程中,将 HTTP 请求里描述的信息通过标准输入 stdin 和环境变量传递给 URL 指定的 CGI 程序,并启动此应用程序进行处理,处理结果通过标准输出 stdout 返回给 HTTP Daemon 子进程。

再由 HTTP Daemon 子进程通过 HTTP 协议返回给客户端。

上面的这段话理解可能还是比较抽象,下面我们就通过一次 GET 请求为例进行详细说明。

如图所示,本次请求的流程如下:

客户端访问 http://127.0.0.1:9003/cgi-bin/user?id=1 127.0.0.1 上监听 9003 端口的守护进程接受到该请求 通过解析 HTTP 头信息,得知是 GET 请求,并且请求的是 /cgi-bin/ 目录下的 user 文件。 将 uri 里的 id=1 通过存入 QUERY_STRING 环境变量。 Web 守护进程 fork 一个子进程,然后在子进程中执行 user 程序,通过环境变量获取到id。 执行完毕之后,将结果通过标准输出返回到子进程。 子进程将结果返回给客户端。

基于PHP语言的Web程序,它的工作机制也类似于CGI的模型,但根据实际的情况,PHP 的具体实现会有些不一样。在PHP中,CGI协议所用到的程序,是通过它自带的 php-cgi 来支持的,这个后文会继续介绍。

SAPI 抽象层

刚刚我们可以注意到,我们可以通过浏览器发送HTTP请求的方式执行PHP,也可以在命令行通过运行文件的方式执行PHP,每种运行方式看起来很不一样,但是实际上它们内部的工作流程是一样的。这得益于PHP代码中的SAPI中间层。

那么,什么是SAPI呢?

首先我们来看看PHP的架构图(图片来自鸟哥的博客 ps: PHP的鸟哥和写 Linux 私房菜的鸟哥不是同一个人哦)

从图片中可以看出,PHP内部从下到上分为4层:

负责 PHP 的执行的 Zend 引擎 Extensions 扩展层,各种基础的库和扩展都在这一层实现 SAPI:Server Application Programming Interface,服务端应用编程接口,它通过一些钩子函数,定义 PHP 与外部应用的交互,通过它可以实现 PHP 与上层应用的隔离,我们可以基于SAPI编写不同的应用适应不同的环境。 Application 层,这里代表了PHP应用的部分,如命令行下的脚本的执行,web服务器的脚本的执行等等

在这篇文章中,我们的关注点主要是在 Application 层与 SAPI 层的部分,理解了这些的话具体的部署自然就是水到渠成了。

对PHP的架构有了一些印象之后,我们可以知道,SAPI是一种不同的应用与PHP内核的交互方式,上层的应用通过SAPI定义的接口把代码和执行需要的环境变量,输入输出等数据交给PHP内核解析。

下面是一些常见的 SAPI 的应用实现:

Shell CLI: 通过命令行执行 PHP 程序用到的 SAPI。 Apache 2.0 Handler: 通过 Apache 服务器的 mod_php 模块部署 PHP 服务的运行方式 PHP 自带的 CGI/FastCGI 接口: PHP 本身实现了一个名为 php-cgi 的程序,它有 CGI、FastCGI 两种工作模式,专门处理 CGI/FastCGI 的请求 PHP-FPM: 这是一个 PHP 专用的 fastcgi 管理器,克服了 php-cgi 本身的一些问题,并且附加了许多适合大流量高并发网站的功能

早期的 PHP 为了适配多种多样的Web服务器环境,内置了许许多多的 SAPI ,到PHP 7以后,只保留了一部分重要的 SAPI,其它的都已经移除,下面是 PHP 7 以后移除的SAPI列表:(来自菜鸟教程)

aolserver, apache, apache_hooks, apache2filter, caudium, continuity, isapi, milter, nsapi, phttpd, pi3web, roxen, thttpd, tux, webjames

通过PHP的 php_sapi_name() 函数 我们可以获得当前PHP运行所使用的sapi的名字,实际的 PHP 代码编写中,我们可以根据这个函数的值判断程序所处的运行环境。

由于 SAPI 的多样,所以就有了许多不同的 PHP 部署方式,下面我会介绍一些常见的部署方式。

通过加载 Module 方式部署 PHP

Web 服务器除了可以通过 CGI 执行动态脚本外,还可以通过加载模块的方式来运行动态脚本,例如 Apache 的环境中是通过 mod_php 模块来实现运行PHP的,它利用了 Apache 2.0 Handler 这个 SAPI 与 PHP 解释器内核通信。

通过 Apache + mod_php 来部署 PHP 具有开箱即用,稳定成熟的特点,同时也有一些缺点:

Web 服务器与 PHP 解释器之间是耦合的,程序出问题的时候不好定位是 Apache 的问题还是 PHP 这一层的问题 由于PHP的执行用户是与 Apache 相同的,这某些情况下可能有安全隐患 这种方式对于高并发大流量的场景下的性能消耗较大

所以我个人不太推荐通过这种方式在实际生产环境中部署PHP,当然,本地的开发环境还是挺适合的,尤其是Windows环境下。

FastCGI

上文提到了 FastCGI,它究竟是何方神圣呢?

首先我们回顾一下刚刚提到的CGI,它每次接到请求的时候,都需要根据请求的信息设置好参数,创建一个新的进程处理这个请求,处理完毕后退出程序,又叫 fork-and-execute 模式。进程的创建和销毁是一个耗费资源的过程,当系统的并发量一大,基于CGI的程序就撑不住了。

顾名思义,FastCGI = Fast + CGI,它是一种为了提高 CGI 程序性能的协议,是早期的 CGI 协议的改进版本。

参考 http://www.php-internals.com/book/?p=chapt02/02-02-03-fastcgi 的介绍,引用如下:

FastCGI是Web服务器和处理程序之间通信的一种协议, 是CGI的一种改进方案,FastCGI像是一个常驻(long-lived)型的CGI, 它可以一直执行,在请求到达时不会花费时间去fork一个进程来处理(这是CGI最为人诟病的fork-and-execute模式)。 正是因为他只是一个通信协议,它还支持分布式的运算,所以 FastCGI 程序可以在网站服务器以外的主机上执行,并且可以接受来自其它网站服务器的请求。

FastCGI 是与语言无关的、可伸缩架构的 CGI 开放扩展,将 CGI 解释器进程保持在内存中,以此获得较高的性能。 CGI 程序反复加载是 CGI 性能低下的主要原因,如果 CGI 程序保持在内存中并接受 FastCGI 进程管理器调度, 则可以提供良好的性能、伸缩性、Fail-Over 特性等。

FastCGI 工作流程如下:

FastCGI 进程管理器自身初始化,启动多个 CGI 解释器进程,并等待来自 Web Server 的连接。 Web 服务器与 FastCGI 进程管理器进行 Socket 通信,通过 FastCGI 协议发送 CGI 环境变量和标准输入数据给 CGI 解释器进程。 CGI 解释器进程完成处理后将标准输出和错误信息从同一连接返回 Web Server。 CGI 解释器进程接着等待并处理来自 Web Server 的下一个连接。

FastCGI 与传统 CGI 模式的区别之一则是 Web 服务器不是直接执行 CGI 程序了,而是通过 Socket 与 FastCGI 响应器(FastCGI 进程管理器)进行交互,也正是由于 FastCGI 进程管理器是基于 Socket 通信的,所以也是分布式的,Web 服务器可以和 CGI 响应器服务器分开部署。Web 服务器需要将数据 CGI/1.1 的规范封装在遵循 FastCGI 协议包中发送给 FastCGI 响应器程序。

相比 CGI,这里又多了个需要我们考虑的程序 —— FastCGI 进程管理器。还有一个通信协议——FastCGI 协议。同时,HTTP 请求也不是 Web 服务器自己处理了,而是封装成 FastCGI 数据包发送到负责 FastCGI 的服务器。

PHP 本身实现了一个名为 php-cgi 的程序,它有 CGI、FastCGI 两种工作模式,专门处理 CGI/FastCGI 的请求。

PHP-FPM

刚刚我们有提到,PHP 可以通过内置的 php-cgi 程序的 FastCGI 模式实现 FastCGI 进程管理器的功能,解决了 CGI 的资源占用和并发的性能问题,同时也支持了分布式计算。然而,在需求日益增长的时候,php-cgi也暴露出了一些问题。最大的问题是,php-cgi 的配置不够人性化,主要体现在其修改 php.ini 后,不支持平滑重启,每次都要先停止服务再启动才能更新配置,这在某些场景下显然是很致命的。

为了解决上面的问题,一位名叫 Andrei Nigmatulin 的大神开发了 PHP-FPM,打破了这个尴尬的局面。

PHP-FPM 是一个为 PHP 量身打造的专用 FastCGI 进程管理器,它解决了上面的问题,并且在许多方面表现十分出色,因此在 PHP 5.3 版本以后,PHP-FPM 已经正式内置在 PHP 中了。官网关于 PHP-FPM 的介绍

综上,需要部署 PHP 环境的话,Apache/Nginx + PHP-FPM 是优于CGI 和 Module 加载的一个很好的选择,下面我就以 Nginx 为例,介绍一下 Nginx 和 PHP-FPM 的配置方法。

Nginx与PHP-FPM的配置

首先我们得装好 Nginx,PHP 和 PHP-FPM,具体安装过程可以参考其它的教程。

由于不同的发行版的安装后的文件路径不太一样,所以这里只会提到一些比较关键的配置部分。

我们需要明确 Nginx、PHP-FPM 各自的角色,Nginx 本身可以是一个提供静态文件分发的Web服务器、也可以是一个反向代理服务器,它的工作模式十分灵活,取决于我们怎么配置Nginx。在这里我的预期是,当 Nginx 收到请求以后,如果请求的是静态文件,那么将这个静态文件返回;如果它是一个要执行 PHP 程序的请求,Nginx 需要将其转发到 PHP-FPM 处理,PHP-FPM 收到请求以后,调用 PHP 内核执行 PHP 脚本,把脚本的输出返回给 Nginx,Nginx 再把响应通过 HTTP 响应的方式返回给用户。

上文我们提到了,FastCGI 的请求、响应是遵循 FastCGI 协议的,而 Web 服务器处理的是 HTTP 协议,所以 Nginx 与 PHP-FPM 一起工作时,就涉及到了 HTTP 协议的请求、响应与 FastCGI 协议的请求、响应的互相转换。这个转换的工作,是通过 Nginx 内置的 fastcgi 模块来实现的。所以,我们需要解决的问题是,如何配置 Nginx,调用 fastcgi 模块来让需要执行PHP的请求正确地转发到 PHP-FPM 中运行呢?

这里我们需要关注两个配置文件,一个是 Nginx 的 nginx.conf ,另一个是 PHP-FPM 的 php-fpm.conf

PHP-FPM 的配置文件

首先是 php-fpm.conf,这是 PHP-FPM 主要的配置文件,不同的安装方式的路径可能不一样,用 Ubuntu 16.04 的 apt 安装的 PHP-FPM,路径位于 /etc/php/7.0/fpm/php-fpm.conf ,如果是手动编译安装的话,假设 prefix 是 /usr/local/php,它一般会位于 /usr/local/php/etc/php-fpm.conf 。

php-fpm.conf 里面默认是 PHP-FPM 的基本配置,这里一般没有多少需要改动的地方,我们的目光放在最后一行的 include=xxxxx ,apt 安装的 PHP-FPM 默认是 include=/etc/php/7.0/fpm/pool.d/.conf ,手动编译安装的是 include=/usr/local/php/etc/php-fpm.d/.conf ,顾名思义,这是一个 include 操作,就是引入 pool.d 或者是 php-fpm.d 目录下所有的 .conf 文件的意思。这个目录下的文件主要的功能是定义了 php-fpm 监听端口等的信息。

定位到 pool.d (也可能是 php-fpm.d ) 目录下可以发现,它里面一般只有一个 www.conf,打开里面的内容,我们可以看到,里面每个配置项前面,都有一大堆详细的注释。这个文件是我们要配置 PHP-FPM 如何处理 PHP 的关键,它定义了 PHP-FPM 监听哪个端口或是 unix socket 的 FastCGI 请求,脚本执行环境的用户,用户组,权限等等。

一般如果对权限没有特殊要求的话我们不需要对它进行修改。这里我们需要记下里面 listen 选项的内容,listen 的值可以分为两种,一种是 TCP socket 地址,另一种是 unix socket 地址。如果我们这里看到的 listen 的值可能是 127.0.0.1:9000,它是 TCP socket,如果它是一个具体的文件路径类似 /run/php/php7.0-fpm.sock 的值,那么它是一个 unix socket,unix socket 是一种进程间的通信方式,它的操作类似网络套接字,但它实际的数据传输是不用经过网络层的,具体的内容可以查找相关的资料。

Nginx.conf 的配置

找到了 PHP-FPM 监听的 socket 之后,我们下一个目标就是配置 Nginx 让 .php 的请求转发到这个 socket 上了。

关于 Nginx 配置,推荐阅读官方文档 https://www.nginx.com/resources/admin-guide/nginx-web-server/#virtual-server 下面解释几个关键的部分

一般来说 Nginx 配置的基本结构是这样的,把 Nginx 用作 Web 服务器,则需要配置 nginx.conf 中的 http 块:

http {
    server {
        # Server configuration
        location / {

        }
        location ~ \.php$ {

        }
    }
}

一个 http 块中可以有多个 server 块,每个 server 块代表了一个 Web 服务器,负责不同的域名、端口的请求的处理。在 server 块中,我们还可以配置不同的 location 块,每个 location 块都设置了它所匹配的 request URI 规则,符合规则的 request URI 将会跳到相应的 location 块中来处理。

location 语句块

利用 location 的强大功能,我们可以把所有 request URI 后缀为 .php 的请求交给一个 location 来处理,查询 Nginx 文档中关于 location 的描述,我们可以发现,location 的匹配 request URI 有两种方式:前缀匹配(prefix)和正则表达式匹配。.php 是后缀,所以需要正则表达式来处理,这里的正则表达式是 .php$ location 后的~ 代表匹配规则是一个正则表达式。

# 匹配 .php 后缀的 location
location ~ \.php$ {

}

fastcgi 模块

启用 fastcgi 转发,需要用到 fastcgi 模块,在 location 块中配置与 fastcgi 相关的指令就可以了,官方文档对 fastcgi 模块的各个配置指令有很详细的介绍 http://nginx.org/en/docs/http/ngx_http_fastcgi_module.html

一般我们使用的时候,我们只需用到 fastcgi 模块的其中两条指令,分别是 fastcgi_pass, fastcgi_param.

fastcgi_pass 这个参数是最最关键的,设置请求通过 fastcgi 协议转发的地址,需要设置为我们刚刚找到的 PHP-FPM 监听的地址。具体用法参考官网文档 fastcgi_param 这条指令描述了请求的一些关键参数的设定,比如要执行的脚本文件路径,请求的 QueryString,请求方法(GET还是POST),请求是否为 HTTPS 等等。详情继续参考官网文档

fastcgi_params

fastcgi_param 一般的用法如下

fastcgi_param parameter value [if_not_empty];

parameter 为当前 fastcgi 协议请求的参数名,value 为 Nginx 要设定此参数的参数值。这个value可以是一个固定的值,也可以是一个变量。我们可以根据实际的需要,来设置不同的 paramter 的参数值 http://nginx.org/en/docs/varindex.html

parameter 中有一个参数是最关键的,它就是 SCRIPT_FILENAME,这个参数定义了这个请求让 PHP-FPM 运行的 php 文件的完整路径,如果没有它,PHP-FPM 就不知道该运行什么脚本,将会返回一个内容为空白的 200 响应(https://nginx.org/en/docs/beginners_guide.html#fastcgi)。(实测在 PHP 7 环境中缺少 REQUEST_METHOD 也会有这种情况)

这个最关键的参数一般是这么设置的,意思是把 SCRIPT_FILENAME 设置为之前用 root 或 alias 设定的路径 + 脚本的相对路径:

fastcgi_param  SCRIPT_FILENAME    $document_root$fastcgi_script_name;

举个例子,假如当前的网站根路径设置为 /var/www/html,我们访问的 .php 文件的地址是 http://example.com/test/test.php ,那么,这时候的 $document_root 的值为 /var/www/html ,$fastcgi_script_name 的值就是 /test/test.php 了。此时的 SCRIPT_FILENAME 将会被设置为 /var/www/html/test/test.php ,PHP-FPM 就会按照这个路径读取 php 代码了。

特殊情况下,我们可以直接把一个确定的路径代替 $document_root:

fastcgi_param  SCRIPT_FILENAME    /var/www/html$fastcgi_script_name;

除了最关键的参数外还有一系列的参数需要设置,参考 Nginx 的文档 PHP FastCGI Example | NGINX

大部分时候,fastcgi_param 的设置是固定的,由于太多人瞎粘贴配置,各种混乱,所以 Nginx 后来为我们提供了一个现成的 fastcgi_param 配置的集合,位于 nginx.conf 同目录的 fastcgi_params 和 fastcgi.conf 中,fastcgi_params 的内容如下:

fastcgi_param  QUERY_STRING       $query_string;
fastcgi_param  REQUEST_METHOD     $request_method;
fastcgi_param  CONTENT_TYPE       $content_type;
fastcgi_param  CONTENT_LENGTH     $content_length;

fastcgi_param  SCRIPT_NAME        $fastcgi_script_name;
fastcgi_param  REQUEST_URI        $request_uri;
fastcgi_param  DOCUMENT_URI       $document_uri;
fastcgi_param  DOCUMENT_ROOT      $document_root;
fastcgi_param  SERVER_PROTOCOL    $server_protocol;
fastcgi_param  REQUEST_SCHEME     $scheme;
fastcgi_param  HTTPS              $https if_not_empty;

fastcgi_param  GATEWAY_INTERFACE  CGI/1.1;
fastcgi_param  SERVER_SOFTWARE    nginx/$nginx_version;

fastcgi_param  REMOTE_ADDR        $remote_addr;
fastcgi_param  REMOTE_PORT        $remote_port;
fastcgi_param  SERVER_ADDR        $server_addr;
fastcgi_param  SERVER_PORT        $server_port;
fastcgi_param  SERVER_NAME        $server_name;

# PHP only, required if PHP was built with --enable-force-cgi-redirect
fastcgi_param  REDIRECT_STATUS    200;

所以,我们在配置 location 时,在 fastcgi_param 的设定上,我们设置完最关键的 SCRIPT_FILENAME 以后,只需要直接 include fastcgi_params; 就能完成任务了,下面是一个 location 配置 PHP 的例子。

location ~ \.php$ {
    fastcgi_pass 127.0.0.1:9000;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    include fastcgi_params;
}

这时候又衍生出了一个问题,fastcgi.conf 是做什么的呢?

fastcgi.conf 和 fastcgi_params 的内容相比,多了刚刚我们提到的最最关键的一行,其它完全一致。

fastcgi_param  SCRIPT_FILENAME    $document_root$fastcgi_script_name;

因为我们平常配置 PHP 环境的时候,直接指出文件的根路径反而会造成许多麻烦,配置文件也不灵活,所以Nginx 为了缓解这种情况,引入了一个 fastcgi.conf 来作为最一般的配置。

有了 fastcgi.conf ,我们的配置又可以更简单一些了~

location ~ \.php$ {
    fastcgi_pass 127.0.0.1:9000;
    include fastcgi.conf;
}

关于 fastcgi_params 和 fastcgi.conf 的关系,可以参考这篇文章 FASTCGI_PARAMS VERSUS FASTCGI.CONF – NGINX CONFIG HISTORY

一些安全的因素

参考 https://huoding.com/2013/10/23/290 火丁笔记 的描述,我们还需要在 nginx 这一层判断一下访问的 PHP 文件是否存在,避免出现因为 php.ini 开启了 cgi.fix_pathinfo=1 导致任意后缀文件都能通过 PHP 解释器解析产生的可能引发的安全问题,虽然这个漏洞在高版本 PHP (>=5.3.9) 已经被补上,但是还是需要注意。这时候我们可以在负责 php 的 location 块增加一个 try_files 来解决。修改后的配置如下:

location ~ \.php$ {
    try_files $uri =404;
    fastcgi_pass 127.0.0.1:9000;
    include fastcgi.conf;
}

PATH_INFO 的配置

许多著名的 PHP 程序、框架如 WordPress、Typecho、Moodle、ThinkPHP 等,会支持形如 /xxxx.php/archives/2333 的 request URI,比如说,Moodle许多文件的路径就是 http://xxx.com/lib/javascript.php/1512059879/lib/requirejs/jquery-private.js 这种类型。这样的URL看起来比较神奇,仿佛 php 文件就是一个文件夹一样,看起来也更加友好一些。

一般 PHP 程序要处理这样的 request URI ,是通过超全局变量 $_SERVER 的一个参数 $_SERVER[‘PATH_INFO’] 来实现的,这是一个很实用的参数,PHP 文档对它的描述如下:

包含由客户端提供的、跟在真实脚本名称之后并且在查询语句(query string)之前的路径信息,如果存在的话。例如,如果当前脚本是通过 URL http://www.example.com/php/path_info.php/some/stuff?foo=bar 被访问,那么 $_SERVER[‘PATH_INFO’] 将包含 /some/stuff。

我们可以看见,PATH_INFO 的信息必须是“客户端提供的”,也就是说需要由 Web 服务器提供,并且它的内容是跟在脚本名称后,在查询语句(QueryString)前的路径信息。Nginx 默认不会提供 PHP_INFO,因此,如果需要这个功能,我们需要为 Nginx 的 fastcgi_param 设置关于 PATH_INFO 的信息。

首先第一步我们要知道,面对 /xxx.php/xxxx 这样的链接,其实 Nginx 会把它当做一个文件夹来解析,而我们之前的配置是必须保证 request URI 是 .php 结尾的,所以我们第一个目标,就是把 /xxx.php/xxxx 能交给处理 PHP 的 location 块。我们变通一下,增加一种匹配 xxx.php/ 的情况:

至于为什么 .php 后一定要有 / 或者是 $(代表文件结尾),主要是考虑到一种风险,有的网站会有上传功能,若用户上传了一个 xxx.php.jpg ,配置不当的时候可能导致这个 xxx.php.jpg 传入 PHP 解释器,产生挂马的可能性。

location ~ \.php(/|$) {

}

这时候请求已经可以转到这个 location 来处理了,但是,这时候,对于 /xxx.php/xxx,Nginx 转发给 PHP-FPM 的 fastcgi 请求中,SCRIPT_FILENAME 就成了 /xxx.php/xxx 。PHP 实际执行脚本的路径就需要依靠 PHP 内部去解析了,而且 PHP 并不知道 PATH_INFO 是什么。所以我们需要进一步操作,把正确的 SCRIPT_FILENAME 和 PATH_INFO 交给 PHP ,这里就用到了 Nginx 的 fastcgi_split_path_info 了。

fastcgi_split_path_info 在 Nginx 官方文档的描述是这样的 : http://nginx.org/en/docs/http/ngx_http_fastcgi_module.html#fastcgi_split_path_info

Defines a regular expression that captures a value for the $fastcgi_path_info variable. The regular expression should have two captures: the first becomes a value of the $fastcgi_script_name variable, the second becomes a value of the $fastcgi_path_info variable. For example, with these settings
location ~ ^(.+\.php)(.*)$ {
    fastcgi_split_path_info       ^(.+\.php)(.*)$;
    fastcgi_param SCRIPT_FILENAME /path/to/php$fastcgi_script_name;
    fastcgi_param PATH_INFO       $fastcgi_path_info;

and the “/show.php/article/0001” request, the SCRIPT_FILENAME parameter will be equal to “/path/to/php/show.php”, and the PATH_INFO parameter will be equal to “/article/0001”.

使用 fastcgi_split_path_info ,需要设置一个含有两对括号正则表达式,第一个括号匹配的值将放入 $fastcgi_script_name 变量中,第二个括号的值是 $fastcgi_path_info 变量中,结合之前提到的 fastcgi_param 设置,SCRIPT_FILE_NAME 就已经正确设置啦,不用劳烦 PHP 去解析了,同时,我们再设置一个 fastcgi_param PATH_INFO 就能把 PATHINFO 设置好了。

这种情况的 location 配置如下:

location ~ \.php(/|$) {
    fastcgi_pass 127.0.0.1:9000;
    fastcgi_split_path_info ^(.+?\.php)(.*)$; #加入 ? 是为了避免类似 /test.php/t.php 匹配出错
    fastcgi_param PATH_INFO $fastcgi_path_info;
    include fastcgi.conf;
}

但这个配置还有一些问题,它没有考虑当访问请求的PHP不存在的时候的情况,访问文件不存在的 PHP 请求还是交给了 PHP-FPM 处理,仍然存在一些风险,一般是可以忽略不计的,但是为了完美还是要改进改进。

之前的配置我们用了 try_files $uri =404; 由于这时候的 request URI 并没有一个文件与之对应,所以使用 try_files $uri =404; 的话,肯定是直接返回 404 Not Found 。或许我们会想,request URI 没有对应的文件,那我们改成 try_files $fastcgi_script_name =404; 不就好啦!

这时候的配置类似这样,值得注意的是,Nginx 的配置并不会像一门编程语言一样有先后执行顺序,经过测试,fastcgi_split_path_info 有着更高的优先级,所以 try_files 不管加在哪一个位置,都能得到正确的结果,这里放在 fastcgi_split_path_info 后面是为了符合我们的直觉。

location ~ \.php(/|$) {
    fastcgi_pass 127.0.0.1:9000;
    fastcgi_split_path_info ^(.+?\.php)(.*)$;
    try_files $fastcgi_script_name =404;
    fastcgi_param PATH_INFO $fastcgi_path_info;
    include fastcgi.conf;
}

但这样的配置又衍生出了新的问题,使用这个配置的时候,虽然 PHP 可以正常执行,但PHP脚本是获取不到 PATH_INFO 信息,这是为什么呢?

我们需要理解一下 try_files 指令的执行流程。根据 Nginx 关于 try_files 的文档 :

Checks the existence of files in the specified order and uses the first found file for request processing; the processing is performed in the current context. The path to a file is constructed from the *file* parameter according to the root and alias directives. It is possible to check directory’s existence by specifying a slash at the end of a name, e.g. “$uri/”. If none of the files were found, an internal redirect to the *uri* specified in the last parameter is made.

try_files 会依次检测传入的参数对应的文件路径是否存在对应的文件,若存在,则按参数所在 location 对应的 request URI 来处理,正如我标注的第一句加粗字体所述,try_files 的处理是取决于这个语句所在的上下文的,当这个 location 设置了 fastcgi_pass ,则这个请求会交给 fastcgi 模块发到后端 PHP-FPM 处理,如果这个 location 内部什么都没有,则会按照默认的文件读取返回来处理;如果前面所有的参数都不匹配的话,则会跳转到最后一个参数描述的内部重定向请求,跳到其他的 location 或返回状态码。

参考科大LUG老板踩过的 https://servers.ustclug.org/2014/09/nginx-try_files-fallacy/

回到刚刚的 try_files $fastcgi_script_name =404; 这里首先尝试的是 $fastcgi_script_name ,也就是说,跳到这一步以后,交回给这个 location 处理的 request URI 变成了 $fastcgi_script_name 这个变量所代表的,到了 .php 以后就结束。原本的 PATH_INFO 已经丢失了,这时候再 fastcgi_split_path_info 显然,$fastcgi_pathinfo 的值也变成了空白。

但是,我们第一步我们已经拿到了正确的 PATH_INFO 了,我们有没有什么办法可以把这个变量临时保存下来而不是在下一次匹配的时候就被覆盖掉呢?答案当然是有的~

这里就需要用到 Nginx 的 rewrite 模块的变量机制了,关于变量,我找到一篇讲解十分详细的文章 Nginx 变量漫谈(一)agentzh新浪博客http://blog.sina.com.cn/s/blog_6d579ff40100wi7p.html

这里用到的指令是 set ,变量需要注意的一点是,变量设置以后,变量名的可见范围是整个 Nginx 配置,在不同的请求中,变量值是独立的。所以,我们可以定义一个变量取名为 $real_path_info ,用来暂时存放这个值,增加了 set 指令以后的配置如下:

location ~ \.php(/|$) {
    fastcgi_pass 127.0.0.1:9000;
    fastcgi_split_path_info ^(.+?\.php)(.*)$;
    set $real_path_info $fastcgi_path_info;
    try_files $fastcgi_script_name =404;
    fastcgi_param PATH_INFO $real_path_info;
    include fastcgi.conf;
}

有些通用的程序为了兼容各种各样的服务器主机环境,它会使用多种不同手段来获取 .php 后面还存在的内容,所以它们也不一定用到 PATH_INFO 的方式,可能不需要配置得那么详细,但不代表我们不需要知道这些。只有在了解这些的情况下,遇到故障,我们也可以更容易更好地排查问题所在。

针对单入口程序的设置

许多 PHP 框架如 Laravel 采用了统一入口的方式,即它是通过 index.php 作为程序的入口,当我把 index 设置为 index.php 以后, /archives/2333 和 /index.php/archives/2333 就是等价的了,显然前者更加美观一些,这样还能实现“URI路由”操作,让不同 controller 来处理不同的 request URI,也就是我们常说的“伪静态”啦。

那么,“伪静态”该怎么实现呢,这里也用到了 Nginx 的 try_files 指令,方法是加一个 location / 的块:

location / {
  try_files $uri $uri/ /index.php$is_args$args
}
location ~ \.php$ {
  fastcgi_pass 127.0.0.1:9000;
  include fastcgi.conf;
}

当访问的文件或目录不存在时,程序将重定向到 /index.php 处理,后面的 $is_args$args 是因为重定向以后 QueryString 丢失了,需要加回来。

我们可以发现,按照这样的配置重定向以后,request URI 变成了 /index.php?xxxx 了, 传给 PHP 的 PATHINFO 信息也丢失了 (https://trac.nginx.org/nginx/ticket/321) ,所以一般这种单入口的程序或框架会有许多判断,一般这些程序会通过 REQUEST_URI 参数来获得 index.php 后的路径信息,实现路由。

需要把路径信息传递到 PATH_INFO 的话,还有一种方案,就是通过 rewrite 来实现,假若文件不存在,则在 request URI 之前加上 /index.php 再继续解析。

参考 Typecho 的源码 (https://github.com/typecho/typecho/blob/master/tools/default_site) :

server {
    listen 80 default_server;
    server_name _;

    root /www;
    index index.html index.php;

    if (!-e $request_filename) {
        rewrite ^(.*)$ /index.php$1 last;
    }

    location ~ \.php(/|$) {
      fastcgi_pass 127.0.0.1:9000;
      fastcgi_split_path_info ^(.+?\.php)(.*)$;
      fastcgi_param PATH_INFO $fastcgi_path_info;
      include fastcgi.conf;
    }

}

这时候也不需要 try_files 了。

可以复制粘贴的 server 配置总结

注意把 fastcgi_pass 设定为服务器中 PHP-FPM 监听的连接。

如果只是想单纯地配一个 .php 的解析

server {
    listen 80 default_server;
    server_name _;

    root /www;
    index index.html index.php;

    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_pass 127.0.0.1:9000;
        include fastcgi.conf;
    }

}

如果想让脚本支持 PATH_INFO

类似 Moodle 的文件下载那种

如果程序是通过 REQUEST_URI 来获取路径而不需要 PATH_INFO 的话:

server {
    listen 80 default_server;
    server_name _;

    root /www;
    index index.html index.php;

    location ~ \.php(/|$) {
        fastcgi_pass 127.0.0.1:9000;
        fastcgi_split_path_info ^(.+?\.php)(.*)$;
        try_files $fastcgi_script_name =404;
        fastcgi_param PATH_INFO $fastcgi_path_info;
        include fastcgi.conf;
    }
}

如果一定要完整获取 PATH_INFO 可以用这个完美支持的版本:

server {
    listen 80 default_server;
    server_name _;

    root /www;
    index index.html index.php;

    location ~ \.php(/|$) {
        fastcgi_pass 127.0.0.1:9000;
        fastcgi_split_path_info ^(.+?\.php)(.*)$;
        set $real_path_info $fastcgi_path_info;
        try_files $fastcgi_script_name =404;
        fastcgi_param PATH_INFO $real_path_info;
        include fastcgi.conf;
    }
}

如果部署的是一个单入口程序,并且对 PATH_INFO 没有要求

server {
    listen 80 default_server;
    server_name _;

    root /www;
    index index.html index.php;

    location / {
          try_files $uri $uri/ /index.php$is_args$args
    }

    location ~ \.php$ {
        fastcgi_pass 127.0.0.1:9000;
        include fastcgi.conf;
    }

}

如果部署的是一个单入口程序,需要支持 PATH_INFO 的话

server {
    listen 80 default_server;
    server_name _;

    root /www;
    index index.html index.php;

    if (!-e $request_filename) {
        rewrite ^(.*)$ /index.php$1 last;
    }

    location ~ \.php(/|$) {
      fastcgi_pass 127.0.0.1:9000;
      fastcgi_split_path_info ^(.+?\.php)(.*)$;
      fastcgi_param PATH_INFO $fastcgi_path_info;
      include fastcgi.conf;
    }

}

关于 Nginx 配置文件的内部执行机理,博主目前还不懂=_=,有些问题也难以解释,现在只能是从外部的表现,官网文档一点点推敲和纠正,待博主有时间有能力搞清楚它的时候,博主会不断地修正这里的描述的,也希望能与各位高手多多交流完善。

以上是博主的一些理解与实践的经验,由于博主的水平有限,可能有一些地方的描述不太妥当,若你发现了本文有不妥甚至错误之处,希望可以尽快在评论区中指出。要深入地理解 Nginx + PHP 配置,还得多参考一下官方的文档、源代码和一些高质量的博客文章。

相关内容