message-queues

2 개의 포스트

discord

Osprey: 규칙 엔진 오픈 소 (새 탭에서 열림)

Discord는 실시간 안전 대응을 위해 개발한 규칙 엔진 Osprey를 ROOST 및 internet.dev 팀과 오픈소스로 공개했다. Osprey는 초당 수천 건의 이벤트를 처리하고, Python 기반 규칙 언어로 탐지 정책을 빠르게 배포하며, 판정 결과와 실행 과정을 추적할 수 있도록 설계됐다. 이를 통해 플랫폼은 새로운 위협에 대응하는 데 필요한 엔지니어링 부담을 줄이고, 탐지 결과를 다음 규칙 개선에 활용할 수 있다. ## Osprey를 개발한 배경과 목표 - 온라인 플랫폼은 스팸, 사기, 악성 사용자 등 유사한 안전 문제를 반복적으로 해결해야 한다. - Osprey는 각 기업이 안전 도구를 처음부터 만들지 않도록 재사용 가능한 규칙 엔진을 제공한다. - 주요 요구사항은 다음과 같다. - **대규모 실시간 처리:** 초당 수천 건의 이벤트를 처리하고 플랫폼 성장에 맞춰 확장 - **신속한 대응:** 표현력 있는 규칙을 작성해 수분 내 적용 - **명확한 판정:** 활동을 안전, 의심, 악성 등으로 판단할 수 있는 결과 제공 - **실행 과정 공개:** 어떤 규칙이 실행됐고 오류가 발생했는지 확인 가능 - **지속적인 학습:** 탐지 결과와 조사 내용을 새로운 규칙 개선에 반영 - **확장성:** 앞으로 등장할 새로운 공격 패턴과 기능을 수용 ## 전체 처리 구조 - Osprey는 플랫폼에서 발생한 **Action**을 입력으로 받는다. - Action은 다음 방식으로 전달할 수 있다. - gRPC를 통한 동기 처리 - 메시지 큐를 통한 비동기 처리 - 입력된 Action은 SML로 작성된 **Rules**를 거친다. - 규칙은 **UDF(User Defined Function)**로 확장할 수 있다. - 실행 과정에서 **Features**와 **Effects**가 생성된다. - 일부 Effects인 **Verdict**는 동기 요청자에게 즉시 판정 결과를 반환한다. - 모든 출력은 Apache Druid 클러스터로 전송되어 조사용 UI에서 검색·분석된다. ## Action: 규칙 엔진의 입력 이벤트 - Action은 Osprey에 전달되는 이벤트이며, 각 이벤트 유형은 고유한 ID와 스키마를 가진다. - 사실상 호출자가 원하는 데이터를 담은 JSON 객체로 구성된다. - 예를 들어 `user_login_attempted` 이벤트에는 사용자 ID, 이름, 이메일, IP 주소 등을 포함할 수 있다. - 이벤트 스키마를 애플리케이션에 맞게 정의할 수 있어 로그인, 메시지 전송, 계정 생성 등 다양한 활동을 처리할 수 있다. ## Rule과 SML 규칙 언어 - Rule은 Osprey의 핵심 구성 요소로, 특정 조건이 충족됐을 때 수행할 조치를 정의한다. - SML(Some Made-up Language)은 Python을 기반으로 한 규칙 언어다. - 기술 지식이 많지 않은 운영·안전 담당자도 작성할 수 있도록 비교적 단순한 문법을 사용한다. - 규칙은 다른 규칙과 데이터를 참조할 수 있어 복잡한 탐지 로직도 구성 가능하다. - 정적 검증을 통해 규칙 작성 방식을 강제할 수 있다. - 변수명 규칙 검사 - 데이터 타입 검사 - 특정 Entity에 적용 가능한 Effect 검사 - 예시에서는 이메일이 특정 값과 일치하면 해당 사용자의 Entity에 `spammer` 라벨을 추가한다. - `EntityJson`으로 사용자 ID를 추출 - `JsonData`로 이메일을 추출 - `Rule`로 스팸 사용자 조건 정의 - `WhenRules`로 조건 충족 시 `LabelAdd` 실행 ## UDF: 규칙 언어를 확장하는 Python 함수 - UDF는 실제 Python으로 작성되며 SML 규칙 어디서든 호출할 수 있다. - `Rule`, `WhenRules`, `JsonData` 등 Osprey의 기본 기능도 UDF로 구현되어 있다. - 사용자가 자체 UDF를 추가해 제품별 기능이나 외부 서비스 연동을 구현할 수 있다. - 예를 들어 외부 머신러닝 서비스에 링크를 전달해 스팸 점수인 `0~1` 범위의 값을 받아 규칙 조건으로 사용할 수 있다. - UDF는 다음과 같은 실행 정보를 정의할 수 있다. - 어떤 기능 범주에 속하는지 - 비동기 실행 여부 - 외부 서비스 접근 방식 - 실행 결과의 타입 - 따라서 규칙 엔진 자체를 수정하지 않고도 새로운 탐지 모델, 데이터 소스, 내부 서비스를 연결할 수 있다. ## Feature: 실행 결과로 생성되는 데이터 - Feature는 Osprey의 전역 네임스페이스에 등록된 변수다. - 모든 Feature는 고유한 이름을 가져야 한다. - 변수명 앞에 `_`를 붙이면 Feature로 외부에 내보내지 않고 현재 파일의 로컬 변수로 유지할 수 있다. - 실행 결과인 Feature는 Druid에 전송·색인된다. - 이후 조사 UI에서 `UserEmail == 'despicable@example.com'`처럼 특정 Feature 값을 기준으로 이벤트를 검색할 수 있다. - 예시의 `UserId`와 `UserEmail`은 모두 Feature다. ## Entity: 효과를 적용할 수 있는 지속적 대상 - Entity는 Feature의 특수한 형태다. - 모든 Entity는 Feature지만, 모든 Feature가 Entity인 것은 아니다. - Discord에서는 사용자, 서버, 이메일처럼 지속적으로 추적할 수 있는 대상을 Entity로 표현한다. - Entity에는 라벨, 분류, 신호 등의 Effect를 적용할 수 있다. - Entity 유형에 따라 적용 가능한 Effect가 달라지며, 이를 정적 검증으로 제한한다. - Osprey UI에서 Entity를 선택하면 해당 대상의 과거 활동과 처리 이력을 확인하는 Entity View로 이동할 수 있다. ## Effect와 Verdict - Effect는 하나 이상의 Rule이 참으로 평가됐을 때 발생하는 결과 또는 조치다. - Effect는 실행 전에 검증되며, 실행이 끝난 뒤 집계해 처리된다. - Entity에 라벨·분류·신호를 부여하는 작업이 대표적인 Effect다. - 동기 Action의 경우 Verdict Effect를 통해 호출자에게 규칙의 판정 결과를 반환할 수 있다. - 이를 활용하면 로그인이나 콘텐츠 게시 요청을 즉시 허용·차단하거나 추가 조사를 요구하는 흐름을 만들 수 있다. ## 실용적인 활용 방향 Osprey는 이벤트 수집, Python 기반 규칙 작성, 외부 탐지 서비스 연동, Druid 기반 조사까지를 하나의 구조로 제공한다. 새로운 위협에 자주 대응해야 하는 플랫폼이라면 규칙을 애플리케이션 코드와 분리하고, 정적 검증과 실행 추적을 갖춘 Osprey 같은 엔진을 활용하는 것이 운영 속도와 투명성을 높이는 방법이 될 수 있다.

