Rust , Discord ,

왜 Discord가 Go에서 Rust로 전환했는지

by Mimul FollowFebruary 05, 2020 · 8 min read · Last Updated:
Share this

최근 Rust 언어에 이끌려 공부를 좀 하고 있는데, Rust의 장점이라든가 어떤 곳에서 필요한지 알 수 있는 좋은 아티클 "Why Discord is switching from Go to RustWhy Discord is switching from Go to Rust"란 포스트가 있어서 번역해 보았습니다. 언어란 것은 원래 목적과 장점에 따라 선택하는 것인데, 혹시 이 글을 보고 혹시 좋고 나쁨의 이분법적인 생각을 하는 것은 좋지 않는 것 같습니다.

Rust는 다양한 분야에서 많이 사용되고 있는 언어가 되고 있다. Discord에서 Rust는 클라이언트 쪽과 서버 쪽에서 능력을 발휘하고 있다. 예를 들어, 클라이언트측에서는 Go Live용 동영상 인코딩 파이프라인, 서버측에서는 Elixir NIFs용으로 사용 되었다. 최근에는 Go에서 Rust로 변경하여 서비스 성능을 획기적으로 개선했다. 이 포스트에서는 서비스를 다시 구현하는 것에 대한 합리적인 이유, 어떻게 이루어졌는지, 그리고 성능 향상에 대해 설명한다.

Read States 서비스

Discord는 제품 중심의 회사이므로 제품의 컨텍스트(고객의 의도나 기대, 경험, 상황, 환경 등)에서 시작한다. 우리가 Go에서 Rust로 마이그레이션한 서비스는 "Read States" 서비스다. Read States의 목적은 읽은 채널과 메세지를 추적하는 것이다. Read States는 Discord에 액세스할 때마다, 메세지를 보낼 때마다, 메세지를 읽을 때마다 액세스 된다. 즉, Read States는 핫 경로(성능이 매우 중요한 경로)에 있다. 우리는 Discord를 항상 빠름을 유지하고 싶기 때문에 Read States는 빨라야 한다.

Go로 구현된 Read States 서비스는 제품의 요구 사항을 지원하지 못했다. 그것은 대부분의 서비스는 빨랐지만, 몇분 단위로 UX에 부정적인 영향을 미치는 큰 지연이 발생했다. 조사 후, 그 원인은 Go의 핵심 기능인 메모리 가비지 콜렉터(GC)에 있다고 판단했다.

Go 언어로 성능 목표를 달성하지 못한 이유

Go가 성능 요구사항을 달성하지 못한 이유를 밝히기 위해서는 서비스의 데이터 구조, 스케일, 액세스 패턴, 아키텍처를 소개해야 한다.

Discord에서 Read State를 저장하는데 사용되는 데이터 구조를 "Read State"라고 한다. Discord에는 각 채널, 사용자마다 Read State가 있는데 전체 수가 수억에 이른다. 각 Read State에는 atomic하게 업데이트해야 하는 몇개의 카운터가 있으며 종종 0으로 재설정이 된다. 예를 들어, 카운터 중 하나는 채널의 멘션 수를 나타낸다. 카운터 업데이트를 불가분하고 신속하게 수행하기 위해 각 Read State 서버에는 Read States의 LRU(Least Recently Used) 캐시가 있으며 초당 수십만개의 캐시 업데이트가 발생한다.

데이터 영속성을 보장하기 위해 Cassandra Database Cluster를 사용하여 캐시를 백업한다. 캐시 키를 삭제하는 경우 Read State를 데이터베이스에 커밋힌다. 또한 Read State가 업데이트되었는지 여부에 관계없이 30초마다 데이터베이스에 대한 커밋을 예약 작업을 한다. 데이터베이스에는 초당 수만 개의 쓰기가 일어난다.

아래 그림은 Go 서비스 피크시 응답시간과 CPU 상태를 보여준다. 보다시피, 지연과 CPU 사용률의 급상승이 약 2분마다 발생한다.

Go 피크시 응답시간과 CPU 상태

