Message Fanout
디스코드에 수많은 기능들이 있지만 거의 대부분은 pub/sub이다. 유저는 WebSocket에 연결하고 session process(GenServer)를 가동시킨다. 그 후 session 프로세스는 guild processes(internal for a “Discord Server”, also GenServers) 를 포함하고 있는 원격 Erlang node와 통신을 한다. guild에서 어떠한 것이라도 publish 되면 guild에 연결되어있는 모든 세션으로 fan out되어 나간다. 사용자가 온라인이 되면 guild에 연결하고 guild는 그 존재를 모든 연결된 세션에게 publish 한다.
이러한 접근법은 디스코드가 25명이나 25명보다 적은 그룹에 대해 작동할때는 괜찮았다. 그러나 사람들이 디스코드를 많은 수의 그룹과 사용하기 시작할때 문제가 생기기 시작했다. 결국에 우리는 Overwatch의 문제처럼 많은 디스코드 서버에 다다르게 되었다(30000 동시대 유저들과 함께). 피크 타임에는 이러한 서버 프로세스들이 메시지 큐를 더이상 따라가지 못하고 실패하는 것을 포착하기 시작했다(pub/sub 구조니까 메시지 큐에 메시지들이 쌓일텐데 그걸 처리하지 못하는 시점이 왔다는 의미로 보임). 어떤 시점에서는 수동으로 개입해서 메시지를 발생시키는 feature를 껐어야 했다.
벤치마킹 툴(테스트 돌려서 시간 측정)을 이용해서 분명한 원인을 발견. Erlang 프로세스들 사이에서 메시지를 보내는 것이 예상보다 싸지 않았던 것, 그리고 reduction cost(Erlang unit of work used for process scheduling)또한 높았음. 단일 send/2 호출이 30us to 70us 정도 걸렸고(Erlang descheduling the calling process) 이는 peak hour에는 큰 길드에서 event를 publish 하는데에 900ms ~ 2.1s 나 걸릴 수 있는 문제였음. Erlang 프로세스는 싱글스레드였기에 병렬적으로 할수 있는 유일한 방법은 그러한 호출을 sharding 하는 방법밖에 없었음.
Erlang에서 process를 시작하는건 값 쌌기에, 첫번째 시도는 그냥 각각의 publish를 위해 프로세스를 새로 띄우는 방법이었음. 그러나 각각의 publish는 다른 시간대에 scheduling 될 수 있었고, Discord 클라이언트는 이벤트의 선형성에 의존하고 있었다. 또한 이 해결책은 스케일링이 잘 되지 않았는데 그 이유는 guild service가 쉬지 않고 증가하는 양의 업무를 이미 맡고 있었기 때문이다.
Erlang 노드 사이에서 메시지 교환의 성능을 끌어올리는 내용에 대해 다루는 블로그에 영감을 받아, Manifold라는 프로젝트가 탄생했다. Manifold는 메시지 전송의 작업을 원격 노드의 PID들에 분배를 했고, 이는 전송 프로세스가 send/2 호출을 최대, 연관된 remote node의 갯수만큼만 호출하는 것을 보장했다.
Manifold는 처음엔 PID 를 그들의 원격 노드에 맞게 grouping 하였고 그후 그들 노드의 Manifold.Partitioner에게 그것을 보냈다. Partitioner는 그 후 PIDs를 일관적으로 hash 하여 코어 개수에 맞게 그들을 grouping 하였고, 그것들을 자식 프로세스에게 전송하였다. 마지막으로 그 worker들이 실제 프로세스에게 메시지를 전송한다. 이것은 partitioner가 overload 되지 않도록 보장하고 send/2 의 선형성 또한 보장한다.
이 방식의 멋있는 부가 효과는 CPU cost를 분배할 뿐만 아니라 node 사이의 network traffic 또한도 줄여줄 수 있었다.
Fast Access Shared Data
디스코드는 consistent hasing 을 통해서 만들어진 분산시스템이다. 이 방식을 이용함에 따라 우리는 particular entity의 node를 찾기 위해 ring data structure를 만들었어야 했다. 우리는 그 lookup이 빠르기를 바랐고,Chris Moos의 library (consistent hashing implementation)를 선택해서 Erlang C port를 통해 이용하게 되었다. 이것은 정말 잘 동작하였지만 Discord가 규모가 커지면서, burst of users가 재연결할 때 이슈를 발견하게 되었다. ring을 제어하는 역할을 맡고 있는 Erlang 프로세스가 엄청 바빠지기 시작하면서 ring에 들어오는 요청을 모두다 커버하지 못하기 시작했고 전체 시스템에 부하가 걸리기 시작했다. 처음에 보이는 해결책은 분명했다 : ring data와 함께 여러개의 프로세스를 돌려서 모든 머신의 core를 request에 응답하기 위해 사용하자. 그러나 우리는 이것이 hot path 라는것을 알아차리게 되었다.
hot path : 정말로 성능 intensive 한 것이거나 또는 지연시간에 민감한 부분이라 성능적으로 critical 해지는 코드 경로
hot path break down
- 사용자는 여러 개의 길드에 포함되어 있을 수 있다. 그러나 평균적으로는 5개 였다.
- session을 담당하고 있는 Erlang VM 은 최대 500,000개의 살아있는 세션을 갖고 있음
- 세션이 연결될 때, 원격 node를 뒤져야 함. 그 세션이 관심있는 각각의 길드를 찾아야 하기 때문에
- request/reply를 이용하여 다른 Erlang 프로세스와 소통하는 비용은 대략 12us 였다.
만약 세션 서버가 크래시나고 다시 재부팅 되면, ring에서의 lookup 하는데에 걸리는 비용만 해도 대략 30초가 걸렸다.
Elixir에서 data access에 속도를 내고 싶을때 첫번째로 사용하는 것은 ETS를 도입하는 것이다. ETS는 빠르고 mutable한 C에서 구현된 dictionary임. tradeoff는 데이터가 거기에서 복사되어 들어가고 나오는 과정이 필요하다는 것. 우리는 C port를 이용해 ring을 제어하고 있었기에 ring 데이터를 ETS로 바로 옮겨가지는 못했음. 그래서 그 코드(
)를 pure Elixir 코드로 전환했다.
hash-ring
Github
hash-ring
Owner
chrismoosUpdated
Sep 25, 2024그것이 구현되고 난 후 하나의 프로세스가 ring을 보유하고 그것을 ETS에 복사하여 다른 프로세스들이 거기에 직접적으로 접근해서 읽을 수 있도록 하였다. 이것은 눈에 띄게 성능을 향상시켰지만, ETS read 또한 7 us 정도 였기에 ring에서 값들을 찾는데 17.5초가 걸렸다. ring data 구조는 꽤나 컸고 그것을 ETS에 복사해서 넣고 나오고 하는 것이 비용의 대부분이었다.
약간의 리서치 후에 mochiglobal이라는 VM의 기능을 이용하는 모듈을 발견했다. Erlang이 항상 똑같은 constant data를 반환하는 함수를 볼 때, 이 반환 데이터를 read-only shared heap에 넣어서 process들이 데이터를 복사하지 않고 그대로 사용할 수 있도록 하는 기능이었음. 데이터가 복사가 되지 않았기에 lookup cost는 0.3us로 줄었고 총 걸리는 시간을 750ms 로 줄일 수 있었다. 그러나 공짜 점심은 없다는 말과 같이 ring 과 같은 크기의 자료 구조를 갖는 모듈을 런타임에 만드는 것은 대략 1초는 걸렸다. 좋은 소식은 ring을 우리는 거의 바꿀 필요가 없었기에 그 1초라는 시간은 우리는 받아들이기로 했다.
Limited Concurrency
node lookup hot path의 성능 향상문제를 해결한 후, guild node들의 guild_pid lookup을 담당하는 프로세스가 막히고 있다는 것을 발견했다. 본연에 존재하는 느린 node lookup으로 인한 back pressure 가 이전에는 이러한 프로세스들을 보호해주고 있었던 것이었음. 새로운 문제는 거의 5,000,000 에 달하는 세션 프로세스들이 이 guild_pid lookup을 담당하는 10개의 프로세스들에 우르르 달려드는 것이었다. 내부적으로 존재하는 문제는 session process의 guild registry에 대한 호출이 timeout 을 내고 있었고 request를 guild registry의 queue에 남겨두고 있다는 것이었다. 그러고 나면 session process는 backoff 이후에 request를 재시도하고 그러나 계속적으로 request가 쌓이고 회복불가능한 상태로 돌입하게 되는 것이었다. Session은 이 request들에 대해 block을 하고 있었고(timeout 응답이 올 때까지), 다른 서비스들에서 메시지를 받으면서. 그래서 message queue가 계속적으로 커지고 있었고 Erlagn VM의 OOM 을 터뜨리게 되었다. ⇒ 다른 서비스들의 outage에도 영향을 미치게 되었음
session process를 더 smart 하게 만드는 것이 필요했다. 이상적으로는 실패가 피할 수 없는 상황이라면 guild registry에 대한 요청을 아예 하지 말아야 했다. circuit breaker는 사용하고 싶지 않았는데 왜냐하면 아무 시도도 하지 않았는데 임시 상태가 되는 timeout 요청을 원하지 않았기 때문이다.
다른 대부분의 언어에서, atomic counter를 사용하여 outstanding request를 tracking 하고 number가 너무 높다면 초기에 아예 요청하지 않는 방식으로 할수 있다(semaphore를 효과적으로 구현하여). Erlang VM은 프로세스들 사이의 communication을 coordinate 하는 기능이 잘 내장되어 있지만, 이러한 coordination을 하는데에 프로세스가 너무 많은 과부하를 받지 않기를 바랐음. 조금의 연구 후에, ETS key 안의 값에 대해
atomic conditional increment연산
을 수행하는 :ets.update_counter/4 를 만나게 되었음. high concurrency가 필요했기 때문에 ETS를 write_concurrency 모드로 실행 시켰고 그래도 :ets.update_counter/4 가 결과값을 반환했기에 읽기도 가능했다. 이 방법은 우리의 Semaphore 라이브러리를 만드는데 기초적인 아이디어가 되었다. 이 라이브러리는 Elixir infrastructure를 보호하는데 아주 효과적인 기능을 했다. 동일한 상황이 발생했을때 , 이번에는 outage가 전혀 발생하지 않았다.