고성능 무비용 서버

620KB 서버 이미지 빚기

일반적으로 서버를 배포하는 과정은 병목이 아니며, 병목이 되어서도 안 된다. 개발자가 서버를 작성하고 클러스터에 띄우는 과정은 극단적으로 간편해야 한다. 물론 적절한 인프라가 갖춰져 있을 때 그렇다는 말이다. 인프라는 공짜가 아니다.

가령 클러스터는 숨만 쉬어도 달에 70$는 요구하는 비싼 자원이다. 실제 돌아갈 인스턴스까지 고려하면 월 100$는 생각해야 한다. 컨테이너 배포가 필요하다면 ECS (opens in a new tab)는 어떤가? 단순하며 AWS managed에 클러스터 비용을 별도로 지불하지 않아도 되니, k8s보다는 나은 듯 싶다. 그러나 load balancer와 서비스 당 적어도 두 대의 인스턴스를 요구하니 경험상 최소 월 50$는 필요하다. 이 마저도 트래픽이 적은 서비스 (opens in a new tab)를 운영할 땐 낭비다.

RPS가 아니라 RPH나 RPD를 따져야 하는 규모에서는 서버리스 솔루션이 적절하다. AWS Lambda (opens in a new tab)를 선택하자. 다만 Lambda 백엔드를 선택하겠다면 애매한 지점이 생긴다. 유행하는 Remix (opens in a new tab)Next (opens in a new tab) 따위의 기본적인 백엔드 기능을 제공하는 프레임워크로 어플리케이션을 작성했을 때, 결국 코드가 돌아가는 물리적 위치는 Lambda가 될 공산이 크기 때문 (opens in a new tab)이다.

어플리케이션이 이미 풀 스택 프레임워크로 작성되어 있는 시점에서 Lambda 백엔드를 별도로 두는 것은 어색한 선택일 수 있다. 같은 프로젝트 안에서 Type을 공유 (opens in a new tab)해가며 개발하는 것이 대세인데 그런 편의성을 포기할 필요가 있는가?

가장 큰 문제는 성능이다. Node.js 런타임은 무겁다. Static page를 포함해 렌더링에 필요한 자원들은 Cloudfront에서 빠르게 가져올 수 있다. Lambda의 응답을 받기 전에 페이지를 그릴 수 있는 것이다. 그래서 Vercel로 배포된 어플리케이션들은 체감상 몹시 빠르다. 그러나 백엔드는 그렇지 않다. Cold start를 맞으면 꼼짝없이 기다려야 한다.

백엔드 Lambda와 프론트 Lambda는 다르게 묶여 배포되기 때문에, 프론트 Lambda의 호출이 백엔드 Lambda의 cold start에 영향을 주지도 못한다. 때문에 간단한 로그인 과정조차 지나치게 오래 기다려야 하는 경우가 빈번하게 생긴다. 아무리 작은 서비스라지만 사용자에게 그러한 경험을 주고 싶지는 않다.

그렇다면 결국 Lambda를 사용하며 cold start를 최소화 하는 방법을 꾀해야 한다. 별도의 런타임이 필요한 javascript, python, kotlin은 즉시 아웃이다. 결국 binary만 있으면 되는 rustgo 정도로 선택지가 좁혀지는데, 둘의 cold start 시간은 대강 비슷하다. rust가 근소하게 빠르니 이를 선택하겠다.

Lambda

간단한 rust 함수를 Lambda로 배포할 요량이라면 cargo-lambda (opens in a new tab)를 쓰면 편리하다. Lambda의 linux 환경에 rust binary를 올려주는데, 빠른 구현이 필요하다면 이만한 조합이 없다. 그러나 문제는 서버를 작성하고 싶으니 DB 연산이 필요하다는 것. rust 환경에서 가장 널리 쓰이는 ORM인 diesel (opens in a new tab)libpqlibmysql등의 native dependency를 필요로 한다. 이들은 Lambda 환경에 포함되어 있지 않으니 정적 링킹을 해 주어야 하는데, 이쯤 되면 docker 배포가 정신 건강에 유리하다.

diesel의 비동기 구현인 diesel-async (opens in a new tab)은 native dependency에 의존하지 않는다고 한다. 그러나 사실 이미 diesel로 구현을 마치기도 했고, 어차피 처음부터 docker 배포를 고려하는 편이 더 낫다. 서비스가 성장한다면 실제 서버를 사용하는게 더 저렴해지는 순간이 올 것이기 때문에 최대한 Lambda agnostic한 서버를 만들어야 좋다.

일단 cargo-lambda가 어떤 기능을 하는지 구경이라도 해보자. cargo lambda new는 다음과 같은 코드를 찍어낸다.

use lambda_http::{run, service_fn, Body, Error, Request, RequestExt, Response};
 
async fn function_handler(_event: Request) -> Result<Response<Body>, Error> {
    let resp = Response::builder()
        .status(200)
        .header("content-type", "text/html")
        .body("Hello AWS Lambda HTTP request".into())
        .map_err(Box::new)?;
    Ok(resp)
}
 
#[tokio::main]
async fn main() -> Result<(), Error> {
    run(service_fn(function_handler)).await
}

Lambda는 기본적으로 AWS 내부의 event를 처리하는 서비스다. Lambda를 촉발하는 서비스에 따라 Lambda가 받는 event (opens in a new tab)가 달라지는데, 우리는 물론 Api Gateway (opens in a new tab)를 상정한다. 로드 밸런서 (opens in a new tab), 내지는 Lambda의 신기능인 Function Url (opens in a new tab)일 수도 있다. Lambda가 http 요청을 받을 수 있는 세 가지 원천이라고 보면 된다. 그런 event의 모양새가 어떠한지 일일히 신경쓸 필요는 없다. lambda-http (opens in a new tab)가 그런 event들을 http::Request로 바꾸어 주기 때문이다. AWS가 직접 관리하는 crate이다.

function_handler에 필요한 로직을 구현하고 cargo lambda deploy 커맨드를 치면 Lambda가 만들어진다. 더할 나위 없이 간편하다. 그러나 우리는 함수 하나가 아닌 완전한 웹 서버를 구현하고 싶다. 이를 Lambda-lith 패턴이라고 부르는데, 대개 지양하는 방식이다. 원한다면 쪼개고 쪼개어도 되겠으나 MSA가 유용할 규모라면 애초에 k8s를 운영하는게 더 저렴할 것이다.

Axum

웹 프레임워크로는 axum (opens in a new tab)을 선택하자. tokio (opens in a new tab) 팀이 개발한 프레임워크인데, DX가 몹시 사랑스럽다. 다만 등장한지 얼마 지나지 않았기 때문에 예제가 상대적으로 적으니 감안하자. 불편하다면 전통적으로 쓰던 actix-web (opens in a new tab)이나 rocket (opens in a new tab)을 선택해도 좋다. axum 서버는 대강 다음과 같이 작성한다.

use axum::{routing::get, Router};
 
#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(|| async { "Hello, World!" }));
 
    axum::Server::bind(&"0.0.0.0:8000".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}

8000포트에서 대기하며 http 요청을 처리하는 전형적인 구성이다. 그러나 우리는 http 요청이 아닌 Lambda로 들어오는 event를 처리하기를 바란다. 앞서 말한 전처리가 필요한 시점이다. 이런 로직은 middleware로 구현하는게 합리적이다. 부지런한 누군가가 이미 구현해 놓은 듯 하니 (opens in a new tab) 게으른 우리는 가져다 쓰도록 하자.

use axum::{routing::get, Router};
 
#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(|| async { "Hello, World!" }));
 
    let app = tower::ServiceBuilder::new()
            .layer(axum_aws_lambda::LambdaLayer::default())
            .service(app);
 
    lambda_http::run(app).await.unwrap();
}

