特殊的服务器环境引发的 Laravel 框架异常

一个使用 Laravel 框架的项目,在某次更新生产环境代码后报了奇怪的错误。错误信息如下:

ErrorException: tempnam(): file created in the system's temporary directory in file /foobar/vendor/laravel/framework/src/Illuminate/Filesystem/Filesystem.php on line 152

Stack trace:
  1. ErrorException->() /foobar/vendor/laravel/framework/src/Illuminate/Filesystem/Filesystem.php:152  
  2. tempnam() /foobar/vendor/laravel/framework/src/Illuminate/Filesystem/Filesystem.php:152  
  3. Illuminate\Filesystem\Filesystem->replace() /foobar/vendor/laravel/framework/src/Illuminate/Foundation/ProviderRepository.php:194  
  4. Illuminate\Foundation\ProviderRepository->writeManifest() /foobar/vendor/laravel/framework/src/Illuminate/Foundation/ProviderRepository.php:165  
  5. Illuminate\Foundation\ProviderRepository->compileManifest() /foobar/vendor/laravel/framework/src/Illuminate/Foundation/ProviderRepository.php:61  
  6. Illuminate\Foundation\ProviderRepository->load() /foobar/vendor/laravel/framework/src/Illuminate/Foundation/Application.php:604
  7. Illuminate\Foundation\Application->registerConfiguredProviders() /foobar/vendor/laravel/framework/src/Illuminate/Foundation/Bootstrap/RegisterProviders.php:17  
  8. Illuminate\Foundation\Bootstrap\RegisterProviders->bootstrap() /foobar/vendor/laravel/framework/src/Illuminate/Foundation/Application.php:230  
  9. Illuminate\Foundation\Application->bootstrapWith() /foobar/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php:151  
 10. Illuminate\Foundation\Http\Kernel->bootstrap() /foobar/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php:135  
 11. Illuminate\Foundation\Http\Kernel->sendRequestThroughRouter() /foobar/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php:109  
 12. Illuminate\Foundation\Http\Kernel->handle() /foobar/public/index.php:55 

因为代码在开发环境和测试环境运行都是正常的,所以首先怀疑是生产环境服务器的配置有问题。然而 Google 搜索关键字没有人报告 Laravel 存在类似的错误。

先定位到 /foobar/vendor/laravel/framework/src/Illuminate/Filesystem/Filesystem.php 文件 152 行附近的代码:

PHP/**
 * Write the contents of a file, replacing it atomically if it already exists.
 *
 * @param  string  $path
 * @param  string  $content
 * @return void
 */
public function replace($path, $content)
{
    // If the path already exists and is a symlink, get the real path...
    clearstatcache(true, $path);

    $path = realpath($path) ?: $path;

    $tempPath = tempnam(dirname($path), basename($path));

    // Fix permissions of tempPath because `tempnam()` creates it with permissions set to 0600...
    chmod($tempPath, 0777 - umask());

    file_put_contents($tempPath, $content);

    rename($tempPath, $path);
}

查看 PHP 的官方文档发现,如果 tempnam() 函数的 $directory 参数传递的目录不存在或者不可写入,则会在系统临时文件夹下创建临时文件:

If the directory does not exist or is not writable, tempnam() may generate a file in the system’s temporary directory

并且自 7.1 版本后,如果在系统临时文件夹下创建临时文件,会触发 notice:

tempnam() now emits a notice when falling back to the temp directory of the system.

看来是 Laravel 框架引导启动时,写入某个缓存失败导致的错误。尝试以下命令手动生成缓存:

BASHphp artisan config:cache

得到和之前相同的错误。看来就是写入 /foobar/bootstrap/cache 目录时出错了,并且和用户权限没有关系。尝试在测试环境中重现该错误,将测服中的 /foobar/bootstrap/cache 的写入权限去除,然而却得到了不一样的错误:

Exception: The /foobar/bootstrap/cache directory must be present and writable. in file /foobar/vendor/laravel/framework/src/Illuminate/Foundation/PackageManifest.php on line 177

这个错误消息一目了然,非常友好,而生产环境中的错误却相当诡异。

再定位到 /foobar/vendor/laravel/framework/src/Illuminate/Foundation/ProviderRepository.php 文件 194 行附近的代码:

PHP/**
 * Write the service manifest file to disk.
 *
 * @param  array  $manifest
 * @return array
 *
 * @throws \Exception
 */
