RustFS 提供基于 Rust 的分布式对象存储服务;Apache Reqsign 则为 HTTP 请求生成云服务认证签名。两者可以通过 AWS Signature Version 4(SigV4)衔接:客户端构建 S3 请求,Reqsign 签名,HTTP 客户端发送,RustFS 验证签名并执行权限检查。
这条路线适合需要自行控制 HTTP 交互的 Rust 项目。它省去了完整 S3 SDK 的部分抽象,但对象 API、XML 解析、重试、分片上传和错误处理仍由应用负责。若需要完整存储客户端能力,成熟的 S3 SDK 或存储抽象库也值得比较。
本文按公众号原文主题整理,并对照 Reqsign 官方说明、预签名示例与 RustFS 文档修正关键代码。示例使用 Reqsign 0.20.x API,未在本文环境编译或连接 RustFS 实测。
架构与兼容性边界
应用构建 HTTP 请求
→ Reqsign 加载凭证并生成 SigV4 签名
→ reqwest 发送签名后的请求和请求体
→ RustFS 验证认证信息与访问策略
→ 返回 S3 API 响应签名解决的是请求认证,不自动授予对象权限。即使签名正确,策略也可能拒绝列举存储桶、读取对象或上传文件。
RustFS 官方说明采用“支持功能范围内的 S3 API 兼容”表述,并提供 S3 兼容性矩阵。因此,不能把更换 endpoint 等同于所有 AWS S3 功能都能原样运行。
两个项目使用 Apache-2.0 许可,但实际分发仍需检查所使用版本和依赖的许可证义务。协议兼容性与软件许可证也是不同维度的问题。
准备凭证、区域与依赖
先准备一个可访问的 RustFS 实例和已经创建的测试存储桶。应用凭证应来自实际部署,赋予测试桶所需的最小权限,不要在业务代码中回退到默认管理员密钥。
客户端使用 AWS 风格环境变量,例如:
export AWS_ACCESS_KEY_ID="<应用访问密钥>"
export AWS_SECRET_ACCESS_KEY="<应用秘密密钥>"
export AWS_REGION="us-east-1"
export S3_ENDPOINT="http://localhost:9000"
export S3_BUCKET="my-bucket"如使用临时凭证,还需按凭证来源配置相应的 session token。us-east-1 只是示例,必须与实际服务端签名区域一致;在代码中显式传给签名器的区域才是本示例的签名配置。
依赖示例:
[dependencies]
anyhow = "1"
http = "1"
percent-encoding = "2"
reqsign = { version = "0.20.7", features = ["aws"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
sha2 = "0.10"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }aws 特性需要显式启用。 Reqsign 的默认上下文会提供凭证加载所需的环境与网络组件,但不等于自动启用了所有云服务签名模块。正式项目还应提交 Cargo.lock,固定经过验证的依赖组合。
完整示例:列出、上传与下载对象
下面的示例把签名和发送放进一个辅助函数。与原始示例相比,关键变化是:签名操作得到的是请求的 Parts,发送前需要与请求体重新组成完整 http::Request,再转换成 reqwest::Request。
上传示例先计算请求体 SHA-256,并在签名前设置相关请求头。数据适合演示小文件;大文件应采用经过验证的分片或流式方案。
use anyhow::{ensure, Context, Result};
use http::{Request, Uri};
use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
use reqsign::aws;
use sha2::{Digest, Sha256};
use std::time::Duration;
// 保留 RFC 3986 的 unreserved 字符,路径层级由 / 单独连接。
const S3_SEGMENT: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'-')
.remove(b'.')
.remove(b'_')
.remove(b'~');
fn object_uri(endpoint: &str, bucket: &str, key: &str) -> Result<String> {
ensure!(
!key.split('/').any(|part| part == "." || part == ".."),
"本示例不处理完整的点路径段,以免 HTTP 客户端规范化对象键"
);
let encoded_key = key
.split('/')
.map(|part| utf8_percent_encode(part, S3_SEGMENT).to_string())
.collect::<Vec<_>>()
.join("/");
Ok(format!("{}/{}/{}", endpoint, bucket, encoded_key))
}
async fn send_signed(
signer: &aws::DefaultSigner,
client: &reqwest::Client,
method: &str,
uri: &str,
data: Vec<u8>,
) -> Result<Vec<u8>> {
let parsed: Uri = uri.parse()?;
let authority = parsed.authority().context("请求缺少 host")?.as_str();
let payload_hash = format!("{:x}", Sha256::digest(&data));
let request = Request::builder()
.method(method)
.uri(uri)
.header("host", authority)
.header("content-length", data.len().to_string())
.header("x-amz-content-sha256", payload_hash)
.body(data)?;
let (mut parts, body) = request.into_parts();
signer.sign(&mut parts, None).await?;
let signed_uri = parts.uri.to_string();
let complete_request = Request::from_parts(parts, body);
let outgoing = reqwest::Request::try_from(complete_request)?;
// 签名之后不允许客户端偷偷改写路径或查询串。
ensure!(
outgoing.url().as_str() == signed_uri,
"HTTP 客户端改写了签名 URI,请检查对象键与 endpoint"
);
let response = client.execute(outgoing).await?;
let response = response.error_for_status()?;
Ok(response.bytes().await?.to_vec())
}
#[tokio::main]
async fn main() -> Result<()> {
// 这个最小示例要求显式提供凭证,不做默认管理员回退。
for name in ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"] {
let value = std::env::var(name).with_context(|| format!("缺少 {name}"))?;
ensure!(!value.is_empty(), "{name} 不能为空");
}
let region = std::env::var("AWS_REGION").context("缺少 AWS_REGION")?;
let endpoint = std::env::var("S3_ENDPOINT").context("缺少 S3_ENDPOINT")?;
let endpoint = endpoint.trim_end_matches('/');
let base: Uri = endpoint.parse()?;
ensure!(
matches!(base.scheme_str(), Some("http" | "https"))
&& base.authority().is_some()
&& matches!(base.path(), "" | "/")
&& base.query().is_none(),
"本示例的 endpoint 应为不带路径或查询串的 http(s) 地址"
);
let bucket = std::env::var("S3_BUCKET").context("缺少 S3_BUCKET")?;
ensure!(
!bucket.is_empty()
&& bucket.bytes().all(|b| b.is_ascii_lowercase()
|| b.is_ascii_digit() || b == b'-' || b == b'.'),
"请使用已创建的有效 S3 存储桶名称"
);
let signer = aws::default_signer("s3", ®ion);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.build()?;
let list_uri = format!("{}/{}?list-type=2", endpoint, bucket);
let listing = send_signed(&signer, &client, "GET", &list_uri, Vec::new()).await?;
println!("对象列表 XML:{}", String::from_utf8_lossy(&listing));
let uri = object_uri(endpoint, &bucket, "examples/hello world.txt")?;
send_signed(
&signer, &client, "PUT", &uri, b"Hello RustFS!".to_vec()
).await?;
println!("上传完成");
let downloaded = send_signed(&signer, &client, "GET", &uri, Vec::new()).await?;
println!("下载内容:{}", String::from_utf8_lossy(&downloaded));
Ok(())
}此处直接输出列表 XML,尚未实现 XML 解析、分页、重试、取消和分片上传;存储桶也需要提前存在。真正封装客户端时,应保留错误响应中的状态、S3 错误码和请求标识,同时避免把认证头和密钥写进日志。
示例使用路径式寻址:endpoint/bucket/object。虚拟主机式寻址是否可用,取决于 RustFS 域名配置、DNS 和 TLS 证书,不能只把桶名拼到主机名前面。
对象键编码为什么要在签名前完成
SigV4 会把请求路径和查询参数纳入签名。签名时的 URI 与真正发出的 URI 必须一致。空格应编码为 %20,不能在路径中用表单编码的 + 代替;中文和其他 Unicode 字符要按 UTF-8 字节编码。
对象键通常可以逐段编码,再保留 / 连接层级。不能把整个键编码成单一段,也不能在签名后再次编码,形成双重转义。
某些 URL 库会规范化 .、.. 或重复路径片段。上面的最小示例拒绝完整点路径段,并检查转换后的 URI。需要支持这类特殊键时,应选择能保留所需线上字节表示的传输方式,进行兼容性测试。
存储应用还涉及数据布局与访问模式,可结合站内的 SlateDB 与对象存储架构进一步理解对象 API 和数据库语义的区别。
预签名 URL 与有效期
Reqsign 官方 0.20.x 示例使用 Some(Duration) 指定预签名有效期,不是原文中的 PresignOptions 结构。
以下片段放在异步函数内,沿用已创建的 signer:
let mut parts = http::Request::builder()
.method("GET")
.uri("http://localhost:9000/my-bucket/report.csv")
.header("host", "localhost:9000")
.body(())?
.into_parts()
.0;
signer
.sign(&mut parts, Some(std::time::Duration::from_secs(900)))
.await?;
let presigned_url = parts.uri.to_string();
// 通过可信通道交给获授权的调用方,不写入公开日志。预签名 URL 本身携带临时访问能力,应视为凭证。有效期越长,泄露后的暴露窗口越大;实际有效时间还可能受到临时凭证过期和服务端策略限制。
签名与 HTTP 方法绑定。为 GET 生成的 URL 不能假设能用于 HEAD,上传的 PUT URL 也不能拿来下载。调用方如果会先发送探测请求,应为实际方法分别签名,并保留签名涉及的请求头。
自定义凭证提供器
默认签名器会按其默认凭证链加载信息。若应用已有自己的密钥管理,可以自定义凭证提供器;官方同时提供静态凭证提供器,适合受控测试或从秘密管理系统读出凭证后组装。
use reqsign::aws::{self, StaticCredentialProvider};
let access_key = std::env::var("AWS_ACCESS_KEY_ID")?;
let secret_key = std::env::var("AWS_SECRET_ACCESS_KEY")?;
let signer = aws::default_signer("s3", "us-east-1")
.with_credential_provider(StaticCredentialProvider::new(
&access_key,
&secret_key,
None,
));临时凭证需要正确传入 session token,并处理到期刷新。复用签名器和 HTTP 客户端通常有利于连接与凭证管理,但不能据此承诺固定的毫秒级收益;性能要在实际请求负载下测量。
流式上传与 SigV2 的兼容性
S3 的分片上传和 AWS 分块流式签名是不同机制。前者把对象拆成多个 API 操作,后者涉及请求体分块格式、内容长度与逐块认证,不能仅增加一个头就完成。
原文给出的 streaming_sign_v4 片段未能从本文核对的 Reqsign 公开 API 中确认,因此不作为可执行接口保留。需要大文件上传时,应查看当前版本明确支持的能力,或使用已有集成测试的 S3 客户端实现。
原文的 rustfs_signer::sign_v2 片段也不是 Reqsign 的通用调用方式。新集成优先使用双方已经验证兼容的 SigV4;确需旧式 SigV2 时,单独核对服务端版本、依赖 API 和回归测试。
Docker 环境中的常见问题
RustFS 的 API 与控制台是不同端口。客户端应连接实际 S3 API 地址,不要把控制台端口误作签名请求入口。
从宿主机访问可使用映射端口;从同一 Compose 网络的其他容器访问,一般使用服务名,例如 http://rustfs:9000。容器内的 localhost 指向自己,不指向 RustFS 服务。
Compose 中如果使用 depends_on: condition: service_healthy,被依赖服务必须定义可用的 healthcheck。原始片段未提供完整健康检查和卷定义,不宜直接作为完整配置使用。可从 RustFS 官方 Compose 示例开始,检查数据卷写权限、密钥、端口与版本,再按环境修改。
部署镜像与依赖应固定到经过验证的版本,生产环境使用 HTTPS,确认代理不会改写签名涉及的 Host、路径或查询串。
签名故障排查顺序
| 现象 | 优先检查 |
|---|---|
SignatureDoesNotMatch | 签名区域、密钥、Host 与端口、路径编码、请求体哈希以及签名后是否被改写 |
| 预签名 GET 可用、HEAD 失败 | 请求方法是否与签名一致,探测动作是否需要独立 URL |
AccessDenied | 除认证外,还要检查桶策略和具体 API 权限 |
| 容器中连接失败 | endpoint 是否误用 localhost,服务网络与端口是否一致 |
| 请求时间相关错误 | 主机时间同步、凭证过期和预签名有效期 |
| 小文件正常、大文件失败 | 请求体长度、分片流程、代理限制与实际流式能力 |
把签名、传输和授权分开排查,比反复更换密钥更有效。先确认线上发出的请求与签名前一致,再检查服务端错误码和对应版本文档。
如果还需要比较托管对象存储的基础配置,站内的 Cloudflare R2 对象存储教程可作为补充。