MinIO 自建对象存储实战:S3 兼容、Nginx 反代签名陷阱与 rclone 自动备份

个人站长的图片文件,最后都会变成一个问题

做站时间长了,图片和附件都会变成一个甩不掉的包袱。放在服务器本地有几个具体的麻烦:网站数据和附件抢同一块磁盘,图片涨到几十 G 之后备份变得极慢;想加第二台服务器做负载均衡,图片却只存在第一台上;用 rclone 往云对象存储同步,又发现自己的数据量早就超过了免费额度。

MinIO 解决的就是这个位置上的问题:它是一个用 Go 写的、兼容 Amazon S3 API 的自建对象存储,一个二进制文件就能跑起来,单机部署占用内存不到 100MB。你把它装在自己的服务器上(或者便宜的存储型 VPS 上),就得到了一个私有的「S3」,网站的图片、附件、备份文件全都可以往上面放,而且用的还是标准的 S3 接口——意味着你的 CMS、备份脚本、rclone 都能直接对接,不需要学新东西。

需要提前说清楚的是它不适合的场景:如果你只有一台服务器,图片也放得下,那直接放本地磁盘配 Nginx 静态服务是最快的,加 MinIO 只会多一层网络跳转。MinIO 真正的价值在于「多机共享存储」和「备份目的地」这两件事,以及你需要 S3 兼容接口的时候。它的性能也不像想象中那么强——单机单盘模式下,小文件的随机读性能其实不如 Nginx 直接读本地文件。想清楚这一点再动手。

安装:别用 root,别用默认端口暴露公网

官方推荐的方式是下载单个二进制文件,然后用 systemd 托管。从 2021 年之后,MinIO 的服务端和客户端已经拆开成两个命令:minio 是服务端,mc(MinIO Client)是命令行客户端。

# 下载服务端与客户端
wget https://dl.min.io/server/minio/release/linux-amd64/minio -O /usr/local/bin/minio
wget https://dl.min.io/client/mc/release/linux-amd64/mc -O /usr/local/bin/mc
chmod +x /usr/local/bin/minio /usr/local/bin/mc

# 建专用系统用户与数据目录,MinIO 拒绝以 root 运行会有安全告警
useradd -r -s /sbin/nologin minio-user
mkdir -p /data/minio
chown -R minio-user:minio-user /data/minio

# 配置文件,存放访问密钥(权限必须收紧)
mkdir -p /etc/minio
cat > /etc/minio/minio.conf <<'EOF'
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=换成你自己的长密码至少8位
MINIO_VOLUMES=/data/minio
MINIO_OPTS="--address :9000 --console-address :9001"
EOF
chmod 600 /etc/minio/minio.conf

几个要点。密钥别用默认的 minioadmin/minioadmin,MinIO 从某个版本起会直接拒绝用默认凭证启动,就算能启动也等于把家门打开。密码长度至少 8 位。--address 是 S3 API 端口(9000),--console-address 是网页管理后台端口(9001),这两个从 2021 年起必须分开指定,老教程里只有一个端口,照抄会报错。

然后写 systemd unit。不要把密钥写进 unit 文件的 Environment 里——systemd 的 unit 文件是全局可读的,密钥会泄露给所有能执行 systemctl 的用户。用 EnvironmentFile 指向那个 600 权限的文件:

# /etc/systemd/system/minio.service
[Unit]
Description=MinIO Object Storage
After=network-online.target
Wants=network-online.target

[Service]
Type=notify
NotifyAccess=main
User=minio-user
Group=minio-user
EnvironmentFile=/etc/minio/minio.conf
ExecStart=/usr/local/bin/minio server $MINIO_OPTS $MINIO_VOLUMES
Restart=always
RestartSec=5
LimitNOFILE=65536
# 安全加固:这套参数能让 MinIO 拿不到多余的权限
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
PrivateTmp=true

