Под капотом Screeps — виртуализация в MMO-песочнице для программистов

В этой статье я расскажу про одну малоизвестную технологию, которая нашла ключевое применение в нашей онлайн-игре для программистов. Чтобы долго не тянуть резину, сразу спойлер: кажется, что такого шаманства в нативном коде Node.js, к которому мы пришли после нескольких лет разработки, до нас еще никто не делал. Движок изолированных виртуальных машин (опенсорсный), который работает под капотом проекта, был написан специально для его нужд, и на данный момент используется в продакшене нами и еще одним стартапом. А возможности изоляции, которые он дает, не имеют аналогов и заслуживают, чтобы о них рассказали.

Но давайте обо всем по порядку.


Предыстория

Вы любите программирование? Не тот рутинный энтерпрайз-кодинг, которым многие из нас вынуждены заниматься 40 часов в неделю, борясь с прокрастинацией, вливая в себя литры кофе и профессионально выгорая;, а программирование — ни с чем не сравнимый волшебный процесс трансформирования мыслей в работающую программу, получение наслаждения от того, что только что написанный тобой код воплощается на экране и начинает жить той жизнью, которой велит ему создатель. В такие моменты слово «Создатель» хочется написать с большой буквы — настолько чувство, возникающее в процессе, иногда бывает близко к благоговению.

mm2a5hmu2k6s26hviyoykwd6tv8.jpeg

Жаль лишь, что совсем немного реальных проектов, связанных с каждодневным заработком, могут предложить своим разработчикам такие чувства. Чаще всего для того, чтобы не терять страсть к программированию, энтузиастам приходится затевать интрижку на стороне: программистское хобби, пет-проект, модный open-source, просто скрипт на питоне по автоматизации своего умного дома… или поведения персонажа в какой-нибудь популярной онлайн-игре.

Да, именно онлайн-игры часто дают неисчерпаемый источник вдохновения для программистов. Уже самые первые игры в этом жанре (Ultima Online, Everquest) привлекли немало умельцев, заинтересованных не столько в том, чтобы отыгрывать роль и наслаждаться фентезийностью мира, сколько применением своих талантов для автоматизации всего и вся в виртуальном игровом пространстве. И по сей день это остается особой дисциплиной онлайн-олимпиады ММО-игр: изощриться так написать своего бота, чтобы остаться незамеченным администрацией и получить максимальный профит по сравнению с другими игроками. Или другими ботами — как, например, в EVE Online, где торговля на густонаселенных рынках чуть менее, чем полностью контролируется торговыми скриптами, прямо как на настоящих биржах.

В воздухе витала идея онлайн-игры, изначально и полностью ориентированной на программистов. Такой игры, в которой написание бота — это не наказуемое деяние, а суть геймплея. Где задачей было бы не выполнение из раза в раз одинаковых действий «Убей Х монстров и найди Y предметов», а написание скрипта, способного грамотно выполнять эти действия от вашего имени. И так как подразумевается онлайн-игра в жанре ММО — то соперничество происходит со скриптами других игроков в реальном времени в едином общем игровом мире.

Так в 2014 году появилась игра Screeps (от слов «Scripts» и «creeps») — стратегическая ММО-песочница реального времени с единым большим persistent world, в котором игроки не имеют никакого влияния на происходящее кроме как через написание скриптов AI для своих игровых юнитов. Все механики обыкновенной стратегической игры — добыча ресурсов, создание юнитов, строительство базы, захват территорий, производство и торговля — требуется программировать самому игроку через JavaScript API, который предоставляются миром игры. Отличие от разных соревнований по написанию AI в том, что мир игры, как и полагается онлайновому игровому миру, постоянно работает и живет своей жизнью в реальном времени 24/7 на протяжении последних 4 лет, запуская AI каждого игрока каждый игровой такт.

Итак, хватит о самой игре — этого должно быть вполне достаточно, чтобы дальше можно было понять суть технических проблем, с которыми мы столкнулись при разработке. Больше представления можно получить из этого видео, но это уже по желанию:


Видео трейлер


Технические проблемы

Суть механики игрового мира заключается в следующем: весь мир разбит на комнаты, которые связаны между собой выходами по четырем сторонам света. Одна комната является атомарной единицей процесса обработки состояния игрового мира. В комнате могут быть некие объекты (например, юниты), у которых есть свое состояние, и на каждом игровом такте они получают команды от игроков. Серверный обработчик берет по одной комнате за раз, выполняет эти команды, меняя состояние объектов, и коммитит новое состояние комнаты в базу. Эта система хорошо горизонтально масштабируется: можно добавлять больше обработчиков в кластер, и так как комнаты архитектурно изолированы друг от друга, то параллельно может обрабатываться столько комнат, сколько запущено обработчиков.

mpd0ed40jp8efrihjrxkfft49fc.png

На данный момент у нас в игре 42 060 комнат. Серверный кластер из 36 четырехъядерных физических машин содержит 144 обработчика. Для формирования очередей мы используем Redis, весь бекенд написан на Node.js.

Это был один этап работы игрового такта. Но откуда берутся команды игроков? Специфика игры в том, что нет никакого интерфейса, где можно было бы кликнуть на юнита и сказать ему отправиться в определенную точку или построить определенное сооружение. Максимум, что можно сделать в интерфейсе — поставить нематериальный флаг в нужном месте комнаты. Чтобы юнит пришел в это место и сделал необходимое действие, нужно, чтобы ваш скрипт на протяжении нескольких игровых тактов выполнял примерно следующее:

module.exports.loop = function() {
  let creep = Game.creeps['Creep1'];
  let flag = Game.flags['Flag1'];
  if(!creep.pos.isEqualTo(flag.pos)) {
    creep.moveTo(flag.pos);
  }
}

Получается, на каждом игровом такте нужно взять функцию loop игрока, выполнить её в полноценном JavaScript-окружении этого конкретного игрока (в котором существует сформированный для него объект Game), получить набор приказов для юнитов, и отдать их на следующий этап процессинга. Кажется, все довольно просто.

poomvhcw4nhpjcsgk2df0a5hg_e.png

Проблемы начинаются, когда дело доходит до нюансов реализации. На данный момент у нас 1600 активных игроков в мире. Скрипты отдельных игроков уже язык не поворачивается назвать «скриптами» — некоторые из них содержат до 25к строк кода, компилируются из TypeScript с примесью wasm (да, мы поддерживаем WebAssembly!), и реализуют концепцию настоящих миниатюрных ОС, в которых игроки разработали собственный пул игровых задач-процессов и их менеджмент через ядро, которое берет столько задач, сколько получается выполнить на данном игровом такте, выполняет их, а невыполненные откладывает в очередь до следующего такта. Так как на каждом такте ресурсы CPU и памяти игрока ограничены, то такая модель хорошо работает. Хотя и не является обязательной — для начала игры новичку достаточно взять скрипт из 15 строк, который к тому же уже написан в рамках туториала.

Но теперь давайте вспомним, что скрипт игрока должен работать в настоящей машине JavaScript, к тому же еще и поддерживающей wasm (например, есть игроки, кто пишут игровой код на Rust и C/С++). И что игра работает в реальном времени — то есть JavaScript-машина каждого игрока должна постоянно существовать, работая с неким заданным темпом, чтобы не замедлять игру в целом. Этап выполнения игровых скриптов и формирования приказов юнитам работает примерно по такому же принципу, как обработка комнат — каждый скрипт игрока является задачей, которую берет на себя один обработчик из пула, в кластере работает множество параллельных обработчиков. Но в отличие от этапа процессинга комнат, здесь уже кроется немало трудностей.

Во-первых, нельзя просто распределять задачи по обработчикам случайным образом на каждом такте так, как это возможно делать в случае комнат. JavaScript-машина игрока должна работать без останова, каждый следующий такт — это просто новый вызов функции loop, но глобальный контекст должен продолжать существовать тот же. Грубо говоря, в игре разрешается делать примерно так:

