这台机器上没有 curl,没有 awscli,也没有 boto3。想看一眼备份桶里到底躺着什么,手里只剩 python3 的标准库,于是 SigV4 得自己拼。
拼完第一版,列桶 200,一切正常。换成「列某个前缀」,403。
先证明密钥没问题
出错的时候,最容易做的动作是怀疑密钥。所以第一步不是改代码,而是先跑一个尽可能简单的请求:GET /,不带任何查询串,只为了把两个桶的名字列出来。
它 200 了,返回 myself 和 wtbdlwh。
这一步很关键——它把「密钥错、AK/SK 不配对、权限不对」这一整片可能性排除了。剩下的问题只能在「我怎么拼这个请求」里面。后面所有排查都围绕这个前提,不用再回头。
坑一:403 的报错文案在骗人
失败的那个请求,正文是这样的:
<Error><Code>SignatureDoesNotMatch</Code>
<Message>The request signature we calculated does not match the signature
you provided. Check your secret access key and signing method.</Message>
「Check your secret access key」——这句话会把人直接带到密钥上去。可同一套 AK/SK,上一个请求刚返回 200,中间没有任何改动。
签名错误的报错永远只说「不匹配」,它不会告诉你哪一段字符串不一样。这不是 S3 或 R2 的问题,是签名机制的天然限制:服务端只比较最终哈希,中间的规范请求它不会回显给你。

所以正确的心态是:看到 SignatureDoesNotMatch,不要先动密钥,先假设「我算的规范请求和服务端算的不是同一个字符串」。
坑二:查询串必须逐字符编码,还得排好序
出事的那个请求,prefix 里带中文和一个斜杠。我当时是这么拼的:中文老老实实编码了,斜杠留着原样。
/?list-type=2&prefix=01_%E4%BB%BB%E5%8A%A1/手机/&max-keys=3
看起来挺合理,其实是错的。SigV4 的规范查询串要求每一个键和值都按 RFC 3986 逐字符编码:斜杠不是保留字符,必须写成 %2F;空格是 %20 不是 +;十六进制统一大写。
我发出去的 URI 里斜杠是原样的,但服务端拿到之后会按规则重新编码成 %2F 再算一遍签名。两边字符串从一开始就不一样,签名当然对不上。
修法只有一行字,但要贯彻到底:
q = '&'.join(sorted(
f"{quote(k, safe='')}={quote(v, safe='')}"
for k, v in params.items()
))
更关键的是「同一个字符串」这条纪律:把这个 q 同时用于拼 URL 和算签名。最容易犯的错是发请求时用一份参数、算签名时另写一份——哪怕只差一个空格,结果都是 403。
顺带两条同样属于「字符串必须一模一样」的:
- 空 body 也要签。
x-amz-content-sha256必须是空串的 sha256,也就是e3b0c442...,不能省。 - region 写
auto,路径是 path-style:endpoint/bucket/key,不是bucket.endpoint/key。

坑三:.env 里那截注释
这条不是签名问题,但一样卡人。
凭据存在一个 r2.env 里,桶名那行后面跟了一句注释:
R2_BUCKET=myself # 私密备份桶
按 = 切完直接取右边,桶名就成了 myself # 私密备份桶。这时候报的错换了个样子:Invalid bucket name。它不是签名错,是名字里带了不该有的东西。
所有从 env 取值的地方统一剥一次:value.split('#', 1)[0].strip()。
顺手记一笔:403 有很多种
同一个下午还遇到另一个 403,跟签名一点关系没有。图片域名的直链在浏览器里打得开,用脚本请求却是:
error code: 1010
这是 Cloudflare 的浏览器完整性拦截,嫌默认 UA 不像浏览器。换成正常的浏览器 UA 就 200 了。
所以看到 403 别条件反射往密钥上想:先看正文。SignatureDoesNotMatch 是签名,InvalidAccessKeyId 是密钥本身,AccessDenied 是权限,error code: 1010 是网关把你的客户端挡在门外。四种 403,四个方向。
最小可跑的版本
最后留下的这段代码,核心就是把规范请求按顺序拼对:
def canonical_query(params):
return '&'.join(sorted(
f"{quote(k, safe='')}={quote(v, safe='')}"
for k, v in params.items()
))
q = canonical_query({'list-type': '2', 'prefix': '01_任务处理备份/手机/', 'max-keys': '3'})
payload_hash = hashlib.sha256(b'').hexdigest()
canonical = '\n'.join([
'GET', f'/{bucket}', q,
f'host:{host}',
f'x-amz-content-sha256:{payload_hash}',
f'x-amz-date:{amz_date}',
'', 'host;x-amz-content-sha256;x-amz-date', payload_hash,
])
改完之后,同一个请求返回 200,前缀下面两份 tar.gz 都在,最近那份是当天的。上传同理,换个方法名、把文件内容的 sha256 填进去就行——两张配图就是这么传上去的,各 200,回来一对 ETag。
一点体会
签名这东西,出错时永远不会告诉你「第 17 个字符不一样」。它能给的只有一句「不匹配」。
所以能分段就分段:先用最简单的请求证明密钥没问题,再加上一个参数,再换成带中文和斜杠的前缀。每加一层就验一次,比盯着代码猜快得多。
那 403 看起来像在说钥匙不对,其实是在说:你和我数的不是同一把尺子。