nginx上,http状态200响应,PHP空白返回的问题

最近的大半年中,编程语言从PHP换到了Golang后,就很少接触PHP,当然,更多的还是恋恋不舍。尽管如此,每当有人在群里聊起PHP的话题时,我总是想插几句,怀念怀念,同时也温故温故知识点,可不能把她给忘了。

昨天朋友tywei问我一个关于PHP奇怪问题,查到原因解决后,没有详细的解释。夜里睡觉时,老是回想这事,早上醒来,决定还是认真记录一下这些问题。也让自己回归正常状态,多写点博客,总结自己,记录自己。

问题描述
PHP+nginx的环境,任何PHP处理的结果,都是空白页面。OS是ubuntu 14.10 ,nginx 1.6.2 ,PHP5.5.12, 问任何PHP的页面,返回的HTTP状态200,但页面内容是空的,什么都没有,不管PHP页面里写的是什么,正常响应。初步怀疑这是拓展的问题,处理请求后,输出内容有冲突,输出为空之类。想查看拓展列表,看看加载了哪些拓展,但任何PHP代码都返回空,万不得已,在CLI模式下运行,确定ini是同一个。粗略的看了加载的模块,列表如下:

Configuration File (php.ini) Path => /etc/php5/cli
Loaded Configuration File => /etc/php5/cli/php.ini
Scan this dir for additional .ini files => /etc/php5/cli/conf.d
Additional .ini files parsed => /etc/php5/cli/conf.d/05-opcache.ini,
/etc/php5/cli/conf.d/10-mysqlnd.ini,
/etc/php5/cli/conf.d/10-pdo.ini,
/etc/php5/cli/conf.d/20-curl.ini,
/etc/php5/cli/conf.d/20-gd.ini,
/etc/php5/cli/conf.d/20-imagick.ini,
/etc/php5/cli/conf.d/20-json.ini,
/etc/php5/cli/conf.d/20-memcache.ini,
/etc/php5/cli/conf.d/20-memcached.ini,
/etc/php5/cli/conf.d/20-mysql.ini,
/etc/php5/cli/conf.d/20-mysqli.ini,
/etc/php5/cli/conf.d/20-pdo_mysql.ini,
/etc/php5/cli/conf.d/20-readline.ini,
/etc/php5/cli/conf.d/20-redis.ini,
/etc/php5/cli/conf.d/20-xdebug.ini

大约如上的模块加载,怀疑xdebug跟opcache冲突,尝试关闭后,仍未解决。
strace看系统调用信息

10:02:03.432445 accept(0, {sa_family=AF_INET, sin_port=htons(49617), sin_addr=inet_addr("127.0.0.1")}, [16]) = 4
10:02:06.125142 times({tms_utime=0, tms_stime=0, tms_cutime=0, tms_cstime=0}) = 1718358917
10:02:06.125177 poll([{fd=4, events=POLLIN}], 1, 5000) = 1 ([{fd=4, revents=POLLIN}])
10:02:06.125332 read(4, "\1\1\0\1\0\10\0\0", 8) = 8
10:02:06.125363 read(4, "\0\1\0\0\0\0\0\0", 8) = 8
10:02:06.125381 read(4, "\1\4\0\1\3)\7\0", 8) = 8
10:02:06.125394 read(4, "\f\0QUERY_STRING\16\3REQUEST_METHODGET\f\0CONTENT_TYPE\16\0CONTENT_LENGTH\v\nSCRIPT_NAME/index.php\v\1REQUEST_URI/\f\nDOCUMENT_URI/index.php\r\22DOCUMENT_ROOT/data/web/test.com\17\10SERVER_PROTOCOLHTTP/1.1\21\7GATEWAY_INTERFACECGI/1.1\17\vSERVER_SOFTWAREnginx/1.6.2\v\10REMOTE_ADDR10.0.2.2\v\5REMOTE_PORT50057\v\tSERVER_ADDR10.0.2.15\v\2SERVER_PORT80\v\10SERVER_NAMEtest.com\17\3REDIRECT_STATUS200\t\10HTTP_HOSTtest.com\17\nHTTP_CONNECTIONkeep-alive\22\tHTTP_CACHE_CONTROLmax-age=0\vJHTTP_ACCEPTtext/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\17yHTTP_USER_AGENTMozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.118 Safari/537.36\24\23HTTP_ACCEPT_ENCODINGgzip, deflate, sdch\24\27HTTP_ACCEPT_LANGUAGEzh-CN,zh;q=0.8,en;q=0.6\v\6HTTP_RA_VER2.10.1\v&HTTP_RA_SIDB4A714D2-20150327-061728-7c932d-97f1f0\0\0\0\0\0\0\0", 816) = 816
10:02:06.125416 read(4, "\1\4\0\1\0\0\0\0", 8) = 8
10:02:06.125439 setitimer(ITIMER_PROF, {it_interval={0, 0}, it_value={60, 0}}, NULL) = 0
10:02:06.125468 rt_sigaction(SIGPROF, {0x6ec3d0, [PROF], SA_RESTORER|SA_RESTART, 0x7f3c26740da0}, {0x6ec3d0, [PROF], SA_RESTORER|SA_RESTART, 0x7f3c26740da0}, 8) = 0
10:02:06.125508 rt_sigprocmask(SIG_UNBLOCK, [PROF], NULL, 8) = 0
10:02:06.125570 times({tms_utime=0, tms_stime=0, tms_cutime=0, tms_cstime=0}) = 1718358917
10:02:06.125601 setitimer(ITIMER_PROF, {it_interval={0, 0}, it_value={0, 0}}, NULL) = 0
10:02:06.125637 fcntl(3, F_SETLK, {type=F_UNLCK, whence=SEEK_SET, start=0, len=0}) = 0
10:02:06.125668 write(4, "\1\6\0\1\0@\0\0X-Powered-By: PHP/5.5.12-2ubuntu4.4\r\nContent-type: text/html\r\n\r\n\1\3\0\1\0\10\0\0\0\0\0\0\0\0\0\0", 88) = 88
10:02:06.125697 shutdown(4, SHUT_WR)    = 0
10:02:06.125713 recvfrom(4, "\1\5\0\1\0\0\0\0", 8, 0, NULL, NULL) = 8
10:02:06.125728 recvfrom(4, "", 8, 0, NULL, NULL) = 0
10:02:06.125797 close(4)                = 0

