特殊的服务器环境引发的 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 文件。