62P by xguru 2023-11-09 | favorite | 댓글 9개

Elixir as a fanout system

  • Discord에서 메시지가 전송되거나 누군가 음성 채널에 참여하는 등 어떤 일이 발생할 때마다 같은 서버("길드"라고도 함)에 있는 온라인 상태인 모든 사용자의 클라이언트에서 UI를 업데이트 해야함
  • 해당 서버에서 일어나는 모든 일의 중앙 라우팅 지점으로 길드당 하나의 Elixir 프로세스를 사용하고, 연결된 각 사용자의 클라이언트에 대해 다른 프로세스("세션")를 사용
  • 길드 프로세스는 해당 길드의 구성원인 사용자의 세션을 추적하고 해당 세션에 작업을 퍼뜨리는 역할을 담당
  • 세션이 업데이트를 받으면 웹소켓 연결을 통해 클라이언트에 전달
  • 어떤 작업은 서버의 모든 사람에게 적용되는 반면, 어떤 작업은 권한을 확인해야 하므로 해당 서버의 역할과 채널에 대한 정보뿐만 아니라 사용자의 역할도 알아야 함
  • 길드의 활동량은 해당 서버의 인원 수에 비례하고, 하나의 메시지를 팬아웃하는 데 필요한 작업량도 해당 서버의 온라인 사용자 수에 비례
    • 즉, 디스코드 서버를 처리하는 데 필요한 작업량은 서버의 규모에 따라 4제곱으로 증가
    • 한 서버에 1,000명이 온라인 상태이고 이들이 모두 "젤리가 좋아요"라고 한 번씩 말했다면 이는 1백만 건의 알림을 처리해야 하는 것
    • 10,000명이라면 1억 건의 알림이 발생하고, 10만 명이면 100억 개의 알림을 전달해야 함
  • 전반적인 처리량 문제 외에도 서버가 커질수록 일부 작업의 속도가 느려지는 경우가 있음
  • 메시지가 전송되면 다른 사람들이 바로 볼 수 있어야 하고, 누군가 음성 채널에 참여하면 바로 참여를 시작할 수 있어야 하는 등 서버가 응답성이 뛰어나다는 느낌을 주려면 거의 모든 작업을 빠르게 처리할 수 있어야 함
  • 비용이 많이 드는 작업을 처리하는 데 몇 초가 걸리면 사용자 경험이 저하됨
  • 이런 문제가 있음에도 1000만명이 넘는 회원이 있으며, 그중 100만명이 항상 온라인 되어있는 Midjourney 서버를 지원할수 있었을까 ?
    • 먼저 시스템의 성능을 파악하는 것이 중요했음
    • 데이터를 확보한 다음에는 처리량과 응답성을 모두 개선할 수 있는 기회를 찾았음

시스템 성능 이해하기

  • Wall time analysis :
    • Process.info(pid, :current_stacktrace) 를 이용한 스택 트레이싱
    • 이벤트 처리 루프를 측정해서 각 유형 메시지 수신 수와 이를 처리하는데 걸린 최댁/최소/평균/총시간을 기록
    • 전체 시간의 1% 미만을 차지하는 작업은 극도로 폭주하는 경우가 아니라면 모두 무시
    • 저렴한 작업을 배제하고, 가장 비용이 많이 드는 작업을 강조
  • Process Heap Memory Analysis
    • 메모리를 어떻게 사용하는지 이해하는 것도 중요
    • 모든 요소를 일일이 보는 대신 큰 (구조체가 아닌) 맵과 목록을 샘플링하여 예상 메모리 사용량을 생성하는 헬퍼 라이브러리를 작성
    • 이 라이브러리는 GC 성능을 이해하는 데 도움이 되었을 뿐만 아니라 최적화를 위해 집중할 가치가 있는 필드와 궁극적으로 관련이 없는 필드를 찾는 데에도 유용
  • 길드 프로세스가 어디에 시간을 소비하는지 파악한 후에는 길드 프로세스가 100% 바쁘지 않도록 하는 전략을 세울 수 있었음
    • 어떤 경우에는 비효율적인 구현을 더 효율적으로 재작성하는 것만으로도 충분
    • 하지만 이 방법으로는 여기까지만 갈 수 있었음. 보다 근본적인 변화가 필요