觉得好简短,好奇怪,访问的是SCRIPT_NAME index.php,怎么都没 lstat \open 这个PHP文件呢?直接返回了?起码要判断SCRIPT_FILENAME是否存在吧,要读取SCRIPT_FILENAME,解析里面的代码吧? 等下…..SCRIPT_FILENAME全部地址是啥?发来的CGI协议包中怎么没有SCRIPT_FILENAME?
仔细看下CGI包的内容

QUERY_STRING
REQUEST_METHODGET
CONTENT_TYPE
CONTENT_LENGTH

SCRIPT_NAME /index.php
REQUEST_URI /
DOCUMENT_URI /index.php
DOCUMENT_ROOT /data/web/test.com
SERVER_PROTOCOL HTTP/1.1
GATEWAY_INTERFACE CGI/1.1
SERVER_SOFTWARE nginx/1.6.2
REMOTE_ADDR 10.0.2.2
REMOTE_PORT 50057
SERVER_ADDR 10.0.2.15
SERVER_PORT 80
SERVER_NAME test.com
REDIRECT_STATUS 200

HTTP_HOST test.com
HTTP_CONNECTION keep-alive

缺少:

SCRIPT_FILENAME
//PATH_TRANSLATED //这个暂时无视

那么问题来了

  • PHP-FPM接收CGI请求时,如果没有SCRIPT_FILENAME怎么处理的?
  • 发来的CGI协议包中,为啥没有SCRIPT_FILENAME? (SCRIPT_FILENAME是什么,干啥用的,这个就不要问了吧。)

问题1:PHP-FPM接收CGI请求时,如果没有SCRIPT_FILENAME怎么处理的?
fpm源码里(PHP5.5.x 为例)

//fpm_main.c
/* {{{ main
 */
int main(int argc, char *argv[])
{
	//...

	//fpm_main.c 1820行
	while (fcgi_accept_request(&request) >= 0) {
		request_body_fd = -1;
		SG(server_context) = (void *) &request;
		init_request_info(); 	//这里对应986行左右的的init_request_info 函数中代码
		char *primary_script = NULL;

		fpm_request_info();

		/* request startup only after we've done all we can to
		 *            get path_translated */
		if (php_request_startup() == FAILURE) {
			fcgi_finish_request(&request, 1);
			SG(server_context) = NULL;
			php_module_shutdown();
			return FPM_EXIT_SOFTWARE;
		}

		/* check if request_method has been sent.
		 * if not, it's certainly not an HTTP over fcgi request */
		if (!SG(request_info).request_method) {//这里判断request.method是否存在,在init_request_info方法里,最上面设置了默认的NULL
			goto fastcgi_request_done;
		}

		//...
		fastcgi_request_done:
			//结束当前request请求,给及响应

	}
}

同样是 fpm_main.c中init_request_info函数的代码如下:

