两年前我在我的 VPS 上面安装了 Matrix Synapse,随着 Element 里面聊天记录越来越多,上传的媒体文件也越来越多,因为 Synapse 是把聊天附带的媒体文件直接存在本地的,随着媒体文件(尤其视频和图片)越来越多,最终导致我的 VPS 空间有些吃紧,我在去年已经额外增加过一次 VPS 磁盘空间了,但是最近又快满了,因为磁盘空间不足连最近几次系统更新都完不成了。

其实去年看到 Synapse 媒体文件太占空间时我就想过能否让它把这些文件存储到云端,比如 Google Cloud Storage 或者 Amazon S3,但是我嫌麻烦,就选择了增加磁盘空间,这样就把问题又延缓了一年😄,今年这会儿又满了,甚至影响系统更新了,我不打算再拖了,也不想额外的增加磁盘空间的开支了(因为存储桶的费用要远低于服务器实例的磁盘空间费用),于是搜索解决办法。当然,一搜就搜到了,Matrix 官方有一个扩展模块 Synapse S3 Storage Provider

那么,我只要根据官方说明安装这个扩展,同时还可以找 AI 助手帮忙,稍微配置一下,接下来再上传的媒体文件就会自动发送到存储桶里面,对于已经存在的媒体文件,它肯定也有一个清理并上传的工具,刚看到这个模块时我是这么想的,可是等我开始操作时,却发现自己想得太太太简单了!

我一开始就把想法组织了一下,告诉了 Grok,它直接给出了实际操作步骤,感觉条理清晰逻辑准确,看起来就和我想的差不多。下面就是 Grok 给出的具体步骤:

  1. 安装必要的工具和依赖
  2. 安装 Synapse S3 存储提供程序
  3. 配置 Synapse 使用 S3
  4. 迁移现有媒体文件到 S3
  5. 重启 Synapse 并测试
  6. (可选)清理本地存储

我大致看了一下,感觉没有问题,于是我想,把 S3 换成 GCS(Google Cloud Storage)应该也没有问题,虽然这个模块是支持 S3 的,但是 GCS 也可以开启兼容 S3 的模式,两者其实差不多,因为 GCS 更便宜,所以我想使用 GCS。于是我让 Grok 把 S3 换成 GCS,它再次给出了完整的步骤。于是我接下来就按照它给的步骤操作(当然是在我确认合理的前提下)。

我创建了存储桶,生成了 HMAC 密钥,配置好了可操作权限。接下来安装 gsutil(sudo dnf install google-cloud-sdk,注意,要先添加 Google Cloud CLI 的源)时卡住了,因为我的磁盘可用空间都不足以安装这个 SDK 了,之所以要安装 gsutil,我是想通过它来先把现有的媒体文件全部上传到存储桶里面,以备万一接下来没搞好丢了文件。于是我又开始仔细清点磁盘,花了很长时间,在现有的磁盘空间可允许的操作下,把有些长时间没用到的东西打包下载到了本地,然后把服务器上的删掉,最终空出来足够安装 gsutil 的空间把 gsutil 安装好。

