适用场景

Stepnex 这类 Flask 博客的图片上传,最容易出问题的不是“能不能上传”,而是上传链路里有四层边界却没人一起维护:浏览器表单提示、Nginx 代理限制、Flask 请求限制、Pillow 图片处理。这个项目当前把 MAX_CONTENT_LENGTH 设成 8388608,上传目录落在 static/uploads/YYYYMM,后台编辑器通过 /admin/upload 走登录态和 CSRF 校验,save_uploaded_image() 会做扩展名白名单、secure_filename、随机文件名、缩放和 WebP 转换。只要其中一层和其他层不一致,线上就会出现“明明图片不大却报 413”“前端一直转圈最后 499”“上传成功但页面路径是 http”“管理员换了大图后 Gunicorn worker 卡住”这类难复现问题。

这篇文章不是再讲一遍上传安全基础,而是把上传链路当成一条可维护的生产路径来审视。它适合三种情况:第一,你已经有后台上传,但偶尔出现 400、413、499、504 或图片损坏;第二,你准备把博客从本机开发环境迁到 Nginx + Gunicorn;第三,你想把“能上传”升级成“失败边界清晰、日志可追、参数可复盘”。如果你还没梳理文件类型和鉴权,可以先补读站内的 Flask 图片上传安全检查清单;如果后台入口已经开始被探测,再结合 后台上传限流清单 一起看。

基础可用:先把四个边界对齐

生产环境里不要先改代码,先统一“允许多大、允许多久、允许传到哪里”。这个项目的基础值已经很明确:Flask MAX_CONTENT_LENGTH=8388608,图片最长边 IMAGE_MAX_DIMENSION=1600,默认转 WebP,上传目录是 static/uploads/。Nginx 如果仍然保留默认 client_max_body_size 1m,那超过 1 MB 的请求会先被代理层拒绝,Flask 根本收不到请求,也不会留下应用日志。因此第一步不是盲目调大,而是让 Nginx 和 Flask 至少说同一种“语言”。

APP_ENV=production
BEHIND_PROXY=true
MAX_CONTENT_LENGTH=8388608
IMAGE_MAX_DIMENSION=1600
IMAGE_CONVERT_TO_WEBP=true
server {
    client_max_body_size 8m;
    client_body_timeout 30s;

    location /admin/upload {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 120s;
        proxy_send_timeout 120s;
        proxy_request_buffering on;
    }
}

这里的技术取舍很简单。小型博客后台上传的通常是封面图、配图和截图,不是几十 GB 的流式文件,所以 proxy_request_buffering on 更稳妥:请求体先由 Nginx 完整接收,再交给 Gunicorn,同步 worker 不会被慢客户端长时间拖住。client_max_body_size 建议只在上传相关 serverlocation 明确配置,不要全站无上限。另一个经常被忽略的点是目录权限:static/uploads/ 不可写时,浏览器看到的往往只是“上传失败”,真正线索却在系统权限或磁盘空间里。

生产加固:明确失败发生在哪一层

上传排障最怕一句“后台报错了”,因为 413、400、499、504 对应的是四种完全不同的问题。Nginx 先拒绝时,用户会得到 413,Flask 路由甚至不会执行;应用层拒绝时,/admin/upload 会返回 JSON 400,例如“未选择文件”或“图片文件无效或已损坏”;客户端主动中断时,Nginx access log 里常见 499;Gunicorn 或图片处理阶段太慢时,才更可能出现 504 或 worker timeout。所以第二步不是先调参数,而是先画出拒绝顺序。

这套项目还依赖 ProxyFixBEHIND_PROXY=true 后,应用会信任 X-Forwarded-ForX-Forwarded-ProtoX-Host。如果 Nginx 少传了这些头,上传本身也许成功,但后台回填到 Markdown 的图片 URL 可能变成错误的协议或主机名,进一步影响 SEO、混合内容告警和 CDN 命中。换句话说,上传链路不是只有文件体,回写出来的 URL 也是产物的一部分。

建议把错误边界写成团队可复用的步骤:

  1. 先看浏览器网络面板里的状态码,是 413、400、499 还是 504。
  2. 再看 Nginx access/error 日志,确认请求有没有到达 upstream。
  3. 最后看 journalctl -u flask-blog,确认应用有没有进入 save_uploaded_image()

如果你的目标是“用户超限时看到明确提示”,那就不要只调大限制;更应该把后台页面上的文件大小提示、Nginx client_max_body_size、Flask MAX_CONTENT_LENGTH 和前端校验保持一致。基础可用先追求错误可解释,之后再谈体验优化。

这里还有一个很实用的判断法:如果 Nginx 日志里已经出现 413,而 Flask 服务日志没有任何对应请求,说明问题在代理层;如果应用日志里能看到路由进入、但最终返回 400 JSON,多半是文件无效、字段名不对或 Pillow 解码失败。把这类“现象 -> 层级”的映射写进值班文档,比在群里反复问“谁最近动过上传配置”更节省时间。

