SeaweedFS跑起来之后,最爽的其实是那套S3兼容接口。内部工具链直接用awscli或者各语言SDK就能对接,可总有那么些场景绕不开手工构造签名:最小化部署的容器里没有Python没有Go,堡垒机上只有curl和openssl,或者要写一个跨机器统一交付的部署脚本。S3协议不是明文HTTP就能裸调的,它有一套Signature V4签名机制,AWS系对象存储全都认这套,SeaweedFS也不例外。这篇文章把我平时在多个环境里调SeaweedFS S3接口的经验整理出来,重点讲清楚怎么用纯Shell加curl构造SigV4签名、怎么封装成一套统一访问脚本,以及那些文档里不会告诉你的坑。适合运维、存储工程师,以及在受限环境里需要操作S3兼容存储的同学参考。
1. 场景与设计思路:为什么必须自己造签名字轮子
1.1 S3接口与SeaweedFS的定位
SeaweedFS是典型的去中心化分布式存储,master负责元数据、volume负责数据存储,对外提供的接口有filer、S3网关、NFS等。S3网关是包在filer之上的一层兼容服务,默认监听8333端口,实现bucket和对象的增删改查、列表、生命周期等一批S3操作。对应用层来说,只要路径和签名正确,访问SeaweedFS的S3网关和访问AWS S3在协议层面基本没区别。
正因为S3协议这么通用,很多运维工具、监控脚本、CI/CD流水线都以S3协议对接存储后端。所以即使后端是SeaweedFS,客户端也完全可以套用S3的SDK和CLI。但客户端工具不是万能的:在精简容器、内网堡垒机、临时交付脚本这类缺工具的环境里,装一个awscli或者引入Java/Go/Python的SDK,成本往往比手写几百行签名逻辑高得多。
另一个实际诉求是排障。用curl裸请求能把请求头、请求体、响应码全部摊在明面上,比黑盒SDK更容易定位问题是出在签名、权限、路径还是网络层。尤其是SeaweedFS这类自建存储,出了问题第一件事就是拿curl打一发请求看返回,这个习惯我一直保留着。
1.2 什么场景下必须手写签名
我总结了一下,遇到下面几种情况基本就可以确定要上手工签名方案:
- 目标机器没有Python/Java/Go运行时,也没有awscli,只有curl、openssl和coreutils这些基础工具。这种环境在离线交付、最小化容器、老旧生产机上非常常见。
- 需要把访问流程写进一个shell脚本统一交付,避免在不同机器上重复安装不同版本的SDK,也为了让后续维护的人只改配置不用改逻辑。
- 需要快速验证SeaweedFS网关配置是否正常,直接打一发
GET /看响应延迟和状态码。 - 同一个脚本想兼容AWS S3、MinIO、Ceph RGW、SeaweedFS等多个S3兼容存储,只需换endpoint和key就能跑。
我自己第一次写这个脚本,就是在一次存储迁移中要跨集群同步大量小对象。源端和目标端都是SeaweedFS集群,迁移机上没有boto,Python还是2.7的,当天用bash把签名逻辑写出来后,几百行脚本一次解决了问题,后面的增量同步直接用crontab跑。所以这套方案的投入产出比其实很高,关键是把协议细节吃透。
2. SigV4签名机制拆解:从Canonical Request到Authorization头
2.1 签名流程的整体视图
SigV4不是一个简单的"把密钥拼进去做哈希",而是一个四层加工过程。打个直观的比方:你给物流公司寄件,必须按模板填写面单(Canonical Request),收件员把面单内容压缩成一段固定格式的摘要(StringToSign),再把你持有的寄件人密钥按固定规则逐级派生出一把当日有效的派件钥匙(SigningKey),最后用钥匙给摘要盖章(Signature)。物流公司收到件后用同一套规则重算,签名一致才签收——这就是服务端校验。
具体的四个步骤是:
- 构造规范化请求CanonicalRequest。
- 构造待签字符串StringToSign,包含对第一步结果的SHA256散列。
- 计算签名密钥SigningKey,由SecretKey逐级HMAC派生。
- 把签名和元信息放进Authorization请求头。
这套流程对AWS S3、MinIO、SeaweedFS、Ceph RGW统统有效。差别只在region是否参与校验:大多数S3兼容存储不校验region值本身,但你签名时仍然得写一个,AWS本尊则严格要求与实际区域一致。
2.2 CanonicalRequest每一行怎么来
规范化请求的格式是固定的六行,顺序缺一不可:
HTTPMethod CanonicalURI CanonicalQueryString CanonicalHeaders SignedHeaders HashedPayload逐行拆开看。第一行是HTTP方法,大写,PUT就是PUT,GET就是GET,不能带路径。第二行是CanonicalURI,即经过URI编码后的请求路径。S3的编码规则比一般URL编码严格:路径中每个segment做RFC3986编码,但/保留不编码。实际场景里绝大多数key都是字母数字下划线点横线,按原样处理就行。如果key里混了中文、空格、+、%这类字符,客户端编码和服务端解析很容易不一致,直接报SignatureDoesNotMatch。
第三行是CanonicalQueryString。查询参数要做两件事:按参数名ASCII码排序,然后做URI编码。比如max-keys=10&prefix=logs这种顺序不规范的写法,需要先排成max-keys=10&prefix=logs。签名时不带任何query,这行就是空字符串,但规范中空行也要保留。
第四行是CanonicalHeaders,所有参与签名的请求头,规则有三个:header名全部转小写、按header名的ASCII码排序、格式是name:value且每行以换行结尾。至少必须包含host,如果要加x-amz-date、x-amz-content-sha256、content-type等,都要并进去。参与签名的头越多,后面curl实际发送时就越要严格一致,否则验签必挂。
第五行是SignedHeaders,即参与签名的header名列表,小写、按ASCII排序、用分号连接,如host;x-amz-content-sha256;x-amz-date。第六行是HashedPayload,即请求体字节流本身的SHA256十六进制值。对于空body,这是一个固定值:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855,也就是SHA256("")。上传对象时对文件内容算sha256,注意是文件内容而不是文件名。
下面是一个PUT上传的CanonicalRequest实际长什么样,注意headers部分末尾多出的那个空行:
PUT /mybucket/hello.txt host:192.168.1.10:8333 x-amz-content-sha256:5d5d5d... x-amz-date:20240620T071234Z host;x-amz-content-sha256;x-amz-date 5d5d5d...中间host:...到x-amz-date:...就是headers块,之后空一行再写SignedHeaders。很多人拼字符串时少了本次的换行,导致算出的hash和服务器不一致,这是最常见的签名失败原因之一。
2.3 StringToSign与SigningKey的层层派生
StringToSign的格式同样是固定的:
AWS4-HMAC-SHA256 <YYYYMMDD'T'HHMMSS'Z'> <YYYYMMDD>/<region>/s3/aws4_request <Hex(SHA256(CanonicalRequest))>第一行指定算法,第二行精确到秒的UTC时间,第三行是scope,包含日期、region、服务名和固定后缀。S3的服务名永远是s3。最后一行是CanonicalRequest的SHA256结果。
SigningKey派生过程是一层套一层的HMAC。用伪代码表达:
kSecret = "AWS4" + SecretAccessKey kDate = HMAC-SHA256(kSecret, DateStamp) kRegion = HMAC-SHA256(kDate, RegionName) kService = HMAC-SHA256(kRegion, "s3") kSigning = HMAC-SHA256(kService, "aws4_request") signature = Hex(HMAC-SHA256(kSigning, StringToSign))注意kSecret只是字符串拼接,不需要做十六进制编码;但从kDate开始,每层的输出是二进制HMAC摘要,下一轮作为HMAC的key时必须以二进制形式参与。shell里为了好传递,通常把中间结果转成十六进制文本,下一轮用openssl的hexkey参数喂进去。这也是脚本里会反复出现hex转换的原因。
最终签名是64位十六进制字符串,Authorization头格式为:
Authorization: AWS4-HMAC-SHA256 Credential=<AccessKey>/20240620/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=<signature>服务端拿到这个头,会解析出scope和SignedHeaders,再用自己的SK重算一遍签名。只要任何一处字节不一致,最后对不上就报SignatureDoesNotMatch。
3. Shell脚本实现:统一S3客户端签名与调用
3.1 最小依赖与初始化
这套脚本只依赖curl、openssl、sha256sum、date、awk、sed、tr、od,都是Linux系统自带工具,BusyBox里也基本可用,唯一要注意BusyBox的date要确认支持-u。不建议依赖jq、python3这类重量级工具,虽然能简化编码过程,但那违背了轻量统一的初衷。
脚本开头先定义可以从外部注入的配置:
#!/usr/bin/env bash # s3_curl.sh - SeaweedFS S3 Signature V4 统一访问脚本 set -euo pipefail S3_ACCESS_KEY="${S3_ACCESS_KEY:-}" S3_SECRET_KEY="${S3_SECRET_KEY:-}" S3_ENDPOINT="${S3_ENDPOINT:-http://127.0.0.1:8333}" S3_REGION="${S3_REGION:-us-east-1}"endpoint、AK、SK全部从环境变量读取,默认值是SeaweedFS本机部署的常见形态。为什么用环境变量而不是硬编码?因为这套脚本会派发给多台机器执行,钥匙写在脚本正文里一旦泄露就是全链路风险,环境变量至少能配合密钥管理系统或者只读权限文件做隔离。
3.2 payload哈希与HMAC工具函数
接下来是三个底层辅助函数。第一个负责计算body哈希,参数可以是空、字符串、文件路径或@-表示stdin:
# 计算payload哈希,参数为:空、字符串、文件路径、@-表示stdin s3_sha256() { if [[ $# -eq 0 || "$1" == "" ]]; then echo "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" elif [[ "$1" == "@-" ]]; then cat | sha256sum | awk '{print $1}' elif [[ -f "$1" ]]; then sha256sum "$1" | awk '{print $1}' else printf '%s' "$1" | sha256sum | awk '{print $1}' fi } # 文本转连续十六进制,用作openssl的hexkey s3_hex() { printf '%s' "$1" | od -An -tx1 | tr -d ' \n' } # 基于hexkey计算HMAC-SHA256,输出十六进制摘要 s3_hmac() { local hexkey="$1" data="$2" printf '%s' "$data" | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$hexkey" | sed 's/^.*= //' }重点说明s3_hmac实现细节。用printf '%s'而不是echo,因为echo在部分发行版会偷偷吞掉-n之类的字符,而且多一个换行就会改变data内容,签名就全错了。openssl输出格式在不同版本不一样,老版本是(stdin)= abc...,新版本是HMAC-SHA256(...)= abc...,所以统一用sed 's/^.*= //'只保留十六进制摘要。
3.3 核心签名函数s3_sigv4
把四步签名流程收进一个函数,输入是方法、URI、query字符串和payload哈希:
s3_sigv4() { local method="$1" uri="$2" query="$3" payload_hash="$4" local amz_date date_stamp host canonical_headers signed_headers local canonical_request hashed_cr scope string_to_sign signature auth_header amz_date=$(date -u +%Y%m%dT%H%M%SZ) date_stamp="${amz_date:0:8}" # 从endpoint提取host,注意保留端口 host="${S3_ENDPOINT#http://}" host="${host#https://}" host="${host%%/*}" canonical_headers=$(printf "host:%s\nx-amz-content-sha256:%s\nx-amz-date:%s\n" \ "$host" "$payload_hash" "$amz_date") signed_headers="host;x-amz-content-sha256;x-amz-date" canonical_request=$(printf "%s\n%s\n%s\n%s%s\n%s" \ "$method" "$uri" "$query" "$canonical_headers" "$signed_headers" "$payload_hash") hashed_cr=$(printf '%s' "$canonical_request" | sha256sum | awk '{print $1}') scope="$date_stamp/$S3_REGION/s3/aws4_request" string_to_sign=$(printf "AWS4-HMAC-SHA256\n%s\n%s\n%s" \ "$amz_date" "$scope" "$hashed_cr") # 派生签名密钥 kDate=$(s3_hmac "$(s3_hex "AWS4$S3_SECRET_KEY")" "$date_stamp") kRegion=$(s3_hmac "$kDate" "$S3_REGION") kService=$(s3_hmac "$kRegion" "s3") kSigning=$(s3_hmac "$kService" "aws4_request") signature=$(s3_hmac "$kSigning" "$string_to_sign") auth_header="AWS4-HMAC-SHA256 Credential=$S3_ACCESS_KEY/$scope, SignedHeaders=$signed_headers, Signature=$signature" printf '%s\n%s\n%s' "$auth_header" "$amz_date" "$payload_hash" }注意canonical_request拼接中,$canonical_headers自己末尾已经带换行,再拼signed_headers,自然形成一个空行分隔。这个细节是验签能否成功的关键。返回值我用三行返回:Authorization头、x-amz-date、payload哈希。这样调用方可以逐一取出来放进curl参数,比全局变量清晰,也避免bash函数只能返回整数退出码的限制。
3.4 封装业务操作
接下来是面向场景的操作函数。先定义一个底层发请求函数:
s3_call() { local method="$1" uri="$2" query="$3" body_src="$4" outfile="$5" local payload_hash sig_result auth amz_date if [[ -n "$body_src" ]]; then payload_hash=$(s3_sha256 "$body_src") else payload_hash=$(s3_sha256 "") fi sig_result=$(s3_sigv4 "$method" "$uri" "$query" "$payload_hash") auth=$(printf '%s\n' "$sig_result" | sed -n '1p') amz_date=$(printf '%s\n' "$sig_result" | sed -n '2p') local curl_args=(-sS -X "$method") curl_args+=(-H "Authorization: $auth") curl_args+=(-H "x-amz-date: $amz_date") curl_args+=(-H "x-amz-content-sha256: $payload_hash") if [[ -n "$query" ]]; then uri="$uri?$query" fi if [[ -n "$body_src" && "$body_src" == @* ]]; then curl_args+=(--data-binary "$body_src") elif [[ -n "$body_src" ]]; then curl_args+=(--data-binary "$body_src") fi if [[ -n "$outfile" ]]; then curl_args+=(-o "$outfile") fi curl "${curl_args[@]}" "$S3_ENDPOINT$uri" }这里有几个关键设计。第一,payload_hash必须和请求体完全一致,所以把body的hash计算放在调用curl之前。第二,curl的-X PUT配合--data-binary @file时,curl会根据文件大小自动设置Content-Length,服务端能拿到正确的长度。第三,如果body为空,像GET/HEAD/DELETE这类请求,绝不能加--data,否则curl默认会发POST行为,S3服务端直接拒掉。
然后是常用的六个操作函数:
# 列出所有bucket s3_list_buckets() { s3_call "GET" "/" "" "" } # 创建bucket s3_make_bucket() { s3_call "PUT" "/$1" "" "" } # 上传对象:s3_put_object bucket key file s3_put_object() { local bucket="$1" key="$2" file="$3" local encoded_key encoded_key=$(uri_encode "$key") s3_call "PUT" "/$bucket/$encoded_key" "" "@$file" } # 下载对象:s3_get_object bucket key outfile s3_get_object() { local bucket="$1" key="$2" outfile="$3" local encoded_key encoded_key=$(uri_encode "$key") s3_call "GET" "/$bucket/$encoded_key" "" "" "$outfile" } # 删除对象 s3_delete_object() { local bucket="$1" key="$2" local encoded_key encoded_key=$(uri_encode "$key") s3_call "DELETE" "/$bucket/$encoded_key" "" "" } # 列对象:s3_list_objects bucket [prefix],返回XML由调用方解析 s3_list_objects() { local bucket="$1" prefix="${2:-}" if [[ -n "$prefix" ]]; then s3_call "GET" "/$bucket" "list-type=2&prefix=$(uri_encode "$prefix")" else s3_call "GET" "/$bucket" "list-type=2" fi }调用示例:
export S3_ACCESS_KEY=any export S3_SECRET_KEY=any export S3_ENDPOINT=http://192.168.1.20:8333 s3_make_bucket "backup-2024" s3_put_object "backup-2024" "logs/app.log" "/var/log/app.log" s3_get_object "backup-2024" "logs/app.log" "/tmp/app.log.restore" s3_list_objects "backup-2024" "logs/"3.5 URI编码辅助函数
URI编码是整个脚本里最容易踩坑的地方,单独拿出来说。S3要求对key的每个字节做RFC3986编码,只保留A-Za-z0-9-_.~。我提供一个优先使用jq、退路用curl的实现:
uri_encode() { local raw="$1" # 如果环境有jq,用jq最省事 if command -v jq >/dev/null 2>&1; then jq -rn --arg v "$raw" '$v|@uri' return fi # 退路:用curl的--data-urlencode把参数编码结果从URL里抠出来 curl -sG --data-urlencode "v=$raw" -o /dev/null -w '%{url_effective}' \ "http://localhost/" 2>/dev/null | sed 's#^http://localhost/?v=##' }jq的@uri是标准RFC3986编码,和S3要求一致。没有jq时,curl --data-urlencode本身就是做application/x-www-form-urlencoded编码的,对普通场景够用。为什么不推荐sed/awk手搓?因为RFC3986编码规则涉及十六进制大小写和保留字符集合,手写正则很容易漏,尤其key里含%、+、空格、UTF-8中文时特别容易翻车。
另外提醒一句:bucket名称本身尽量不要用特殊字符,S3规范只允许小写字母、数字、点和横线,SeaweedFS也遵循这套命名限制。只要bucket名合规、key用uri_encode处理,绝大多数签到问题都能规避。
4. curl调用S3实际场景:上传、下载、列表与常见报错排查
4.1 请求细节:为什么不能临时拼头部
上文两个函数,一个生成签名,一个发请求,之间有严格约定:签名时用了哪些header,curl发送时就必须原样带齐。SeaweedFS的S3网关验签时,会按照SignedHeaders列表逐个校验你实际发过来的header。如果签名时没把content-type列进去,而curl因为带了body自动补了一个Content-Type: application/x-www-form-urlencoded,网关一校验就发现这个头不在签名覆盖范围内,直接4xx。
所以上传文件时,要么显式指定业务Content-Type并把它加进signed_headers,要么就干脆别带content-type头让curl默认处理。大多数归档同步场景并不需要content-type,所以我的脚本默认只签三个头:host、x-amz-date、x-amz-content-sha256,形成一个最小化签名集,把出错概率降到最低。
用curl调试时强烈建议加-v参数看原始请求,尤其当签名对不上时。-v会把发送和接收的完整头都打出来,能直观看到host、x-amz-date、content-type这些头是否和服务端预期的一致。线上脚本里我默认改为-sS -o 文件 -w "%{http_code}",把HTTP状态码单独拿出来方便自动化判断。
4.2 常见问题速查表
把排查过的S3访问问题整理成一张表,遇到对应报错直接对号入座:
| 现象 | 报错文本 | 最可能原因 | 解决动作 |
|---|---|---|---|
| 403 | SignatureDoesNotMatch | CanonicalRequest拼接不一致,常见是headers末尾换行、query编码、时间偏差 | 加curl -v对比发送头,把脚本里的canonical_request明文打出来逐行核对 |
| 403 | RequestTimeTooSkewed | 客户端与服务器时钟偏差超过15分钟 | 执行date -u看客户端时间,用ntp/chrony同步 |
| 403 | AccessDenied | AK/SK错误、bucket不存在或无权限 | 检查环境变量传递,核对服务端weed s3配置的key |
| 400 | InvalidArgument | query参数写法不符合S3规范,或list-type=2在旧版本不被支持 | 断开list-type=2改用v1的prefix参数测试 |
| 400 | MalformedXML | 列表接口返回或传入的XML格式有问题 | 检查query参数URI编码是否丢失 |
| 411 | Length Required | PUT/POST请求没有Content-Length | 确认body确实通过--data-binary传入 |
| 500 | 网关内部错误 | SeaweedFS S3网关与filer/master之间的内部异常 | 查看weed s3进程日志,确认底层volume状态健康 |
实际故障里SignatureDoesNotMatch占七成以上,而且大部分是机械性的拼接错误而不是密钥错误。遇到这类问题不要慌,把客户端和服务端两个版本的canonical_request逐字节对比,通常五分钟内能找到差异。
4.3 两个容易忽略但致命的边界情况
第一个是空文件上传。空文件内容sha256恰好就是那个固定的空body哈希,但它又确实是个PUT请求,有Content-Length: 0。这种请求如果直接用--data-binary @emptyfile,curl会设置Content-Length: 0,服务端能正常接收。如果你偷懒用空字符串当body,签名时算的是空body哈希,curl又不带body数据,网关可能也当空对象处理,但这样容易混淆。稳妥做法是空文件也走@file方式传,让长度继承文件大小。
第二个是删除后立即用相同key上传。S3对象存储一般保证写后读一致性,但SeaweedFS底层有volume再平衡,如果对象跨volume或存在延迟缓存,刚删除后立刻PUT同名对象,个别版本会出现短暂404。这不属于签名问题,而是后端一致性窗口。自动同步脚本里最好对PUT结果做一次HEAD确认,或者失败重试一次。这也是我所有线上脚本保留的一个基本习惯。
4.4 HTTPS与证书校验的问题
S3网关如果挂在TLS后面,比如前面有nginx终止TLS再反代到8333,curl默认会校验证书,自签名证书会报curl: (60) SSL certificate problem。两种情况分别处理:
- 如果网关证书是内网CA签发的,把CA证书下载到固定路径,脚本里加
--cacert /etc/ssl/s3-ca.crt,请求头不用变,签名逻辑完全不受TLS层影响。 - 如果只是临时联调,可以加
-k跳过校验,但生产环境不建议。注意-k只是跳过TLS校验,不影响S3应用层签名验证,两者是独立的两道关卡。
另外,内网跨机房传输大文件时,偶尔会遇到curl 56这类传输层错误。常见原因是连接被异常重置(对端超时或代理中断),或者Expect: 100-continue机制干扰。前者需要检查链路和S3网关负载,后者可以给curl加-H "Expect:"显式去掉Expect头。我发现加上这个头之后,大批量PUT的传输稳定性明显提升,这个细节值得记下来。
5. 工程化扩展与实操建议
5.1 把脚本做成可配置的统一入口
前面那个脚本本质是函数库,真正用在交付环境里,我习惯再加一个只有几十行的入口封装:通过第一个参数选择子命令,第二个参数指定bucket,第三个参数指定key或文件,同时支持从命令行读AK/SK或从环境变量读。这样它就变成一个能进crontab、能被监控平台调用、也能给一线运维手动操作的多面手。
一个简化的dispatch示例:
case "${1:-}" in ls) s3_list_buckets ;; mb) s3_make_bucket "$2" ;; put) s3_put_object "$2" "$3" "$4" ;; get) s3_get_object "$2" "$3" "$4" ;; rm) s3_delete_object "$2" "$3" ;; mbl) s3_list_objects "$2" "$3" ;; *) echo "用法: $0 {ls|mb|put|get|rm|mbl} ..." >&2 exit 1 ;; esac入口里还应该做两件事:检查必要环境变量是否设置,缺失时直接报错退出,避免跑到一半才发现AK是空的;另一个是初始化日志目录,把curl的HTTP状态码和请求时间追加写进日志,后期排查全靠这些记录。状态码非2xx或3xx时最好让入口脚本返回非0退出码,这样crontab里就能触发告警。
5.2 多集群与批量操作场景
因为签名函数从endpoint、AK、SK全局配置读取,同一时间只能访问一个集群。要同时维护多个SeaweedFS集群,最省事的办法不是改脚本内部逻辑,而是为每个集群单独写一个env文件,内容类似:
export S3_ENDPOINT=http://192.168.1.20:8333 export S3_ACCESS_KEY=clusterA export S3_SECRET_KEY=xxx调用前source对应env文件就行。批量备份时,用for循环遍历文件列表逐个执行put,注意在循环里为每个文件重新计算payload hash,不能复用上一次的哈希值,否则传几个对象就挂几个。
增量同步我实际就是这么干的:先通过list-type=2&prefix=...拿远端对象列表,解析出key集合,和本地目录文件做差集,只上传新增和变化的文件。解析XML时用grep抓<Key>标签就够,不追求完整XML解析:
remote_keys=$(s3_list_objects "$bucket" "$prefix" | grep -oP '(?<=<Key>).*?(?=</Key>)')这个策略在对象数量几千到几万的批次里都没问题,跑得又快又稳。
5.3 个人实操经验总结
写到这里,把最深的几条体会再强调一下。第一,签名失败时先别怀疑密钥,先验证时间,再验证canonical_request的字节级结构,最好是直接把脚本算出的canonical_request明文打印出来,跟AWS的官方示例一行行对比。第二,header的签名范围必须和实际发送范围相等,多一个少一个都会挂。最保险的就是只用host、x-amz-date、x-amz-content-sha256这三个头形成最小化签名集,业务头全部不加。第三,上传场景务必预计算文件内容sha256,不能用date +%s拼进去,那是完全没有意义的等价于自创协议。最后,这套手动脚本不要试图去实现多部分上传(Multipart Upload),手工签名做分片涉及UploadId、PartNumber、ETag连续多个请求,复杂度翻好几倍。真有大批量的大文件需求,老老实实上awscli或者Go客户端,手工签名适合对象文件大小相对均匀的中小型同步任务。
我自己现在的环境里,这套curl签名脚本依然在每天跑增量同步,已经稳定运行一年半。它证明了一件事:没有重型依赖的轻量实现,只要把协议理解到位,一样能扛住生产环境的日常节奏。你要是也卡在只有curl和openssl的墙里,照这个思路写一个属于你自己的s3curl,用起来会比你想象中顺手得多。