서버 사이드 아키텍처 개요

Screeps는 프로그래머가 프로그래머를 위해 만든 게임이기 때문에, 서버에서 어떻게 동작하는지 궁금할 수도 있습니다. 저희도 이 프로젝트에 적용된 몇 가지 고급 아키텍처 솔루션을 공개하고자 합니다.

핵심 사실

  • 서버 사이드에서 사용하는 기술은 Node.js 8.9.3, MongoDB 3, Redis 3입니다.
  • 서버 사이드 JavaScript 코드 2만 라인.
  • 런타임 계산은 OVH의 40대 쿼드코어 전용 서버에서 병렬로 수행되며, 총 160개의 Intel Xeon CPU E3-1231 v3 코어(그리고 동일한 수의 node 인스턴스)를 사용합니다.
  • 각 샤드의 MongoDB는 24코어/128GB RAM 머신에서 구동되며 초당 3만 건의 업데이트 요청을 처리합니다.
  • 플레이어 런타임 코드는 메모리에서 동작하도록 최적화되어 있으며, 하드 드라이브나 데이터베이스에 대한 직접 요청을 수행하지 않습니다.

아키텍처 개요

모든 게임 데이터는 MongoDB에 저장됩니다. 각 게임 오브젝트는 별도의 DB 문서입니다. 이것이 DB에서 부여되는 id 속성이 오브젝트의 정체성으로 사용되는 이유입니다.

각 게임 틱은 Redis 기반의 특수 동기화(syncing) 코드로 제어됩니다. 틱은 두 단계로 구성됩니다:

  1. 플레이어 스크립트 계산.
  2. 명령 처리.

단계 처리 흐름을 더 크게 보면 다음과 같이 표현할 수 있습니다:

각 단계마다 작업 큐(task queue)가 생성됩니다. 1단계의 작업은 모든 활성 플레이어의 스크립트이고, 2단계는 게임 월드의 각 룸을 대상으로 합니다. 큐는 Redis List로 저장되며, 각 작업은 별도의 머신에서 개별적으로 처리됩니다.

틱은 활성 플레이어 목록을 만들고 이를 큐에 넣어 각 플레이어의 게임 스크립트를 처리하는 것으로 시작합니다. 모든 런타임 서버는 큐로부터 작업을 받아, 플레이어가 필요로 하는 DB 데이터를 요청하고, 게임 스크립트 계산을 실행해 여러 게임 오브젝트에 대한 명령을 수집합니다. 1단계 큐가 모두 처리되면 2단계가 시작됩니다. 활성 명령들이 큐에 들어가고, 런타임 서버는 각 룸의 오브젝트에 대한 명령 처리를 수행합니다.

처리 단계에서는 서로 다른 룸이, 계산 단계에서는 서로 다른 플레이어가 병렬로 별도로 처리되지만, 병렬 프로세스 수는 CPU 코어 수와 정확히 일치합니다. 하나의 룸과 하나의 플레이어는 하나의 코어에서 동기적으로 처리되므로, 여러 레이스 컨디션이 배제됩니다.

두 단계가 모두 끝나면 DB에서 게임 오브젝트를 변경하기 위한 일정 수의 요청이 만들어집니다. 이 요청들은 처리 단계가 끝난 뒤 벌크로 실행됩니다. MongoDB 3는 새 스토리지 엔진 WiredTiger를 사용하며, 문서 레벨 동시성 덕분에 DB 서버에서 여러 병렬 스레드의 장점을 활용할 수 있습니다. DB 변경이 완료되면 시스템은 다음 틱 처리로 전환합니다.

DB 오브젝트 업데이트는 하드 드라이브 접근이 필요한 유일한 작업입니다. DB 서버에서 디스크 플러시는 분당 1회만 수행되며, 디스크를 전혀 사용하지 않는 런타임 서버(디스크가 없습니다)에는 영향을 주지 않습니다. 런타임 서버는 작업 시작 전에 이미 RAM에 로드된 게임 오브젝트 데이터와 Memory 오브젝트를 전달받습니다. 유의미한 계산은 모두 런타임 서버의 CPU 코어에서 수행되며, 틱의 1단계(즉 계산 단계)에서 플레이어가 “임대”하여 사용합니다.

스케일링

이 시스템은 두 가지 레벨에서 쉽게 스케일링할 수 있도록 설계되었습니다:

  • DB 부하가 증가하면(즉 플레이어가 샤드에서 더 활발해지면) WiredTiger를 위한 CPU 코어 수를 늘리거나, 더 많은 월드 샤드(각각 독립 DB)를 추가할 수 있습니다.
  • 플레이어 계산으로 인한 총 CPU 부하가 증가하면, 이러한 계산을 수행하는 런타임 서버를 추가하기만 하면 됩니다. 런타임 서버는 기동 후 1분 이내에 Redis 큐에서 작업을 받아 처리할 수 있습니다.

스크립트 실행 환경

게임 스크립트 계산 단계의 작업을 수행할 때 Node.js의 vm 라이브러리를 사용합니다. 각 node 인스턴스 프로세스는 부모 프로세스에 접근할 수 없는 별도의 포크(fork)를 실행합니다. 이 포크는 계산에 필요한 데이터를 DB에 미리 요청합니다. 이후 유저를 위한 컨텍스트를 생성하고 vm.runInContext를 실행합니다. 컨텍스트는 이후 재사용을 위해 포크 안에 저장되며, 이를 통해 스크립트에서 global 오브젝트와 require 캐시를 반복적으로 사용할 수 있습니다. 또한 스크립트 컴파일 시 코드 캐시 데이터가 생성되어 저장되며, 이후 컴파일 속도를 높이는 데 사용됩니다.

runInContext는 플레이어별 실행 타임아웃을 적용해 호출되지만, 특정 종류의 고부하 작업에서는 스크립트 실행을 “우아하게” 종료하지 못하는 경우가 있습니다. 이런 상황이 발생하면 vm이 아니라 전체 포크 프로세스를 타임아웃에 따라 종료합니다. 그러면 해당 프로세스의 모든 플레이어 컨텍스트는 사라지고, 다시 처음부터 생성됩니다.

앞으로는 여러분이 로컬 머신에서 Screeps 시뮬레이션을 실행하고 연구할 수 있도록, 시스템 전체 코드를 오픈 소스로 공개할 계획도 있습니다.