start / blog / netzwerk
Veröffentlicht: 18. Dezember 2025 12 Min. Lesezeit netzwerk
Dedicated-Server-Architektur: 48 Spieler pro Container

Dedicated-Server-Architektur: 48 Spieler pro Container

Wir haben den autoritativen Game Server vom Backend getrennt, Matchmaking und Raumzuweisung neu gebaut und die realen Kosten pro gleichzeitigem Spieler gemessen.

Playtest-Nacht: 40 Spieler, ein Prozess, ein Crash

Im Februar letzten Jahres haben wir an einem Freitag um 21:00 Uhr einen geschlossenen Playtest mit 40 Leuten gefahren. Das Spiel war auf 8-Spieler-Matches ausgelegt, und an diesem Abend liefen fünf Matches gleichzeitig. Um 23:40 Uhr flog ein Drittel der Spieler auf einen Schlag raus. Übrig blieb eine einzige Zeile: OutOfMemoryException, darunter fünf verschiedene matchId-Werte.

Die Ursache war kein Bug, sondern die Architektur. Fünf Matches lebten in einem einzigen Prozess, in einem einzigen Unity Headless Build. Ein Leak in einem Match riss die anderen vier mit. Was wir in dieser Nacht gelernt haben: Isolation pro Raum ist keine Optimierung, sondern eine Frage der Korrektheit. Wir haben auch ein Speicherlimit pro Prozess versucht, gestorben ist trotzdem der gemeinsame Prozess.

In den folgenden drei Monaten haben wir die Game-Server-Architektur von Grund auf neu gebaut. Die Zahlen und die verworfenen Alternativen weiter unten sind Messungen aus dieser Zeit, alle aus einem Prototyp für einen kompetitiven 8-Spieler-Shooter. Die Engine ist Unity 2022 LTS, der Transport ein eigener zuverlässiger Kanal über UDP.

Warum ein autoritativer Server nicht verhandelbar ist

In der ersten Version berechnete der Client die Bewegung und meldete das Ergebnis an den Server. Im zweiten Playtest haben drei der 40 Spieler die Pakete manipuliert und ihre Laufgeschwindigkeit verdoppelt. Kaputt war nicht der Erkennungscode, sondern die Architektur: Wenn der Client Positionen schreibt, wird Cheating zu einem Wettlauf um Validierung, und den verliert man. Wir haben es mit einem serverseitigen Tempolimit versucht, aber jede neue Bewegungsfähigkeit machte ein Nachjustieren nötig.

Auf einem autoritativen Server schickt der Client nur noch Input. Bei uns ist das ein InputCommand-Struct: Tick-Nummer, Bewegungsvektor, Blickwinkel und Button-Bits, insgesamt 14 Byte. Der Server rechnet die Simulation mit festem 30-Hz-Tick und sendet den Zustand. Der Client sagt lokal weiterhin voraus, aber die Autorität wandert nie zu ihm.

Der Preis ist real: Der Server rechnet jetzt die Physik für jeden Spieler. In unseren Messungen belegte eine Session mit acht Spielern rund 38 % eines 3,4-GHz-Kerns, inklusive Character Controller, Hit-Scan und Sichtbarkeitsfilter. Diese eine Zahl wurde zur Eingangsgröße für jede weitere Skalierungs- und Kostenrechnung. Die 38 % sind ein Mittelwert und keine Spitze; in dichten Gefechten sahen wir dieselbe Session bei 55 %.

Game Server und Game Backend sind zwei Dinge

Ein Game Server hält Zustand, lebt kurz, und wenn er stirbt, stirbt genau ein Match mit ihm. Das Game Backend, also Accounts, Inventar, Progression und Freundeslisten, ist zustandslos, lebt lange, und wenn es ausfällt, trifft es alle. Beides in einem Prozess zu halten hat uns in der ersten Version zwei Wochen gespart und danach zwei Monate gekostet. Meine Regel: Wenn ein Datum das Match überlebt, geht es den Game Server nichts an.

