该文章最后更新于 2026-10-09,部分内容可能已经不适用

博客的图片想放回自己服务器,顺便当个网盘用。手上只有一台 2G 内存的小机器,MinIO 对它来说有点重,最后选了 SeaweedFS。

部署方式

SeaweedFS 单机的组件有四个:master 管元数据、volume 存数据、filer 提供文件目录、s3 是 S3 兼容网关。官方给了两种跑法,四个容器分开跑,或者用 mini 命令把全部塞进一个进程。我选了后者,容器少、维护省事,还自带空间回收的维护任务。

mini 是比较新的命令,老镜像里没有。如果启动报 unknown subcommand "mini",说明镜像太旧,换成 latest 就行。

容量先按盘定死

我划了 15G 给数据盘。SeaweedFS 的容量上限是「单卷大小 × 卷数量」,两个参数配合着写:

yaml 复制代码
command: >
  mini
  -dir=/data
  -master.volumeSizeLimitMB=256
  -volume.max=56
  -volume.index=leveldbMedium
  -bucket=picgo

256MB × 56 差不多 14G,留一点给元数据和回收时的临时文件。索引默认放在内存里,图床这种小文件多的场景内存会慢慢涨,改成 leveldbMedium 让索引落盘,代价是读的时候多一次磁盘开销。-bucket 是启动时自动建桶,省得再进容器敲命令。

认证文件:读公开,写要密钥

图床有个矛盾,上传必须鉴权,否则谁都能往你服务器里塞文件;但图片直链得让所有人能访问,不然文章里全是裂图。

s3.json 里配两类身份就能同时满足:

json 复制代码
{
  "identities": [
    { "name": "anonymous", "actions": ["Read"] },
    {
      "name": "admin",
      "credentials": [{ "accessKey": "key", "secretKey": "你的密钥" }],
      "actions": ["Admin", "Read", "List", "Tagging", "Write"]
    }
  ]
}

anonymous 这个身份对应未签名的请求,只给读权限,上传交给 admin。

这个文件必须是 UTF-8 且不带 BOM。用 PowerShell 5.1 的 Set-Content -Encoding UTF8 生成会带上 BOM,新版 SeaweedFS 用 protojson 解析,直接报 invalid value,容器起不来。服务器上如果文件已经有这个头,用 sed -i '1s/^\xEF\xBB\xBF//' s3.json 去掉再重启容器。

nginx 反代

端口不打算直接暴露,用 nginx 挂了域名和证书。下面三条是必须的:

nginx 复制代码
client_max_body_size 128m;
proxy_set_header Host $host;
proxy_read_timeout 300s;

client_max_body_size 默认 1M,稍微大点的图就 413 了。Host 那行最容易被忽略:S3 的 SigV4 签名把 Host 算进签名内容,nginx 不改这行的话会把 Host 换成上游地址,后端算出来的签名对不上,返回 403。

另外别用子路径转发。像 location /s3/ 配 proxy_pass http://upstream/; 这种剥掉前缀的写法,签名里包含路径,剥完必然不匹配。要挂就给它一个独立域名或独立端口。

nginx 和 SeaweedFS 都用容器的话,把两个容器放进同一个 docker 网络,proxy_pass 直接写容器名。这里我踩过:compose 的 networks 里写 driver: bridge,两边会各自建一个同名但独立的网络,容器之间根本不通,得用 external: true 指向同一个预先创建的网络。

PicGo 的两个参数

PicGo 装在本机,它只是上传客户端,服务器上不用装。装 s3 插件,填 accessKey、secret、桶名,EndPoint 填你的 HTTPS 域名。

有两个字段容易填错。

「代理」是给 HTTP 代理用的,不是填直链前缀的地方。我把 https://域名/picgo 填进去,插件就拿我的域名当代理服务器去连,报错只有一句 S3 Plugin 1 of 1 failed,看不出任何线索。

「路径式请求(pathStyle)」必须开。SeaweedFS 用路径风格寻址,bucket 在 URL 路径里;插件默认走虚拟主机风格,会把请求发到 picgo.你的域名,这个子域根本不存在。

插件把真实错误藏起来了,看不明白的时候去 %APPDATA%\picgo\picgo.log 翻。

先用 curl 分清是谁的问题

排查时我用 curl 直接打服务端,绕开 PicGo,省得两边猜:

