1/10의 비용으로 노드 운영자를 위한 제로 인증 다운로드 지원

Sui 의 네트워크 스냅샷을 위한 리포지토리로 Cloudflare의 R2 서비스를 추가하여 800 기가바이트 파일을 제공하는 비용을 크게 절감했습니다.

1/10의 비용으로 노드 운영자를 위한 제로 인증 다운로드 지원

Sui 네트워크에서 실행되는 검증자와 풀 노드는 높은 처리량과 확장 가능한 블록체인을 제공하기 위해 최고 수준의 안정성과 가동 시간을 갖춰야 합니다. 스테이트풀 애플리케이션을 안정적으로 실행하는 데 있어 중요한 부분은 하드웨어 장애 조치를 비교적 쉽게 수행할 수 있도록 하는 것입니다. 디스크가 고장 나거나 다른 유형의 정전이 검증자를 실행하는 시스템에 영향을 미치는 경우, 모든 체인 기록을 다시 처리할 필요 없이 검증자를 쉽게 마이그레이션할 수 있는 방법이 있어야 합니다. 

원활한 장애 조치를 보장하기 위해 스냅샷이 필요합니다. 상태 스냅샷은 Sui 네트워크에서 공식 및 데이터베이스의 두 가지 형태로 제공됩니다. 공식 스냅샷은 에포크가 끝날 때 모든 검증인 합의 정보를 구성하는 최소한의 상태를 포함합니다. 데이터베이스 스냅샷은 노드 데이터베이스의 전체 사본입니다. 

스냅샷은 쉽고 안정적으로 액세스할 수 있는 곳에 저장하지 않으면 그다지 유용하지 않습니다. Sui 네트워크 제네시스에서 스냅샷을 업로드하기 시작할 때, Amazon Web Services(AWS)의 S3는 Sui 네트워크의 초기 노드 운영자들과 공유할 수 있는 안정적인 스토리지 백엔드로서 완벽한 선택이었습니다. Mysten Labs는 모든 노드 운영자가 풀 노드 또는 검증자를 빠르게 동기화하고 네트워크에 도입하는 데 사용할 수 있는 공개 스냅샷을 S3에서 호스팅하기 시작했습니다. 

그러나 상태 스냅샷의 공개 다운로드를 허용하는 S3 버킷을 호스팅하는 간단한 작업은 예상보다 더 고통스러운 사용자 경험으로 드러났습니다. 상태 스냅샷의 형식은 단일 스냅샷에 포함된 많은 파일을 다운로드하기 위해 AWS 명령줄 인터페이스(CLI)를 사용해야 합니다. AWS CLI를 호출할 때 연결할 기존 AWS 자격 증명 세트가 없는 경우, 문서로 간단히 설명된 AWS CLI의 주문을 사용해야 합니다: aws s3 cp --no-sign-request

사용자 문제 외에도 Sui의 스냅샷은 기하급수적인 속도로 증가하고 있었습니다. 처리량이 매우 높은 블록체인인 Sui 에서 생성되는 데이터의 양은 거의 전례가 없을 정도입니다. 상태 스냅샷을 다운로드할 때마다 800기가바이트가 넘는 데이터를 S3에서 노드 운영자의 호스트로 가져오는 데 몇 시간이 걸렸습니다.

AWS S3 가격 책정의 수학에 익숙한 독자라면 다음과 같은 공공재에 대해 잘 알고 있을 것입니다. s3://mysten-mainnet-snapshots 는 빠르게 비싸지기 시작했습니다. S3는 S3 외부로 전송되는 데이터에 대해 기가바이트당 요금을 부과합니다. 대부분의 운영자가 AWS 외부에서 노드를 실행하고 있기 때문에 Mysten Lab의 스냅샷 버킷에도 이 요금이 적용됩니다. 이 퍼블릭 리소스를 호스팅하는 데 매달 5자리 숫자의 비용이 빠르게 증가했습니다.

상태 스냅샷을 유효성 검사기와 전체 노드에 제공한 결과, AWS S3에서 매일 40테라바이트에 가까운 데이터가 유출되었습니다.

Cloudflare는 최근 S3의 경쟁자인 R2를 발표했는데, 이그레스 비용이 0원이라는 독특한 가격 모델을 선보였습니다. 이는 정기적으로 가져오는 데이터 집합을 호스팅하는 데 매우 적합하며, S3보다 큰 장점입니다. 

