把图片和附件从"服务器上的 uploads/ 目录"搬到正经文件服务,通常不是容量问题,而是几件事同时发生:磁盘写满要扩容、换机器要迁移、内部文档直链被转进群、CDN 账单突然涨三倍。这些问题在目录结构里没有答案,得先把文件服务当成有接口的系统来设计。
1. 自建文件服务要回答的五个问题
| 问题 | 要定的具体项 | 定错的后果 |
|---|---|---|
| 存哪 | 本地盘、对象存储、NAS | 单盘无冗余,坏盘丢一批 |
| 怎么访问 | 直链、预签名、应用代理 | 直链公开后被爬、被刷流量 |
| 谁有权 | 桶策略、签名有效期、应用鉴权 | 私有文件被匿名读到 |
| 怎么算成本 | 存储、出网流量、请求次数 | 只算存储,流量超预算 |
| 怎么备份 | 版本化、异地镜像、回收站 | 误删后没有回滚点 |
后四个问题都依赖第一个问题的答案,所以选型必须先定。
2. 对象存储选型
| 方案 | 优势 | 代价 | 适用 |
|---|---|---|---|
| MinIO 自建 | S3 兼容、无出网费、可控 | 自己运维、自己做异地备份 | 内网、量大、数据不出域 |
| 云厂商 OSS/S3 | 免运维、CDN 生态完整 | 出网单价高、有请求费 | 面向公网的中小站 |
| 本地磁盘 + nginx | 最简单、零依赖 | 无冗余、扩容停机、无签名 | 单机、小体量 |
常见组合:热数据放对象存储,图片走 CDN,冷数据转低频层。比全部放本地盘省事,也比全部放云上省钱。
3. MinIO 的部署要点
# 单机单盘:最简单,但没有纠删码
docker run -d --name minio \
-p 9000:9000 -p 9001:9001 \
-e MINIO_ROOT_USER=admin -e MINIO_ROOT_PASSWORD='CHANGE_ME' \
-v /srv/minio/data:/data \
quay.io/minio/minio server /data --console-address ":9001"
# 单机多盘:启用纠删码(4 块盘按半数校验,可坏 2 块)
minio server /data{1...4}
| 模式 | 冗余 | 最小规模 | 扩容 | 说明 |
|---|---|---|---|---|
| 单机单盘 | 无 | 1 盘 | 换大盘 | 测试或可重算数据 |
| 单机多盘 | 纠删码 | 4 盘 | 加盘需重建 | 单机故障仍全丢 |
| 分布式 | 纠删码跨节点 | 4 节点起 | 加节点集 | 生产常用,按集扩容 |
纠删码的关键是校验块比例:MinIO 的 EC:N 指每组校验块数量,EC:4 就是 4 个校验块配 4 个数据块,可用容量约一半。bucket replication 用于跨站点,替代不了纠删码。
$ mc alias set local http://127.0.0.1:9000 admin 'CHANGE_ME'
$ mc mb local/media
$ mc admin info local # 容量、节点、盘状态
$ mc ls --recursive local/media | head
$ mc du local/media # 桶占用
$ mc admin user add local appuser 'APP_SECRET'
$ mc admin policy attach local readwrite --user appuser
4. 桶与权限模型
| 模式 | 访问方式 | 风险 | 适用 |
|---|---|---|---|
| 私有桶 + 预签名 | 后端签发临时 URL | 有效期太长等于公开 | 私有附件 |
| 公共读桶 | 直接 GET |
被爬、被刷流量、外泄 | 公开图片 |
| 公共读写 | 匿名可传 | 被当免费图床 | 不该存在 |
| 前缀隔离 | private/{userId}/... |
前缀不是权限边界 | 多租户分目录 |
# 公共读(只给需要的桶开,且最好挂在 CDN 后面)
$ mc anonymous set download local/public-img
# 生成预签名下载链接(有效期按需,不要给 7 天)
$ mc share download --expire 1h local/media/2025/11/report.pdf
# 生命周期:把 90 天前的对象转低频层
$ mc ilm add --expiry-days 365 local/media
S3 SigV4 预签名 最长 7 天(604800 秒),这是协议上限不是建议值。"一次下载"的链接按分钟级给。
5. 上传链路:直传还是中转
| 方案 | 链路 | 优势 | 代价 |
|---|---|---|---|
| 服务端中转 | 浏览器 → 应用 → 存储 | 鉴权、校验、改名都在服务端 | 应用带宽与内存成瓶颈 |
| 预签名直传 | 浏览器 → 存储 | 应用只签发 URL,不碰字节 | 校验靠回调或 HEAD 复核 |
| 分片续传 | 按 part 上传后合并 | 大文件可续传 | 要维护 uploadId 与清单 |
# 预签名直传:应用只发一个短时 PUT URL
url = s3.generate_presigned_url(
"put_object",
Params={"Bucket": "media", "Key": key, "ContentType": "image/webp"},
ExpiresIn=300,
)
分片上传的两个硬约束:除最后一片外每片不小于 5 MiB,总片数不超过 10000。100 GB 单文件不能靠"1 MB 一片"解决。
秒传靠内容哈希:客户端算 sha256,带哈希问服务端"有没有",命中则建引用。两点注意:哈希要整文件算;命中结果要区分"同内容已存在"和"同 key 已存在"。
6. 图片处理接入
# URL 参数式处理(七牛 Mogr2 风格,参数名按各家文档核对)
https://img.example.com/a.jpg?imageMogr2/thumbnail/!300x300r/quality/80/format/webp
https://img.example.com/a.jpg?imageMogr2/thumbnail/!50p/blur/1x0
// 自建链路:sharp 生成固定规格,结果落盘或回写对象存储
const sharp = require('sharp');
await sharp(src).rotate()
.resize(300, 300, { fit: 'cover', position: 'centre' })
.webp({ quality: 80 })
.toFile(dst);
| 做法 | 时机 | 优势 | 代价 |
|---|---|---|---|
| URL 参数(Mogr2 类) | 请求时 | 规格随便改,不用预生成 | 首请求要回源,缓存必须打到位 |
| 预生成规格 | 上传时 | 首屏快、可长缓存 | 规格多时存储翻倍 |
| 自建(sharp/ImageMagick) | 上传或请求时 | 可控、可加水印 | 要防 SSRF 与解码炸弹 |
ImageMagick 同等操作:convert in.jpg -resize '300x300^' -gravity center -extent 300x300 -quality 80 out.webp。哪条路都要限制输入尺寸与解码像素数,否则一张超大图能打满 worker。
7. ZFile 这类"挂载层"的定位
| 维度 | ZFile 的定位 | 不是什么 |
|---|---|---|
| 角色 | 把多种存储挂成统一目录,提供浏览、搜索、直链 | 不做上传治理、去重、生命周期 |
| 存储后端 | 本地、S3 兼容、OneDrive、Google Drive、WebDAV | 不适合高并发上传后端 |
| 鉴权 | 站点密码、目录权限、直链开关 | 不是多租户 IAM |
| 适用 | 把已有云盘内容变成可浏览站点 | 需要回源鉴权的图床 |
它解决的是展示层:把对象存储或网盘的目录树映射成网页目录。真正的生命周期仍在存储侧。
8. CDN 与回源
# 源站给 CDN 的缓存头:公开图片长缓存,私有资源不缓存
location /media/ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header X-Content-Type-Options nosniff;
}
location /private/ {
add_header Cache-Control "private, no-store";
# 或改为校验签名后再 proxy_pass 到对象存储
}
| 策略 | 设置 | 原因 |
|---|---|---|
| 公开图片 | 长缓存加内容哈希文件名 | 内容变则 URL 变,缓存不失效 |
| 私有附件 | private, no-store 或签名校验 |
边缘缓存会把私有文件变公开 |
| 签名 URL | 缓存 TTL 小于签名有效期 | 过期后边缘仍有副本 |
| 回源鉴权 | 源站校验 Referer 或回源头 |
防绕过 CDN 直打源站 |
签名 URL 与 CDN 的冲突最易被忽略:签名保护源站,不保护已进边缘缓存的副本。
9. 成本与容量
| 成本项 | 典型量级对比 | 优化手段 |
|---|---|---|
| 存储 | 相对便宜,按月每 GB | 转低频、删冗余规格 |
| 出网流量 | 通常比存储单价高一个数量级以上 | CDN 命中、压缩、限制直链 |
| 请求次数 | 小文件多时不可忽略 | 合并小文件、长缓存 |
| 跨区复制 | 按流量计 | 只对关键桶开 |
判断口径:流量比容量痛。所以优先做的是"减少回源与出网",而不是"压缩存储"。同一批图片,统一转 WebP 并按需生成缩略图,通常能同时降低存储与流量。
10. 备份与恢复
| 手段 | 防的是什么 | 不防什么 |
|---|---|---|
| 纠删码/副本 | 单盘、单节点故障 | 误删、勒索、逻辑损坏 |
| 版本化 | 覆盖写与误删 | 桶被整体删除 |
| 异地镜像 | 机房级故障 | 源端已污染的数据 |
| 回收站 | 手滑 | 恶意删除 |
# 版本化 + 定期镜像到另一个站点,才是"备份"
$ mc version enable local/media
$ mc mirror --preserve --watch local/media remote-backup/media
$ mc mirror --newer-than 7d local/media /backup/snap-$(date +%F)/media
对象存储不能只靠副本:副本跟着源一起被删。mc mirror --watch 是持续同步,误删也会同步过去——要么用默认的不删目标端,要么加版本化与不可变窗口。
11. 排错:七种现场
| 现象 | 原因 | 处理 |
|---|---|---|
| 能上传、访问 403 | 桶私有却用直链 | 改预签名或对前缀开匿名读 |
| 大文件到 99% 失败 | 分片小于 5 MiB 或片数超 10000 | 调大分片 |
| 部分文件名 404 | key 含空格、#、?、中文 |
key 用哈希,原名放 metadata |
| 换 CDN 后老图 404 | 缓存 key 与源站路径不一致 | 统一路径规则,做一次全量预热 |
| 预签名发出去就失效 | 时钟偏差或有效期过短 | 校时(NTP),按场景定有效期 |
| 磁盘满但对象数没涨 | 分片残留与 .minio.sys |
mc admin heal、清未完成上传 |
| 扩容后旧数据读不到 | 按集扩容却没 rebalance | 走官方扩容流程,别混盘 |
12. 边界
| 场景 | 更合适 | 理由 |
|---|---|---|
| 只放几张图 | 本地盘 + nginx | 对象存储运维成本高于收益 |
| 多端强一致共享 | NAS/SMB | 对象存储是 HTTP 语义 |
| 版本控制与协同编辑 | Git LFS 或文档系统 | 对象存储不懂文件语义 |
| TB 级、偶尔读 | 归档层 + 解冻 | 标准层成本过高 |
| 极高频小文件 | 本地缓存 + 对象存储做后端 | 请求费很贵 |
13. 自测
- 单机四盘、半数校验的 MinIO,能容忍几块盘同时损坏,可用容量大约多少?
- 为什么"私有桶 + 直链"必然 403,而"公共读桶 + CDN"又可能被刷流量?
- SigV4 预签名 URL 的有效期上限是多少,为什么不该给到上限?
- 分片上传对单片大小与总片数分别有什么硬约束?
- 签名 URL 已经设了 1 小时有效期,为什么边缘缓存仍可能泄漏文件?
mc mirror --watch为什么不算备份,缺的是哪一环?