2분마다 지연과 CPU 사용률이 늘어나는 이유

Go에서 캐시 키를 삭제하면 메모리가 즉시 해제되지 않는다.

대신 가비지 컬렉터가 자주 실행되고 참조되지 않은 메모리를 찾아서 해제한다. 즉, 메모리가 더 이상 사용되지 않는 직후에 해제되는 대신 가비지 컬렉터가 메모리가 실제로 사용되지 않았는지 확인할 때까지 메모리가 잠시 중단된다. 가비지 콜렉션시 Go는 다양한 작업을 수행하여 사용 가능한 메모리를 결정해야 하며 프로그램 속도가 느려질 수 있다.

Go의 소스 코드를 파보면 Go는 최소 2분마다 가비지 컬렉션을 실행하는 것으로 나타났다. 즉, 힙 증가와 상관없이 가비지 콜렉션이 2분 이상 실행되지 않았을 때 Go는 가비지 콜렉션을 강제로 실행하고 있다.

우리는 이러한 부하의 급상승을 개선하기 위해 가비지 컬렉션을 더 자주 실행하도록 튜닝해야 한다고 생각했다. 그리고 Go 서비스에 엔드 포인트를 구현하고 가비지 컬렉터의 GC Percent를 직접 변경했다.

불행히도 GC Percent를 어떻게 변경하더라도 상황은 아무 것도 변경되지 않았다. 그 이유는 가비지 컬렉터가 자주 실행하기에 충분한 메모리를 할당하지 않았기 때문이다.

Go의 소스 코드를 계속 살펴보면서 부하가 급격히 증가하는 이유가 실제로 참조되지 않는 메모리가 많은 것이 아니라 가비지 컬렉터가 전체 LRU 캐시를 스캔하여 정말 메모리 참조가 없는지 확인하는 것이 문제라는 것을 알았다. 따라서 LRU 캐시를 작게 하면 가비지 콜렉터가 스캔하는 것도 줄어들어 가비지 콜렉션이 빨라진다고 생각했다. 그래서 Go 서비스에 다른 설정을 추가하여 LRU 캐시의 크기를 조정하고 서버별로 여러개의 분할된 LRU 캐시를 갖도록 아키텍처를 변경했다.

이 방법은 성공했다. LRU 캐시를 줄이면서 가비지 콜렉션은 부하를 줄여 주었다.

불행히도, LRU 캐시를 작게한 트레이드 오프(반대 급부)로 99% 응답 지연(대기시간)이 발생했다. 캐시가 작으면 사용자의 Read State가 캐시에 있을 가능성이 낮기 때문이었다.(캐시 히트율이 떨어짐) 사용자의 Read State가 캐시에 없는 경우 데이터베이스에 액세스해야 한다.

다양한 캐시의 크기로 테스트를 실시한 결과, 좋은 설정을 발견했다. 100% 만족은 아니지만, 충분히 만족할 수 있는 수준으로, 잠시 동안 그 설정으로 서비스를 운용했다.

그 사이에, Discord의 다른 부분에서 Rust가 점점 더 성공을 거두었고 Rust로 새로운 서비스를 구축하는 데 필요한 라이브러리와 프레임워크를 만들기로 결정했다. Read States 서비스는 작고 독립형이었기 때문에 Rust에 적용하기에 딱 좋은 후보였지만 동시에 그동안은 Rust가 지연의 급상승을 해결할 것으로 기대만 했지 실제 적용하지 않았다. 그래서 Rust가 서비스 개발에 사용할 수 있는 언어임을 증명하고 난 뒤 사용자 경험을 향상시킬 것으로 기대하고 Read States를 Rust에 이식하는 작업을 시작했다.

Rust의 메모리 관리

Rust는 매우 빠르고 메모리 효율적이며 런타임이나 가비지 컬렉터가 없으므로 성능이 중요한 서비스에 적합하고 임베디드 디바이스에도 동작하며 다른 언어와 쉽게 통합할 수 있다.

Rust는 가비지 컬렉터가 없기 때문에 Go 때처럼 지연이 급증하지 않는다고 생각했다.