let counter = 0;
let song = ['EX-', 'TER-', 'MI-', 'NATE!'];

module.exports.loop = function () {
  Game.creeps['DalekSinger'].say(song[counter]);
  counter++;
  if(counter == song.length) {
    counter = 0;
  }
}

szskjecusiurkgvg1apq3sxzjdw.gif

Такой крип будет петь по одной строчке песни каждый игровой такт. Номер строчки песни counter хранится в глобальном контексте, который сохраняется между тактами. Если каждый раз выполнять скрипт этого игрока в новом процессе обработчика, то контекст будет теряться. Значит, все игроки должны быть распределены по конкретным обработчикам, и менять их должны как можно реже. Но как тогда быть с балансировкой нагрузки? Один игрок может затратить 500 мс выполнения на этой ноде, а другой игрок — 10 мс, и очень трудно спрогнозировать это заранее. Если на одну ноду вдруг попадут 20 игроков по 500 мс, то работа такой нода займет 10 секунд, в течение которых все остальные будут ждать её завершения и простаивать. А чтобы перебалансировать этих игроков и перекинуть на другие ноды, приходится терять их контекст.

Во-вторых, окружение игрока должно быть хорошо изолировано от других игроков и от серверного окружения. И это касается не только безопасности, но и комфорта для самих пользователей. Если соседний игрок, выполняющийся на той же ноде в кластере, что и я, творит что попало, генерирует много мусора, и вообще ведет себя неподобающе, то я не должен это чувствовать. Так как ресурсом CPU в игре является время выполнения скрипта (он подсчитывается с момента старта и до конца метода loop), то трата ресурсов на посторонние задачи во время выполнения моего скрипта могут быть очень чувствительными, ведь расходуются из моего бюджета ресурсов CPU.

В попытках справиться с этими проблемами мы пришли к нескольким решениям.


Первая версия

Первая версия движка игры была основана на двух базовых вещах:


  • штатный модуль vm в поставке Node.js,
  • форки рантайм-процессов.

Выглядело это примерно следующим образом. На каждой машине в кластере существовало 4 (по числу ядер) процесса обработчиков игровых скриптов. При получении новой задачи из очереди игровых скриптов, обработчик запрашивал нужные данные из базы и передавал их в дочерний процесс, который поддерживался в постоянно запущенном состоянии, перезапускался в случае сбоя и переиспользовался разными игроками. Дочерний процесс, будучи изолированным от родительского (в котором содержалась бизнес-логика кластера), умел только одно: создать объект Game из полученных данных и запустить виртуальную машину игрока. Для запуска использовался модуль vm в Node.js.

mupkxlqzbbgh4zjnnymqjdjapm0.png

Почему это решение было неидеальным? Строго говоря, здесь не решались вышеописанные две проблемы.

vm работает в таком же однопоточном режиме, что и сам Node.js. Поэтому чтобы иметь на 4-ядерной машине четыре параллельных обработчика на каждом ядре, нужно иметь 4 процесса. Перемещение «живущего» в одном процессе игрока на другой процесс приводит к полному пересозданию глобального контекста, даже если это происходит в рамках одной и той же машины.

Кроме того, vm на самом деле не создает полностью изолированную виртуальную машину. Что оно делает, так это лишь создает изолированный контекст, или область видимости, но выполняет код в том же экземпляре виртуальной машине JavaScript, откуда происходит вызов vm.runInContext. А значит — в том же экземпляре, в каком запускаются и другие игроки. Хоть игроки и разделены по изолированным глобальным контекстам, но, будучи частью одной и той же виртуальной машины, имеют общую heap-память, общий garbage collector и генерируют мусор совместно. Если игрок «А» сгенерировал много мусора за время выполнения своего игрового скрипта, закончил работу, и управление перешло к игроку «Б», то в этот момент вполне может вызваться сбор всего мусора процесса, и игрок «Б» заплатит своим временем CPU за сбор чужого мусора. Не говоря уже о том, что все контексты работают в одном и том же event loop, и теоретически возможно выполнение чужого промиса в любой момент, хотя мы и пытались это предотвращать. Также vm не позволяет контролировать, сколько heap-памяти выделяется под выполнение скрипта, доступна вся память процесса.