性能与安全:别把图片处理成本藏起来

这个项目的上传实现会在 save_uploaded_image() 里执行 image.load()ImageOps.exif_transpose()thumbnail(),然后按配置转 WebP 或 JPEG。也就是说,请求体即使只有 5 MB,真正的 CPU 和内存开销也取决于原图分辨率、色彩模式和转换质量,而不只取决于字节大小。很多人把 client_max_body_size 从 8m 改到 20m,以为问题解决了,结果只是把压力从代理层转移到了 Pillow 和 Gunicorn worker。

更稳的做法是把“大小限制”和“处理复杂度”分开管理。我的建议是:

  • 管理员上传配图维持 8 MB 左右的上限,优先规范素材尺寸,而不是无限放大代理层阈值。
  • IMAGE_MAX_DIMENSION 保持在真实展示需求附近,博客封面和正文插图通常不需要 4000 像素原图。
  • 上传目录单独纳入备份,避免数据库恢复成功但文章图片全部丢失。
  • 后台上传继续要求登录态和 CSRF,不要因为是“内部接口”就给匿名入口开大文件能力。

避坑点也要提前讲清楚。第一,不要只改 Nginx,不改 Flask;否则代理能放行 10 MB,应用却在 8 MB 拒绝,最后看起来像随机失败。第二,不要只改 Flask,不改 Nginx;否则应用永远收不到超限请求。第三,不要把 proxy_request_buffering off 当成万能优化,小博客后台上传通常没有必要做流式透传。第四,不要忘了看磁盘空间和 /var/lib/nginx 或默认 request body 临时目录,图片处理中间文件和代理缓冲都可能吃掉空间。第五,如果线上只在上传大截图时超时,要先怀疑图片解码和转换耗时,而不是先怀疑网络。

自动化与维护:把验证命令写进发布流程

上传链路稳定以后,最有价值的动作不是“调过一次参数”,而是把验证命令变成固定流程。每次变更 Nginx、图片质量、后台权限或服务器规格后,都至少跑下面这组命令:

sudo nginx -t
sudo systemctl reload nginx
sudo journalctl -u flask-blog -n 100 --no-pager
sudo grep ' /admin/upload ' /var/log/nginx/access.log | tail -n 20
sudo grep ' 413 ' /var/log/nginx/access.log | tail -n 20
df -h /var/www/blog /var/lib/nginx

如果你们已经有早间巡检脚本,可以把“昨天 /admin/upload 的 4xx/5xx 数量”“最近一次上传目录备份时间”“上传目录增长量”加进去。对 Stepnex 这种以稳定维护为优先的小站来说,这比上复杂对象存储改造更划算。自动化发布同样别忘了上传路径:数据库是 database.db,媒体资产却在 static/uploads/,恢复演练时必须两者一起验证,否则文章详情页会出现大量历史图片 404。

验证方式不要只停留在“我本机试过一次”。至少做三类检查:一类是小图正常上传;一类是略超限制的文件是否稳定返回 413 或明确 400;一类是上传成功后生成的 Markdown 图片 URL 是否为正确的 HTTPS 地址、是否能在公开文章页打开。只要这三类验证都保留下来,后续换 Nginx 配置、换服务器或调图片质量时,团队就不会从零猜起。

如果你们要做恢复演练,别只恢复数据库再打开首页。请抽查一篇旧文章、一篇新文章和一张最近上传的封面图,确认 /static/uploads/YYYYMM/ 下的文件还能被 Nginx 正常返回,确认后台再次上传时目录仍然可写,并检查文章详情页、站点地图引用和编辑器回填链接都没有断裂。很多博客“看起来恢复成功”,其实只是因为首页缓存还在,历史图片和正文插图已经悄悄失联。

复盘清单

每次遇到上传故障,我会按下面的复盘清单收尾,而不是只记住“把参数调大了”:

  • 适用场景是否变了:上传对象还是博客配图,还是已经变成高分辨率素材库。
  • 配置是否一致:Nginx client_max_body_size、Flask MAX_CONTENT_LENGTH、页面提示值是否对齐。
  • 命令执行后有没有验证:nginx -t、服务 reload、上传成功页、失败页、日志抽查都要留痕。
  • 日志能不能定位层级:413 在代理层,400 在应用层,499 是客户端中断,504 才更像上游超时。
  • 备份是否覆盖 static/uploads/,恢复时是否抽查文章图片。

做完这些,你得到的不是“上传功能还能用”,而是一条基础可用、生产加固、性能与安全、自动化与维护都能自圆其说的上传链路。对长期维护的 Flask 博客来说,这种可复查的工程笔记,比一次性调参更有价值。