Workers는 Cloudflare사의 서버리스 컴퓨팅 서비스로, 전세계의 수많은 Cloudflare Edge 네트워크를 통해 빠른 반응속도로 응답할 수 있는 매력적인 서비스다. Workers 코드를 Rust로 작성해보면서 느낀 점을 공유해보고자 한다.

Disclaimer

이 포스트가 작성된 시점에서 workers-rs 패키지의 버전은 0.0.6으로, 빠진 기능이 많고 API도 계속해서 추가/변경되고 있다. 이후에는 이 포스트의 내용이 잘 맞지 않을 수 있으니 실제 documentation을 확인하기 바란다.

서버리스 컴퓨팅?

서버리스 컴퓨팅은 특정 서버에 코드를 올려놓고 요청을 처리하는 것이 아니라, 말 그대로 어떤 서버에 종속되지 않은(serverless) 코드를 배포하여 필요할 때마다 요청을 처리하는 기술이다.

기존의 서버-종속적인 컴퓨팅 모델과 비교해 서버리스 컴퓨팅은 다음과 같은 이점을 가진다.

  • 비용 절감: 대부분의 서버리스 서비스는 해당 코드가 ‘실제로 사용한’ 자원(CPU, 메모리, 네트워크)에 대해서만 요금을 부과한다. 기존의 서버-종속적인 컴퓨팅 모델은 요청이 들어오지 않아도 해당 코드가 서버를 계속 점유하고 있어야 하지만, 서버리스 컴퓨팅의 경우에는 요청이 들어올 때만 대응되는 코드를 실행하고 남는 시간에는 다른 요청에 대해 코드를 실행하는 식으로 자원을 공유할 수 있기 때문에 운영사 입장에서는 쓴 만큼만 요금을 부과할 수 있다.

  • 자동 스케일 조정: 특정 요청이 몰릴 때 더 많은 서버에서 같은 코드를 실행하도록 자동적으로 확장/축소할 수 있다.

  • 개발 기간 단축: 대부분의 서버리스 서비스는 REST 엔드포인트처럼 동작하므로 HTTP 서버를 구성하거나 하는 시간적 비용을 줄일 수 있다. 또한 서버리스 코드는 태생적으로 ‘stateless’하기도 하므로(하지만 workers에서는 stateless가 아니긴 하다. :P) 프로그래머는 로직과 상태를 분리할 수 있게 된다.

Cloudflare Workers

Workers를 사용하면 기존 서버리스 서비스인 AWS Lambda, GCP Functions와 비교하여 다음과 같은 장점이 있다고 한다.

  • 타사에 비해 폭넓은 에지 수: Cloudflare의 데이터센터가 더 많으니 전세계 범위에서 평균적인 레이턴시가 더 적다고 주장한다. (자기 나라에 있을 확률이 높으니까)
  • Cold start 없음: 타사 서비스는 일정 시간동안 코드가 실행되지 않으면 다음의 첫 실행이 오래 걸리는데 Workers의 경우 이러한 문제가 없다고 한다.
  • Rust 공식 지원

특히, (무료 제공량 기준) CPU time으로 요청당 50ms씩 제공한다는 사실이 특이했다. 다른 서비스와 달리 I/O 시간은 측정하지 않겠다는 뜻이다.

How Cloudflare Workers Works

