Tacticool Mobile Online Shooter - Metaserver-Architektur

    Ein weiteres Gespräch mit Pixonic DevGAMM Talks - diesmal von unseren Kollegen bei PanzerDog. Der leitende Software-Ingenieur der Firma Pavel Platto zerlegte den Metaserver des Spiels mit einer serviceorientierten Architektur und erklärte, welche Lösungen und Technologien ausgewählt wurden, was und wie sie skaliert wurden und mit welchen Schwierigkeiten sie zu kämpfen hatten. Der Text des Berichts, Folien und Links zu anderen Aufführungen von der Mitap, wie immer, unter dem Schnitt.


    Zunächst möchte ich einen kleinen Trailer für unser Spiel zeigen:


    Der Bericht besteht aus 3 Teilen. Im ersten werde ich darüber sprechen, welche Technologien wir ausgewählt haben und warum, im zweiten darüber, wie unser Metaserver aufgebaut ist, und im dritten werde ich über die verschiedenen unterstützenden Infrastrukturen sprechen, die wir verwenden, und darüber, wie wir das Update ohne Ausfallzeiten implementiert haben .


    Technologischer Stack Der

    Metaserver wird auf Amazon gehostet und in Elixir geschrieben. Es ist eine funktionale Programmiersprache mit einem Akteurmodell der Berechnung. Da wir keine Ops haben, sind Programmierer an der Operation beteiligt, und der größte Teil der Infrastruktur wird als Code unter Verwendung von HashiCorp Terraform beschrieben.

    Tacticool befindet sich derzeit in der offenen Beta, der Metaserver befindet sich seit etwas mehr als einem Jahr in der Entwicklung und ist seit fast einem Jahr in Betrieb. Mal sehen, wie alles begann.



    Als ich zum Unternehmen kam, hatten wir bereits grundlegende Funktionen als Monolith in einem C / C ++ - Mix und PostageSQL-Speicher implementiert. Diese Implementierung hatte bestimmte Probleme.

    Erstens gab es aufgrund des niedrigen C-Niveaus einige schwer fassbare Fehler. Beispielsweise bleibt die Matchmaking-Funktion bei einigen Spielern aufgrund einer fehlerhaften Nullung des Arrays vor dessen Wiederverwendung hängen. Natürlich war es ziemlich schwierig, die Beziehung zwischen diesen beiden Ereignissen zu finden. Und da der Status mehrerer Threads überall im Code geändert wurde, waren die Race-Bedingungen nicht ohne.

    Die parallele Bearbeitung einer Vielzahl von Aufgaben kam ebenfalls nicht in Frage, da der Server zu Beginn von ca. 10 Worker-Prozessen gestartet wurde, die durch Anfragen an Amazon oder die Datenbank blockiert wurden. Und selbst wenn Sie diese Blockierungsanforderungen vergessen, begann der Dienst bei einigen Hundert Verbindungen, die außer Ping keine Operationen ausführten, auseinanderzufallen. Darüber hinaus konnte der Dienst nicht horizontal skaliert werden.

    Nachdem wir einige Wochen damit verbracht hatten, die kritischsten Fehler zu suchen und zu beheben, entschieden wir, dass es einfacher war, alles von Grund auf neu zu schreiben, als zu versuchen, alle Mängel der aktuellen Lösung zu beheben.

    Und wenn Sie von vorne anfangen, ist es sinnvoll, eine Sprache zu wählen, mit der Sie einige der vorherigen Probleme vermeiden können. Wir hatten drei Kandidaten:

    • C #;
    • Geh;
    • Elixier



    C # war auf der Bekanntschaftsliste, as Der Client und der Spieleserver sind in Unity geschrieben, und die meisten Erfahrungen im Team wurden mit dieser Programmiersprache gesammelt. Go und Elixir wurden in Betracht gezogen, da es sich um moderne und recht beliebte Sprachen handelt, die für die Entwicklung von Serveranwendungen entwickelt wurden.

    Die Probleme der vorherigen Iteration halfen uns, die Kriterien für die Bewertung von Kandidaten zu bestimmen.

    Das erste Kriterium war die Bequemlichkeit der Arbeit mit asynchronen Operationen. In C # wurde das bequeme Arbeiten mit asynchronen Vorgängen nicht beim ersten Versuch angezeigt. Dies führte dazu, dass wir einen „Zoo“ von Lösungen haben, die meiner Meinung nach immer noch ein wenig auf der Seite stehen. In Go und Elixir wurde dieses Problem beim Entwerfen dieser Sprachen berücksichtigt. Beide verwenden Lightweight-Threads (in Go sind sie Goroutinen, in Elixir sind sie Prozesse). Diese Streams haben einen viel geringeren Overhead als System-Threads, und da wir sie in Zehntausenden und Hunderttausenden erstellen können, müssen wir sie nicht blockieren.

    Das zweite Kriterium war die Fähigkeit, mit wettbewerbsfähigen Prozessen zu arbeiten. C # out of the box bietet nichts anderes als Thread-Pools und gemeinsamen Speicher, auf den der Zugriff mit verschiedenen Synchronisationsprimitiven geschützt werden muss. Go hat ein weniger fehleranfälliges Modell in Form von Goroutinen und Kanälen. Elixir bietet dagegen ein Darstellermodell ohne Shared Memory mit Messaging an. Das Fehlen eines gemeinsam genutzten Speichers ermöglichte die Implementierung von Technologien, die für eine wettbewerbsfähige Ausführungsumgebung zur Laufzeit nützlich sind, z.

    Das dritte Kriterium war die Verfügbarkeit von Werkzeugen für die Arbeit mit unveränderlichen Datentypen. Alle meine Entwicklungserfahrungen haben gezeigt, dass ein ziemlich großer Teil der Fehler mit falschen Datenänderungen verbunden ist. Eine Lösung dafür gibt es schon lange - unveränderliche Datentypen. In C # können diese Datentypen erstellt werden, allerdings auf Kosten einer Tonne Boilerplate. In Go ist dies überhaupt nicht möglich. Und in Elixir sind alle Datentypen unveränderlich.

    Und das letzte Kriterium war die Anzahl der Spezialisten. Hier liegen die Ergebnisse auf der Hand. Am Ende haben wir uns für Elixir entschieden.

    Mit der Wahl des Hostings war alles viel einfacher. Wir haben bereits Spieleserver in Amazon GameLift gehostet. Darüber hinaus bietet Amazon eine Vielzahl von Diensten, mit denen wir die Entwicklungszeit reduzieren können.



    Wir haben uns vollständig der Cloud ergeben und stellen selbst keine Lösungen von Drittanbietern bereit - Datenbanken, Nachrichtenwarteschlangen - all dies wird von Amazon für uns verwaltet. Meiner Meinung nach ist dies die einzige Lösung für ein kleines Team, das ein Online-Spiel entwickeln möchte, nicht die Infrastruktur dafür.

    Wir haben die Wahl der Technologien herausgefunden und wollen uns nun der Funktionsweise des Metaservers widmen.



    Im Allgemeinen: Clients stellen über Web-Socket-Verbindungen eine Verbindung zu Amazon Load Balancer her. Der Balancer verteilt diese Verbindungen zwischen mehreren Front-End-Instanzen, das Front-End sendet Client-Anforderungen an das Back-End. Das Front-End und das Back-End kommunizieren jedoch indirekt über Nachrichtenwarteschlangen. Es gibt eine separate Warteschlange für jeden Nachrichtentyp, und das Frontend bestimmt anhand des Nachrichtentyps, wo es geschrieben werden soll, und das Backend überwacht diese Warteschlangen.

    Damit das Backend eine Antwort auf die Anfrage an den Client oder eine Art Ereignis senden kann, verfügt jedes Frontend über eine separate Warteschlange (die speziell dafür reserviert wurde). Und bei jeder Anforderung erhält das Backend eine Frontend-ID, um zu bestimmen, in welche Warteschlange die Antwort geschrieben werden soll. Wenn er ein Ereignis senden muss, ruft er die Datenbank auf, um herauszufinden, mit welcher Frontend-Instanz der Client verbunden ist.

    Gehen wir mit dem allgemeinen Schema zu den Details über.



    Zunächst werde ich auf einige Funktionen der Client-Server-Interaktion eingehen. Wir verwenden unser Binärprotokoll, weil es sehr effizient ist und es ermöglicht, Verkehr zu sparen. Zweitens sendet der Server für alle Vorgänge mit einem Konto, das es ändert, diese Änderungen nicht an den Client, sondern an die vollständige (aktualisierte) Version dieses Kontos. Dies ist zwar etwas weniger effizient, nimmt jedoch nicht viel Platz in Anspruch und vereinfacht unser Leben sowohl auf dem Client als auch auf dem Server erheblich. Das Frontend stellt außerdem sicher, dass der Client nicht mehr als eine Anforderung gleichzeitig ausführt. Auf diese Weise können Sie Fehler auf dem Client abfangen, wenn er beispielsweise zu einem anderen Bildschirm wechselt, bevor der Player das Ergebnis der vorherigen Operation sieht.

    Nun ein wenig darüber, wie das Frontend aufgebaut ist.



    Ein Frontend ist im Wesentlichen ein Webserver, der auf Web-Socket-Verbindungen wartet. Für jede Sitzung werden zwei Prozesse erstellt. Der erste Prozess dient der Web-Socket-Verbindung selbst und der zweite ist eine Statusmaschine, die den aktuellen Status des Clients beschreibt. Basierend auf diesem Status wird die Gültigkeit von Anforderungen vom Client bestimmt. Beispielsweise können fast alle Anforderungen erst abgeschlossen werden, wenn die Autorisierung abgeschlossen ist. Da es außer diesen Sitzungen keinen Status auf dem Frontend gibt, ist es sehr einfach, neue Frontend-Instanzen hinzuzufügen, aber es ist etwas schwieriger, die alten zu löschen. Vor der Deinstallation müssen Sie alle Clients ihre aktuellen Anforderungen ausführen lassen und sie auffordern, die Verbindung zu einer anderen Instanz wiederherzustellen.

    Nun darüber, wie das Backend aussieht. Derzeit besteht es aus fünf Diensten.



    Der erste behandelt alles, was mit Konten zu tun hat - von Einkäufen für die Spielwährung bis zur Erledigung von Quests. Der zweite funktioniert mit allem, was mit Spielen zu tun hat - er interagiert direkt mit GameLift und Spieleservern. Der dritte Service ist das Einkaufen für echtes Geld. Der vierte und fünfte Teil sind für soziale Interaktionen zuständig - einer für Freunde, der andere für ein Gesellschaftsspiel.

    Jeder der Backend-Services sieht aus architektonischer Sicht absolut identisch aus. Sie sind eine Reihe von Pipelines, die jeweils einen Nachrichtentyp verarbeiten. Die Pipeline besteht aus zwei Elementen: Produzent und Verbraucher.



    Die einzige Aufgabe des Produzenten besteht darin, Nachrichten aus der Warteschlange zu lesen. Daher ist es in einer ganz allgemeinen Form implementiert und für jede Pipeline müssen wir nur angeben, wie viele Produzenten es gibt, aus welcher Warteschlange zu lesen ist und wie viele Konsumenten jeder Produzent bedienen wird. Consumer hingegen wird für jede Pipeline separat implementiert und ist ein Modul mit einer einzigen obligatorischen Funktion, das eine Nachricht akzeptiert, die gesamte erforderliche Arbeit erledigt und eine Liste der Nachrichten zurückgibt, die an andere Dienste an den Client oder den Spieleserver gesendet werden müssen. Der Produzent setzt auch einen Gegendruck ein, damit bei einem starken Anstieg der Anzahl der Nachrichten keine Überlastung auftritt und er nach Nachrichten fragt, die nicht mehr als freie Verbraucher haben.

    Back-End-Services enthalten keinen Status, daher können alte Instanzen problemlos hinzugefügt und entfernt werden. Das einzige, was Sie vor dem Löschen tun müssen, ist, die Produzenten aufzufordern, keine neuen Nachrichten mehr zu lesen und den Verbrauchern ein wenig Zeit zu geben, um die Verarbeitung aktiver Nachrichten abzuschließen.

    Wie erfolgt die Interaktion mit GameLift? GameLift besteht aus mehreren Komponenten. Bei den von uns verwendeten handelt es sich um einen FlexMatch-Matchmaker, eine Reihe von Placements, die bestimmen, in welcher Region eine Spielsitzung mit diesen Spielern stattfinden soll, sowie um die Flotten selbst, die aus Spielservern bestehen.



    Wie läuft diese Interaktion ab? Meta kommuniziert nur direkt mit dem Matchmaker, sendet ihm Anfragen, um das Match zu finden. Und er benachrichtigt das Meta über alle Ereignisse während des Matchmaking über dieselben Nachrichtenwarteschlangen. Sobald er eine geeignete Gruppe von Spielern für den Beginn des Spiels gefunden hat, sendet er eine Anfrage an die Platzierungswarteschlange, die wiederum einen Server für sie auswählt.

    Die Interaktion von Meta mit dem Gameserver ist denkbar einfach. Der Spielserver benötigt Informationen zu Konten, Bots und einer Karte, und das Meta sendet alle diese Informationen in einer einzigen Nachricht an die Warteschlange, die speziell für diese Übereinstimmung erstellt wurde.



    Nach der Aktivierung hört der Spieleserver diese Warteschlange ab und empfängt alle benötigten Daten. Am Ende des Spiels sendet er seine Ergebnisse an die allgemeine Warteschlange, die das Meta abhört.

    Gehen wir nun zu der zusätzlichen Infrastruktur über, die wir verwenden.



    Das Bereitstellen von Diensten ist ganz einfach. Sie alle arbeiten in Docker-Containern, und für die Orchestrierung verwenden wir Amazon ECS. Es ist viel einfacher als Kubernetes, natürlich weniger ausgefeilt, aber es führt die Aufgaben aus, die wir von ihm benötigen. Nämlich: Skalierung von Diensten und Rolling Releases, wenn wir eine Art Bugfix durchführen müssen.

    Und der letzte Service, den wir auch nutzen, ist AWS Fargate. Dies erspart uns die unabhängige Verwaltung des Maschinenclusters, auf dem unsere Docker-Container ausgeführt werden.



    Als Hauptspeicher verwenden wir DynamoDB. Zunächst haben wir uns dafür entschieden, weil es sehr einfach zu bedienen und zu skalieren ist. Wir verwenden Redis auch als zusätzlichen Speicher über den von Amazon ElasiCache verwalteten Dienst. Wir verwenden es für die Aufgabe der globalen Rangfolge von Spielern und zum Zwischenspeichern grundlegender Kontoinformationen in Situationen, in denen wir Daten von Hunderten von Spielkonten sofort an den Kunden zurückgeben müssen (z. B. in derselben Bewertungstabelle oder in der Freundesliste).

    Zum Speichern von Konfigurationen, Meta-Gameplay-Mechanismen, Beschreibungen von Waffen, Helden usw. Wir verwenden eine JSON-Datei, die wir an die Images der Dienste anhängen, die sie benötigen. Weil es für uns viel einfacher ist, eine neue Version des Dienstes mit aktualisierten Daten bereitzustellen (falls ein Fehler entdeckt wurde), als eine Entscheidung zu treffen, mit der diese Daten in der Laufzeit von einem externen Speicher dynamisch aktualisiert werden.

    Für die Protokollierung und Überwachung verwenden wir einige Dienste.



    Beginnen wir mit CloudWatch. Dies ist ein Überwachungsdienst, bei dem Metriken aller Amazon-Dienste fließen. Aus diesem Grund haben wir uns entschlossen, Metriken von unserem Metaserver dorthin zu senden. Und für die Protokollierung verwenden wir einen gemeinsamen Ansatz sowohl auf dem Client als auch auf dem Spieleserver und auf dem Metaserver. Wir senden alle Protokolle an den amazonischen Dienst Kinesis Firehose, der sie wiederum an Elasticseach und S3 weiterleitet.

    In Elasticseach speichern wir nur relativ aktuelle Daten und suchen mit Hilfe von Kibana nach Fehlern, lösen einige Aufgaben der Spielanalyse und erstellen operative Dashboards, zum Beispiel mit dem CCU-Zeitplan und der Anzahl der Neuinstallationen. S3 enthält alle historischen Daten und wird über den Athena-Service verwendet, der eine SQL-Schnittstelle über die Daten in S3 stellt.

    Nun ein wenig darüber, wie wir Terraform verwenden.



    Terraform ist ein Tool, mit dem Sie die Infrastruktur deklarativ beschreiben können. Wenn sich eine Beschreibung ändert, werden automatisch die Maßnahmen festgelegt, die Sie ergreifen müssen, um Ihre Infrastruktur auf den neuesten Stand zu bringen. Mit einer einzigen Beschreibung erhalten wir eine nahezu identische Umgebung für Inszenierung und Produktion. Außerdem sind diese Umgebungen vollständig isoliert, da sie unter verschiedenen Konten bereitgestellt werden. Der einzige wesentliche Nachteil von Terraform ist für uns die unvollständige Unterstützung von GameLift.

    Ich werde auch darüber sprechen, wie wir das Update ohne Ausfallzeiten implementiert haben.



    Wenn wir Updates veröffentlichen, erstellen wir eine Kopie der meisten Ressourcen: Dienste, Nachrichtenwarteschlangen, einige Labels in der Datenbank. Die Spieler, die die neue Version des Spiels herunterladen, stellen eine Verbindung zu diesem aktualisierten Cluster her. Spieler, die noch nicht aktualisiert wurden, können einige Zeit mit der alten Version des Spiels weiterspielen und eine Verbindung zum alten Cluster herstellen.

    Wie wir es umgesetzt haben. Erstens mit der Modul-Engine in Terraform. Wir haben ein Modul zugeordnet, in dem wir alle versionierten Ressourcen beschrieben haben. Und diese Module können mit unterschiedlichen Parametern mehrfach importiert werden. Dementsprechend importieren wir für jede Version dieses Modul und geben die Nummer dieser Version an. Außerdem hat uns das Fehlen eines Schemas in DynamoDB geholfen, das es ermöglicht, Datenmigrationen nicht während des Updates durchzuführen, sondern sie für jedes Konto zu verschieben, bis sich der Besitzer bei der neuen Version des Spiels anmeldet. Und im Balancer legen wir einfach die Regeln für jede Version fest, damit er weiß, wo er Spieler mit verschiedenen Versionen weiterleiten kann.

    Zum Schluss noch ein paar Dinge, die wir gelernt haben. Zunächst muss die Konfiguration der gesamten Infrastruktur automatisiert werden. Das heißt Wir haben einige Dinge für einige Zeit mit unseren Händen eingerichtet, aber früher oder später haben wir einen Fehler in den Einstellungen gemacht, aufgrund dessen es Fakaps gab.



    Und das Letzte: Sie benötigen entweder eine Replik oder eine Sicherungskopie für jedes Element Ihrer Infrastruktur. Und wenn Sie es nicht für etwas tun, wird uns diese spezielle Sache jemals im Stich lassen.

    Fragen aus dem Publikum


    - Aber stört es Sie nicht, dass die automatische Skalierung aufgrund eines Fehlers zu hoch ist und Sie eine Menge Geld erhalten?

    - Für die automatische Skalierung werden trotzdem Grenzen gesetzt. Wir werden kein zu großes Limit setzen, um nicht auf viel Geld hereinzufallen. Dies ist die Hauptlösung + Überwachung. Sie können Warnungen festlegen, wenn etwas zu stark ist.

    - Was sind Ihre aktuellen Grenzen? Bezogen auf die aktuelle Infrastruktur in Prozent.

    - Jetzt haben wir eine offene Beta-Testphase in 11 Ländern. Es ist also keine so große CCU, die zumindest irgendwie evaluiert werden kann. Jetzt ist die Infrastruktur für die Anzahl der Menschen, die wir haben, zu überlastet.

    - Und es gibt noch keine Grenzen?

    - Ja, es ist nur so, dass sie 10-100 Mal höher sind als unsere CCU. Tu nicht weniger.

    - Sie sagten, dass Sie Linien zwischen dem Frontend und dem Backend haben - das ist sehr ungewöhnlich. Warum nicht gleich?

    - Wir wollten, dass zustandslose Dienste den Sicherungsmechanismus einfach implementieren, damit der Dienst nicht mehr Nachrichten anfordert, als er freie Handler hat. Wenn beispielsweise ein Handler ausfällt, gibt die Warteschlange dieselbe Nachricht an einen anderen Handler weiter - möglicherweise ist dies erfolgreich.

    - Bleibt die Warteschlange irgendwie bestehen?

    Ja Dies ist ein Amazonian SQS-Dienst.

    - In Bezug auf die Warteschlangen: Wie viele Kanäle werden während des Spiels erstellt? Haben Sie eine bestimmte Anzahl von Kanälen für jedes Match?

    - Es entsteht relativ wenig. Die meisten Warteschlangen, z. B. Anforderungswarteschlangen, sind statisch. Es gibt eine Warteschlange für Autorisierungsanfragen und eine Warteschlange für den Beginn des Spiels. Von den dynamisch erstellten Warteschlangen haben wir nur Warteschlangen für jedes Frontend (beim Start erstellt es für eingehende Nachrichten für Clients) und für jede Übereinstimmung erstellen wir eine Warteschlange. Bei diesem Service kostet es fast nichts, sie haben jede Anfrage gleich abgerechnet. Das heißt Jede Anfrage an SQS (eine Warteschlange erstellen, etwas daraus lesen) kostet dasselbe und gleichzeitig löschen wir diese Warteschlangen nicht zum Speichern, sie werden später gelöscht. Und die Tatsache, dass es sie gibt, kostet uns nichts.

    - In dieser Architektur ist dies keine Grenze für Sie?

    - Nein.

    Weitere Gespräche mit Pixonic DevGAMM Talks



    Jetzt auch beliebt: