pingora로 로드밸런서 직접 구성하기

[원본 링크]

pingora는 Cloudflare가 기존의 프록시/로드밸런서 환경들이 너무 느리다고 Rust로 다시 만든 라이브러리다.
결과적으로 Cloudflare는 nginx 대비 CPU와 메모리 사용량을 70% 정도 절약할 수 있었고, 그 외에도 많은 성능 향상이나 비용 절감 효과를 많이 봤다고 한다. 인프라 회사이니 더 그럴 것이다.

이처럼 극적인 비용 효과를 원하고, 직접 인프라를 운영할 능력이 된다면 선택할만한 옵션인 것 같다.
그냥 웹서버 올리는 정도면 성능은 낮지만 더 좋은 선택지가 많다.

실행 가능한 바이너리 형태가 아니라 라이브러리 형태로 제공되기 때문에, 사용하려면 Rust를 알아야 한다.
여기서는 Rust를 안다고 가정하고 넘어가겠다.

그리고 Linux 환경을 사용하는 것을 권장한다. Linux가 아니라면 제한되는게 있고 좀 열받을 수 있다.




기본 설정

먼저 아래 crate 2개를 추가해준다.

async-trait="0.1"
pingora = { version = "0.1", features = [ "lb" ] }

async-trait은 표준에 추가되었기 때문에 아마 향후에는 없어질 것 같다.

좀 귀찮은 부분인데, 현재 pingora는 cmake에 종속성이 있다. 우선 깔아준다.

왜인지는 모르겠다.

그리고 다음과 같은 코드를 작성해보자.

이게 가장 기본적인 코드다.
이걸 실행하면 컴파일은 잘 되겠지만, 아무것도 할 수 없다. 아무것도 없기 때문이다.
하나씩 채워보자.




Cloudflare 라우팅해보기

Cloudflare는 1.1.1.1 같은 특이한 IP 대역을 독점하고 있는데, 이 IP들을 엔드포인트로 활용해서 로드밸런서를 구성해보겠다.

이 페이지다.

먼저 로드밸런서 타입을 정의한다.

밸런싱 알고리즘은 국룰인 라운드 로빈으로 하겠다.

그리고 async trait인 ProxyHttp를 구현해줘야 한다.
이게 크다.

upstream_peer는 말 그대로 각 peer, 그러니까 개별 서버에 분배를 해주는 핵심적인 역할을 정의한다.

원본 객체가 RoundRobin을 구성한 타입이기 때문에, 그냥 빈 문자열로 해시 없이 select해도 라운드 로빈으로 자동 선택이 된다.
그리고 SNI까지 cloudflare로 선택해서 피어를 반환해줬다. 그러면 클라이언트가 이걸 받아서 처리하는 것이다.


cloudflare를 SNI로 쓸때는 위 헤더를 설정해줘야 한다.
upstream_request_filter는 요청을 보내기 전에 가로채서 조작을 하는 용도로 쓴다.


그리고 이런식으로 아이피를 라우팅해서 실행해보자.
44444 포트로 열리도록 했다.

curl로 쏴보거나

curl 0.0.0.0:44444 -svo /dev/null

브라우저로 접속해보면

기대한대로 잘 동작할 것이다.


로그를 보아도 골고루 잘 분배해주는 것을 볼 수 있다.

아래는 테스트에 사용한 전체 코드다.

use std::sync::Arc;

use async_trait::async_trait;
use pingora::prelude::*;

// 로드밸런서 정의
pub struct LB(Arc<LoadBalancer<RoundRobin>>);

#[async_trait]
impl ProxyHttp for LB {
    // 간단한 예제라서 CTX를 사용하지 않습니다.
    type CTX = ();
    fn new_ctx(&self) -> () {
        ()
    }

