项目里要存的东西越来越多,用户头像、上传的文档、生成的报表,一开始全扔服务器本地磁盘,路径随便起,/data/uploads/2024/ 下面套了一层又一层。后来服务器迁移,光是把这些文件拷过去就折腾了一整天,还丢了一批。再后来多台机器要共享,NFS 挂上去动不动超时,折腾得够呛。
我后来换了个思路:自建一套对象存储。AWS S3 太贵,国内的 OSS 又不想绑死一个云厂商,看了看 MinIO,用了半年多了,把踩过的坑整理一下。
为啥不直接用本地磁盘了
单机的时候问题不大,mkdir 一搞,fwrite 写进去就完事。但到了这些场景就开始疼了:
多台应用服务器要访问同一批文件。NFS 能解决,但性能和可靠性都不好。我遇到过 NFS 挂载点卡死导致整个 df -h 命令 hang 住,连 SSH 进去都费劲。
备份和迁移。几千个小文件 cp -r 慢得要死,rsync 好点但还是得跑很久。而且你还不知道拷完有没有缺的,得再跑一次校验。
权限和临时链接。有时候要给第三方一个临时下载地址,本地文件得自己写签名逻辑,或者干脆开个 Nginx alias 暴露出去,安全也没法保证。
MinIO 对这些问题都有现成方案,而且兼容 S3 API,后面换云厂商的 OSS 也方便,不用担心绑死。
Docker 部署 MinIO 的几个要点
我直接用 Docker 跑的, docker-compose.yml 大概长这样:
version: "3.8"
services:
minio:
image: quay.io/minio/minio:RELEASE.2024-11-07T00-52-20Z
container_name: minio
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: admin
MINIO_ROOT_PASSWORD: your_strong_password_here
volumes:
- ./data:/data
command: server /data --console-address ":9001"
restart: unless-stopped
有几个地方踩过坑。
端口 9000 和 9001 要分开
9000 是 API 端口(S3 兼容),9001 是管理控制台。一开始我只映射了 9000,心想一个端口够了,结果发现没有控制台页面,想建 bucket 还得用 mc 命令行。后来把 9001 也映射出来,浏览器打开就能操作,省了不少事。
其实如果你不想暴露控制台端口,只留 9000 也行,用 mc 命令行工具全搞定。但我建议前期调试的时候把控制台开着,图形界面看 bucket 和文件比命令行直观多了。
密码别太短
MinIO 对 root 密码有最低长度要求,好象是 8 位。我第一次设了个 6 位的,容器直接起不来,日志报错 ERROR Unable to validate credentials。翻了一下文档才知道长度不够。密码设强点没错,反正存环境变量里也不用每次手敲。
数据目录的权限
./data 这个目录,Docker 里面 minio 进程的 uid 是 1000。如果你的宿主机目录权限不对,容器起来会报 permission denied。我碰到过一次,直接 chown -R 1000:1000 ./data 解决。
chown -R 1000:1000 ./data
mc 命令行工具的基本操作
MinIO 的管理控制台虽然能用,但实际操作我还是更习惯 mc(MinIO Client)。装起来也简单:
# 下载 mc
curl -O https://dl.min.io/client/mc/release/linux-amd64/mc
chmod +x mc
mv mc /usr/local/bin/
# 配置连接
mc alias set myminio http://localhost:9000 admin your_strong_password_here
# 创建 bucket
mc mb myminio/uploads
# 查看 bucket 列表
mc ls myminio
# 上传测试文件
mc cp test.txt myminio/uploads/
# 设置 bucket 的访问策略(只读)
mc anonymous set download myminio/uploads
mc alias set 里面的 myminio 是你自己起的别名,后面所有操作都拿这个别名跑。一开始我手抖把端口写错了,连不上还以为 MinIO 没起来,浪费了半小时。
坑一:Python SDK 连接报错 SSL 和 endpoint 的问题
项目用 Python,改成 MinIO 以后第一件事就是装 SDK。pip install minio 就行,代码也不复杂:
from minio import Minio
client = Minio(
"your-server:9000",
access_key="admin",
secret_key="your_strong_password_here",
secure=False # 没有 HTTPS 的时候写 False
)
# 上传文件
client.fput_object("uploads", "test.txt", "/local/path/test.txt")
# 下载文件
client.fget_object("uploads", "test.txt", "/local/path/downloaded.txt")
# 生成临时下载链接
url = client.presigned_get_object("uploads", "test.txt", expires=timedelta(hours=1))
print(url)
但连上去就报错了。Secure=False 这个参数很重要。我的 MinIO 跑在内网没有配 HTTPS,一开始没写这个参数,SDK 默认走 HTTPS,连不上直接报 SSL 错误。折腾了一会才意识到得关掉。
还有一个坑,endpoint 里面不要带协议前缀。写成 http://your-server:9000 会直接报错,正确写法是 your-server:9000。SDK 自己拼协议。这俩坑我踩了得有一个小时。
坑二:bucket 策略搞反了,公开了不该公开的东西
MinIO 的 bucket 默认是私有的,上传的文件外面访问不到。但有些场景你又需要公开访问,比如用户头像。我用 mc anonymous set download myminio/uploads 把整个 bucket 设成了公开可读。
结果问题来了:uploads 这个 bucket 里还有内部报表之类的文件,不应该公开。后来我学乖了,把 bucket 分开:avatars 放头像(公开),private-docs 放内部文档(私有),temp 放临时文件(7天生命周期自动删)。
用代码设置 bucket 策略(只对某个前缀公开):
from minio.commonconfig import ENABLED
from minio.lifecycleconfig import LifecycleConfig, Rule
from minio.lifecycleconfig import Expiration
# 设置 temp bucket 的生命周期:7天后自动删除
config = LifecycleConfig(
[
Rule(
ENABLED,
rule_id="delete-after-7days",
expiration=Expiration(days=7),
),
],
)
client.set_bucket_lifecycle("temp", config)
经验就是:bucket 按访问权限分开,别把所有东西扔一个桶里。
坑三:大文件上传超时和分片断传
用户上传一个 200MB 的文件,接口超时了。MinIO 的大文件上传跟小文件不一样,得用分片上传。Python SDK 里面 fput_object 其实内部会自动分片,但有个坑:你的应用层超时设置如果比 MinIO 的短,中间就断了。
我的做法是把 Nginx 的代理超时调大:
location /minio/ {
proxy_pass http://127.0.0.1:9000/;
proxy_connect_timeout 300;
proxy_read_timeout 300;
proxy_send_timeout 300;
client_max_body_size 500m; # 允许上传最大 500MB
}
client_max_body_size 这个参数默认才 1MB,不调的话稍大点的文件直接 413。我一开始被这个坑了好久,看日志全是 413 Request Entity Too Large,还以为是 MinIO 的问题,结果是 Nginx 拦的。
另外如果用的是分片上传(超过 64MB 的文件 SDK 会自动走分片),中途断了没有完整文件,需要你自己做一下清理或者续传。mc 里面有个 mc ls --incomplete myminio/uploads 可以看未完成的上传,mc rm --incomplete myminio/uploads/xxx 可以清掉残留的分片。不及时清的话,这些分片文件是算在你的存储空间里的,看着 bucket 容量莫名其妙就满了。
坑四:presigned URL 不更新权限
临时链接这个功能挺好用的,给第三方一个链接,时间到了自动失效。但有个容易忽略的地方:生成 presigned URL 时,它取的是当时那个账号对 bucket 的权限。
我碰到一次:用的是 admin 账号生成了链接,后来我给 admin 的权限改了(把某个 bucket 改成只读了),但之前生成的 presigned URL 居然还能写。我后来查了才知道,presigned URL 在生成时就把签名写死了,跟你后来改不改权限没关系。
所以如果你的 access key 泄露了,光改 bucket 策略是不够的,得把那个 access key 直接删掉换新的。
坑五:多节点部署的纠删码坑
后来业务大了,单节点扛不住,上了多节点。MinIO 的分布式部署用纠删码来做冗余,也就是说你的数据会被分片冗余存储,挂一块盘还能恢复。
docker-compose 多节点配置大概长这样:
version: "3.8"
services:
minio:
image: quay.io/minio/minio:RELEASE.2024-11-07T00-52-20Z
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: admin
MINIO_ROOT_PASSWORD: your_strong_password_here
volumes:
- /mnt/data1:/data1
- /mnt/data2:/data2
- /mnt/data3:/data3
- /mnt/data4:/data4
command: server /data{1...4} --console-address ":9001"
restart: unless-stopped
纠删码的规则是:N 块盘,最多允许挂 N/2-1 块。4 块盘最多挂 1 块还能读写,挂 2 块就只能读不能写了。
我之前以为 4 块盘能挂 2 块还正常读写,挂了 2 块以后上传全报错,仔细看文档才明白。所以节点数和允许挂盘数的关系得提前算好,别到出事了再翻文档。
另外一个容易搞错的地方:多节点的 MinIO 启动命令里的路径格式是 /data{1...4},这个花括号展开是 MinIO 自己的语法,不是 shell 的。你如果写 /data1 /data2 /data3 /data4 也行,但 1...4 这种写法更省事。只是要注意花括号前面不要有空格,{1...4} 不是 {1 ... 4},多一个空格都不行。
监控和告警
MinIO 自带了一个 Prometheus metrics 接口,http://your-server:9000/minio/v2/metrics/cluster,直接接到你的 Prometheus 里就行。我之前那套监控体系里加了几个关键指标:
# Prometheus scrape 配置
scrape_configs:
- job_name: 'minio'
metrics_path: /minio/v2/metrics/cluster
static_configs:
- targets: ['your-server:9000']
重点关注的指标:
minio_cluster_disk_free_bytes:磁盘剩余空间minio_s3_requests_total:请求总量minio_s3_errors_total:错误总量
我在 Grafana 里配了一个简单的面板,磁盘剩余低于 20% 就发钉钉告警。这一步不算难,就是 Prometheus 的 scrape 路径别写错了,/minio/v2/metrics/cluster 和 /minio/v2/metrics/node 是两个不同的端点,cluster 看整体,node 看单节点,搞混了数据不全。
换云厂商怎么办
这是当初选 MinIO 的原因,S3 API 兼容。你代码里用的是标准 S3 SDK,endpoint 指向 MinIO 的地址。后面如果想换到阿里云 OSS 或者 AWS S3,只需要改 endpoint 和 access key,代码逻辑一行都不用动。
Python 这边,boto3 库直接接:
import boto3
s3 = boto3.client(
"s3",
endpoint_url="http://your-minio-server:9000",
aws_access_key_id="admin",
aws_secret_access_key="your_strong_password_here",
)
# 上传
s3.upload_file("local.txt", "uploads", "local.txt")
# 下载
s3.download_file("uploads", "local.txt", "downloaded.txt")
boto3 跟 minio SDK 的区别就是 endpoint_url 的写法,其他 API 调用一模一样。
现在的配置总结
跑了大半年了,现在比较稳了:
- 单节点 4 块盘跑纠删码
- bucket 按权限分开:avatars(公开)、private-docs(私有)、temp(7天生命周期)
- Nginx 反代,超时调到 5 分钟,body 限 500MB
- presigned URL 最多 24 小时
- Prometheus + Grafana 监控磁盘和请求
- 定期跑
mc ls --incomplete清理残留分片
搭建到稳定运行大概花了两天,但从本地磁盘迁移到 MinIO 之后,后面不管扩容、备份还是共享访问,确实省了很多事。早知道早点搞了。
评论一下?