패시브 세션 - 불필요한 작업 피하기

  • 처리량 병목 현상을 해소하는 가장 좋은 방법 중 하나는 작업을 줄이는 것
  • 이를 위한 한 가지 방법은 클라이언트 애플리케이션의 요구 사항을 고려하는 것
  • 원래 토폴로지에서는 모든 사용자가 자신이 속한 모든 길드에서 볼 수 있는 모든 행동을 수신
  • 하지만 일부 사용자는 여러 길드에 소속되어 있으며 일부 길드에서 무슨 일이 일어나고 있는지 확인하기 위해 클릭조차 하지 않을 수 있음
  • 사용자가 클릭할 때까지 모든 내용을 보내지 않는다면 어떨까? 모든 메시지에 대한 권한을 일일이 확인하지 않아도 되고, 결과적으로 클라이언트에 보내는 데이터도 훨씬 줄어들 것
  • 우리는 이를 'Passive' 연결이라고 부르며 모든 데이터를 수신해야 하는 'Active' 연결과는 별도의 목록에 보관했음
  • 그 결과, 대규모 서버에서 사용자-길드 연결의 약 90%가 패시브 연결이었기 때문에 팬아웃 작업의 비용이 90% 절감
  • 덕분에 어느 정도 숨통이 트였지만, 커뮤니티가 계속 성장함에 따라 당연히 이것만으로는 충분하지 않았음
    (작업량이 10배 감소하면 최대 커뮤니티 규모에서 약 3배의 이득을 얻을 수 있음)

릴레이 - 여러 머신에 걸쳐 팬아웃 분할하기

  • 단일 코어 처리량 한계를 확장하기 위한 표준 기술 중 하나는 작업을 여러 스레드(또는 Elixir 용어를 사용하자면 프로세스)로 분할하는 것
  • 이 아이디어를 바탕으로 길드와 사용자 세션 사이에 '릴레이'라는 시스템을 구축
  • 세션을 처리하는 작업을 하나의 프로세스에서 모두 처리하는 대신 여러 릴레이로 분할하여 단일 길드가 더 많은 리소스를 사용하여 대규모 커뮤니티에 서비스를 제공할 수 있도록 할 수 있었음
  • 일부 작업은 여전히 메인 길드 프로세스에서 수행해야 하지만, 이를 통해 수십만 명의 회원을 보유한 커뮤니티를 처리할 수 있게 됨
  • 이를 구현하려면 릴레이에서 수행해야 하는 중요한 작업과 길드에서 수행해야 하는 작업, 두 시스템 모두에서 수행할 수 있는 작업을 식별해야 했음
  • 무엇이 필요한지 파악한 후에는 시스템 간에 공유할 수 있는 로직을 추출하기 위한 리팩토링 작업을 시작
    • 예를 들어, 팬아웃을 수행하는 방법에 대한 대부분의 로직은 길드와 릴레이에 모두 사용되는 라이브러리로 리팩터링
    • 이렇게 공유할 수 없는 일부 로직은 다른 솔루션이 필요했고, 음성 상태 관리는 기본적으로 릴레이가 최소한의 변경만으로 모든 메시지를 길드에 프록시하는 방식으로 구현
  • 처음 릴레이를 출시할 때 내린 흥미로운 디자인 결정 중 하나는 각 릴레이의 상태에 전체 멤버 목록을 포함시키는 것이
    • 필요한 모든 회원 정보를 사용할 수 있다는 점에서 단순성 측면에서 좋은 결정
    • 하지만 회원 수가 수백만 명에 달하는 Midjourney 규모에서는 이러한 설계가 점점 더 의미가 없어지기 시작
  • 수천만 명의 회원 정보를 수십 개의 복사본이 RAM에 저장되어 있을 뿐만 아니라 새 릴레이를 만들려면 모든 회원 정보를 직렬화하여 새 릴레이로 전송해야 하므로 길드가 수십 초 동안 지연되는 문제가 발생
  • 이 문제를 해결하기 위해 릴레이가 실제로 작동하는 데 필요한 회원을 식별하는 로직을 추가했는데, 이는 전체 회원 중 극히 일부에 불과했음