전체 마이그레이션을 하는 대신, 저희는 S3의 대체 소스로 R2를 추가하고 S3를 요청자 지불 모델로 전환하기로 결정했습니다. S3는 글로벌 전송 가속과 같은 뛰어난 성능과 기능을 갖추고 있어 포기하고 싶지 않았기 때문입니다. 이번 마이그레이션에서 가장 중요한 부분은 R2에 쓰도록 도구를 조정하는 것이 아니라(R2는 S3 API와 호환됨), R2에서 쉽게 읽을 수 있도록 Sui 애플리케이션을 수정하는 것이었습니다. 

R2에서 무허가 다운로드 지원

사용자에게 R2에 대해 AWS CLI를 사용하도록 요청하는 것은 허용되지 않는 경험이었고, 대신 사용자가 툴링을 통해 sui.io 에 접속하여 인증 없이도 호스팅된 파일을 읽을 수 있습니다(누구나 Sui 풀 노드를 최대한 쉽게 실행할 수 있도록 하기 위해 인증 옵션이 없는 것이 중요했습니다). 

AWS S3 요청 서명은 Amazon S3 리소스와 안전하게 상호 작용하는 데 있어 중요한 측면입니다. 오브젝트 업로드, 파일 다운로드, 오브젝트 나열 또는 기타 작업 등 S3 버킷에 요청을 할 때는 요청의 진위 여부와 무결성을 보장하기 위해 요청에 서명해야 합니다. 이 과정에는 일반적으로 사용자의 액세스 키(액세스 키 ID, 비밀 액세스 키)로 서명하여 인증된 http 요청을 생성하는 것이 포함됩니다. 

공개적으로 액세스할 수 있는 파일이나 객체의 경우, 대부분의 클라우드 제공업체에서 리소스 읽기(리스팅은 제외) 요청에 대한 서명을 기술적으로 우회할 수 있지만 저희가 사용하고 있는 Rust 객체 저장소 라이브러리에서는 지원되지 않습니다. 따라서 저희는 코드베이스에 이 지원을 추가하기로 결정했습니다. 저희는 제로 인증 지원을 추가하되, 사용자가 서명된 요청(버킷에 요청자 지불 모드가 활성화되어 있으므로)이 있는 S3에서 스냅샷을 복원하거나 서명하지 않고 R2에서 스냅샷을 복원하는 옵션을 제공하고자 했습니다. 이를 깔끔하게 수행하기 위해 먼저 코드베이스에서 일반적인 개체 저장소 작업에 대한 추상화를 선언했습니다:

#[async_trait]
pub trait ObjectStoreGetExt: std::fmt::Display + Send + Sync + 'static {
   /// Return the bytes at given path in object store
   async fn get_bytes(&self, src: &Path) -> Result<Bytes>;
}

#[async_trait]
pub trait ObjectStoreListExt: Send + Sync + 'static {
   /// List the objects at the given path in object store
   async fn list_objects(
       &self,
       src: Option<&Path>,
   ) -> object_store::Result<BoxStream<'_, object_store::Result<ObjectMeta>>>;
}

#[async_trait]
pub trait ObjectStorePutExt: Send + Sync + 'static {
   /// Write the bytes at the given location in object store
   async fn put_bytes(&self, src: &Path, bytes: Bytes) -> Result<()>;
}

#[async_trait]
pub trait ObjectStoreDeleteExt: Send + Sync + 'static {
   /// Delete the object at the given location in object store
   async fn delete_object(&self, src: &Path) -> Result<()>;
}

서명된 구현과 서명되지 않은 구현 사이를 깔끔하게 전환하기 위해 사용 함수도 약간 수정해야 했습니다:

/// Read object at the given path from input store using either signed or 
/// unsigned store implementation
pub async fn get<S: ObjectStoreGetExt>(store: &S, src: &Path) -> Result<Bytes>;

/// Returns true if object exists at the given path
pub async fn exists<S: ObjectStoreGetExt>(store: &S, src: &Path) -> bool;

/// Write object at the given path. There is no unsigned put implmenetation
/// because writing an object requires permissioned user signing requests
pub async fn put<S: ObjectStorePutExt>(store: &S, src: &Path, bytes: Bytes) -> Result<()>;

그런 다음 위 특성의 서명된(이미 사용하던 객체 저장소 라이브러리로 되돌아가서) 및 서명되지 않은(개별 클라우드 제공업체의 REST API를 활용하여) 구현을 구현했습니다: 

/// Implementation for making signed requests using object store lib
#[async_trait]
impl ObjectStoreGetExt for Arc<DynObjectStore> {
   async fn get_bytes(&self, src: &Path) -> Result<Bytes> {
       self.get(src)
           .await?
           .bytes()
           .await
           .map_err(|e| anyhow!("Failed to get file: {} with error: {}", src, e.to_string()))
   }
}

/// Implementation for making unsigned requests to [Amazon 
/// S3](https://aws.amazon.com/s3/).
#[derive(Debug)]
pub struct AmazonS3 {
   /// Http client wrapper which makes unsigned requests for S3 resources
   client: Arc<S3Client>,
}