//fpm_main.c 986行
static void init_request_info(void)
{
	char *env_script_filename = sapi_cgibin_getenv("SCRIPT_FILENAME", sizeof("SCRIPT_FILENAME") - 1);
	char *env_path_translated = sapi_cgibin_getenv("PATH_TRANSLATED", sizeof("PATH_TRANSLATED") - 1);
	char *script_path_translated = env_script_filename;
	char *ini;
	int apache_was_here = 0;

	/* some broken servers do not have script_filename or argv0
	 * an example, IIS configured in some ways.  then they do more
	 * broken stuff and set path_translated to the cgi script location */
	if (!script_path_translated && env_path_translated) {
		script_path_translated = env_path_translated;
	}

	/* initialize the defaults */
	SG(request_info).path_translated = NULL;
	SG(request_info).request_method = NULL;
	SG(request_info).proto_num = 1000;
	SG(request_info).query_string = NULL;
	SG(request_info).request_uri = NULL;
	SG(request_info).content_type = NULL;
	SG(request_info).content_length = 0;
	SG(sapi_headers).http_response_code = 200;	//	这里默认给了200的响应

	/* script_path_translated being set is a good indication that
	 * we are running in a cgi environment, since it is always
	 * null otherwise.  otherwise, the filename
	 * of the script will be retreived later via argc/argv */
	if (script_path_translated) {
		if (CGIG(fix_pathinfo)) {
			//对pathinfo做处理,剥离出SCRIPT_FILENAME,并重置SCRIPT_FILENAME
		} else {
		}

		if (is_valid_path(script_path_translated)) {
			//这里如果script_path_translated是合法路径,就给转化一下,赋值给SG(request_info).path_translated
			SG(request_info).path_translated = estrdup(script_path_translated);
		}

		SG(request_info).request_method = sapi_cgibin_getenv("REQUEST_METHOD", sizeof("REQUEST_METHOD") - 1);//这里从CGI包李获取method,赋值给request_method
		// ...
	}
}

从代码里看出,script_path_translated变量就是cgi协议包中SCRIPT_FILENAME的结果,其中1115行左右,判断如果script_path_translated为空,并且env_path_translated不为空,则用env_path_translated赋值到script_path_translated上。
之后,对request_info的几个属性给予了默认值,包括request_method为null,以及http_response_code默认200的http响应。
在后面的代码中if (script_path_translated) ,因为CGI包中没有SCRIPT_FILENAME,也没有PATH_TRANSLATED,即script_path_translated为空,故没有对request_method进行赋值,其默认值为NULL。
回到main函数中1839行附近if (!SG(request_info).request_method) ,则直接goto到了fastcgi_request_done,直接结束当前request请求,由于之前有设置过默认的http 响应状态为200 ,也就导致了每次返回http状态200成功响应的空白页面的问题。 同时也解释了strace系统调用中,没出现lstat、open 操作SCRIPT_FILENAME的记录了。

问题2,发来的CGI协议包中,为啥没有SCRIPT_FILENAME?
PHP-FPM接收到的CGI协议包都是来自前面nginx的,cgi协议包中没有这个,肯定是nginx没发来,查看nginx配置看到fastcgi_params中没有这项。加上后就可以了。

解决办法:
在nginx配置的 fastcgt_params中加上SCRIPT_FILENAME的配置(在ubuntu的apt-get形式安装nginx配置中,默认是有这条的),比如

  fastcgi_param  SCRIPT_FILENAME  $document_root$fastcgi_script_name;

题外话:
在FPM的fpm_main.c文件的main函数中,1848行对SG(request_info).path_translated的判断,晚于1849行SG(request_info).request_method的判断。而在init_request_info函数中,对SG(request_info).path_translated的赋值却是比SG(request_info).request_method早的。 而且,明明找不到需要执行的脚本,却还返回200的http响应,很奇怪,也不方便排查。我觉得把对SG(request_info).request_method的判断放到SG(request_info).path_translated后面更合适一些。或者若找不到SCRIPT_FILENAME的话,http响应状态改为404,同时,写入LOG日志,便于排查。

后记:
从fpm代码里可以看到,其实作者是有考虑到没有SCRIPT_FILENAME的问题的,只是判断顺序搞错了。所以,我觉得这应该是个bug,就提了一下:BUG #69625:php-fpm return http 200 response on nginx without SCRIPT_FILENAME,不知道官方是否认为这是个BUG。

新的问题:

  • 为什么如果SCRIPT_FILENAME不存在时,用PATH_TRANSLATED来代替它?PATH_TRANSLATED是每个CGI前端都要发送的吗?

这个问题,后来认真看了下,感觉还挺复杂,跟CGI客户端有关,PHPFPM针对IIS\APACHE\NGINX的处理都不一样。以后再写吧。

参考资料:
RFC3875 – The Common Gateway Interface (CGI) Version 1.1

如果你在阅读PHP源码,或者阅读PHP SAPI、PHP拓展源码,可以关注一下PHP源码执行流程图 关于FPM的执行流程,可以看下下面这幅图:
fpm__main_8c_a0ddf1224851353fc92bfbff6f499fa97_cgraph

2018年07月09日更新:
Fix bugs #75120 and #69625 #3226里修复了我在2015年3月提的 bug report Fix bug #69625 php-fpm return http 200 response without SCRIPT_FILENAME #1270

知识共享许可协议CFC4N的博客CFC4N 创作,采用 署名—非商业性使用—相同方式共享 4.0 进行许可。基于https://www.cnxct.com上的作品创作。转载请注明转自:nginx上,http状态200响应,PHP空白返回的问题

7 thoughts on “nginx上,http状态200响应,PHP空白返回的问题

  1. 楼主精通各种装逼,能把这个问题解释成这样……这装逼功夫也算到家了

  2. 为什么我的nginx配置有fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    还会出现这种情况

Comments are closed.