public function writeManifest($manifest)
{
    if (! is_writable($dirname = dirname($this->manifestPath))) {
        throw new Exception("The {$dirname} directory must be present and writable.");
    }

    $this->files->replace(
        $this->manifestPath, '<?php return '.var_export($manifest, true).';'
    );

    return array_merge(['when' => []], $manifest);
}

Laravel 框架先通过 is_writable() 函数判断了目标路径是否可写,然后再通过 Filesystem::replace() 方法写入缓存。而问题就出在 is_writable() 函数认为目录是可写的,返回了 true;而 tempnam() 函数认为目录不存在或者不可写,从而引起了错误异常。

因为开发人员无法接触到生产环境,排错效率很低,最终也无法定位引发错误的原因,只能将测试环境中生成的 cache 文件上传,草草了事。


为了进一步排查错误,下载了 PHP 7.4 的源代码,试图推断导致问题的原因。

通过分析 PHP 源代码,在 /ext/standard/filestat.c 文件中找到了内置函数 is_writable() 的实现代码。PHP 实现的伪代码如下:

PHPconst S_IWOTH = 2;
const S_IWGRP = 16;
const S_IWUSR = 128;

function is_writable($filename)
{
    $wmask = S_IWOTH;
    $stat = stat($fielname);
    if ($stat['uid'] == posix_getuid()) {
        $wmask = S_IWUSR;
    } elseif ($stat['gid'] == posix_getegid()) {
        $wmask = S_IWGRP;
    } else {
        $groups = posix_getgroups();
        if ($groups && in_array($stat['gid'], $groups)) {
            $wmask = S_IWGRP;
        }
    }
    
    return ($stat['mod'] & $wmask) != 0;
}

通过代码可以知道,is_writable() 仅仅通过文件权限的 mode 来判断文件是否可以写入。

接着在 /ext/standard/file.c 文件中找到内置函数 tempnam() 的实现代码:

C/* {{{ proto string tempnam(string dir, string prefix)
   Create a unique filename in a directory */
PHP_FUNCTION(tempnam)
{
	char *dir, *prefix;
	size_t dir_len, prefix_len;
	zend_string *opened_path;
	int fd;
	zend_string *p;

	ZEND_PARSE_PARAMETERS_START(2, 2)
		Z_PARAM_PATH(dir, dir_len)
		Z_PARAM_PATH(prefix, prefix_len)
	ZEND_PARSE_PARAMETERS_END();

	p = php_basename(prefix, prefix_len, NULL, 0);
	if (ZSTR_LEN(p) > 64) {
		ZSTR_VAL(p)[63] = '\0';
	}

	RETVAL_FALSE;

	if ((fd = php_open_temporary_fd_ex(dir, ZSTR_VAL(p), &opened_path, PHP_TMP_FILE_OPEN_BASEDIR_CHECK_ALWAYS)) >= 0) {
		close(fd);
		RETVAL_STR(opened_path);
	}
	zend_string_release_ex(p, 0);
}
/* }}} */

继续跟踪到 /main/php_open_temporary_file.c 中的 php_open_temporary_fd_ex() 函数:

CPHPAPI int php_open_temporary_fd_ex(const char *dir, const char *pfx, zend_string **opened_path_p, uint32_t flags)
{
	int fd;
	const char *temp_dir;

	if (!pfx) {
		pfx = "tmp.";
	}
	if (opened_path_p) {
		*opened_path_p = NULL;
	}

	if (!dir || *dir == '\0') {
def_tmp:
		temp_dir = php_get_temporary_directory();

		if (temp_dir &&
		    *temp_dir != '\0' &&
		    (!(flags & PHP_TMP_FILE_OPEN_BASEDIR_CHECK_ON_FALLBACK) || !php_check_open_basedir(temp_dir))) {
			return php_do_open_temporary_file(temp_dir, pfx, opened_path_p);
		} else {
			return -1;
		}
	}

	if ((flags & PHP_TMP_FILE_OPEN_BASEDIR_CHECK_ON_EXPLICIT_DIR) && php_check_open_basedir(dir)) {
		return -1;
	}

	/* Try the directory given as parameter. */
	fd = php_do_open_temporary_file(dir, pfx, opened_path_p);
	if (fd == -1) {
		/* Use default temporary directory. */
		if (!(flags & PHP_TMP_FILE_SILENT)) {
			php_error_docref(NULL, E_NOTICE, "file created in the system's temporary directory");
		}
		goto def_tmp;
	}
	return fd;
}

