ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Django图片服务器实战:上传、访问、下载与部署全解析

Django图片服务器实战:上传、访问、下载与部署全解析 最近在给团队搭一个基于 Django 的图片服务器目标是把散落在各业务系统里的图片统一收口提供上传、访问和下载的能力。一开始我觉得这事儿不大无非是文件上传加静态目录结果越往里钻越发现一条完整的图片服务链路里藏着不少细节尤其是 StreamingHttpResponse 里的 content_type 和 content-disposition 这两个参数参数名又长又容易搞混但只要理解了它们的配合关系图片预览和下载这两种行为就完全可控。这篇文章就把整套流程从头到尾梳理一遍从浏览器传文件到磁盘落盘从 Django 返回图片流到浏览器预览或下载再到用 Waitress 和 Nginx 把它部署到 Windows 10 上稳定跑起来。适合正在用 Django 做后台开发、被图片上传访问下载折腾过的朋友也适合准备自建图片存储管理的人拿来当参考。1. 把整体流程先立起来Django 在图片服务里到底管哪几件事1.1 MTV 模式在图片服务器里的映射关系Django 的 MTV 模式很多人刚接触时只当它是概念实际上放到图片服务器这个具体场景里理解不理解的差别非常明显。MTV 说白了就是 Model-Template-View它是 MVC 的一种变体这里的 View 在传统 MVC 里其实扮演的是 Controller 的角色而 Template 才是传统意义上的 View。这个映射关系在图片服务器里会很直接地落在代码结构上。拿我现在的项目举例我专门建了一个叫 gallery 的 app执行的是python manage.py startapp gallery把所有和图片相关的逻辑收在里面。Model 层对应图片的元数据表村里人叫法可能不一样但职责是一致的记录文件叫什么名、存在哪个路径、大小多少、谁上传的、什么时候传的。Template 层负责上传页面、图片预览页面以及后台管理页面。View 层处理上传请求、图片查询和图片流的输出。三者的协作关系是浏览器请求进来之后URLconf 先做路由分发把请求送到对应的 ViewView 负责处理业务逻辑需要读数据就调 Model需要渲染页面就用 Template。放到图片服务器的语境里就是 Model 不存图片本身只存图片的档案图片本体的二进制字节都在磁盘上View 需要返回图片时才去读取文件流。这个认知一旦建立后面很多问题都能迎刃而解。我见过不少同事把图片二进制塞进数据库里理由是这样好管理结果数据库没几天就膨胀得没法看。图片这种大字段天然适合文件系统存储数据库只保存路径引用。MySQL 也好、SQLite 也好负责的是结构化数据文件系统负责的是非结构化数据两者配合才是正路。1.2 图片的四种流转路径上传、存储、访问、下载把整条业务链路拆开图片服务器其实只有四条路径分别是上传、存储、访问和下载。上传是浏览器把文件通过 multipart/form-data 格式 POST 到 Django 后端后端把文件流落盘同时往数据库里写入一条记录存储是文件按照约定的目录规则存到 MEDIA_ROOT 下这个目录规则最好按日期分目录避免一个目录下文件过多导致文件系统性能下降访问是浏览器通过 URL 直接拿到图片字节流在页面上展示出来下载则是服务器在响应头里告诉浏览器这个资源你要以附件形式保存从而触发浏览器的下载行为。这四条路径听起来简单但每条路径都有它的关键配置。上传路径的关键是表单的 enctype 设置和视图里对 request.FILES 的处理存储路径的关键是 MEDIA_ROOT 与 MEDIA_URL 的规划以及 upload_to 的命名规则访问路径在开发环境和生产环境的实现方式完全不同开发环境靠 Django 帮你路由生产环境基本都交给 Nginx 接管下载路径则是 HTTP 响应头里 Content-Disposition 的天下。把四条路径画在一张表里会更清楚阶段入口关键代码/配置产物上传POST /upload/request.FILES、表单验证磁盘文件 数据库记录存储upload_to 规则按日期/UUID 命名MEDIA_ROOT 下的物理文件访问GET /media/xxx.jpg开发环境 static()、生产 Nginx alias图片字节流下载GET /api/download/?idFileResponse Content-Disposition下载或预览响应这套流程不仅仅适用于纯图片服务器。热词里提到的多媒体资源管理系统实战包本质也是这个骨架数据库存元数据、后台管理、前端浏览、下载功能四者齐全。所以我一直觉得把图片这条链路吃透再去做视频、文档类的资源管理只是换了个文件类型和 MIME 的问题架构不用大改。1.3 什么时候用 Django 自建而不是引入独立对象存储聊完流程得说句公道话不只是所有场景都适合用 Django 自建图片服务器。如果你的项目用户量巨大、图片访问量轻松过亿、需要 CDN 加速和多地容灾那么老老实实用阿里云 OSS 或者 MinIO 这类专业对象存储才是正解。Django 自建方案的适用场景是中小型项目、内网系统、工具型应用或者业务初期不想引入额外基础设施的情况。我这次选择自建主要是因为部署环境在 Windows 10 内网图片量级日均新增几千张访问压力也不大单独为这个体量引入一套对象存储服务有点杀鸡用牛刀。Django 自建的优势在于开发效率高和现有业务系统的用户体系、权限体系无缝衔接再加上 Django admin 开箱即用的后台管理和维护成本都很低。缺点也明显文件服务能力受限于单机磁盘和 Django 进程横向扩展要自己想办法。所以这个方案是有边界的。如果你的图片服务将来可能要扛大规模流量那我的建议是先把这套流程跑通但存储层别写死尽量通过 Django 的 storage 抽象来操作文件这样将来切换到第三方对象存储只需要替换 backend 和少量配置。别等到文件都存了几十万张再考虑这事迁移成本会非常痛。2. 上传链路从浏览器到磁盘每一环都不能掉链子2.1 先把地基打对MEDIA_ROOT 与 MEDIA_URL 的配置上传链路的第一步不是写视图而是把 settings.py 里的 MEDIA_ROOT 和 MEDIA_URL 配对。MEDIA_ROOT 是文件在磁盘上的物理存储路径MEDIA_URL 是浏览器访问这些文件的 URL 前缀。我见过太多人把这两个变量搞混或者在部署时写死了一个绝对路径结果代码迁移到别的机器上直接 404。正确做法是在 settings.py 里用路径拼接的方式动态生成from pathlib import Path BASE_DIR Path(__file__).resolve().parent.parent MEDIA_URL /media/ MEDIA_ROOT BASE_DIR / media注意 MEDIA_URL 必须以斜杠结尾否则模板里拼出来的图片地址会少一个斜杠浏览器访问时路径就错了。这个问题很隐蔽因为本地 runserver 测试时可能碰巧正常一旦部署到 Nginx 的反向代理后面路径拼接错误就会反复出现。MEDIA_ROOT 则必须是绝对路径Django 内部存储文件时会把它作为基准目录如果你填的是相对路径文件会跑到当前工作目录下面时机一久连你自己都找不到文件在哪。还有一个当初让我踩坑的点Windows 环境下的路径分隔符。Windows 默认用反斜杠但 Django 的存储后端会统一处理路径所以你在代码里写路径时尽量用正斜杠或者 Path 对象来拼接避免硬编码反斜杠导致跨平台时出问题。另外给 MEDIA_ROOT 单独建一个目录别把上传文件放到项目源码目录里否则部署时源码目录被覆盖或清理图片就全没了。2.2 模型设计数据库只存档案不存文件本体模型层是图片服务器的核心数据结构。推荐用 ImageField 来声明图片字段因为它继承了 FileField 的所有能力并且 Django 会自动帮你校验上传内容是否是合法图片。前提是你安装了 Pillow 库没有的话启动项目会直接报错。我这里说的校验合法性不是只看扩展名Pillow 会尝试解析文件内容一个把 HTML 改成 .jpg 后缀的文件在 ImageField 这里就会被拦下来。字段定义可以这样写from django.db import models from django.utils import timezone import uuid import os def image_upload_to(instance, filename): ext os.path.splitext(filename)[1].lower() return fimages/{timezone.now():%Y/%m/%d}/{uuid.uuid4().hex}{ext} class Image(models.Model): title models.CharField(max_length200, verbose_name标题) image models.ImageField(upload_toimage_upload_to, verbose_name图片文件) uploaded_at models.DateTimeField(auto_now_addTrue, verbose_name上传时间) size models.IntegerField(editableFalse, verbose_name文件大小) class Meta: verbose_name 图片 verbose_name_plural 图片 def save(self, *args, **kwargs): if self.image and not self.size: self.size self.image.size super().save(*args, **kwargs) def __str__(self): return self.titleupload_to 可以接收一个可调用对象这是我强烈推荐的做法。它允许你根据业务动态生成存储路径比如按时间分目录。日期分目录的好处很多一是避免单目录文件数量过大导致磁盘索引变慢二是后面做定期归档清理时可以直接按目录操作三是 URL 上带日期可以让浏览器和 CDN 缓存策略更好做。文件名的处理是上传链路里最容易被忽视的安全点。不能直接使用用户上传的原始文件名否则可能遇到中文乱码、文件名冲突甚至路径穿越攻击。我用 uuid.uuid4().hex 生成随机文件名只从原始文件名里提取扩展名这样既保证文件名唯一又避免特殊字符问题。size 字段我在 save 方法里自动填充这样前端展示的时候不用再去磁盘上拿文件信息。在 Django admin 里注册这个模型之后后台管理页面直接就能看到图片缩略图和上传时间这就是含后台管理功能的由来。如果你后面把数据库从 SQLite 切换到 MySQL只需要改 DATABASES 配置模型和视图代码一行都不用动Django 的 ORM 把这种差异屏蔽得很好。2.3 视图处理request.FILES 的正确打开方式上传视图的核心是处理 request.FILES。很多新手第一次写上传接口时直接在视图里用 request.POST.get(image) 拿文件结果拿回来是个空原因是文件不在 POST 数据里而在 FILES 里。POST 数据是表单字段FILES 是文件字段Django 在解析 multipart 请求时就帮你分好了。用 Django Form 来处理上传会更规范from django import forms from .models import Image class ImageUploadForm(forms.ModelForm): class Meta: model Image fields [title, image] def clean_image(self): image self.cleaned_data.get(image) if image: if image.size 10 * 1024 * 1024: raise forms.ValidationError(图片大小不能超过 10MB) if image.content_type not in [image/jpeg, image/png, image/webp]: raise forms.ValidationError(不支持的图片格式) return image然后在视图里这样使用from django.shortcuts import render, redirect from .forms import ImageUploadForm def upload_image(request): if request.method POST: form ImageUploadForm(request.POST, request.FILES) if form.is_valid(): image_obj form.save() return redirect(image_detail, image_idimage_obj.id) else: form ImageUploadForm() return render(request, gallery/upload.html, {form: form})form.save() 会做几件事把上传的图片文件写入 MEDIA_ROOT 对应目录在数据库里创建一条记录并把文件路径保存到模型的 image 字段。如果不想依赖 Form也可以手动处理 request.FILES但对于图片服务器这种业务用 ModelForm 是最省事的。它在模型字段和表单字段之间做了双向映射文件从浏览器到磁盘再到数据库的路径你几乎不用写胶水代码。模板里的表单必须设置 enctype这可能是整个上传链路里最常见的低级错误form methodpost enctypemultipart/form-data {% csrf_token %} {{ form.as_p }} button typesubmit上传/button /form没有 enctypemultipart/form-data浏览器会把文件字段的名字和值作为普通文本提交Django 的 request.FILES 自然就是空的而且这种情况后台不会报任何错误排查起来特别迷惑。2.4 上传环节最容易忽略的几个细节第一个细节是请求体大小限制。Django 层面没有默认的请求体大小上限但在生产环境前面挡着 Nginx 时就被限制了。Nginx 默认 client_max_body_size 是 1MB这意味着超过 1MB 的图片上传会直接返回 413 错误。这个值必须根据你的业务图片大小主动调大一般建议设置成 10M 或 20M具体看你的用户通常传多大图。第二个细节是 Django 对上传文件的内存与临时文件策略。默认情况下小于 2.5MB 的文件会保存在内存中超过这个阈值会写入系统临时文件这个阈值由 FILE_UPLOAD_MAX_MEMORY_SIZE 控制。如果你把图片服务器部署在内存有限的服务器上可以通过这个参数控制内存占用但别调太小否则大量小文件频繁写临时目录磁盘 IO 反而成为瓶颈。第三个细节是权限控制。图片上传接口千万别做成匿名可用的否则你的服务器很快就会变成别人免费图床。在视图里加上 login_required 装饰器或者配合 Django 自带的权限系统做分组控制。内网系统被人乱传文件不仅浪费磁盘还可能被用来存放违规内容这种事在开发阶段就要堵住。第四个细节是文件类型校验不能只依赖 MIME。客户端的 content_type 是可以伪造的浏览器上报的 MIME 类型只能作为参考。真正可靠的是用 Pillow 打开文件内容做校验也就是 ModelForm 里 ImageField 做的事情。额外要警惕 SVG 文件SVG 本质是 XML 文本里面可以嵌脚本如果允许上传 SVG 且直接通过浏览器访问存在存储型 XSS 风险。稳妥做法是禁止用户上传 SVG或者上传后强制转成 PNG。我补充一个关于 admin 后台的实际经验在 admin.py 里注册模型后默认列表页不会显示图片缩略图需要自定义一个方法from django.contrib import admin from .models import Image admin.register(Image) class ImageAdmin(admin.ModelAdmin): list_display [title, image_tag, uploaded_at, size] list_per_page 20 def image_tag(self, obj): if obj.image: return fimg src{obj.image.url} stylewidth: 100px;/ return image_tag.short_description 缩略图 image_tag.allow_tags True在后台直接看到缩略图管理体验会提升一个档次尤其是图片量多的时候。3. 访问与下载链路StreamingHttpResponse、Content-Type 与 Content-Disposition 的配合3.1 开发环境下让图片能直接被访问文件存进 MEDIA_ROOT 之后下一步就是让图片可以被浏览器访问到。开发环境下Django 提供了一个非常方便的方法在 urls.py 里追加一条 static 路由。from django.conf import settings from django.conf.urls.static import static urlpatterns [ # ... 其他路由 ] static(settings.MEDIA_URL, document_rootsettings.MEDIA_ROOT)它的原理是把/media/xxx.jpg这样的请求映射到 MEDIA_ROOT 下对应的物理文件然后返回文件内容。这个方案只适用于 DEBUGTrue 的开发环境因为它是 Django 进程直接读文件返回给浏览器的性能和并发都不够看。生产环境里必须由 Nginx 这类 Web 服务器来干这件事原因后面部署部分细说。模板里引用图片的方式也要说清楚。ImageField 实例有一个 url 属性它等于 MEDIA_URL 拼接上文件相对于 MEDIA_ROOT 的路径所以模板里直接写{{ image_obj.image.url }}就能得到完整的可访问地址。不需要自己手动拼接 MEDIA_URL 和文件名Django 都帮你做完了。3.2 用 StreamingHttpResponse 精确控制图片响应当你有权限控制、防盗链、水印等定制需求时就不适合让 Web 服务器直接返回文件了而是需要在 Django 视图里读取文件并返回给浏览器。这时候用 StreamingHttpResponse 是正路。StreamingHttpResponse 和普通 HttpResponse 的核心区别是它接收一个迭代器作为响应内容并且以流式方式一块一块地把数据发送给客户端而不是一次性把整个文件加载到内存。这在大图片场景下是刚需。一张 10MB 的高清图如果一次性 read 进内存再来几个并发请求Python 进程的内存就会被吃满。一个基础版图片流接口长这样import os from django.conf import settings from django.http import StreamingHttpResponse def stream_image(request, path): # path 由 URL 捕获形如 images/2025/01/01/xxxx.jpg file_path os.path.join(settings.MEDIA_ROOT, path) real_path os.path.realpath(file_path) media_root os.path.realpath(settings.MEDIA_ROOT) if not real_path.startswith(media_root os.sep): return HttpResponseForbidden(非法路径) def file_iterator(file_path, chunk_size8192): with open(file_path, rb) as f: while True: chunk f.read(chunk_size) if not chunk: break yield chunk if not os.path.exists(real_path): return HttpResponseNotFound(图片不存在) response StreamingHttpResponse( file_iterator(real_path), content_typeimage/jpeg ) response[Content-Disposition] inline; filenameimage.jpg return response这里的 chunk_size8192 意思是每次从磁盘读取 8KB 数据就发给客户端读一点发一点。这个值不是越大越好太大了内存占用高太小了网络 IO 次数变多影响吞吐。实测下来 8KB 到 64KB 之间都是合理区间我自己习惯用 8192兼容性和内存占用都比较均衡。路径校验这段代码不是多此一举。如果 path 来自用户输入必须防路径穿越攻击也就是防止用户传../之类的路径去读取 MEDIA_ROOT 之外的文件。用 os.path.realpath 把路径解析成真实绝对路径再和 MEDIA_ROOT 的真实路径做前缀匹配能有效挡住这种攻击。这个细节可能很多教程不会提但真实生产环境里这是致命漏洞。3.3 Content-Type告诉浏览器这个东西是什么Content-Type 头是 HTTP 响应头里的一个标准字段作用就是告诉浏览器响应体的数据是什么类型。放在图片场景里Content-Type: image/jpeg就是说这段内容是一张 JPEG 图片浏览器收到之后会按图片去渲染显示在页面上。如果设置成了text/html浏览器会把二进制图片数据当 HTML 去解析页面显示出来就是一堆乱码。所以当你在 StreamingHttpResponse 里指定 content_type 参数时本质是在给浏览器贴标签。不同图片格式对应不同的 MIME 值常见的有下面这几种图片格式对应的 Content-TypeJPEGimage/jpegPNGimage/pngGIFimage/gifWebPimage/webpSVGimage/svgxml如果图片格式不固定接口里不能写死 content_type需要动态判断。可以依赖文件的扩展名通过 mimetypes 模块来推断也可以用 Pillow 打开图片读取实际格式。Pillow 的方式最准确因为它读的是文件内容的真实格式而不是文件名from PIL import Image def get_image_content_type(file_path): with Image.open(file_path) as img: image_format img.format return fimage/{image_format.lower()}需要注意 Pillow 返回的 JPEG 是小写 jpeg而它对应的标准 MIME 就是 image/jpeg所以直接转小写拼上去没问题。但 WebP 这个格式在部分 Python 环境的 mimetypes 内置映射里可能查不到如果你用 mimetypes.guess_type 去判断最好手动补一条映射import mimetypes mimetypes.add_type(image/webp, .webp)还有一个容易忽略的点是当流式响应已经开始传输之后再设置 Content-Type 已经晚了。因为浏览器是边收数据边按响应头解析的所以所有响应头必须在响应对象创建时或者返回响应的第一段数据之前设置好。3.4 Content-Disposition是预览还是下载由它说了算Content-Disposition 头是上流式响应里第二个关键参数它控制着浏览器对这个资源采取直接展示还是强制下载的行为。这个头的基本语法是Content-Disposition: inline Content-Disposition: attachment; filenamephoto.jpginline 意思是这个东西请在页面里展示。对图片来说浏览器收到 inline 响应头后会把图片直接渲染在页面里就像你用 img 标签引用一张图一样。attachment 意思是请把它当作一个文件保存到本地浏览器会隐藏图片内容的直接渲染改为弹出下载提示或者直接下载到默认目录。这两种行为的差异对用户体验影响巨大。图片预览接口必须用 inline图片下载接口必须用 attachment。如果不小心把下载接口写成了 inline用户点击下载按钮后在浏览器里看到的是一张图片而不是下载文件体验非常奇怪且难以排查。在 Django 里设置这个响应头有两种方式。一种是手动设置适合用 StreamingHttpResponse 的场景response StreamingHttpResponse(file_iterator(real_path), content_typeimage/jpeg) response[Content-Disposition] inline; filenamecover.jpg return response另一种是用 FileResponse 自带的参数这个后面细说。但无论哪种方式有个坑值得单独说文件名编码问题。HTTP 响应头只支持 ASCII 字符如果文件名是中文直接写进 Content-Disposition 会导致浏览器解析乱码或者直接忽略文件名的指定值。正确做法是使用 RFC 5987 定义的 filename* 语法做 URL 编码from urllib.parse import quote filename quote(我的图片.jpg) response[Content-Disposition] fattachment; filename*UTF-8{filename}兼容写法是把文件名同时以 ASCII 形式的 filename 和编码形式的 filename* 都带上老浏览器读 filename新浏览器自动识别 filename*这个方法在兼容性和功能上都能兼顾Content-Disposition: attachment; filenamephoto.jpg; filename*UTF-8%E6%88%91%E7%9A%84%E5%9B%BE%E7%89%87.jpg我实际项目里用过这个方法解决了很多用户反馈的下载文件名乱码问题这个细节也是标题里专门提到 content-disposition 参数的原因。3.5 更进一步用 FileResponse 让 Django 帮你处理细节虽然有 StreamingHttpResponse 能精确控制每个响应头但它要求你手写文件迭代器、手动猜测 Content-Type、手动拼接 Content-Disposition一堆细节都要自己操心。当你没有太定制化的需求时Django 已经把这一切封装得更完善了那就是 FileResponse。FileResponse 是 StreamingHttpResponse 的子类专门为文件服务设计。它自动做了三件事用 mimetypes 猜测文件的 Content-Type、生成 Content-Length 响应头、自动处理 Content-Disposition。看代码from django.http import FileResponse def download_image(request, image_id): image_obj Image.objects.filter(idimage_id).first() if not image_obj: return HttpResponseNotFound(图片不存在) file_path image_obj.image.path response FileResponse( open(file_path, rb), as_attachmentTrue, filenameimage_obj.image.name ) return responseas_attachmentTrue 等于把 Content-Disposition 设置成 attachmentFalse 则是 inline。filename 参数会自动进行编码处理中文文件名也能正确显示。如果你只是做图片预览def preview_image(request, image_id): image_obj Image.objects.filter(idimage_id).first() if not image_obj: return HttpResponseNotFound(图片不存在) return FileResponse(open(image_obj.image.path, rb), content_typeimage/jpeg)FileResponse 底层同样使用迭代器分块读取所以大文件的内存占用问题它天然规避掉了。我在项目里的通用原则是优先用 FileResponse只有当需要精细控制响应头时才手动用 StreamingHttpResponse。比如你需要在响应头里加防盗链验证或者其他自定义业务字段时再走底层方案。4. 生产环境部署Waitress Nginx 把图片服务跑稳4.1 Windows 上为什么选 Waitress 而不是 runserver开发阶段用python manage.py runserver很方便有自动重载出错信息也友好。但它只是一个开发服务器Python 官方文档都明确写着不能用于生产环境。原因很简单runserver 是单进程的无法充分利用多核 CPU它对并发请求的支持很差一个慢请求卡住其他请求全都排队而且它没有做任何安全加固HTTP 头、超时控制这些生产环境的基本要求都不完善。生产环境部署 Django 需要一个正式的 WSGI 服务器。社区里最流行的是 Gunicorn但它对 Windows 的支持一直不好在 Windows 10 上没法稳定运行。另一个选择是 uWSGI配置复杂在 Windows 上同样比较折腾。所以如果你的部署环境是 Windows 10Waitress 是最省心的方案。Waitress 是一个纯 Python 实现的 WSGI 服务器没有平台相关的依赖Windows 和 Linux 都能跑安装只需要一条命令pip install waitress启动 Django 项目的方式也很简单waitress-serve --listen127.0.0.1:8000 --threads8 myproject.wsgi:application这条命令的意思是让 Waitress 监听本机的 8000 端口开启 8 个工作线程来处理并发请求加载 myproject 项目里的 WSGI 应用。threads8 表示同一时间最多有 8 个请求在处理如果请求量更大请求会排队等待。线程数不是越多越好因为 Python 的 GIL 锁决定了多线程在 CPU 密集型任务上的并发能力有限对于图片服务器这种 IO 密集型的场景线程数可以适当调大但不能盲目设置成几百否则线程切换开销反而拖慢性能。8 到 16 是常见的安全范围。Waitress 还支持通过配置文件方式启动适合做成 Windows 服务。你可以用 NSSM 把 waitress 命令注册成系统服务开机自启崩溃自动重启这样部署在服务器上才像样。虽然这不在本文主题范围内但如果你真的要把图片服务器长期跑在 Windows 上这一步是必不可少的。4.2 Nginx 接入后的完整请求流转Waitress 在 8000 端口待命但如果直接对外开放浏览器请求直接打在 Waitress 上会有两个问题一是图片这类静态资源本来就不需要经过 Python 进程直接让 Nginx 返回文件会更高效二是 Nginx 在反向代理、负载均衡、超时控制、请求体大小限制方面比裸 WSGI 服务器强得多。于是生产环境的架构就成了这个样子浏览器请求先到 NginxNginx 判断请求路径如果请求 /media/ 开头的静态资源直接读取磁盘文件返回如果是其他动态接口转发给 Waitress 处理Waitress 处理 Django 视图逻辑返回响应给 NginxNginx 把响应回传给浏览器对应的 Nginx 配置核心片段server { listen 80; server_name images.example.com; client_max_body_size 20m; location /media/ { alias D:/projects/media_server/media/; expires 30d; add_header Cache-Control public; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_read_timeout 30s; } }这里的 alias 和 root 是两个很容易搞混的指令。root 会把整个 URL 拼在指定目录后面alias 则会把 location 匹配的前缀替换成指定目录。如果用 root 实现同样效果应该写成location /media/ { root D:/projects/media_server/; }请求 /media/xxx.jpg 时root 方式拼出来的路径是 D:/projects/media_server/media/xxx.jpgalias 方式拼出来的是 D:/projects/media_server/media/xxx.jpg。两者结果一样但配置方式有区别。很多人在 root 里写上完整的 media 目录导致路径重复变成 D:/projects/media_server/media/media/xxx.jpg排查半天才发现是这个问题。expires 30d 的意思是让浏览器把 /media/ 下的图片缓存 30 天这样用户第二次访问同样的图片时直接从浏览器本地缓存读取不回源服务器能显著降低 Nginx 和磁盘 IO 的压力。对于图片服务器静态资源加缓存是一个成本极低的优化手段。4.3 上传大小、超时时间和并发的实测建议部署之后有几个参数需要根据实际业务调优这里说几个我实测过的经验值。Nginx 的 client_max_body_size 默认只有 1MB如果你允许用户上传单张 5MB 的照片这个值必须调大否则前端上传到一半会收到 413 Request Entity Too Large 错误。设成 20m 在多数场景下都够用如果你做的是高清图库或者设计稿分享可能还要再大一些。注意这个参数是 Nginx 层面的限制调小了 Django 收不到任何请求问题出在 Nginx 日志里而不是 Django 日志里排查时要先看对日志。proxy_read_timeout 控制 Nginx 等待后端 Waitress 响应的超时时间。如果某个接口处理大图片处理得慢超过默认的 60 秒后 Nginx 会主动断开连接前端看到的是一次 504 Gateway Timeout。图片处理这类耗时接口建议把这个值设为 60 秒以上或者根据你单张图片的最大处理耗时 x3 来设定。Waitress 的线程数方面我上面提到 8 到 16 是一个合理范围。真实负载上来之后可以通过 Waitress 的监控日志或者 Windows 自带的性能监视器观察线程池的繁忙程度。如果经常出现排队等待可以增加线程数如果线程数加了 CPU 占用率已经到了 80% 以上说明瓶颈在 CPU这时候加线程也没用考虑多开几个 Waitress 进程配合 Nginx upstream 做负载均衡才对。我给来看这篇文章的人一个部署排查顺序先确认 Waitress 能不能直接通过http://127.0.0.1:8000访问通再把 Nginx 加进来但只做反向代理不接管 /media/验证动态接口通不通最后把 /media/ 的 alias 加上验证静态图片访问。分步走每一步有问题都能定位到具体那一层不要一次性全配好再排查那样只会浪费更多时间。5. 高频问题排查与避坑实录5.1 图片访问 404 的排查路径图片 404 是所有图片服务器最常见的问题但原因可能藏在好几个不同的层面。我总结了一套从后往前的排查路径按这个顺序走基本能快速定位排查位置排查动作常见的坑数据库记录检查image_obj.image.url和.path的值路径拼接少了斜杠URL 和实际路径对不上磁盘文件确认物理文件确实存在于磁盘文件被手动删了记录成了孤儿数据settings.py确认 MEDIA_ROOT 是绝对路径且存在路径写成相对路径文件写到了别处Nginx对比 alias 和 root 的使用root 里多写了一层目录导致路径重复文件权限确认 Nginx 进程有目录读取权限Windows 下权限问题少但 Linux 下很常见我先排查磁盘文件再看 Nginx 配置最后回到 Django 自身。有一个细节是当请求从浏览器经过 Nginx 再到 Django 时如果 Nginx 把 /media/ 路径交给了 Django 处理而不是自己读文件Django 的 URLconf 里又没有对应路由也会出现 404。这种情况通常在 Nginx 配置的 location 匹配写错了导致静态资源走了动态代理。访问图片时还常遇到一类问题Django 返回了 200但浏览器显示的是乱码或者直接触发下载。这就回到第三部分讲的 Content-Type 和 Content-Disposition 了。200 状态码不代表一定正常你要在浏览器开发者工具的 Network 面板里点开这个请求看响应头里 Content-Type 到底是什么Content-Disposition 是 inline 还是 attachment。这两个响应头不对图片状态码再正常也白搭。5.2 下载文件名中文乱码的解决办法下载文件名中文乱码这个问题几乎每个做资源下载的人都会碰上我也不例外。现象是接口返回的 Content-Disposition 头里明明写了 filename产品说明书.jpg但浏览器下载时保存的文件名是一串乱码浏览器不同表现还不一样Chrome 和 Edge 的结果都有差异。问题根源是 HTTP 响应头标准只允许 ASCII 字符中文字符直接写进去浏览器可能用不同编码去解码于是就是乱码。解决办法就是前面提过的 RFC 5987 filename* 语法用 URL 编码的方式把中文文件名写进响应头。具体在 FileResponse 场景下直接把 filename 参数传中文就行Django 内部已经做了编码处理response FileResponse( open(file_path, rb), as_attachmentTrue, filename产品说明书.jpg )这里有个旧版本注意点文件名的编码处理是 Django 3.x 之后才完善的如果你的项目还在用很老的 Django 版本遇到这个问题需要手动拼接编码后的 Content-Disposition。如果你是因为发现乱码才查到这里先看你项目里的django.VERSION低于 3.2 的建议直接升级不只是因为文件名编码安全补丁也差了很多。5.3 删了数据库记录文件却还在磁盘上这个问题很有代表性。很多人在后台把图片记录删了以为磁盘占用会跟着释放结果发现文件还躺在 MEDIA_ROOT 里时间一长磁盘占满管理员来质问你为什么删了数据磁盘没变小。原因在于 Django 的模型 delete() 方法默认只删除数据库记录不会自动删除关联的物理文件。要删文件必须手动调用字段的 delete 方法而且顺序有讲究image_obj Image.objects.get(idsome_id) # 正确先删物理文件再删数据库记录 image_obj.image.delete(saveFalse) image_obj.delete() # 错误只删了记录文件成为孤儿文件 image_obj.delete()image.delete(saveFalse) 会调用存储后端删除文件saveFalse 的意思是告诉 Django 删除文件后不要立刻保存模型因为你马上就要删除这条记录了。如果顺序反了先删记录再删文件你就拿不到 image 字段里的路径信息了文件就彻底变成了无法追踪的孤儿文件。还有一种情况是删除的文件碰巧不存在Django 的存储后端在部分版本会抛 FileNotFoundError。稳妥的写法是加上存在性判断再删除import os image_obj Image.objects.get(idsome_id) if image_obj.image and os.path.exists(image_obj.image.path): image_obj.image.delete(saveFalse) image_obj.delete()如果你希望每次删除模型时自动清理文件可以用 Django 信号里的 post_delete 来做但我个人建议在业务代码里显式处理逻辑更清晰也容易测试。用信号的话一旦信号逻辑出错反而会导致删不了记录或者报了莫名奇妙的异常排查成本更高。5.4 大图片上传失败和访问卡顿上传大图片时如果 Nginx 返回 413说明 client_max_body_size 太小子这个上面已经说过。还有一种情况是上传过程一直转圈最后浏览器提示连接重置这种多半是请求体过大Nginx 或者 Waitress 在处理超时时间内没能接收完整个请求。解决办法是同时调整 Nginx 的 client_max_body_size 和 client_body_timeout。访问大图卡顿的问题则要回到响应方式上。如果你在视图里硬编码了这种写法return HttpResponse(open(file_path, rb).read())那就是把整个文件一次性读进内存再返回图片只要几 MB 就会让响应时间明显变长并发稍微上来Python 进程内存直接飙升。正确做法就是前面讲的 StreamingHttpResponse 或者 FileResponse用迭代器分块传输内存占用恒定响应首字节时间也更快。另一个经常被忽略的是前端展示大图时的性能。原图 5000 像素宽直接给 img 标签加载浏览器也要渲染半天。合理做法是服务器端生成缩略图列表页显示缩略图点开才加载原图。Django 生态里可以用 Pillow 在保存图片时同步生成一张固定宽度的缩略图或者用 django-imagekit 这类库自动管理。如果没有现成的缩略图方案先按列表小图、详情大图、下载原图的思路去设计用户体验会好很多。刚才还提到一个跨域问题如果你的图片服务接口要被前端页面上 axios 或 fetch 直接调用浏览器会有 CORS 限制。在 Django 项目里最省事的方案是使用 django-cors-headers 这个库把允许访问的域名加到白名单里。对于img标签直接引用图片 URL 这种场景CORS 默认不限制所以图片展示没有跨域问题但如果你要在前端 canvas 里操作图片或者用 fetch 下载文件CORS 就必须配置正确。最后再分享一个维护经验图片服务器上线后最好定期做一个磁盘文件与数据库记录的对照检查清理掉那些数据库里没有记录但磁盘上还留着的孤儿文件。可以用脚本遍历 MEDIA_ROOT 下的文件再用 ORM 查询比对路径是否在数据库中存在。这个问题平时不出事一出就是磁盘告警处理起来还有点棘手。未雨绸缪比事后再折腾要省心得多。
返回列表