    async fn upstream_peer(&self, _session: &mut Session, _ctx: &mut ()) -> Result<Box<HttpPeer>> {
        // 전송할 업스트림 선택
        let upstream = self
            .0
            .select(b"", 256) // round robin에서는 해시가 필요없음
            .unwrap();

        // 업스트림 정보 로깅
        println!("upstream peer is: {upstream:?}");

        // Set SNI to one.one.one.one
        let peer = Box::new(HttpPeer::new(upstream, true, "one.one.one.one".to_string()));
        Ok(peer)
    }

    async fn upstream_request_filter(
        &self,
        _session: &mut Session,
        upstream_request: &mut RequestHeader,
        _ctx: &mut Self::CTX,
    ) -> Result<()> {
        // cloudflare 1.1.1.1 요청에는 다음 헤더 설정이 필요합니다.
        upstream_request
            .insert_header("Host", "one.one.one.one")
            .unwrap();
        Ok(())
    }
}

fn main() {
    let mut my_server = Server::new(None).unwrap();

    // 피어 목록 정의
    let upstreams = LoadBalancer::try_from_iter(["1.1.1.1:443", "1.0.0.1:443"]).unwrap();

    let mut lb = http_proxy_service(&my_server.configuration, LB(Arc::new(upstreams)));

    // 엔드포인트 등록
    lb.add_tcp("0.0.0.0:44444");

    // 로드밸런서 등록
    my_server.add_service(lb);

    my_server.run_forever();
}



직접 IP에 라우팅해보기 (SNI 없이)

이번에는 로컬에다 도커로 서버 몇개 띄워서 라우팅을 해보겠다.
먼저 다음 명령을 사용해서 서버를 2개 띄운다. 내가 만들어둔 이미지다.

sudo docker run -p 44443:80 myyrakle/node-server-for-test:3
sudo docker run -p 44445:80 myyrakle/node-server-for-test:3

이렇게 각자 잘 떴다.
이 두놈한테 라우팅을 해보자.


이번에는 SNI를 적용하지 않을 것이기 때문에 그냥 피어를 바로 전달하고, SNI 옵션을 전부 껐다.


그리고 IP를 로컬 IP로 변경해서 넣어줬다.

그렇게 해서 실행해보면

잘 실행되고


분배도 잘 되는 것을 볼 수 있다.

아래는 전체 예제코드다.

use std::sync::Arc;

use async_trait::async_trait;
use pingora::prelude::*;

// 로드밸런서 정의
pub struct LB(Arc<LoadBalancer<RoundRobin>>);

#[async_trait]
impl ProxyHttp for LB {
    // 간단한 예제라서 CTX를 사용하지 않습니다.
    type CTX = ();
    fn new_ctx(&self) -> () {
        ()
    }

    async fn upstream_peer(&self, _session: &mut Session, _ctx: &mut ()) -> Result<Box<HttpPeer>> {
        // 전송할 업스트림 선택
        let upstream = self
            .0
            .select(b"", 256) // round robin에서는 해시가 필요없음
            .unwrap();

        // 업스트림 정보 로깅
        println!("upstream peer is: {upstream:?}");

        // Set SNI to one.one.one.one
        let peer = Box::new(HttpPeer::new(upstream, false, "".to_string()));
        Ok(peer)
    }
}

fn main() {
    let mut my_server = Server::new(None).unwrap();
    let upstreams = LoadBalancer::try_from_iter(["0.0.0.0:44443", "0.0.0.0:44445"]).unwrap();

    let mut lb = http_proxy_service(&my_server.configuration, LB(Arc::new(upstreams)));

    lb.add_tcp("0.0.0.0:44444");

    my_server.add_service(lb);

    my_server.run_forever();
}



헬스체크

근데 이제까지의 코드에는 문제가 많다. 아무런 보장이나 관리를 하지 않기 때문이다.

만약 서버가 하나 내려간다면

502가 떨어진다.
그럴 수 있다. 하지만 계속해서 이 상태를 유지해서는 안된다.
일반적인 범용 로드밸런서는 헬스체크를 통해 유효한 피어와 망가진 피어를 구분하고, 망가진 피어는 분배를 하지 않는다.

pingora는 이에 대한 기능도 조합형으로 제공을 한다.
아래와 같이 코드를 약간만 작성해주면 된다.