在这里看到了本文开头抛出的错误消息。显然,是 php_do_open_temporary_file() 函数返回了预料之外的 -1 导致的 。继续跟踪到 php_do_open_temporary_file() 函数内:

Cstatic int php_do_open_temporary_file(const char *path, const char *pfx, zend_string **opened_path_p)
{
#ifdef PHP_WIN32
	char *opened_path = NULL;
	size_t opened_path_len;
	wchar_t *cwdw, *pfxw, pathw[MAXPATHLEN];
#else
	char opened_path[MAXPATHLEN];
	char *trailing_slash;
#endif
	char cwd[MAXPATHLEN];
	cwd_state new_state;
	int fd = -1;
#ifndef HAVE_MKSTEMP
	int open_flags = O_CREAT | O_TRUNC | O_RDWR
#ifdef PHP_WIN32
		| _O_BINARY
#endif
		;
#endif

	if (!path || !path[0]) {
		return -1;
	}

#ifdef PHP_WIN32
	if (!php_win32_check_trailing_space(pfx, strlen(pfx))) {
		SetLastError(ERROR_INVALID_NAME);
		return -1;
	}
#endif

	if (!VCWD_GETCWD(cwd, MAXPATHLEN)) {
		cwd[0] = '\0';
	}

	new_state.cwd = estrdup(cwd);
	new_state.cwd_length = strlen(cwd);

	if (virtual_file_ex(&new_state, path, NULL, CWD_REALPATH)) {
		efree(new_state.cwd);
		return -1;
	}

#ifndef PHP_WIN32
	if (IS_SLASH(new_state.cwd[new_state.cwd_length - 1])) {
		trailing_slash = "";
	} else {
		trailing_slash = "/";
	}

	if (snprintf(opened_path, MAXPATHLEN, "%s%s%sXXXXXX", new_state.cwd, trailing_slash, pfx) >= MAXPATHLEN) {
		efree(new_state.cwd);
		return -1;
	}
#endif

#ifdef PHP_WIN32
	cwdw = php_win32_ioutil_any_to_w(new_state.cwd);
	pfxw = php_win32_ioutil_any_to_w(pfx);
	if (!cwdw || !pfxw) {
		free(cwdw);
		free(pfxw);
		efree(new_state.cwd);
		return -1;
	}

	if (GetTempFileNameW(cwdw, pfxw, 0, pathw)) {
		opened_path = php_win32_ioutil_conv_w_to_any(pathw, PHP_WIN32_CP_IGNORE_LEN, &opened_path_len);
		if (!opened_path || opened_path_len >= MAXPATHLEN) {
			free(cwdw);
			free(pfxw);
			efree(new_state.cwd);
			return -1;
		}
		assert(strlen(opened_path) == opened_path_len);

		/* Some versions of windows set the temp file to be read-only,
		 * which means that opening it will fail... */
		if (VCWD_CHMOD(opened_path, 0600)) {
			free(cwdw);
			free(pfxw);
			efree(new_state.cwd);
			free(opened_path);
			return -1;
		}
		fd = VCWD_OPEN_MODE(opened_path, open_flags, 0600);
	}

	free(cwdw);
	free(pfxw);
#elif defined(HAVE_MKSTEMP)
	fd = mkstemp(opened_path);
#else
	if (mktemp(opened_path)) {
		fd = VCWD_OPEN(opened_path, open_flags);
	}
#endif

#ifdef PHP_WIN32
	if (fd != -1 && opened_path_p) {
		*opened_path_p = zend_string_init(opened_path, opened_path_len, 0);
	}
	free(opened_path);
#else
	if (fd != -1 && opened_path_p) {
		*opened_path_p = zend_string_init(opened_path, strlen(opened_path), 0);
	}
#endif
	efree(new_state.cwd);
	return fd;
}
/* }}} */

最有可能出现问题的地方应该就是 mkstemp(opened_path) 执行返回错误。也就是说,不知道运维对服务器做了哪些手脚,导致 is_writable('/foobar/bootstrap/cache') 返回的是 true;而在该路径下创建临时文件却失败了。怀疑是服务器上有某种安全保护机制,禁止动态创建 .php 文件。