axum_aws_lambdaLambdaLayer를 사용한다. 이제 이 axum 서버는 훌륭한 Lambda로 기능할 것이나, 일반적인 웹 서버로서는 사용할 수 없다. Interoperability를 바라기도 하고, 일단 로컬에서 개발은 해야하니 다음과 같이 구현하는 것이 낫겠다.

use axum::{routing::get, Router};
 
#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(|| async { "Hello, World!" }));
 
    #[cfg(debug_assertions)]
    {
        axum::Server::bind(&"0.0.0.0:8000".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
    }
 
    #[cfg(not(debug_assertions))]
    {
        let app = tower::ServiceBuilder::new()
                .layer(axum_aws_lambda::LambdaLayer::default())
                .service(app);
 
        lambda_http::run(app).await.unwrap();
    }
}

debug 빌드에서는 일반적인 웹 서버로, release 빌드에서는 Lambda로 동작한다. 훗날 Lambda 종속성에서 벗어나고 싶다면 분기를 지우면 된다.

Optimize

서버를 작성했으니 빌드를 해 보자. Lambda는 요청이 들어올 때마다 ECR에서 이미지를 가져와 띄울 것이다. 그러니 Cold start 시간은 이미지의 크기와 비례할테다. 이미지에 넣을 binary의 크기에도 신경을 써야 한다.

min-sized-rust (opens in a new tab)라는 유명한 문서가 있다. rust binary의 크기를 줄이는 방법을 망라해 놓았는데 꽤나 재밌다. 개인적으로는 다음 설정을 무난하게 사용한다.

[profile.release]
strip = true
lto = true
codegen-units = 1
opt-level = "z"
panic = "abort"

opt-level 3은 속도에, z는 크기에 초점을 맞춘 최적화 레벨이다. release 빌드의 기본 레벨은 3이다. 지금과 같은 상황에서는 크기가 곧 속도이니 z를 선택하지만 사실 큰 차이는 없으리라 본다. 이 binary의 크기는 1.3M이다. 나쁘지 않다.

Containerize

물론 이렇게 만든 binary에는 필요한 dependency들이 포함되어 있지 않다. 이 binary가 standalone으로 기능하기 위해서는 정적 링킹이 필요하다고 했다. 이 역시 부지런한 누군가가 구현해 두었는데, 이 이미지 (opens in a new tab) 위에서 빌드를 진행하면 된다. 친절한 주석이 달려있으니 읽어보자. openssllibpq를 직접 빌드하고 정적 링킹을 위한 flag를 켠다. 물론 musl 기반이다.

FROM registry.gitlab.com/rust_musl_docker/image:stable-latest AS builder
WORKDIR /app
COPY . .
RUN cargo build --release --target x86_64-unknown-linux-musl

이제 diesel을 추가한다면 필요한 dependency들이 전부 binary에 포함될 것이다. musl에 의존했을 때 성능 하락이 있다는 관찰 (opens in a new tab)이 있으니 유의하자. 그러나 말했듯 Lambda에서 크기는 곧 속도이니 감수해야 한다.

이제 이를 경량 이미지에 담아보자. 무난한 선택은 alpine이나, 어차피 잠깐 살다 죽을 운명인 Lambda에겐 이마저도 사치다. scratch를 선택하자.

FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/rust-on-lambda /run
EXPOSE 8000
CMD ["./run"]

이미지의 크기는 0.85mb가 되었다. 물론 압축된 결과다. 일반적인 웹 서버 이미지의 크기가 수백 MB 단위까지 올라가는 것을 생각하면 상당히 근사한 사이즈다. 물론 로직을 추가하면 더 커지겠지만 10MB를 넘기기는 쉽지 않으리라 본다. 실제로 이렇게 운영중인 서버 이미지의 용량은 6MB를 채 넘기지 않는다. 여러 기능이 구현된 monolith인데도 말이다.