Die Grenze haben wir so gezogen: Während eines Matches schreibt der Game Server in keine persistente Datenbank. Am Matchende geht ein einziger MatchResult-Body an das Backend, mit Score, Dauer, Schaden und benutzten Items pro Spieler. Statt dass acht Server im selben Moment in die Inventartabelle schreiben, entstehen ein paar hundert kleine Requests pro Minute, die sich in eine Queue legen lassen. Für Spieler, die mitten im Match rausfliegen, schicken wir alle 30 Sekunden einen Zwischenstand, damit ein Crash niemandem den Fortschritt löscht.

Auch die Authentifizierung gehört ins Backend. Beim Login bekommt der Spieler ein sessionTicket mit 120 Sekunden Gültigkeit und übergibt es zusammen mit der Raumadresse an den Game Server, der es in einem einzigen Aufruf gegen das Backend prüft. Der Game Server hat überhaupt keine Datenbank-Credentials. Ein kompromittierter Game Server kommt nicht ans Inventar, er kann höchstens dieses eine Match ruinieren.

Wie Matchmaking-Queue und Raumzuweisung arbeiten

Matchmaking ist bei uns ein einziger Service: MatchQueue. Wer die Queue betritt, wird mit Skill-Rating, Region und Party-ID eingetragen. Der Matcher scannt die Queue alle 2 Sekunden, gruppiert Spieler derselben Region in einem Band von ±100 Rating und weitet das Band alle 5 Sekunden um 50 Punkte. Nach 45 Sekunden erreicht das Band seine Obergrenze, und Wartezeit schlägt Qualität.

Sind acht Spieler gefunden, übernimmt der RoomAllocator. Die Alternative haben wir ausprobiert: pro Match einen frischen Container hochfahren. Ein Unity Headless Build brauchte 6-9 Sekunden, um die Szene zu laden und bereit zu sein, und das reicht, um eine bereits gebildete Gruppe wieder auseinanderfallen zu lassen. Stattdessen halten wir pro Region vier leere Sessions warm; die Zuweisung ist in unter 300 ms durch.

Läuft der Warm Pool leer, starten neue Sessions im Hintergrund, und die Spieler sehen im schlimmsten Fall diese 8 Sekunden. Legen Sie die Poolgröße nicht fest: Zwischen 21:00 und 24:00 Uhr liegt unsere Zahl gleichzeitiger Spieler sechsmal über dem Tagesniveau, also wandert der Pool nach Stundenprofil von 4 auf 12. Mit festem Pool wächst entweder nachts die Queue, oder Sie bezahlen tagsüber Maschinen, die nichts tun.

Sessions pro Container und wie Skalierung funktioniert

Ein Game-Server-Prozess führt genau ein Match aus und beendet sich, wenn das Match vorbei ist. Gemessen haben wir 180 MB RSS pro Session und im Mittel 0,42 vCPU. Auf einen Node mit 2 vCPU / 4 GB passen bequem vier Sessions, mit Druck sechs. Sechs Sessions mal acht Spieler sind 48 gleichzeitige Spieler pro Node. Wir haben versucht, mehr hineinzupacken; ab der achten Session überschritt der Tick 33 ms, und die Bewegung wurde sichtbar schlechter.

Hängen Sie die Skalierung nicht an die CPU-Auslastung. Die richtige Metrik ist die Zahl freier Sessions: Fällt freeSessions unter 8, wird ein neuer Node angefordert; steigt der Wert über 24, wird ein Node zum Drainen markiert. Unsere erste, CPU-getriebene Version wollte ständig Nodes abschalten, auf denen noch Spieler saßen, sobald Matches endeten und die Last einbrach. Führen Sie diese Metrik pro Region, sonst verdeckt freie Kapazität in Frankfurt einen Engpass in Istanbul.