discord

디스코드가 수조 개의 (새 탭에서 열림)

Discord는 메시지 검색을 Elasticsearch 기반으로 운영했지만, 메시지와 트래픽이 조 단위로 증가하면서 Redis 큐 유실, 장애에 취약한 벌크 색인, 대규모 클러스터의 운영 부담, 단일 인덱스의 문서 수 제한 문제가 발생했다. 이를 해결하기 위해 Kubernetes와 Elastic Kubernetes Operator(ECK)를 도입하고, 거대한 클러스터 대신 여러 개의 작은 클러스터를 운영하는 셀(cell) 아키텍처로 전환하려 했다. 목표는 장애 격리, 무중단 업그레이드, 확장성, 비용 효율성을 동시에 확보하는 것이었다. ## 기존 메시지 검색 구조 - 2017년 Discord는 Elasticsearch에 메시지를 색인했다. - 메시지는 Discord 서버(guild) 또는 DM 단위로 Elasticsearch 인덱스에 분산했다. - 같은 guild의 메시지를 한곳에 모아 검색 속도를 높였다. - 클러스터를 여러 개로 나누어 관리 가능한 규모를 유지했다. - 사용자가 검색을 이용하지 않는 경우를 고려해 메시지는 지연 색인(lazy indexing)했다. - Redis 기반 메시지 큐와 작업자가 메시지를 묶음으로 가져와 Elasticsearch 벌크 색인을 수행했다. ## Redis 메시지 큐의 메시지 유실 - Elasticsearch 장애로 색인 작업이 밀리면 Redis 큐에 메시지가 빠르게 쌓였다. - 큐가 과도하게 커지면서 Redis CPU 사용량이 한계에 도달했고, 결국 메시지가 유실됐다. - 즉, 색인 대상 메시지를 안정적으로 보관해야 할 큐 자체가 장애 지점이 되었다. ## 장애에 취약한 벌크 색인 - 한 번의 벌크 요청에 서로 다른 인덱스와 Elasticsearch 노드에 속한 메시지가 함께 포함됐다. - 예를 들어 50개 메시지를 색인하는 요청이 최대 50개 노드로 분산될 수 있었다. - 그중 단 하나의 노드라도 실패하면 벌크 요청 전체가 실패하고, 모든 메시지를 다시 큐에 넣어 재시도해야 했다. - 100개 노드 클러스터에서 50개 메시지를 무작위로 색인할 때, 특정 노드 하나가 장애 나면 요청 중 약 40%가 실패할 수 있었다. - 실제 장애 하나가 색인 실패와 재시도를 대량으로 유발해 Redis 큐 적체를 악화시켰다. ## 대규모 Elasticsearch 클러스터의 운영 부담 - 메시지와 guild 수가 증가할 때 노드와 인덱스를 추가하는 방식으로 수평 확장했다. - 하지만 클러스터가 커질수록 하나의 벌크 작업이 더 많은 인덱스와 노드로 분산됐다. - 이로 인해 노드 간 조정과 네트워크 팬아웃 비용이 커져 색인 성능이 저하됐다. - 노드 수가 많아질수록 어느 한 노드에서 장애가 발생할 가능성도 증가했다. ## 롤링 재시작과 보안 업데이트의 어려움 - 단일 노드 장애에도 색인 시스템이 크게 영향을 받았기 때문에 안전한 롤링 재시작이 어려웠다. - 200개가 넘는 노드와 수 테라바이트의 데이터를 가진 클러스터를 노드별로 비우고 재시작하는 데 지나치게 오랜 시간이 걸렸다. - 그 결과 오래된 운영체제와 Elasticsearch 버전을 계속 사용해야 했고, 보안 패치와 성능 개선을 적용하지 못했다. - Log4Shell 대응 당시에는 `log4j2.formatMsgNoLookups=true` 설정을 적용하기 위해 전체 검색 시스템을 중단하고 모든 노드를 재시작해야 했다. ## 대형 guild의 인덱스 크기 제한 - 일부 인덱스에는 매우 큰 guild의 메시지가 집중됐다. - Elasticsearch 인덱스는 내부적으로 하나의 Lucene 인덱스이며, 약 20억 개 문서라는 `MAX_DOC` 제한이 있다. - 이 한도에 도달하면 해당 인덱스의 모든 색인 작업이 실패한다. - 당시에는 Safety 팀과 협력해 스팸 목적의 guild를 찾아 삭제하는 방식으로 복구했다. - 그러나 정상적인 대규모 커뮤니티가 20억 개 이상의 메시지를 축적하는 상황도 지원해야 했다. ## Kubernetes와 Elastic Operator 도입 - Discord는 무상태 서비스 운영에서 이미 Kubernetes의 편의성과 비용 최적화 효과를 경험하고 있었다. - 이후 Elasticsearch 같은 상태 저장 서비스에도 Elastic Kubernetes Operator(ECK)를 적용하는 방안을 선택했다. - ECK를 사용하면 다음을 선언적으로 관리할 수 있다. - Elasticsearch 클러스터 토폴로지 - 노드 구성과 설정 - Kubernetes 노드풀 위의 클러스터 배포 - 운영체제 업그레이드를 자동화하고, 안전한 롤링 재시작과 Elasticsearch 업그레이드 도구를 활용할 수 있게 됐다. ## 여러 소형 클러스터를 사용하는 셀 아키텍처 - 기존처럼 200개가 넘는 노드를 가진 거대한 클러스터 하나를 운영하는 대신, 더 많은 수의 작은 Elasticsearch 클러스터를 운영하는 구조를 구상했다. - 작은 클러스터는 다음과 같은 이점을 제공한다. - 장애 범위 축소 - 클러스터 자체의 조정 오버헤드 감소 - 노드 장애가 전체 검색 시스템에 미치는 영향 완화 - 업그레이드와 유지보수의 단순화 - Kubernetes와 ECK를 기반으로 이러한 클러스터들을 표준화하고 운영하려 했다. 대규모 검색 시스템에서는 단순히 노드를 추가하는 것보다 장애 격리와 운영 가능성을 함께 설계하는 것이 중요하다. 특히 벌크 작업을 부분 실패에 강하게 만들고, 클러스터를 작은 단위로 나누며, 롤링 업그레이드가 가능한 플랫폼을 채택하는 것이 장기적인 안정성에 유리하다.