接下来是验证登录Google账户,gcloud auth login,登录后,就可以执行上传操作了,我直接使用的 Grok 给出的命令 gsutil -m cp -r /var/lib/matrix-synapse/media/* gs://matrix-media-gcs/matrix-media/,等待一段时间后上传完成,看起来很顺利。

为什么用 gcloud auth login 而不是 gcloud init?(Grok 的回答 ↓)

gcloud init vs gcloud auth login

gcloud init:
    初始化整个 Google Cloud SDK,会引导你设置项目、区域等。
    如果你需要配置多个项目或复杂环境才用。
    多余步骤,不必要。
gcloud auth login:
    只登录你的 Google 账号,授权 gsutil 访问 GCS。
    够用且快,适合只想迁移文件的任务。

现在存储桶已经有备份了,我开始安装 synapse-s3-storage-provider,这里有一个注意事项,安装这个模块要先看 Synapse 是怎么安装到系统上的,是直接通过包安装的还是虚拟环境安装的。如果是包安装的,可以直接 pip3 install synapse-s3-storage-provider,如果是虚拟环境安装的,则要保证此模块也同样的安装到 Synapse 的虚拟环境下。

source /path/to/synapse/env/bin/activate
pip install synapse-s3-storage-provider

至于怎么辨别 Synapse 是全局环境还是虚拟环境这里就不赘述了。

我的 Synapse 是全局安装的,所以我使用 pip3 install synapse-s3-storage-provider 来安装此模块,安装完成后,修改 homeserver.yaml,加上 media_storage_providers 部分的配置,也就是这个模块需要的相关配置(存储桶名称,密钥等)。配置部分,Synapse S3 Storage Provider 的官方 Github 页面有示例,只需参照示例改成自己的就好了。

下面是官方的配置示例:

media_storage_providers:
- module: s3_storage_provider.S3StorageProviderBackend
  store_local: True
  store_remote: True
  store_synchronous: True
  config:
    bucket: <S3_BUCKET_NAME>
    # All of the below options are optional, for use with non-AWS S3-like
    # services, or to specify access tokens here instead of some external method.
    region_name: <S3_REGION_NAME>
    endpoint_url: <S3_LIKE_SERVICE_ENDPOINT_URL>
    access_key_id: <S3_ACCESS_KEY_ID>
    secret_access_key: <S3_SECRET_ACCESS_KEY>
    session_token: <S3_SESSION_TOKEN>

    # Server Side Encryption for Customer-provided keys
    #sse_customer_key: <S3_SSEC_KEY>
    # Your SSE-C algorithm is very likely AES256
    # Default is AES256.
    #sse_customer_algo: <S3_SSEC_ALGO>

    # The object storage class used when uploading files to the bucket.
    # Default is STANDARD.
    #storage_class: "STANDARD_IA"

    # Prefix for all media in bucket, can't be changed once media has been uploaded
    # Useful if sharing the bucket between Synapses
    # Blank if not provided
    #prefix: "prefix/to/files/in/bucket"

    # The maximum number of concurrent threads which will be used to connect
    # to S3. Each thread manages a single connection. Default is 40.
    #
    #threadpool_size: 20

这部分配置内容加到 Synapse 的配置文件 homeserver.yaml 里面,不需要的可以省略,比如存储桶类型,加密算法等。下面是我加入这段配置后 homeserver.yaml 最终的样子:

...
...
media_store_path: /var/lib/synapse/media_store
max_upload_size: "210M"
max_image_pixels: "64M"
dynamic_thumbnails: false
url_preview_enabled: false
max_spider_size: "10M"

#媒体文件迁移到 GCS ↓
media_storage_providers:
  - module: "s3_storage_provider.S3StorageProviderBackend"
    store_local: True
    store_remote: True
    store_synchronous: True
    config:
      bucket: "存储桶名称"
      region_name: "存储桶区域"
      access_key_id: "**************"
      secret_access_key: "**********************************"
      endpoint_url: "https://storage.googleapis.com"
      prefix: "synapse/media_store" #这里为了对应 GCS 存储桶的目录,我写成了这样
#上面是 GCS 的配置 ↑
...
...

因为我一开始想用 GCS,所以这里是按照 GCS 来写的,所以有 endpoint_url,S3 的话就不用写了。

修改好 homeserver.yaml 后,重启 Synapse,然后上传图片测试,结果并有预期的那样,图片没有上传到 GCS,而是依然存储在本地。这就有点莫名其妙了,因为前面说过这个模块安装时要根据 Synapse 是系统安装还是虚拟安装来看,它得安装到与 Synapse 相同的环境下,所以在和 Grok 排查错误时也考虑到了这点,Grok 给出的第一个可能原因就是 synapse-s3-storage-provider 未正确加载:模块可能没安装到 Synapse 使用的 Python 环境中,我也认同,于是运行 python3 -c "import s3_storage_provider; print(s3_storage_provider.__file__)" 查看,果然,输出的是:/usr/local/lib/python3.12/site-packages//s3_storage_provider.py,而我需要的应该是:/usr/lib64/python3.12/site-packages/s3_storage_provider.py,显然,虽然前面是用的 pip3 install synapse-s3-storage-provider 来安装的,可是它压根没有安装到 /usr/lib64/ 这个目录。

于是把模块先卸载掉,然后指定目录重装:

sudo pip3 uninstall synapse-s3-storage-provider #卸载

sudo pip3 install synapse-s3-storage-provider --target=/usr/lib64/python3.12/site-packages/ #指定位置重装

结果运行指定位置安装命令时出错了,提示:

× Getting requirements to build wheel did not run successfully.
exit code: 1
  ╰─> [21 lines of output]
      running egg_info
      writing psycopg2.egg-info/PKG-INFO
      writing dependency_links to psycopg2.egg-info/dependency_links.txt
      writing top-level names to psycopg2.egg-info/top_level.txt

      Error: pg_config executable not found.

错误原因(Grok 说的)

psycopg2 默认从源码编译,需要 PostgreSQL 开发包(包括 pg_config)。
Fedora 上没装 PostgreSQL 开发工具,导致构建失败。
错误提示建议装 psycopg2-binary(预编译版)或安装开发包。

那么,根据建议安装预编译版:

sudo pip3 install psycopg2-binary --target=/usr/lib64/python3.12/site-packages/

然后再次运行安装模块的命令,依然同样的错误,不同方式试了很多次,就是搞不对,于是放弃预编译版 psycopg2-binary,直接安装 pg_config,而安装 pg_config 也是遇到了几个错误,此时我感觉 Grok 的回答不太靠谱了,转而求助 ChatGPT,尝试了几次之后,解决了 libpq-devel 依赖的问题,解决了 twisted 版本太新导致不兼容的问题(需要 twisted 24.7.0,而我的服务器上面是 24.11.0,这里也挺坑的,按理说降级覆盖就可以了,可是无论怎么安装,pip3 show twisted 都依然显示的是最新版 24.11.0,后来运行了 sudo pip3 uninstall twisted,再次查看,版本变成了 24.7.0 😂,给我的感觉就是第一次降级安装后就是两个版本同时存在的状态了,而不是覆盖,然后运行这个卸载命令后剥离掉了最新版的,没功夫研究这个了,所以也没深究),最后终于成功把 synapse-s3-storage-provider 以及它的各种依赖全部安装完成,都安装在了 /usr/lib64/ 这个目录下面。

然后,再次重启 Synapse,上传图片测试,又出错了:

boto3.exceptions.S3UploadFailedError: Failed to upload /var/lib/synapse/media_store/local_content/Uj/Be/ShnZSLYBkHhDwutPbuek to matrix/synapse/media_store/local_content/Uj/Be/ShnZSLYBkHhDwutPbuek: An error occurred (AccessDenied) when calling the PutObject operation: Access denied.

对了,上面的错误是通过运行 journalctl -u synapse.service -f 实时监测看到的,看到这样子的错误提示,我猜测可能是 GCS 权限部分没有配置好,于是再到 Google Cloud 那边重新一步一步仔细的配置了一个新的 Service Account HMAC 并确定权限没有问题,然后回来继续上传图片测试,结果依然是同样的错误。

于是单独写了一个 python 脚本,调用 boto3 来测试,结果发现可以列出 GCS 存储桶的内容,但是上传时会抛出错误:

Error: Failed to upload /tmp/test.txt to matrix/synapse/media_store/test.txt: An error occurred (SignatureDoesNotMatch) when calling the PutObject operation: Invalid argument.

那么就说明至少读取是没有问题的,进而说明我配置的权限也没有问题,因为我非常明确,要么权限压根不对,要么就肯定能读也能写入。所以权限也没有问题,另外这个错误提示 SignatureDoesNotMatch, Invalid argument. 本身也很明确了,于是搜索相关的信息,发现很多人都遇到过这个错误,虽然场景不一样,但是确实不少遇到这个错误的,尝试了几个网上说的解决办法,但是都不行,比如指定签名版本 signature_version='s3v4',指定存储桶区域,修改上传方式等等,都不行。行进到这一步,我已经快没有精力了,于是我先放弃了使用 GCS 的想法,转而使用 S3。

于是登录 AWS 账户,新建了存储桶,分配权限获取了密钥,然后安装 AWS Cli,我一开始用的 sudo dnf install awscli,结果继续运行 aws configure 时出错:

aws configure
Traceback (most recent call last):
  File "/usr/bin/aws", line 19, in <module>
    import awscli.clidriver
  File "/usr/lib/python3.12/site-packages/awscli/clidriver.py", line 22, in <module>
    import botocore.session
  。。。
  中间省略
  。。。
    from urllib3.util.ssl_ import (
ImportError: cannot import name 'DEFAULT_CIPHERS' from 'urllib3.util.ssl_' (/usr/lib64/python3.12/site-packages/urllib3/util/ssl_.py)

又是环境错乱导致的,于是改为用 pip 安装 sudo pip3 install awscli,然后运行 aws configure 填入相关配置信息,然后和前面的思路一样,先使用 aws s3 sync 命令把本地的媒体文件全部上传到存储桶,这里要注意 aws s3 有两个命令,一个是 aws s3 sync ,一个是 aws s3 cp,具体可以参考 AWS S3 Sync Command – Guide with Examples 这篇文章。

aws s3 sync /var/lib/synapse/media_store s3://my-synapse/media_store/

S3 的同步速度还是非常快的,比我第一次使用 gsutil 同步时速度快,不过这个只是我这一次的观察,不具备参考性。等同步完成后,再次重启 Synapse 并上传图片测试,这次没有问题,上传成功了,但是我发现一个问题,不光 S3 里面保存了刚上传的图片,本地媒体文件目录同样也保存了一份,这可和预期的不一样。

其实在进行整个的转移操作之前我基本上已经全网搜索遍了,只找到了两篇相关的可以真正参考的文章(除了 synapse-s3-storage-provider 本身的说明文档外)。分别是: https://write.jacen.moe/s3-media-storage-for-matrix-synapsehttps://www.yateam.cc/post/kw66f7zr,在我有限的搜索下,全网只有这两篇文章介绍了相关的内容,第二个链接虽有提到,但是没有更深入说,第一个链接这篇文章相当切题,作者就是因为磁盘空间占用太大然后想把媒体文件转移到 S3,他列举了遇到的坑,以及多次强调 synapse-s3-storage-provider 官方文档过于简陋。当然文章一开始他也提到了,这个模块要做的事情其实和自己一开始认为它能做的是不一样的。同样,等我测试完上传图片后,发现了,确实是这么回事,跟预期的完全不一样,我们预期的是这个模块能完全接管媒体文件的上传下载,不再经由本地存储,而实际这个模块要做的其实是把本地文件同步到 S3,而且它附带了一个脚本可以清理本地媒体文件,但是它并不能直接接管上传下载逻辑,也就是说绕不开本地存储。

至此,逻辑也基本清晰了,尤其是如果仔细翻看一下主模块代码和清理脚本代码,就能清晰的看出它的逻辑了。这个模块相当于充当一个中间人,可以把新媒体文件上传到 S3(但是 Synapse 依然会把新媒体文件存储到本地),可以根据指定的日期清理旧媒体文件,比如一个月内未用的媒体文件(当然,这个判断需要结合数据库),而且它自带一个 cache.db 数据库用来记录差异。比如,你可以做到:把最新一周之内(7d)的媒体文件保留,超过这个日期的媒体文件全部上传到 S3,同时如果加上 –delete,它就可以在上传的同时把本地的媒体文件删除,这样一来磁盘空间就清理出来了。而且,不仅如此,本地的媒体文件删除之后,如果用户在聊天窗口往前翻,翻到已经本地删除的媒体文件,它会把对应的文件再从 S3 里面下载到本地供 Synapse 调用。整个逻辑的执行其实全靠 Synapse 的数据库记录。

下面我列一下相关的配置以及命令:

首先,为了确保清理脚本(s3_media_upload)正确运行,我们需要建立一个 database.yaml 文件用来存储数据库相关信息。

这是官方给的示例:

> cd s3_media_upload
# cache.db will be created if absent. database.yaml is required to
# contain PG credentials
> ls
cache.db database.yaml
# Update cache from /path/to/media/store looking for files not used
# within 2 months
> s3_media_upload update /path/to/media/store 2m
Syncing files that haven't been accessed since: 2018-10-18 11:06:21.520602
Synced 0 new rows
100%|█████████████████████████████████████████████████████████████| 1074/1074 [00:33<00:00, 25.97files/s]
Updated 0 as deleted

> s3_media_upload upload /path/to/media/store matrix_s3_bucket_name --storage-class STANDARD_IA --delete
# prepare to wait a long time

其实如果查看 s3_media_upload 的代码,能发现如果把这个清理脚本也放到 Synapse 和 synapse-s3-storage-provider 的安装目录的话,它应该能自动读取 homeserver.yaml 来获取数据库信息。但是如果把这个脚本放到其他位置(比如我放到了用户目录下的 synapse-clean 里面,方便写自动执行脚本和设置 cron 任务)的话,就需要在同一目录下放一个 database.yaml 文件,里面写入数据库信息,格式如下:

user: 数据库用户名
password: "数据库密码"
database: 数据库名称
host: localhost
port: 5432

至于 cashe.db,如果不存在,脚本运行时会自动创建一个。然后就可以运行标记和清理命令:

python3 s3_media_upload update /var/lib/synapse/media_store 1m # 扫描并标记超过一个月未使用的媒体文件,结果当然是存储在 cache.db 里面

这个 update 命令运行后会有下面这样的提示:

[coke@coke synapse-clean]$ python3 s3_media_upload update /var/lib/synapse/media_store 1m
Syncing files that haven't been accessed since: 2025-02-12 12:19:48.908790
Synced 3895 new rows
100%|████████████████████████████████████████████████████████████████████| 3895/3895 [00:00<00:00, 29327.53files/s]
Updated 0 as deleted

然后就可以运行上传命令,把第一步标记的文件上传到 S3 存储桶,注意,如果想把这批文件在本地删除掉,那么要加上 –delete 选项。

python3 s3_media_upload upload /var/lib/synapse/media_store my-synapse --delete

如果上来就运行这个命令,又会出错,提示

[coke@coke synapse-clean]$ sudo python3 s3_media_upload upload /var/lib/synapse/media_store my-synapse --delete
  0%|                                                                                  | 0/3895 [00:00<?, ?files/s]
Traceback (most recent call last):
  File "/home/go/synapse-clean/s3_media_upload", line 643, in <module>
    main()
...
...
...
botocore.exceptions.NoCredentialsError: Unable to locate credentials

这就是上面文章作者再次吐槽的地方,模块官方说明文档压根提都没提,这里之所以出错,是因为上传模块找不到 S3 密钥,所以我们应该把 key 和 region 信息放到环境变量里面:

export AWS_ACCESS_KEY_ID=AWS_S3_BUCKET_KEY
export AWS_SECRET_ACCESS_KEY= AWS_S3_BUCKET_SECRET
export AWS_DEFAULT_REGION=ap-southeast-1

然后再次运行上传命令就可以了,最终会有下面这样的提示:

[coke@coke synapse-clean]$ sudo python3 s3_media_upload upload /var/lib/synapse/media_store my-synapse --delete
100%|███████████████████████████████████████████████████████████████████████| 3895/3895 [16:31<00:00,  3.93files/s]
Uploaded 3895 media out of 3895
Uploaded 3939 files
Uploaded 6.8G
Deleted 3895 media
Deleted 3940 files
Deleted 6.8G

因为要删除文件,我的用户没有权限,所以我前面加了 sudo。至此整个流程就走完了,需要注意的是,如果文件非常多,最好是在 screen 或者 tmux 下运行这个命令,以免因为一些特殊情况(比如网络连接断开)导致进程中断。

最后,可以写一个依次执行上述命令的脚本,然后通过 cron 定时运行,比如每周运行一次,更新一周(7d)内媒体文件,然后同步上传一周之外的到 S3 同时把本地的删除。

我在我的用户目录下面建了一个 synapse-clean 目录用来放置 s3_media_upload,database.yaml 和 cache.db,同时把包含有下面指令的 clean.sh 脚本放在了同一目录,然后给 clean.sh 加上执行权限。

#!/bin/bash

export AWS_ACCESS_KEY_ID=access_key_id
export AWS_SECRET_ACCESS_KEY=secret_key_id
export AWS_DEFAULT_REGION=s3_region

cd /home/coke/synapse-clean/ &&
python3 s3_media_upload update /var/lib/synapse/media_store 7d &&
sleep 2 &&
python3 s3_media_upload upload /var/lib/synapse/media_store my-synapse --delete

然后切换到 root 用户,编辑 cron 任务,加入了下面的定时任务:

3 3 * * 3 /home/coke/synapse-clean/clean.sh

之所以使用 root 用户的 cron 任务,就是想使用 root 用户来运行 clean.sh 脚本,因为我的普通用户没有删除 synapse 文件的权限,这和上面的 upload 命令前加 sudo 是一个原因。设定好定时任务后就不用管了,到时它会自动执行标记和上传以及清理任务,另外,其实可以让 cron 把任务执行信息发送到指定邮箱的,或者只发送错误信息,这个可以自行搜索或者咨询 Grok。

【⚠额外的几个注意事项⚠】

🔴 使用 aws s3 sync 命令时,千万不要在后面加 –delete,sync 更注重的是同步,cp 更侧重单一文件的复制,如果 sync 时加上这个参数,那么它会把 S3 里面存在而本地不存在的文件全部删除‼

🔵 s3_media_upload upload 这个命令,后面跟的是存储桶名称,貌似只能这样,如果加上额外的路径(比如 my-synapse/media_store)脚本会报错。所以,homeserver.yaml 里面模块配置部分也就不适合加 prefix 路径了,因为最后同步时不能加路径,前面如果加了后面再同步时就没法一致了,换句话说 s3_media_upload upload 只能把媒体文件同步到存储桶根目录下。

🔶 无论是 aws s3 sync 命令还是 s3_media_upload upload 命令,如果 S3 存储桶已经存在相同的文件了,它们都不会覆盖(重传),这是比较合理的。不过,aws s3 sync cp 命令会是会覆盖的。

【最后,补充下 boto3 上传文件到 GCS 的测试】

虽然我已经把媒体文件转移到 S3 搞定了,但是我对 GCS 依然不死心,后来我又让 Grok 和 ChatGPT 帮忙写 boto3 往 GCS 上传文件的测试代码,不同的写法试了很多种,都是 SignatureDoesNotMatch 这个错误,后来我自己搜索,找到了有人成功的案例,一位网友的做法是通过预签名并自己搭配 URL 请求参数的方式成功了,还有一位网友给了一部分代码,不全,但是他说成功了。我试了预签名这个方式,果然成功了。而根据第二位网友的代码修改后的测试代码依然会报错,所以我怀疑可能是之前的 boto3 版本奏效,而现在新版的不行了。

通过我一系列的测试,基本上可以得出结论,目前新版的 boto3 使用自带的 .upload_file 方式是没法上传到 GCS 的,可能原因就是它打包的请求里面有某个参数 GCS 不允许,因为如果查看 boto3 的日志,会发现它上传时带的参数实在是太多了。而且可以确认,这个错误与签名版本没关系(因为我后面没有额外指定 s3v4 签名照样成功了,可能是新版的 boto3 默认就使用了 s3v4 签名),与存储桶区域也没关系,S3 要求区域,但是 GCS 的 S3 兼容模式是不要求区域的。

最后,给出我测试成功的代码(Grok 写的):

import boto3
import requests
from botocore.client import Config

# 配置 GCS 访问参数
ACCESS_KEY = 'KEYKEYKEYKEYKEYKEYKEYKEYKEYKEYKEY'
SECRET_KEY = 'SECRETSECRETSECRET'
BUCKET_NAME = 'BUCKET'
ENDPOINT_URL = 'https://storage.googleapis.com'

# 创建 S3 客户端
s3_client = boto3.client(
    's3',
    aws_access_key_id=ACCESS_KEY,
    aws_secret_access_key=SECRET_KEY,
    endpoint_url=ENDPOINT_URL,
    config=Config(signature_version='s3v4') # 这一行去掉,照样没有问题
)

# 测试上传文件
def upload_test_file():
    try:
        local_file = '/tmp/test.txt'
        object_name = 'test01.txt'

        # 生成预签名 URL
        params = {'Bucket': BUCKET_NAME, 'Key': object_name}
        presigned_url = s3_client.generate_presigned_url(
            'put_object',
            Params=params,
            ExpiresIn=600  # URL 有效期 10 分钟 ,去掉这一行也不会有影响
        )
        #这里也没有必要把 AWSAccessKeyId 替换成 GoogleAccessId,替不替换无所谓
        #presigned_url = presigned_url.replace('AWSAccessKeyId', 'GoogleAccessId')
        print(f"生成的预签名 URL: {presigned_url}")

        # 使用 requests 模拟 upload_file ,requests.put 是上传成功的关键所在
        with open(local_file, 'rb') as f:
            response = requests.put(presigned_url, data=f)
            response.raise_for_status()

        print(f"文件成功上传到 {BUCKET_NAME}/{object_name}")

        # 验证上传
        response = s3_client.head_object(Bucket=BUCKET_NAME, Key=object_name)
        print(f"文件大小: {response['ContentLength']} bytes")

    except Exception as e:
        print(f"上传失败: {str(e)}")

if __name__ == "__main__":
    upload_test_file()

有些说明我写在 comment 里面了,总之,之所以这个测试成功了,就是因为预签名以及 requests.put 这个上传方式,但凡调用 boto3 的上传就会失败。下面是 Grok 给出的区别(注意,仅供参考,有可能不对):

特性 s3_client.put_object s3_client.upload_file requests.put
所属库 boto3 (AWS SDK) boto3 (AWS SDK) requests (通用 HTTP 库)
功能层级 低级 API,直接上传对象 高级 API,封装上传逻辑 通用 HTTP PUT 请求
用法示例 s3_client.put_object(Bucket='b', Key='k', Body=f) s3_client.upload_file('file.txt', 'b', 'k') requests.put(url, data=f)
输入要求 文件内容 (Body,需手动读取) 文件路径 (自动读取) URL 和文件内容 (需手动提供)
签名位置 请求头 (Authorization) 请求头 (Authorization) URL 查询参数 (预签名 URL)
分块支持 不支持 (单次上传) 支持 (大文件自动分块,> 8MB) 不支持 (单次上传)
传输机制 单次 HTTP 请求 单次或分块上传 (视文件大小) 单次 HTTP 请求
复杂度 中等 (需手动读取文件) 低 (一行完成) 高 (需生成 URL 并发送请求)
错误处理 内置 (boto3 异常) 内置 (boto3 异常+重试) 手动 (raise_for_status)
GCS 兼容性 (你的案例) 失败 (SignatureDoesNotMatch) 失败 (SignatureDoesNotMatch) 成功 (结合预签名 URL)
依赖 仅 boto3 仅 boto3 boto3 (生成 URL) + requests
适用场景 小文件、精确控制 大文件、简单上传 客户端上传、预签名 URL