Rust는 메모리에 "소유권"이라는 개념을 도입한 비교적 독특한 메모리 관리 접근법을 채택한다. 기본적으로 Rust는 누가 메모리를 읽고 쓸 수 있는지 추적한다. 프로그램이 메모리를 사용하는 시점을 인식하고 더 이상 필요하지 않으면 즉시 메모리를 해제한다. Rust는 컴파일 타임에 메모리 규칙을 적용하기 때문에 런타임 메모리 버그를 발생시키는 것은 사실상 불가능하다. 메모리 관리는 컴파일러가 자동으로 처리하므로 메모리를 수동으로 추적할 필요가 없다.

그래서 Rust 버전의 Read States에서는 사용자의 Read State가 LRU 캐시에서 삭제되자마자 메모리가 해제된다. Read State 메모리는 가비지 컬렉터가 사용되지 않는 메모리를 해제할 때까지 기다릴 필요가 없다. Rust는 메모리가 더 이상 사용되지 않으면 즉시 그것을 해제한다. 해방의 시비를 판단하는 런타임 프로세스는 없다.

Rust의 Async

그러나 Rust의 생태계에는 문제가 있다. 이 서비스를 Rust로 다시 구현했을 때, 안정 버전의 Rust는 비동기 처리에 대해서는 불완전했다. 네트워크 서비스에서 비동기 프로그래밍이 필요했다. 비동기 처리를 가능하게 한 유저의 라이브러리가 있었지만, 엄청난 용기가 필요했고 에러 메세지는 대략적이어서 매우 알기 어려웠다.

다행히 Rust 팀은 비동기 프로그래밍을 단순화하는데 매우 의욕적이었고 Rust의 Nightly 채널에서는 비동기 프로그래밍이 이용할 수 있는 수준이 되었다.

Discord는 유망한 새로운 기술을 채택하는 것을 두려워하지 않았다. 예를 들어, 우리는 Elixir, React, React Native, Scylla를 일찍 채택 했었다. 기술의 일부가 유망하고 우리에게 이점을 가져다 주면 첨단 기술 특유의 어려움, 불안정성은 작은 문제라고 생각했다. 이는 50명 미만의 엔지니어가 빨리 2억 5,000만명 이상의 사용자들을 수용할 수 있게 만드는 방법 중 하나였다.

Rust Nightly의 새로운 async 기능을 채택하는 것은 그러한 새로운 기술을 채택하는 것에 대한 의지의 표현중에 하나이다. 엔지니어링 팀으로서 Rust Nightly를 채택할 가치가 있다고 판단하고 async가 Rust의 안정 버전에서 완전히 지원될 때까지 Rust Nightly를 사용하기로 결정했다. 동시에 우리는 Rust Nightly에서 발생한 문제를 해결하고 안정적인 Rust버전에서 비동기 처리를 지원했다. 우리의 도전은 성공했다.

구현, 부하 테스트, 출시

코드를 다시 작성하는 것은 매우 간단했다. 우선은 러프하게 변환하는것부터 시작해서 그 다음으로 슬림화를 진행했는데 이해가 어느 정도 되는 부분에 먼저 코드의 슬림화를 실시했다. 예를 들어, Rust는 제네릭에 대한 광범위한 지원을 제공하는 훌륭한 타입 시스템을 가지고 있기 때문에 Go에서 제네릭이 부족했기 때문에 발생했던 불필요한 코드를 삭제할 수 있었다. 또한 Rust의 메모리 모델은 여러 스레드에 걸쳐 메모리 안정성을 추론할 수 있기 때문에 Go에서 필요한 수동 cross-goroutine 메모리 보호가 더 이상 필요하지 않았다.

부하 테스트를 시작하자마자 만족스러운 결과가 나왔다. Rust 버전 Read States의 지연은 Go와 거의 같았고, Go와 같은 지연의 급상승은 없었다!