[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now minio
systemctl status minio --no-pager
ss -tlnp | grep -E '9000|9001'

反向代理:只有这一种写法是对的

MinIO 的 S3 API 不支持路径重写,也不接受在反向代理后面丢失 Host 信息。这是它和普通 Web 应用最大的区别,也是最容易配错的地方。核心是三点:proxy_set_header Host $http_host;(注意不是 $host,要带上端口)、关闭缓冲区(大文件上传不能缓冲)、以及 S3 的预签名 URL 依赖正确的时间戳,所以不要加那些会改写请求的模块。

# /etc/nginx/conf.d/minio.conf
upstream minio_s3 {
    server 127.0.0.1:9000;
    keepalive 32;
}

server {
    listen 443 ssl http2;
    server_name files.example.com;

    ssl_certificate     /etc/letsencrypt/live/files.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/files.example.com/privkey.pem;

    # 对象存储必须允许大文件;想公开分享就放宽,否则设成很小
    client_max_body_size 5G;
    # 大文件上传慢,超时要给足
    proxy_read_timeout 300;
    proxy_send_timeout 300;

    ignore_invalid_headers off;
    chunked_transfer_encoding off;
    proxy_buffering off;          # 关键:不缓冲响应体,支持流式下载
    proxy_request_buffering off;  # 关键:不缓冲请求体,支持流式上传

    location / {
        proxy_set_header Host $http_host;      # 必须用 $http_host,$host 会丢端口
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_connect_timeout 300;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_pass http://minio_s3;
    }
}

# 管理后台单独一个域名,加 basic auth,别直接暴露 9001
server {
    listen 443 ssl http2;
    server_name minio-admin.example.com;

    ssl_certificate     /etc/letsencrypt/live/minio-admin.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/minio-admin.example.com/privkey.pem;

    auth_basic "MinIO Console";
    auth_basic_user_file /etc/nginx/.htpasswd_minio;

    location / {
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_pass http://127.0.0.1:9001;
    }
}

那个 $http_host 的细节值得展开说。S3 的签名机制(Signature V4)把 Host 头也算进签名内容里。如果你的 Nginx 把 Host 改成了不带端口的 $host,而客户端签名时用的是 files.example.com:443,两边算出来的签名不一致,服务端就会返回 SignatureDoesNotMatch。这个错误的排查方向很容易跑偏成「密钥错了」,其实密钥完全正确,只是 Host 头被 Nginx 改了。

命令生成 basic auth 密码文件:htpasswd -c /etc/nginx/.htpasswd_minio youruser(没装的话 apt install apache2-utils)。

用 mc 管理桶与权限

装好之后先配一个别名,之后所有命令都用它:

# 配置客户端别名(指向你走反代的域名,或本地 127.0.0.1:9000)
mc alias set myminio https://files.example.com ACCESS_KEY SECRET_KEY

# 建桶。站点图片放一个,备份放另一个,权限策略不一样
mc mb myminio/site-media
mc mb myminio/backups

# 设成公开可读:图片要能被浏览器直接访问,这一步必须做
mc anonymous set download myminio/site-media
# 备份桶保持私有
mc anonymous set none myminio/backups

# 看看桶和匿名策略是否生效
mc ls myminio
mc anonymous get myminio/site-media

# 上传测试并生成临时分享链接(默认 7 天,预签名 URL)
echo hello > /tmp/t.txt
mc cp /tmp/t.txt myminio/site-media/t.txt
mc share download --expire 7d myminio/site-media/t.txt

mc anonymous set download 是给站点图片用的关键一步。刚建好的桶默认是私有的,直接访问 URL 会返回 AccessDenied。很多人的第一反应是去折腾 Bucket Policy 的 JSON,其实 mc anonymous 这条命令就够了,它内部帮你生成了正确的策略。

另外,出于安全考虑,不要用 root 密钥给日常操作。用 mc admin user add 建一个只能读写特定桶的子账号,再配 mc admin policy 授权。这样万一某个脚本里的密钥泄露,损失范围是可控的:

mc admin user add myminio siteuser siteuser-secret-key
cat > /tmp/site-policy.json <<'EOF'
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
      "Resource": ["arn:aws:s3:::site-media/*"]
    },
    {
      "Effect": "Allow",
      "Action": ["s3:ListBucket"],
      "Resource": ["arn:aws:s3:::site-media"]
    }
  ]
}
EOF
mc admin policy create myminio site-policy /tmp/site-policy.json
mc admin policy attach myminio site-policy --user siteuser

把网站图片迁上去并让 Nginx 回源

最平滑的做法不是让 PHP 直接上传到 MinIO,而是Nginx 先检查本地有没有,没有再去 MinIO 取并缓存下来。这样老图片路径不变,SEO 不受影响,转换是渐进的:

# 站点 server 块里,图片目录走「本地优先,MinIO 兜底」
location /wp-content/uploads/ {
    # 先找本地文件,找不到走 @minio
    try_files $uri @minio;
}

location @minio {
    proxy_set_header Host files.example.com;    # 保持与签名一致的 Host
    proxy_set_header X-Real-IP $remote_addr;
    proxy_pass https://files.example.com/site-media$uri;

    # 取回来的图片在本地留一份,下次就不去 MinIO 了
    # 需要先编译 ngx_http_proxy_cache 或用 openresty
    proxy_cache media_cache;
    proxy_cache_valid 200 7d;
    proxy_cache_use_stale error timeout updating;
    add_header X-Cache-Status $upstream_cache_status;
    resolver 223.5.5.5 valid=300s;   # 反代域名必须配 resolver,否则解析不到
}

这里又有一个容易踩的点:当 proxy_pass 的目标是一个域名(不是 IP)时,Nginx 只在启动时解析一次。如果 MinIO 的地址变了(换机、DNS 调整),Nginx 会一直连着旧 IP,表现为「MinIO 明明正常但网站图片全 404/502」。解决办法就是在 location 里显式加 resolver 指令,并在 proxy_pass 里用一个变量来引用目标,强制 Nginx 运行期每次重新解析。

拿 MinIO 当备份目的地,配 rclone 自动上传

这是 MinIO 对个人站长最实在的用途。rclone 原生支持 S3 协议,直接对接:

# rclone.conf
[myminio]
type = s3
provider = Minio
endpoint = https://files.example.com
access_key_id = siteuser
secret_access_key = siteuser-secret-key
region = us-east-1          # MinIO 不校验 region,但字段不能空

# 测试连通
rclone lsd myminio:

# 每日把所有站点备份同步上去(--fast-list 大目录时更快,sync 会删除远端多余文件)
rclone sync /var/backups/websites myminio:backups/websites \
  --transfers 4 --checkers 8 --fast-list --log-file /var/log/rclone-backup.log --log-level INFO

# cron:每天凌晨 3 点半跑
# 30 3 * * * /usr/bin/rclone sync /var/backups/websites myminio:backups/websites --log-file /var/log/rclone-backup.log --log-level ERROR

关于 sync 和 copy 的区别要清楚:sync 会让远端和本地完全一致,本地删了的文件远端也会被删。这对「镜像」场景是对的,但如果你要的是「历史版本都留着」,就得用 copy,或者配合版本控制。千万别在目标写反的情况下用 sync——把本地路径和远端路径写颠倒,一条命令就能清空你的备份。第一次跑之前,务必用 --dry-run 演练一遍,看看它打算删什么:

rclone sync /var/backups/websites myminio:backups/websites --dry-run -v

另外记得在 MinIO 桶上开启版本控制,这样即使 sync 误删,也能在控制台里恢复上一版本:mc version enable myminio/backups。这个开关的价值,在你真的手抖一次之后就会充分体现。

单盘、多盘与纠删码:该选哪种模式

MinIO 启动时传入的 MINIO_VOLUMES 参数决定了它的运行模式,这是部署时就要定下来、后面很难改的事。传一个目录,就是单盘模式(Single Drive):没有冗余,盘坏了数据就没了,性能取决于这块盘,适合个人站放图片这种「丢了能重新生成」的数据。传多个目录,MinIO 会自动进入纠删码(Erasure Coding)模式,把每个对象切成 N 份数据块 + M 份校验块分散到各个盘上,允许若干块盘同时损坏而不丢数据。

纠删码看着很香,但它有两个隐藏代价,个人站长必须知道。第一,纠删码要求至少 4 块盘(或 4 个目录)才能启用,少于这个数会退化成单盘行为。第二,写入要经过编码计算和多盘同步,在小文件、低并发的场景下,性能反而明显低于单盘直写。所以如果你手上只有一块数据盘,别为了「看起来更专业」去凑多个目录搞纠删码,那只会白白损失性能:

# 单盘模式(一个目录),个人站图片/备份的首选,简单够用
MINIO_VOLUMES=/data/minio

# 纠删码模式(多个目录必须分布在不同的物理磁盘上才有意义)
# 四个目录写在同一个盘上毫无冗余价值,只是自欺欺人
MINIO_VOLUMES="/data/disk1/minio /data/disk2/minio /data/disk3/minio /data/disk4/minio"

# 查看当前部署的纠删码配置与各盘状态
mc admin info myminio

还有一条容易被忽略的约束:纠删码模式下的存储目录一旦初始化就与当前磁盘集合绑定,你不能事后往里加一块盘然后期望容量自动增长——扩展必须按「扩容集(Server Pool)」的粒度整组添加。对个人站长来说,这意味着选单盘模式 + 定期备份到别处,往往比上纠删码更现实。

生命周期规则:让老备份自动过期

备份桶最怕的不是丢数据,是无限增长。每天同步一次数据库备份,一年就是 365 份,很快就把存储吃满。MinIO 支持 S3 兼容的生命周期规则,可以自动删除超过 N 天的对象,这件事交给服务端做比写脚本删靠谱得多:

# 写一条规则:backups 桶里超过 90 天的对象自动删除
cat > /tmp/lifecycle.json <<'EOF'
{
  "Rules": [
    {
      "ID": "expire-old-backups",
      "Filter": { "Prefix": "websites/" },
      "Status": "Enabled",
      "Expiration": { "Days": 90 }
    },
    {
      "ID": "cleanup-incomplete-uploads",
      "Filter": { "Prefix": "" },
      "Status": "Enabled",
      "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 }
    }
  ]
}
EOF
mc ilm import myminio/backups < /tmp/lifecycle.json
mc ilm ls myminio/backups

第二条规则同样重要,但极少有人主动加。大文件上传中断时,MinIO 会留下已上传的分片数据,这些分片不占用任何对象名,用 mc ls 根本看不到,却实实在在占着磁盘。配置 AbortIncompleteMultipartUpload 之后,七天内没完成的上传会被自动清理。如果你曾经遇到「删光了桶里的文件磁盘还是满的」,八成就是这些残留分片在作祟。排查命令是 mc admin trace myminio 配合 du -sh /data/minio/.minio.sys/tmp,后者的体积异常大就说明有分片没清掉。

注意事项小结

  1. 端口:9000 是 S3 API,9001 是控制台,两个都要显式指定,新版缺一个就起不来。
  2. 密钥:不放 unit 文件里,不用默认值,日常操作别用 root 密钥。
  3. 反代:Host 必须用 $http_host;必须关 proxy_buffering 和 proxy_request_buffering;域名上游必须配 resolver。
  4. 权限:站点图片桶设 anonymous download,备份桶保持私有并开启版本控制。
  5. 备份:sync 前先 --dry-run,路径写反的代价是不可逆的。

MinIO 不是银弹,单机场景下它的性能不会超过 Nginx 直接读本地文件。但当你的图片开始需要跨机器共享、当你需要一个自己的、不依赖第三方额度的备份目的地时,它是自建方案里最省心的一个:一个二进制、一份配置、一套标准 S3 API,接上去就能用。

Last modification:September 29th, 2026 at 12:27 pm

Leave a Comment