62P by xguru 6달전 | 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를 사용하여 이 동작을 조정할 수 있음
    • 이 값을 몇 메가바이트로 늘리자 병적인 가비지 수집 동작이 사라졌고, 매니폴드 오프로드를 켜고 성능이 크게 향상되는 것을 볼 수 있었음

엘릭서 파이팅

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

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

엄청 멋지네요 ㄷㄷ

엘릭서 화이팅

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

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

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

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

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

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