서버 반응성 유지

  • 처리량 한도 내에 머무르는 것 외에도 서버의 반응성을 유지해야 했음
  • 여기에서도 타이밍 데이터를 살펴보는 것이 유용
  • 총 지속 시간보다는 콜당 지속 시간이 긴 작업에 더 집중하는 것이 더 효과적
  • 작업자 프로세스 + ETS
    • 응답이 없는 가장 큰 원인 중 하나는 길드에서 실행하고 모든 구성원을 반복해야 하는 작업
    • 이런 경우는 매우 드물지만 발생하곤 함. 예를 들어, 누군가 모두 핑을 수행하면 해당 메시지를 볼 수 있는 서버에 있는 모든 사람을 알아야 함
    • 하지만 이러한 확인 작업에는 몇 초가 걸릴 수 있음. 이를 어떻게 처리할 수 있을까?
    • 길드가 다른 작업을 처리하는 동안 이 로직을 실행하는 것이 가장 이상적이지만, Elixir 프로세스는 메모리를 잘 공유하지 않음. 그래서 다른 솔루션이 필요
    • 프로세스가 데이터를 공유할 수 있는 메모리에 데이터를 저장하는 데 사용할 수 있는 Erlang/Elixir의 도구 중 하나는 ETS
    • 이것은 여러 엘릭서 프로세스가 안전하게 액세스할 수 있는 기능을 지원하는 인메모리 데이터베이스
    • 프로세스 힙에 있는 데이터에 액세스하는 것보다 효율성은 떨어지지만 여전히 매우 빠름. 또한 프로세스 힙의 크기를 줄여 가비지 컬렉션의 지연 시간을 줄여주는 이점도 있음
    • 멤버 목록을 보관하기 위한 하이브리드 구조를 만들기로 결정:
      • 다른 프로세스에서도 읽을 수 있도록 멤버 목록을 ETS에 저장하되, 최근 변경 사항(삽입, 업데이트, 삭제)도 프로세스 힙에 저장하는 것
      • 대부분의 멤버는 항상 업데이트되지 않으므로 최근 변경 사항 집합은 전체 멤버 집합에서 매우 작은 부분
    • 이제 ETS의 멤버를 사용하여 작업자 프로세스를 생성하고 고비용 작업이 있을 때 작업할 ETS 테이블 식별자를 전달할 수 있음
    • 작업자 프로세스는 길드가 다른 작업을 계속하는 동안 비용이 많이 드는 부분을 처리할 수 있음. 이 작업을 수행하는 간단한 방법도 참고(원문에 코드 스니펫)
    • 이 방법을 사용하는 한 가지 예는 한 머신에서 다른 머신으로 길드 프로세스를 넘겨야 할 때(보통 유지 관리 또는 배포를 위해)
    • 이 과정에서 새 머신에서 길드를 처리할 새 프로세스를 생성한 다음 이전 길드 프로세스의 상태를 새 프로세스로 복사하고 연결된 모든 세션을 새 길드 프로세스에 다시 연결한 다음 해당 작업 중에 쌓인 백로그를 처리해야 함
    • 작업자 프로세스를 사용하면 기존 길드 프로세스가 계속 작업하는 동안 대부분의 멤버(수 GB의 데이터가 될 수 있음)를 전송할 수 있으므로 핸드오프를 할 때마다 몇 분씩 지연되는 시간을 줄일 수 있음
  • 매니폴드 오프로드
    • 응답성을 개선하고 처리량 한계를 극복하기 위한 또 다른 아이디어는 매니폴드를 확장하여 (길드 프로세스에서 팬아웃을 수행하는 대신) 별도의 "발신자" 프로세스를 사용하여 수신자 노드에 팬아웃을 수행하는 것
    • 이렇게 하면 길드 프로세스의 작업량을 줄일 수 있을 뿐만 아니라, 길드와 릴레이 간의 네트워크 연결 중 하나가 일시적으로 백업되는 경우에도 BEAM의 역압으로부터 보호할 수 있습니다(BEAM은 엘릭서 코드가 실행되는 가상 머신)
    • 이론적으로는 쉽게 해결할 수 있을 것 같았지만 안타깝게도 이 기능(매니폴드 오프로드라고 함)을 사용해보니 실제로 성능이 크게 저하되는 것을 발견
    • 어떻게 그럴 수 있을까? 이론적으로는 작업량이 줄어드는데 프로세스가 더 바빠졌을까?
    • 자세히 살펴보니 추가 작업의 대부분이 가비지 컬렉션과 관련된 것임을 알 수 있었음
    • 이때 erlang.trace 함수가 구세주처럼 등장
    • 이 함수를 통해 길드 프로세스가 가비지 컬렉션을 수행할 때마다 데이터를 수집할 수 있었기 때문에 가비지 컬렉션이 얼마나 자주 발생하는지뿐만 아니라 무엇이 가비지 컬렉션을 유발했는지에 대한 인사이트를 얻을 수 있었음
    • 이러한 추적 정보를 바탕으로 BEAM의 가비지 컬렉션 코드를 살펴본 결과, 매니폴드 오프로드가 활성화되었을 때 주요(전체) 가비지 컬렉션의 트리거 조건이 가상 바이너리 힙이라는 것을 알아냄
    • 가상 바이너리 힙은 프로세스가 가비지 컬렉션을 수행할 필요가 없는 경우에도 프로세스 힙 내부에 저장되지 않은 문자열이 사용하는 메모리를 해제할 수 있도록 설계된 기능
    • 안타깝게도 우리 사용 패턴은 기가바이트 크기의 힙을 복사하는 대가로 수백 킬로바이트의 메모리를 회수하기 위해 가비지 컬렉션을 계속 트리거하는 것을 의미했는데, 이는 분명히 그만한 가치가 없는 절충안이었음
    • 다행히도 BEAM에서는 프로세스 플래그인 min_bin_vheap_size를 사용하여 이 동작을 조정할 수 있음
    • 이 값을 몇 메가바이트로 늘리자 병적인 가비지 수집 동작이 사라졌고, 매니폴드 오프로드를 켜고 성능이 크게 향상되는 것을 볼 수 있었음

