4

2022-11: 新轮子 reqsign

 2 years ago
source link: https://xuanwo.io/reports/2022-11/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

2022-11: 新轮子 reqsign

这周主要的时间都在搓新轮子 reqsign,用于对用户的请求进行签名,使得用户不再需要依赖完整的 SDK,我将其概括为 Signing API requests without effort。今天这期周报就来聊聊为什么要造这个轮子。

开发云上服务难免需要使用 SDK,它帮助用户处理认证,构造请求,解析响应等任务,使得用户不需要关心服务内部的细节。对于绝大多数用户来说,使用 SDK 已经足够满足需求,但使用 SDK 也有不小的弊端。

首先,不同服务的 SDK 维护质量参差不齐。大多数 SDK 都无法及时响应来自用户的需求,没有定期的漏洞补丁和安全审查。对小众语言来说这个问题尤为严重,以 Rust 为例,对象存储服务中提供官方 SDK 支持的只有 AWS S3,其他的服务都只有社区维护的 SDK。

其次,跨 SDK 的行为很难统一。云上服务不可避免地会出现偶尔的内部错误和超时,所以很多 SDK 都包装了自己的重试和超时逻辑。应用如果想设置统一的重试和超时行为,就需要逐个服务的去研究如何进行配置。底层的数据库服务还会需要操作 SDK 内部的 Client,控制并发,实现限流限速,控制内存占用等,这就更难以实现了。

最后,代码生成的 SDK 往往复杂、臃肿又难用。为了降低 SDK 的维护成本,很多服务使用代码生成的方式来构建 SDK。为了统一内部不同服务的接口,往往需要增加无数层抽象,把简单的 HTTP API 变成了一大堆复杂的 HTTP Middleware。为了使用 aws-sdk-s3,需要依赖 aws-endpointaws-httpaws-sig-authaws-sigv4aws-config 等一大包 crate。这个问题在业务只需要使用 SDK 中特定几个方法的时候变得尤为严重,为了发送一个 get_object 请求,应用的构建时间增加了 120 秒,二进制体积变大了 6 MiB。为了支持从 AWS STS 服务获取临时的 token,aws-config 需要依赖 aws-sdk-stsaws-sdk-sso,在 aws-sdk-sts 中同样需要走一遍完整的请求构建流程。

于是我开始思考,为什么简单的 HTTP API 变成了如今这样?核心逻辑明明只有 HTTP/1.1 GET https://127.0.0.1/abc ,我们为什么需要做这么多额外的工作?如果自己来构造这个请求会不会变得更简单?

一个星期之前,我在 opendal 的讨论区记录下了这个想法:Self maintianed SDK。但是我很快意识到,这样的做法是不可取的:在项目中为用到的每一个服务维护独立的 SDK 在未来会变成一个沉重的负担。更糟糕的是,我在重复造轮子,所有现有的 SDK 踩过的坑,实现的细节我需要在项目中重新趟一遍。不仅如此,其他的开源项目无法复用我的工作成果,从整个开源社区来看是收益比极低的做法。只能满足项目的需求对我来说是不够的,要怎么做才能最大程度的使得整个开源社区受益呢?

紧接着,我开始思考为什么维护 SDK 的成本会这么高。以 opendal 为例,它只需要使用 aws-sdk-s3 中的五个接口,需要做哪些事情能让它在不依赖 aws-sdk-s3 的前提下运作起来,而其中成本最高的部分又在哪里?很快我意识到了症结:签名认证。

实现 get_object, delete_object 等接口是非常简单的:

pub(crate) async fn delete_object(&self, path: &str) -> Result<hyper::Response<hyper::Body>> {
  let mut req =
    hyper::Request::delete(&format!("{}/{}/{}", self.endpoint, self.bucket, path))
        .body(hyper::Body::empty())
        .expect("must be valid request");

  self.client.request(req).await.map_err(|e| {
    error!("object {} delete_object: {:?}", path, e);
    Error::Unexpected(anyhow::Error::from(e))
  })
}