(From https://developers.cloudflare.com/workers/learning/how-workers-works#isolates)

구조상으로 특기할 점은, 수행하려는 코드마다 프로세스를 띄우는 다른 서버리스 서비스와 달리 Workers는 V8 런타임을 공유하되 서로 격리된 공간 안에서 여러 코드를 동시에 실행한다는 것이다. 이를 통해 startup 시간을 대폭 줄이고 CPU time에 대해서만 과금할 수 있는 것으로 추정된다. (I/O 중에는 async하게 다른 코드를 실행하면 낭비가 없으므로)

workers-rs

crates.io

Cloudflare Workers의 Rust SDK이다. 다음과 같은 기능을 지원한다.

Routing

Workers 코드는 일종의 웹 서버처럼 동작하기 때문에 기존의 백엔드 코드와 비슷한 모양새를 갖추게 된다.

use worker::*;


#[event(fetch)]
pub async fn main(req: Request, env: Env) -> Result<Response> {
    let router = Router::new();

    console_log!("{:?}", &req); // console.log로 출력된 로그는 Workers Dashboard에 실시간으로 표시된다.

    router
        .get_async("/:foo/:bar", handler) // foo, bar argument를 캡쳐할 수 있다
        .run(req, env)
        .await
}

async fn handler(req: Request, ctx: RouteContext<()>) -> Result<Response> {
    if let Some(key) = ctx.param("foo") { // Path argument `foo`를 가져옴
        Response::ok("Hello".to_string())
        // .with_headers(headers) // Response header도 설정할 수 있다.
    }
}

Workers KV

GET/PUT/DELETE를 지원하는 간단한 KV 스토리지다. Workers에 배포된 코드는 전세계의 엣지에서 실행되기 때문에 KV도 완벽한 일관성(consistency) 을 보장하지 않는다. 모든 엣지에 변경사항이 전파되는 데 최대 1분 걸릴 수 있다고 한다. KV의 value에는 만료 시간(TTL)과 메타데이터를 추가로 지정할 수 있다.

let kv = ctx.kv("foo")?;
kv.put(&hashed_key, body)?
    .expiration_ttl(ttl)
    .execute();

Durable Objects

Workers 코드에 state를 부여할 수 있는 방법이라고 하는데, 아직은 사용해보지 않았다(결정적인 이유로 KV와 다르게 이쪽은 free plan이 없다. miniflare와 같은 도구로 로컬에서 시험해볼 수는 있어보인다.)

토이 서비스: 메이플스토리 설정 연동 (https://pc.cro.sh)

pc.cro.sh preview

Rust SDK를 사용한 프로젝트로 메이플스토리 게임의 설정을 저장하여 업로드/다운로드하는 간단한 서비스를 제작하였다. 메이플스토리는 게임 설정을 HKLM\SOFTWARE\WOW6432Node\Wizet\MapleStory 레지스트리 키 아래에 모두 저장하기 때문에 Windows의 reg export 명령어로 쉽게 추출이 가능하다. 주 목적은 PC방에서 추출한 설정을 다운로드받아서 게임을 켜기 전에 설정을 연동해두는 것이다.

소스코드는 Github public repo로 공개되어 있다. https://github.com/cr0sh/pc.cro.sh

내부 로직: Rust (Webassembly)

최대한의 보안성을 위해 KV상에 저장/다운로드될 게임 설정은 클라이언트단에서 암호화/복호화를 수행해야 한다. 이는 최대한의 성능을 위해 Rust로 작성하여 Webassembly로 컴파일하여 프론트엔드에 탑재하였다.

프론트엔드: React(TSX/Functional Components) + Material-UI 4.0

Typescript도 복습할 겸 리액트로 프론트엔드를 구성했다. UI에는 기존에 사용해봤던 MUI를 사용해서 무난한 look-and-feel을 구성했다. 다음과 같은 일을 수행한다.

  • 업로드 폼/다운로드 폼 구성
  • 업로드할 설정 파일 검증 및 암호화(AES-256)
  • Fetch API를 사용하여 업로드/다운로드(reqwest 사용)
  • 다운로드한 설정 파일 복호화 및 옵션 조정
    • 최근에 메모리 할당량 설정이 추가되었는데, 이는 사용하려는 PC의 사양에 맞춰 설정해야 하므로 다운로드 UI에 포함시켜 자동으로 조절하도록 하였다.
    • Device Memory API를 사용하면 대략적인 컴퓨터의 메모리 크기를 추측할 수 있어 도움이 된다.
      • 아쉽게도 Firefox/IE에서는 지원하지 않는다.

시범적으로 Google의 reCAPTCHA v3도 적용해보았는데, react-google-recaptcha-v3 패키지를 사용하니 정말 간단하게 적용할 수 있었다.

백엔드: workers-rs

Workers SDK를 시험해보는 게 목적이니 당연히 위에 설명된 worker가 기반이 된다.

  • PUT 리퀘스트 처리: reCAPTCHA 토큰을 검증하고 KV에 주어진 설정 파일을 저장한다.
  • GET 리퀘스트 처리: reCAPTCHA 토큰을 검증하고 KV에서 주어진 설정 파일을 가져온다.

클라이언트단에서 대부분의 로직을 담당하기 때문에(보안성을 위해), 백엔드가 할 일은 사실상 단순한 KV API의 래핑 처리일 뿐이다. 그러나 workers-rs가 아직 구현이 덜 되어있어서 다음과 같은 추가적인 처리가 필요했다.

  • CORS 고려가 되어있지 않아 보인다. CORS preflight(HTTP OPTIONS 리퀘스트)에 대한 핸들링이 필요하고 응답 시에 헤더에도 Allow-Origin 헤더를 적절히 넣어줘야 한다.

trait Cors {
    fn cors(self, req: &Request) -> Result<Response>;
}

impl Cors for Response {
    fn cors(self, req: &Request) -> Result<Response> {
        static ALLOWED_ORIGINS: &[&str] = &["http://localhost:3000", "https://pc.cro.sh"];

        let mut cors_header = Headers::new();
        if let Some(origin) = req.headers().get("Origin")? {
            if ALLOWED_ORIGINS.iter().find(|&&x| x == origin).is_some() {
                cors_header.append("Access-Control-Allow-Origin", &origin)?;
            }
        }

        Ok(self.with_headers(cors_header))
    }
}

// Usage example: Response::error("Bad Request", 400)?.cors(&req)
  • 문제는 이것이 사용자 코드 레벨에서 처리되었기 때문에 unhandled exception 등이 발생하면 응답 헤더의 래핑이 되지 않고 브라우저 입장에서 CORS 요청이 실패했다는 결과만 받게 된다. Catch-all 패턴 등을 통해 추가적으로 코드를 감싸야 하는 것이다.

  • 또한, KV에 대응되는 API(worker-kv crate)가 아직 미완성이다. Text payload만 지원하기 때문에 넣고 뺄 때 base64 인코딩을 거쳐야 했다. 거치지 않고 직접 바이너리 페이로드를 집어넣으려면 직접 js_sys 바인딩을 통해 raw API에 접근해야 한다.

    • Update: worker-kv 0.4.0에서 [u8] 타입도 지원하는 것으로 보인다.

후기

아직 workers-rs는 v0.1도 달성하지 않았기 때문에 개선의 여지가 많다. 앞서 언급한 엣지 케이스들에 대한 처리가 아직 완벽하지 않기 때문에 당장 실사용하기에는 어렵지만, 빠르게 발전하고 있기 때문에 앞으로 Rust로 강력한 서버리스 서비스를 제작할 수 있는 선택지가 하나 더 늘어나기를 기대한다.

개인적인 의견으로 네이티브하게 Rust 코드가 실행되는 것이 아니라 Webassembly로 컴파일되어 동작하기 때문에 CPU-bound한 로직의 성능을 최대한 뽑기 위해 사용하는 목적보다는(특히 무료 버전에서는 CPU-time을 아주 짧게 제공하는 대신 I/O 등에 걸리는 시간을 계산하지 않는 모델임을 생각해보자) 기존의 Rust 코드베이스를 클라우드로 이전하기 쉽다는 점을 포인트로 잡고 홍보해야 할 것 같다. 이런 방향에서는 기존의 Actix/Warp/Tide와 같은 웹 프레임워크에 대한 migration을 최대한 잘 지원해야 할 것이다.