엘릭서 파이팅

패시브 세션은 기술적으로는 별 것 아니지만, 좋은 아이디어 같습니다.
확실하게 부담을 절감할 수 있겠네요

디스코드 뿐 아니라 다른 곳에서도 이런 기능을 구현해 두었을 텐데, 서비스 별로 어떤 차이점이 있을지 궁금합니다.

엄청 멋지네요 ㄷㄷ

요즘 유명한 nextjs의 streaming ssr의 종착지도 엘릭서의 phoenix프레임워크더군요. 여러모로 엘릭서는 현대 프로그래밍 언어의 최전선에 있는것 같습니다

엘릭서 화이팅

몇년전에 Discord 의 기술블로그를 참고해서 리얼타임 서비스에 Elixir 를 도입하게 되었고 개발속도나 안전성에서 저를 비롯한 담당임원까지 매우 만족스럽게 서비스를 출시해서 좋은 기억이 많네요.

엘릭서가 더 인기 많아지면 좋겠어요

IE를 강요하던 전통이 있는, 정부 이례로 네카라쿠배까지 스프링 독점인 한국 IT에게 어려운 과제입니다...

요즘 네카라는 그 정돈 아닌 것 같고 오히려 중소 스타트업들이 스프링 독점인 것 같습니다. 그 스타트업 매니저들이 스프링 전문인 경우가 대부분이니 어쩔 수 없구요.

모든 비효율은 돈과 규모로 해결하면 됩니다. 회사는 어차피 잘 모르니까요.