fn main() {
    let mut my_server = Server::new(None).unwrap();
    my_server.bootstrap();

    let mut upstreams = LoadBalancer::try_from_iter(["0.0.0.0:44443", "0.0.0.0:44445"]).unwrap();

    // health check 설정
    let hc = TcpHealthCheck::new();
    upstreams.set_health_check(hc);
    upstreams.health_check_frequency = Some(std::time::Duration::from_secs(1));

    let background = background_service("health check", upstreams);
    let upstreams = background.task();

    let mut lb = http_proxy_service(&my_server.configuration, LB(upstreams));
    lb.add_tcp("0.0.0.0:6188");

    // health check 백그라운드 서비스 추가
    my_server.add_service(background);

    my_server.add_service(lb);
    my_server.run_forever();
}

그러면 피어 목록에 대해서 검증을 다 한다.

만약 B 서버가 터져도 A가 실행이 되면

그쪽으로 유지를 한다. 44445 포트로의 분배가 없는 것을 볼 수 있다.

이 상태에서 44445 포트 서버를 다시 되살리면

헬스체크가 통과되고 다시 분배를 해준다.




CLI 옵션 사용하기

pingora는 자체적으로 CLI를 통한 옵션 부여 기능을 제공한다.

설정은 간단하다. 서버 객체에 저거 하나만 넣어주면 된다.

그러면 이렇게

플래그를 넘기면서 실행할 수 있다.




CLI: 백그라운드 실행

-d 옵션을 사용하면 자동으로 백그라운드 실행이 된다.

잘 뜬다.

백그라운드 로드밸런서를 다시 죽이려면 pkill을 사용하면 된다.

pkill -SIGTERM 프로세스명




CLI: 설정파일 사용

pingora는 yaml 설정파일을 통한 구성 부여도 제공한다.
이런식으로 파일 하나 두고

---
version: 1
threads: 2
pid_file: /tmp/load_balancer.pid
error_log: /tmp/load_balancer_err.log
upgrade_sock: /tmp/load_balancer.sock

실행할때 c 옵션으로 넘겨주기만 하면 된다.

RUST_LOG=INFO cargo run -- -c conf.yaml -d

그럼 잘 실행되고

설정도 잘 적용되는 것을 볼 수 있다.




무중단 교체

당연한 말이지만, 로드밸런서는 교체 주기가 잦은 편이다.
피어는 언제든 추가되거나, 줄어들거나, 교체될 수 있기 때문이다. 근데 그럴때마다 서버 다시 띄운다고 다운타임을 만들 수는 없다.

때문에 당연히 pingora는 무중단 교체 기능을 제공한다.
다음과 같이 SIGQUIT 신호로 kill을 날리고, 동시에 재실행을 날리면 무중단으로 교체가 된다.

pkill -SIGQUIT 프로세스명 &&\
RUST_LOG=INFO cargo run -- -c conf.yaml -d -u

이 명령은 다음의 과정을 통해 무중단을 달성한다.

  1. 여기서 -SIGQUIT을 받은 old 프로세스는 listening sockets을 닫지 않은 채로 일단 실행을 한다.
  2. 예전에 받은 요청은 old 프로세스가 처리한다.
  3. 새로 뜬 new 프로세스는 old 프로세스에게서 listening sockets을 전달받는다.
  4. 이때부터는 new 프로세스가 새로운 요청들을 받아서 처리한다.
  5. old 프로세스가 맡은 요청들을 전부 끝냈다면, old 프로세스는 종료된다.

교체되는 찰나의 순간에는 약간의 버벅임이 있을 수도 있겠지만, listening sockets이 닫히지 않고 유지되기 때문에 client의 입장에서는 완벽한 무중단으로 보인다.



참조
https://blog.cloudflare.com/pingora-open-source
https://github.com/cloudflare/pingora/blob/main/docs/quick_start.md
https://blog.cloudflare.com/how-we-built-pingora-the-proxy-that-connects-cloudflare-to-the-internet