isolated-vm

Живет на свете такой замечательный человек по имени Марсель Лаверде. Для одних он в свое время стал замечателен тем, что написал библиотеку node-fibers, для других — тем, что взломал Facebook и был нанят там работать. А для нас он замечателен потому, что щедро участвовал в нашей самой первой краудфандинговой кампании и по сей день является большим фанатом Screeps.

Наш проект уже несколько лет как вышел в опенсорс — сервер игры опубликован на GitHub. Хотя официальный клиент и продается платно через Steam, но существуют альтернативные его версии, а сам сервер доступен для изучения и модификации в любых масштабах, что мы всячески поощряем.

И вот как-то раз Марсель пишет нам: «Ребята, у меня хороший опыт в нативной разработке C/C++ под Node.js, и мне нравится ваша игра, но не во всем нравится как она работает — давайте мы с вами напишем совершенно новую технологию запуска виртуальных машин под Node.js специально для Screeps?».

Так как денег Марсель не просил, мы не смогли отказаться. Через несколько месяцев нашего сотрудничества на свет появилась библиотека isolated-vm. И это поменяло абсолютно все.

isolated-vm отличается от vm тем, что изолирует не контекст, а isolate в терминах V8. Не вдаваясь в детали, это означает, что создается полноценный отдельный экземпляр JavaScript-машины, который обладает не только собственным глобальным контекстом, но и собственной heap-памятью, сборщиком мусора и работает в рамках отдельного event loop. Из минусов: на каждую запущенную машину требуется небольшой оверхед RAM (порядка 20 Мб), а также внутрь машины невозможно передавать объекты или вызывать функции напрямую, весь обмен надо сериализовать. На этом минусы заканчивается, в остальном — это просто панацея!

ghcz5vxih2oaferse6kgv-zruvq.png

Теперь стало действительно возможным запускать скрипт каждого игрока в своем собственном полностью изолированном пространстве. У игрока есть свои 500 Мб хипа, если он закончился — то это значит, что закончился именно твой собственный хип, а не хип общего процесса. Если сгенерировал мусор — то это твой собственный мусор, тебе его и собирать. Повисшие промисы выполнятся только тогда, когда твоему изоляту перейдет управление в следующий раз, и не ранее. Ну и секьюрность — ни при каких обстоятельствах невозможно получить доступ куда-то вовне изолята, только если найти где-то уязвимость на уровне V8.

Но что насчет балансировки? Еще один плюс isolated-vm в том, что он запускает машины из этого же процесса, но в отдельных тредах (здесь пригодился опыт работы Марселя над node-fibers). Если у нас 4-ядерная машина, мы можем создать пул из 4 тредов, и запускать в один момент времени 4 параллельных машины. При этом находясь в рамках одного и того же процесса, а значит, имея общую память, мы можем перекидывать любого игрока из одного треда в другой внутри этого пула. Хоть каждый игрок и остается привязанным к одному конкретному процессу на одной конкретной машине (чтобы не терять глобальный контекст), но балансировки между 4 тредами оказывается достаточно, чтобы решить проблемы распределения «тяжелых» и «легких» игроков между нодами так, чтобы все обработчики заканчивали работу одновременно и вовремя.

После обкатки этой функции в экспериментальном режиме мы получили огромное количество положительных отзывов от игроков, скрипты которых стали работать гораздо лучше, стабильнее и предсказуемее. И теперь это наш движок по умолчанию, хотя игроки до сих пор могут по желанию выбрать legacy runtime чисто в целях обратной совместимости со старыми скриптами (некоторые игроки осознанно ориентировались на специфику shared-окружения в игре).

Конечно, есть еще пространство для оптимизации и дальше, а также есть и другие интересные области проекта, в которых мы решали разные технические проблемы. Но об этом в другой раз.

© Habrahabr.ru