upx (opens in a new tab)binary를 압축하면 0.62mb까지 줄일 수 있다. 그러나 upx가 유발하는 오버헤드는 오히려 높은 확률로 cold start를 늘릴 것이다. 애초에 binary가 작기 때문에 이런 기행은 굳이 필요하지 않다. 그래도 차력쇼는 항상 재밌다.

아무튼 이제 웹 서버의 크기 때문에 늘어나는 cold start는 사실상 없다고 보아도 좋을 것이다. cold start 시점에서 응답을 받는데 300ms 정도가 걸리는데, 로컬에서 서버를 여는데 200ms 남짓의 시간이 필요하니 용납 가능한 정도다. 이후로는 꾸준히 40ms 정도를 기록한다. Fargate에서 30ms가 나오는걸 감안하면 약간 느리기는 하다.

scratch 이미지에는 우리의 binary를 제외하면 아무것도 없다는 점에 유의하자. 필요한 정보가 있다면 직접 넣어주어야 한다. 가령 https 통신을 위해서는 인증서 정보가 필요하다. Timezone 정보가 필요할 수도 있다. builder에서 전달해 주면 된다. 완전한 Dockerfile은 대강 이렇게 생겼다. 지나친 유난이라고 느껴진다면 그냥 alpine을 써도 된다.

FROM registry.gitlab.com/rust_musl_docker/image:stable-latest AS builder
WORKDIR /app
RUN DEBIAN_FRONTEND=noninteractive && \
    apt-get update && \
    apt-get install -y ca-certificates tzdata upx
COPY . .
RUN cargo build --release --target x86_64-unknown-linux-musl
RUN upx --lzma --best /app/target/x86_64-unknown-linux-musl/release/rust-on-lambda
 
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/rust-on-lambda /run
EXPOSE 8000
CMD ["./run"]

Conclusion

이제부터는 즐겁게 서버를 작성하면 된다. 간단한 CI 파이프라인은 이렇다 (opens in a new tab). Terraform 등으로 배포하여 관리할 수도 있긴 하지만 큰 의미는 없다고 본다. 이후로는 Function Url에 Cloudfront를 붙여 사용해도 되고 Api Gateway에 붙여 써도 된다. 계속 강조하듯 비용 교차점이 발생한다면 유연한 전환이 가능하다. Lambda를 서버로서 사용할 때 항상 고려해야 하는 지점이라고 본다.

물론 비용이 발생하는 부분은 서버 뿐만이 아니다. 가령 데이터베이스나 캐시는 어떤가? AWS managed 서비스를 사용하겠다면 적어도 각각 월 20$는 필요하다. 사실 아무리 작은 서비스여도 RDS 정도에는 비용을 지불할 가치가 있다고 본다. 그러나 당장 20$도 아쉽다면 supabase (opens in a new tab)planetscale (opens in a new tab)을 이용하는 것도 방법이겠다. 생각보다 관대한 free-tier 서비스를 제공한다.

비슷한 맥락에서 캐시를 노리는 upstash (opens in a new tab)라는 서비스가 있으나 그렇게까지 호들갑을 떨 필요는 없다. 어차피 가장 가까운 서버가 싱가포르에 있기 때문에 있으나 마나다. DynamoDB (opens in a new tab)로 얼추 때우길 추천한다. 자주 쓰는 redis 커맨드를 모방해 놓으면 교체가 쉬워 좋다. 대강 이런 조합이 프론트엔드 사이드의 Vercel과 함께 Scaling to zero를 가능케 하는 가장 간단한 스택이라고 본다.

아쉬운 점도 많다. 가령 Lambda는 하나의 요청만 처리하기에 rust의 강력한 concurrency를 누리기 어렵다. 마찬가지로 커넥션 풀을 만들어 놓아도 의미가 없다. 아쉽지만 어쩌겠는가? 그것이 Lambda인 것을. rust의 잘못도, Lambda의 잘못도 아니다. 거의 무료로 이런 인프라를 누릴 수 있다는건 클라우드 시대의 축복이다. 감사히 사용하자.


Source code (opens in a new tab)