놀랍게도 Rust 버전을 구현 했을 때 우리는 최적화에 대해 아주 기본적인 생각만 했다. 기본적인 최적화만으로도 Rust 버전은 세심하게 최적화한 Go 버전보다 우수한 성능을 발휘했다. 이는 Go에서 수행해야하는 정교한 최적화와 비교하여 Rust가 효율적인 프로그램을 만드는 것이 얼마나 쉬운일인지 보여주는 큰 증거다.

그러나 우리는 단지 Go의 성능에 맞추는 것에 만족하지 않았다. 약간의 프로파일링과 성능 최적화를 거친 후 모든 성능 지표에서 Go의 성능을 능가할 수 있었다. 지연, CPU 사용률, 메모리 사용량 모두에서 우수했다.

Rust의 성능 최적화를 위해 한 일은 다음과 같다.

  • 메모리 사용량을 최적화하기 위해 LRU 캐시의 BtreeMap을 HashMap으로 변경했다.
  • 내부의 Metrics Library를 최신 Rust의 병렬화를 채용했다
  • 메모리 복사 횟수 감소

만족스러워서 우리는 서비스를 출시하기로 했다.

부하 테스트를 실시했기 때문에 출시는 매우 매끄러웠다. 처음에 하나의 Canary 노드에 배포했고 누락된 몇개의 엣지 케이스를 찾아 수정했다. 그런 다음 모든 환경에도 배포했다.

결과는 아래 그림에 나와 있다. Go는 보라색 라인, Rust는 파란색 라인이다.

Go와 Rust의 응답시간과 CPU 상태

캐시 용량 늘리기

서비스가 며칠 동안 성공적으로 운영된 후 LRU 캐시의 크기를 다시 늘리기로 결정했다. 앞서 언급했듯이 Go 버전에서는 LRU 캐시의 용량을 늘리면 가비지 콜렉션에 소요되는 시간이 늘어난다. Rust 버전에서는 가비지 콜렉션을 더 이상 필요로 하지 않으므로 캐시 상한을 늘려 성능을 더욱 향상시킬 수 있다고 생각했다. 메모리 상한을 늘리고 메모리 사용량을 줄이기 위해 데이터 구조를 최적화하고 800만의 Read States를 저장할 수 있을 때까지 캐시 용량을 늘렸다.

결과는 다음과 같다. 평균 시간은 마이크로초(μs)로, @mention이 최대일 때가 밀리 세컨드(ms)이다.

캐시 용량 늘렸을 때 Rust의 응답시간과 CPU 상태

진화하는 생태계

마지막으로 또 다른 Rust의 장점은 점점 진화하는 생태계가 있다는 것이다. 최근 tokio(우리가 사용하는 async 런타임)가 버전 0.2를 출시했다. 우리는 업데이트를 했고 공짜로 CPU의 성능상 혜택을 얻을 수 있었다. 아래 그림과 같이 16일 이후의 CPU 사용률이 일관되게 낮아지고 있다.

Go와 Rust의 응답시간과 CPU 상태

결론

현재 Discord는 소프트웨어 스택 전반에 걸쳐 다양한 곳에서 Rust를 사용하고 있다. 게임 SDK, Go Live의 영상 캡처 및 인코딩, Elixir NIFs, 일부 백엔드 서비스 등에서 사용하고 있다.

새로운 프로젝트 또는 소프트웨어 컴포넌트를 개발할 때 우리는 Rust의 사용을 고려한다. 물론 Rust를 사용하는 의미가 있는 경우에만 사용하고자 한다.

성능 외에도 Rust를 채택하는 것은 엔지니어링 팀에게 다양한 이점이 있다. 예를 들어, 타입 안전(type safety)및 대여 검사기(borrow checker)는 제품 요구사항 변경되거나 Rust에 대한 새로운 학습 내용을 발견할 때 코드를 리팩토링하는 것은 매우 간단하다. 또한 Rust의 생태계와 도구는 매우 우수하며 그 뒤에는 상당한 추진력이 있다. 여기까지 읽었다면 당신은 아마 Rust에 대해 흥분하고 있을 것이다. Rust를 전문적으로 사용하고 흥미로운 문제를 해결하고 싶다면 Discord에서 일하는 것을 고려해 보라.