#[async_trait]
impl ObjectStoreGetExt for AmazonS3 {
   async fn get_bytes(&self, location: &Path) -> Result<Bytes> {
       let result = self.client.get(location).await?;
       let bytes = result.bytes().await?;
       Ok(bytes)
   }
}

/// Implementation for making unsigned requests to [Google Cloud 
/// Storage](https://cloud.google.com/storage/).
#[derive(Debug)]
pub struct GoogleCloudStorage {
   /// Http client wrapper which makes unsigned requests for gcs resources
   client: Arc<GoogleCloudStorageClient>,
}

#[async_trait]
impl ObjectStoreGetExt for GoogleCloudStorage {
   async fn get_bytes(&self, location: &Path) -> Result<Bytes> {
       let result = self.client.get(location).await?;
       let bytes = result.bytes().await?;
       Ok(bytes)
   }
}
pub struct ObjectStoreConfig {
  /// Which object store to use i.e. S3, GCS, etc
  #[serde(skip_serializing_if = "Option::is_none")]
  #[arg(value_enum)]
  pub object_store: Option<ObjectStoreType>,
  /// Name of the bucket to use for the object store. Must also set
  /// `--object-store` to a cloud object storage to have any effect.
  #[serde(skip_serializing_if = "Option::is_none")]
  #[arg(long)]
  pub bucket: Option<String>,
  #[serde(default)]
  #[arg(long, default_value_t = false)]
  pub no_sign_request: bool,
  ...
}

impl ObjectStoreConfig {
  pub fn make_signed(&self) -> Result<Arc<DynObjectStore>, anyhow::Error> {
    match &self.object_store {
      Some(ObjectStoreType::File) => self.new_local_fs(),
      Some(ObjectStoreType::S3) => self.new_s3(),
      Some(ObjectStoreType::GCS) => self.new_gcs(),
      _ => Err(anyhow!("At least one backed is needed")),
    }
  }
}

pub trait ObjectStoreConfigExt {
   fn make_unsigned(&self) -> Result<Arc<dyn ObjectStoreGetExt>>;
}

impl ObjectStoreConfigExt for ObjectStoreConfig {
   fn make_unsigned(&self) -> Result<Arc<dyn ObjectStoreGetExt>> {
       match self.object_store {
           Some(ObjectStoreType::S3) => {
               let bucket_endpoint = { };
               Ok(AmazonS3::new(&bucket_endpoint).map(Arc::new)?)
           }
           Some(ObjectStoreType::GCS) => {
               let bucket_endpoint = { };
               Ok(GoogleCloudStorage::new(&bucket_endpoint)).map(Arc::new)?)
           }
           _ => Err(anyhow!("At least one backend is needed")),
       }
   }
}

위의 모든 사항을 갖추고 나면 사용자가 제공한 구성에 따라 서명된 구현과 서명되지 않은 구현 간에 깔끔하게 전환할 수 있습니다:

let store: Arc<dyn ObjectStoreGetExt> = if store_config.no_sign_request {
  store_config.make_unsigned()?
} else {
  store_config.make_signed().map(Arc::new)?
}; 

제로 인증 스냅샷 다운로드를 지원한다는 목표에 거의 근접했지만 아직 거기에 도달하지는 못했습니다. 마지막 과제는 서명 요청 없이 R2 버킷에 파일을 나열할 수 있는 수단이 없다는 것이었습니다(S3에서는 서명되지 않은 공개 목록 액세스를 허용할 수 있음). 그리고 파일을 다운로드하기 전에 RocksDB 스냅샷 디렉토리에 파일을 나열해야 했습니다. 저희는 스냅샷 생성 과정에서 모든 파일 경로가 포함된 매니페스트 파일을 추가하여 이 문제를 해결했습니다. 이 매니페스트는 이제 스냅샷 디렉터리에 있는 모든 파일과 그 상대 경로에 대한 진실의 출처가 됩니다.

최종 결과

결국 R2를 기본 스냅샷 다운로드 옵션으로 허용함으로써 이러한 스냅샷을 제공하는 비용을 70~80%까지 절감할 수 있었고, Sui 네트워크 내에서 노드를 시작하고 장애 조치하는 데 필요한 장벽을 낮출 수 있었습니다.

참고: 이 콘텐츠는 일반적인 교육 및 정보 제공 목적으로만 제공되며 자산, 투자 또는 금융 상품의 구매, 판매 또는 보유에 대한 보증이나 추천으로 해석하거나 의존해서는 안 되며 재무, 법률 또는 세무 자문으로 간주되지 않습니다.