powershell 复制代码
# 匿名读,返回 404 说明链路通、匿名读也生效
curl.exe -sS -o NUL -w "%{http_code}`n" "https://域名/picgo/not-exist.jpg"

# 签名上传,返回 200 说明服务端没问题,故障在 PicGo 的配置
curl.exe -sS --aws-sigv4 "aws:amz:us-east-1:s3" --user "KEY:SECRET" -T test.png "https://域名/picgo/img/test.png"

两条印象最深的坑

nginx 起不来时,日志里可能有好几条 [emerg],要看第一条。我遇到的是 host not found in upstream "weed-mini",一直往网络方向查,实际原因是证书路径写错了,nginx 压根没起来,网络报错只是跟着冒出来的假象。

另一个是改完 compose 或 nginx 配置后,restart 有时不生效,得 docker compose up -d --force-recreate,容器才会重新加载配置、重新加入网络。

现在日常用起来是这样:截图,PicGo 传到服务器,返回直链直接贴进文章。管理界面平时不用,偶尔想看文件,本地开个 SSH 隧道,浏览器访问 127.0.0.1 上的端口就行,那些端口没对公网开放。整套跑起来内存占用一两百兆,2G 的机器还有余量。

附:compose 文件

参数都在这里,可以直接抄。跑之前先建共享网络 docker network create common_net,s3.json 放在 compose 同目录。

yaml 复制代码
services:
  weed:
    image: chrislusf/seaweedfs:latest
    container_name: weed-mini
    restart: unless-stopped
    ports:
      - "8333:8333"             # S3 接口
      - "127.0.0.1:8888:8888"   # filer 文件管理,只给本机
      - "127.0.0.1:9333:9333"   # master 状态页,只给本机
      - "127.0.0.1:3646:23646"  # 管理台,只给本机
    command: >
      mini
      -dir=/data
      -ip.bind=0.0.0.0
      -master.volumeSizeLimitMB=256
      -volume.max=56
      -volume.index=leveldbMedium
      -volume.fileSizeLimitMB=64
      -master.defaultReplication=000
      -master.telemetry=false
      -bucket=picgo
      -s3.config=/etc/seaweedfs/s3.json
      -s3.port.iceberg=0
      -s3.port.lance=0
      -webdav=false
    volumes:
      - ./data:/data
      - ./s3.json:/etc/seaweedfs/s3.json:ro
    networks:
      - common_net
    deploy:
      resources:
        limits:
          memory: 1024M

networks:
  common_net:
    external: true

宿主机端口(18333 这几个)是为了避开机器上已有的服务,随便改,容器之间通信用的是容器名加容器端口,改映射不影响 nginx。filer 和管理台那几个端口只绑在本机,要看就开 SSH 隧道。数据目录和内存上限按自己的机器调整。

附:s3.json

和 compose 放同目录(compose 里挂在容器的 /etc/seaweedfs/s3.json)。密钥换成自己生成的随机串:

json 复制代码
{
  "identities": [
    { "name": "anonymous", "actions": ["Read"] },
    {
      "name": "admin",
      "credentials": [{ "accessKey": "key", "secretKey": "换成足够长的随机串" }],
      "actions": ["Admin", "Read", "List", "Tagging", "Write"]
    }
  ]
}

保存时注意选 UTF-8 无 BOM,改完要重启容器才生效。

附:nginx 站点配置

两个容器在同一个 docker 网络时,proxy_pass 直接写容器名。宿主机装的 nginx 把地址换成 http://127.0.0.1:18333 即可。

nginx 复制代码
server {
    listen       80;
    listen       443 ssl;
    server_name  你的域名;

    ssl_certificate     /etc/nginx/cert/你的域名_bundle.pem;
    ssl_certificate_key /etc/nginx/cert/你的域名.key;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_session_cache   shared:SSL:1m;
    ssl_session_timeout 5m;

    client_max_body_size 128m;

    location / {
        resolver 127.0.0.11 valid=30s ipv6=off;
        set $weed http://weed-mini:8333;
        proxy_pass $weed;

        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_set_header X-Forwarded-Proto $scheme;
        proxy_http_version 1.1;
        proxy_request_buffering off;
        proxy_read_timeout 300s;
    }
}

证书路径要和自己 compose 里的证书挂载点对上。resolver 那两行是让 nginx 在请求时才解析容器名,省掉启动顺序的麻烦。改完用 docker exec nginx nginx -t 验一下语法,再 docker compose up -d --force-recreate。