Das Abschalten läuft immer über Drain. Der Node bekommt keine Zuweisungen mehr, und die Matches darauf dürfen natürlich zu Ende gehen. Da ein Match bei uns höchstens 20 Minuten dauert, liegt auch die schlechteste Drain-Zeit bei 20 Minuten. Aus demselben Grund nutzen wir Spot- und Preemptible-Maschinen nur für den Warm Pool; ein Node mit laufenden Matches sollte nicht der billige sein.

Logging und Crash-Sammlung gehören in diese Schicht. Jeder Prozess schreibt ein JSON-Objekt pro Zeile nach stdout, und jede Zeile trägt sessionId, matchId, buildId und die Tick-Nummer. Weil der Prozess bei einem Crash verschwindet, muss der Collector unabhängig von ihm laufen und Player.log samt Crash Dump nach draußen schreiben, bevor der Container stirbt. Bis wir das eingerichtet hatten, haben wir die Ursache von drei getrennten Crashes nie gefunden. Das Logvolumen liegt bei rund 400 Zeilen pro Session und Minute, klein genug, um ohne Sampling alles zu behalten.

Region, Kosten und wo ein Relay reicht

Die Regionswahl richtet sich nach Ping, nicht nach der Vorliebe des Spielers. Aus Bursa gemessene Round-Trip-Zeiten: Istanbul 18-22 ms, Frankfurt 48-55 ms, Nord-Virginia 128-140 ms. Bei 30 Hz dauert ein Tick 33 ms, Frankfurt ist also spielbar, Nord-Virginia für einen kompetitiven Shooter nicht. Beim Login schickt der Client je eine UDP-Probe an drei Regionen und geht mit den besten beiden in die Queue. Sitzen Party-Mitglieder in verschiedenen Städten, gewinnt die beste gemeinsame Region, und der schlechteste Ping der Party entscheidet.

Rechnen Sie die Kosten pro gleichzeitigem Spieler, nicht pro Maschine. Ein Node mit 2 vCPU / 4 GB kostet uns rund 0,09 Dollar pro Stunde und trägt 48 Spieler, voll ausgelastet also 0,0019 Dollar pro Spielerstunde. Voll ist er allerdings nie: Unsere durchschnittliche Auslastung liegt bei 35 %, und mit Warm Pool und Mindestkapazität pro Region steht die echte Zahl bei 0,006 Dollar. Bei 500 gleichzeitigen Spielern mit einem täglichen Vier-Stunden-Peak sind das etwa 270 Dollar im Monat. Die Bandbreite blieb bei 8 % der Gesamtrechnung, rund 20 kbit/s Upstream pro Spieler.

Für ein Dreierteam ist diese Zahl klein neben dem eigentlichen Aufwand. Allocation-Service, Drain-Logik, Log-Pipeline und Crash-Sammlung haben uns etwa einen Monat Vollzeit gekostet. Wenn Sie ein nicht kompetitives Koop-Spiel für 4-6 Leute bauen und Ihre Gleichzeitigkeit im niedrigen dreistelligen Bereich bleibt, reicht ein Listen Server über ein Relay. Das Relay löst NAT, die Autorität bleibt beim Host, und der einzige Preis ist der Host-Vorteil.

Meine Schwelle ist einfach: Sobald etwas Leaderboards, Ranking oder eine Ökonomie berührt, brauchen Sie einen Dedicated Server, und zwar einen autoritativen; sonst fangen Sie mit einem Relay an. Billig wird der spätere Wechsel nur durch eines: die Spiellogik von Anfang an serverseitig lauffähig zu halten. Unsere Klasse GameSession hängt an keinem MonoBehaviour, und derselbe Code läuft im Listen Server wie im Headless Build. Wer diese Trennung am ersten Tag zieht, zahlt für das Aufschieben der Entscheidung fast nichts.

← Alle Beiträge