但是为了能签名这个请求,我们需要做非常多事情:

  • 从这个请求中构造出 CanonicalRequest
  • 基于 CanonicalRequest 来生成 StringToSign
  • 构造 SigningKey
  • 然后通过 SigningKeyStringToSign 计算出本次请求的 signature
  • 再按照要求把 signature 追加到请求中合适的位置

为了满足各种场景的需求,围绕着核心的签名计算逻辑,我们还需要做其他的工作

  • 同时支持通过 HTTP Header 和 HTTP Query 来签名
  • 支持从环境变量,配置文件,AWS STS 服务,AWS EC2 Metadata 服务等多种途径获取密钥
  • 支持 Security Token 这样会过期的认证信息,为此还需要搭配一套完整的过期自动更新机制

假设我们有一个库,能把上面的这些跟请求签名相关的工作全部搞定,让我们的 API 实现变成这样:

pub(crate) async fn delete_object(&self, path: &str) -> Result<hyper::Response<hyper::Body>> {
  let mut req =
    hyper::Request::delete(&format!("{}/{}/{}", self.endpoint, self.bucket, path))
        .body(hyper::Body::empty())
        .expect("must be valid request");

  self.signer.sign(&mut req).await.expect("sign must success");

  self.client.request(req).await.map_err(|e| {
    error!("object {} delete_object: {:?}", path, e);
    Error::Unexpected(anyhow::Error::from(e))
  })
}

一切是不是迎刃而解了呢?

怀揣着这样的思路,我搓出来了 reqsign

它的目标就是让简单的 HTTP API 回归简单,专注于搞定请求签名这一件事情:

  • 传入一个 Request
  • reqsign 签名好并返回
  • 用户用自己的 Client 将它发出去

DONE!就是这么简单,没有多余的操作,没有复杂的抽象,仿佛本来就该如此。

reqsign 现在支持了 AWS SigV4,它能够自动的从环境变量,配置文件,AWS STS 服务中获取必要的签名信息,对用户的请求进行签名:

use reqsign::services::aws::v4::Signer;
use reqwest::{Client, Request, Url};
use anyhow::Result;

#[tokio::main]
async fn main() -> Result<()>{
    // Signer will load region and credentials from environment by default.
    let signer = Signer::builder().service("s3").build().await?;
    // Construct request
    let url = Url::parse( "https://s3.amazonaws.com/testbucket")?;
    let mut req = reqwest::Request::new(http::Method::GET, url);
    // Signing request with Signer
    signer.sign(&mut req).await?;
    // Sending already signed request.
    let resp = Client::new().execute(req).await?;
    println!("resp got status: {}", resp.status());
    Ok(())
}

reqsign 没有直接依赖任何 AWS 的库,而是选择参考 aws-sigv4 的代码自行实现了完整的签名认证逻辑,在测试中将签名结果与 aws-sigv4 直接对比,并通过构造请求发送到 AWS S3 和 minio 来确保自己的实现正确。

目前 opendal 已经彻底切换到了 reqsign 上,在 PR refactor: Say goodbye to aws-s3-sdk 中能看到 opendal 一口气删除了十个依赖:

aws-config = "0.8"
aws-endpoint = "0.8"
aws-http = "0.8"
aws-sdk-s3 = "0.8"
aws-sig-auth = "0.8"
aws-sigv4 = "0.8"
aws-smithy-client = "0.38"
aws-smithy-http = "0.38"
aws-smithy-http-tower = "0.38"
aws-types = { version = "0.8", features = ["hardcoded-credentials"] }

同时还删除了一大堆 AWS SDK Middleware 相关的代码,非常的痛快。

社区的小伙伴正在 PR feat: Add support for azure storage 中尝试实现 Azure Storage 服务的支持,我也计划在 reqsign 中实现 OAuth2 的支持,并完善集成测试,保证 reqsign 生成的签名结果与官方的 SDK 相符。

欢迎大家一起来贡献和完善 reqsign,走过路过点个赞吧,哈哈~


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK