图片上传失败的完整排查:413、临时目录与权限的五层定位

一、为什么"能显示但用不了"最难查

图片上传这件事,大部分教程只讲到"把 upload_max_filesize 调到 64M"。可实际运行一段时间之后,读者反馈的问题往往是这种:图片显示成裂图、上传按钮转圈后无声失败、缩略图生成了但是糊的、明明是同样的图有人能传有人传不了、第二天重启服务器就全变成 403。这些问题都不会在 error_log 里留下显眼的报错,因为从 PHP 的角度看,请求根本没进来。

这篇文章把图片上传拆成五个环节:浏览器端 → Nginx(请求体与超时)→ PHP-FPM(上传限制与临时目录)→ 磁盘与权限(落盘)→ 后续处理(缩略图与展示)。每个环节各有一组会"静默失败"的参数,我按自己踩坑的先后顺序写,命令和配置都可以直接抄。

二、浏览器端:文件还没出发,限制就已经生效

前端 <input type="file"> 或富文本编辑器的上传接口,往往自带一个 JS 层的大小检查。这个检查跟服务器配置毫无关系,但用户看到的是"上传失败"。排查时第一件事是让用户打开 F12 的 Network 面板看请求有没有发出去:

  • 没有请求:前端 JS 拦的,检查 accept、前端 maxSize 常量、以及编辑器的图片格式白名单(有些老编辑器不认 webp/avif)。
  • 有请求但状态 0 或直接断:多半是 Nginx 按 client_max_body_size 直接返回 413 并关闭连接,前端捕获不到响应体。
  • 状态 200 但返回 JSON 里 code 非 0:PHP 层的 $_FILES['file']['error'] 非 0,具体值决定原因。

顺手提醒一个常被忽略的点:手机照片的 EXIF 方向。iPhone 拍的照片像素是横向存的、靠 EXIF Orientation 标记旋转,很多程序读 getimagesize() 拿到的是未旋转的宽高,于是竖图被裁成横图或者缩略图变成侧着的。上传成功后做一次 exif_read_data() 并按 Orientation 旋转,比事后让用户手动旋转友好太多。

三、Nginx:413 的三种来源,以及别只看 client_max_body_size

先看现场:

curl -s -o /dev/null -w '%{http_code}\n' -F "file=@/tmp/big.jpg" https://你的域名/upload.php
# 413 就是 Nginx 挡的,PHP 根本没收到

Nginx 侧要改三个地方,缺一个都会漏:

http {
    client_max_body_size 64m;      # 单个请求体上限,默认只有 1m
    client_body_buffer_size 256k;  # 超过这个大小就落临时文件
    client_body_temp_path /var/lib/nginx/body 1 2;   # 临时目录
    client_body_timeout 60s;       # 接收请求体的超时,默认 60s
    keepalive_timeout 65;
}

几个坑:

1. 只写在 location 里不管用的情况。 client_max_body_size 写在 location ~ \.php$ 里是有效的,但如果请求先被一个 rewrite 或另一个更精确的 location 接管,就不会继承。最保险是写在 server 级,或者在所有可能接收上传的 location 里都写。用 nginx -T | grep -n client_max_body_size 看最终生效的全部值。

2. 413 之后连接被 reset,用户只看到"网络错误"。 可以在 413 上加一个错误页,至少让前端拿到可读的 JSON:

error_page 413 = @too_large;
location @too_large {
    default_type application/json;
    return 413 '{"code":413,"msg":"文件超过 64MB 限制"}';
}

3. 反向代理后面的真实大小限制在你手里,不在上游。 如果 Nginx 前面还有一层 CDN 或云负载均衡,它们普遍有 10MB 或 100MB 的请求体上限,且属于不可配置项。curl 从本机直连能传、从外网传不了,就是这个原因。

4. /var/lib/nginx/body 满了同样会失败。 Nginx 在把请求体交给 PHP 之前,超过 client_body_buffer_size 的部分会写到 client_body_temp_path。这个目录如果在 tmpfs 或小分区上,高峰期会写满,然后返回 500。检查:

df -h /var/lib/nginx /tmp
find /var/lib/nginx/body -type f | wc -l
find /var/lib/nginx/body -type f -mmin +30 -ls   # 残留的临时文件说明有请求被中断

四、PHP-FPM:三个参数必须同时满足,缺一不可

PHP 的文件大小限制不是单一参数,而是三个值取最小:

upload_max_filesize = 64M
post_max_size = 68M        # 必须比 upload_max_filesize 略大,留表单其他字段空间
memory_limit = 256M        # 处理图片(尤其 ImageMagick/GD 解码)很吃内存
max_file_uploads = 20
max_execution_time = 120
max_input_time = 120

post_max_size 小于 upload_max_filesize 是最常见的配置错误。后果极其隐蔽:当整个请求体超过 post_max_size 时,PHP 会清空 $_POST 和 $_FILES,且不抛任何错误,于是你的代码走进"没收到文件"的分支,前端收到一个语焉不详的失败。有经验的做法是在入口统一判断:

<?php
// 请求体超限时 $_POST 与 $_FILES 都是空数组,但 CONTENT_LENGTH 有值
if ($_SERVER['REQUEST_METHOD'] === 'POST'
    && empty($_POST) && empty($_FILES)
    && (int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) {
    $limit = ini_get('post_max_size');
    http_response_code(413);
    exit(json_encode(['code' => 413, 'msg' => "请求体超过 post_max_size ($limit)"], JSON_UNESCAPED_UNICODE));
}

第二件事:改完 php.ini 一定要确认 FPM 真的重载了,而且改的是 FPM 实际读的那份 ini。

php -i | grep -E 'Loaded Configuration|upload_max_filesize|post_max_size'
# 用 FPM 的 SAPI 看,而不是 CLI
php-fpm8.2 -i 2>/dev/null | grep -E 'Loaded Configuration'
# 或者直接打一个 phpinfo 出来看 Server API 是否为 FPM/FastCGI
ps -o args= -C php-fpm8.2 | head -2    # 看 -c 指向哪个 ini
systemctl restart php8.2-fpm           # reload 有时不重读 ini,restart 最稳

CLI 与 FPM 用的 ini 路径通常不同(/etc/php/8.2/cli/php.ini vs /etc/php/8.2/fpm/php.ini)。我见过一个站点连续三天"改了没生效",就是因为 SSH 里 php -i 显示 64M、而 FPM 读的还是旧的 2M——他用 vi 改的是 cli 那份。

五、error code 1 到 8:$_FILES['file']['error'] 的完整读法

如果请求进到了 PHP,问题就在这个整数字段里。务必把数字记下来,不要只看"上传失败":

  • 0:成功。但注意,成功不代表文件可信,还要做 MIME 与内容校验。
  • 1 (UPLOAD_ERR_INI_SIZE):超过 upload_max_filesize。
  • 2 (UPLOAD_ERR_FORM_SIZE):超过表单里的 MAX_FILE_SIZE 隐藏字段。
  • 3 (UPLOAD_ERR_PARTIAL):只传了一部分,通常是用户取消或网络中断。
  • 4 (UPLOAD_ERR_NO_FILE):没选文件就提交,或者字段名对不上(name="file[]" 而你读的是 $_FILES['file'])。
  • 6 (UPLOAD_ERR_NO_TMP_DIR):upload_tmp_dir 不存在。最常见的静默失败原因之一。
  • 7 (UPLOAD_ERR_CANT_WRITE):临时目录不可写,或分区满。
  • 8 (UPLOAD_ERR_EXTENSION):被某个 PHP 扩展中断(如安全模块、防病毒扫描钩子)。

6 和 7 值得单独说。upload_tmp_dir 为空时 PHP 用系统的 sys_get_temp_dir()(通常是 /tmp)。有些发行版或加固脚本会把 /tmp 做成 noexec,nosuid,nodev 甚至独立的 tmpfs,而 systemd 的 PrivateTmp=true 会让服务看到的是自己的私有 /tmp 命名空间——你在 shell 里 ls /tmp 看到的和 FPM 看到的是两个不同目录,这点会彻底打乱排查思路。

# 看 FPM 单元是否开了 PrivateTmp
systemctl show php8.2-fpm -p PrivateTmp
# 看 FPM 进程真实的 /tmp
ls -ld /proc/$(pgrep -f 'php-fpm: pool' | head -1)/root/tmp
# 显式指定临时目录最稳,且放在数据盘而不是 tmpfs
# /etc/php/8.2/fpm/conf.d/99-upload.ini
upload_tmp_dir = /var/lib/php/uploads

建目录时注意属主:

install -d -o www-data -g www-data -m 0700 /var/lib/php/uploads
# 如果用了 systemd 的 ProtectSystem=strict,需要在 unit 里放行
# ReadWritePaths=/var/lib/php/uploads /var/www/uploads

ProtectSystem=strict 是我最推荐也最容易配错的一项加固。它会把整个文件系统挂成只读,只允许 /dev、/proc、/sys 和声明过的 ReadWritePaths 写入。结果就是:上传代码本身没错、目录权限也对,但 FPM 根本写不进去,返回 error 7。查法:

systemctl cat php8.2-fpm | grep -E 'ProtectSystem|ReadWritePaths|PrivateTmp'
sudo -u www-data touch /var/www/uploads/.probe && echo writable || echo NOT-writable

六、落盘:权限、命名与"第二天变 403"

临时文件上传成功后,业务代码要做 move_uploaded_file() 到最终目录。这里的三条经验:

1. 目录不要用 777。 用 www-data:www-data + 0755(文件 0644)就够了。777 的唯一效果是让同机的其他低权限用户能写入你的上传目录——那正是 webshell 最喜欢的位置。

2. 文件名必须自己生成,绝不能沿用用户传来的名字。 用户传来的 filename 里可以带 ../、可以带 .php 后缀、可以带 200 字节的路径做溢出。正确做法是 bin2hex(random_bytes(16)) 加上根据真实 MIME 推断的扩展名,原始文件名只存进数据库用于展示。

3. 上传目录必须禁掉脚本执行。 这一条几乎是被挂马站点的共性问题:图片目录里被塞进一个 shell.php.jpg,而 Nginx 的 location ~ \.php$ 匹配到了 .jpg 后面那段?不会,但 PHP 的 cgi.fix_pathinfo=1(默认值)会让 /uploads/x.jpg/anything.php 这种路径被交给 FPM 执行 x.jpg。修法两层:

# Nginx:上传目录彻底禁止脚本
location ^~ /uploads/ {
    location ~ \.(php|php5|phtml|phar)$ { deny all; }
    add_header Content-Disposition "inline";
    expires 30d;
}
; php.ini
cgi.fix_pathinfo = 0

4. "重启后全 403" 的经典原因是上传目录建在 /tmp 或 /run 下。这两个目录在 Debian 上是 tmpfs 或由 systemd 每次启动重建,重启后目录消失、内容丢失,而目录权限回到默认的 1777 或不存在。把上传目录固定到 /var/www/站点/uploads 或独立数据盘,问题永久消失。

七、图片专用:处理阶段的三个静默失败

文件存下来了,但缩略图糊、或者某些图生成不了。原因通常在这三处:

大图内存耗尽。 GD 解码一张 6000×4000 的 JPEG,内存占用约为 宽×高×4 字节 ≈ 96MB,再加上缩放后的副本,很容易超过 128M 的 memory_limit。报错是 Allowed memory size exhausted,但很多库会 catch 掉并返回 false,前端只看到"处理失败"。修法不是无脑调大 memory_limit,而是先判断尺寸:

$info = getimagesize($tmp);
if ($info && $info[0] * $info[1] > 30_000_000) {
    // 超过 3000 万像素,先按需缩小再交给 GD,或直接拒绝
}

GD 缺格式支持。 编译时没带 --with-webp 就没有 imagewebp(),没带 freetype 就 imagettftext() 报错。检查一行就够:

php -r 'print_r(gd_info());'
# 关注 WebP Support、AVIF Support、JPEG Support、FreeType Support

ImageMagick 的 policy.xml 限制。 用 Imagick 的站点经常遇到"这张图就是处理不了、别的都能处理"。Debian 的 /etc/ImageMagick-6/policy.xml 默认限制了 PDF、PS、EPS 的读写,也限制了单张图片的像素与内存上限。放开时只放开你需要的格式,别整体注释掉——ImageMagick 的历史 CVE 多半正是从这些格式解析器进来的。

grep -v '^ *<!--' /etc/ImageMagick-6/policy.xml | grep -E 'policy domain'

八、一份可以直接抄的上传参数模板

这是我现在所有站的统一配置,前端 64M 上限,后台上传走独立 location,处理组件单独限制内存:

; /etc/php/8.2/fpm/conf.d/99-upload.ini
file_uploads = On
upload_max_filesize = 64M
post_max_size = 68M
max_file_uploads = 20
upload_tmp_dir = /var/lib/php/uploads
max_execution_time = 120
max_input_time = 120
memory_limit = 256M
cgi.fix_pathinfo = 0
# Nginx
server {
    client_max_body_size 64m;
    client_body_buffer_size 256k;
    client_body_timeout 60s;

    location ^~ /uploads/ {
        location ~ \.(php|phtml|phar)$ { deny all; }
    }
}

配套的 Nginx 侧限流也值得加,防止单 IP 刷爆磁盘:

limit_req_zone $binary_remote_addr zone=upload:10m rate=10r/m;
location = /upload.php {
    limit_req zone=upload burst=5 nodelay;
    limit_req_status 429;
}

九、排查顺序总结(贴在运维笔记里的那一版)

  1. 让用户看 F12 有没有发出请求。没发 → 前端限制。
  2. 有请求,状态 413 → Nginx client_max_body_size 或前置 CDN。
  3. 状态 200 或 500,日志无异常 → 判断 $_FILES 是否为空,空则是 post_max_size。
  4. 有 $_FILES 但 error 非 0 → 按 1~8 对照表定位:6 查 upload_tmp_dir,7 查权限与 ProtectSystem。
  5. 落盘成功但展示 403 → 检查上传目录是否在 tmpfs、Nginx 是否有 deny 规则误伤。
  6. 缩略图失败 → gd_info() 看格式支持,查 memory_limit 与 pixels 上限。

这六步走完,图片上传相关的绝大多数问题都能在十分钟内定位到具体那一行配置。真正费时间的从来不是修,而是不知道在哪一层——所以我更喜欢在上传接口里就把 $_FILES['file']['error']、CONTENT_LENGTH、ini_get('post_max_size') 三个值写进日志。事后翻日志,一眼就知道是浏览器没发、Nginx 挡了,还是 PHP 自己放弃了。

Last modification:October 20th, 2026 at 12:24 pm

Leave a Comment