Dedicated server mimarisi: konteyner başına 48 oyuncu
Otoriter oyun sunucusunu backend'den ayırdık, matchmaking kuyruğunu ve oda tahsisini yeniden yazdık, eş zamanlı oyuncu başına gerçek maliyeti ölçtük.
Playtest gecesi: 40 oyuncu, tek süreç, bir çökme
Geçen yılın şubat ayında cuma akşamı 21.00'de 40 kişilik kapalı bir playtest yaptık. Oyun 8 kişilik maçlar üzerine kuruluydu ve o gece beş maç aynı anda dönüyordu. 23.40'ta oyuncuların üçte biri aynı anda bağlantıyı kaybetti. Geriye tek bir satır kaldı: OutOfMemoryException ve altında beş farklı matchId.
Sebep bir hata değil, mimariydi. Beş maç tek bir süreçte, tek bir Unity headless build'i içinde yaşıyordu. Bir maçtaki sızıntı diğer dördünü de öldürdü. O gece öğrendiğimiz şey şuydu: oda başına izolasyon bir optimizasyon değil, doğruluk meselesidir. Süreç başına bellek tavanı koymayı da denedik ama ölen yine paylaşılan süreç oldu.
Sonraki üç ayda oyun sunucusu mimarisini baştan kurduk. Aşağıdaki sayılar ve elenen alternatifler o dönemin ölçümleri; hepsi 8 kişilik rekabetçi bir nişancı prototipinden geliyor. Motor Unity 2022 LTS, taşıma katmanı UDP üzerine yazdığımız kendi güvenilir kanalımız.
Otoriter sunucu neden pazarlık konusu değil
İlk sürümde hareketi istemci hesaplıyor, sonucu sunucuya bildiriyordu. İkinci playtest'te 40 oyuncudan üçü paketleri düzenleyip koşu hızını iki katına çıkardı. Hatalı olan tespit kodu değildi, mimarinin kendisiydi: istemciye pozisyon yazdırırsanız hile bir doğrulama yarışına dönüşür ve o yarışı kaybedersiniz. Sunucu tarafında hız sınırı koymayı denedik, ama her yeni hareket yeteneği sınırı yeniden ayarlamayı gerektirdi.
Otoriter sunucuda istemci yalnızca girdi gönderir. Bizde bu InputCommand yapısı: tick numarası, hareket vektörü, bakış açısı ve buton bitleri, toplam 14 bayt. Simülasyonu 30 Hz sabit tick ile sunucu yürütür ve durumu yayınlar. İstemci kendi tarafında tahmin yapar, ama otorite asla ona geçmez.
Bunun bedeli gerçek: sunucu artık her oyuncunun fiziğini kendisi çalıştırır. Ölçümümüzde 8 oyunculu bir oturum, 3,4 GHz'lik bir çekirdeğin yaklaşık %38'ini kullanıyordu; karakter denetleyici, mermi hit-scan ve görünürlük filtresi dahil. Bu tek sayı, ilerideki bütün ölçeklendirme ve maliyet hesabının girdisi oldu. Üstelik %38 tepe değeri değil ortalaması; kalabalık çatışma anlarında aynı oturumda %55'i gördük.
Oyun sunucusu ile oyun backend'i ayrı şeylerdir
Oyun sunucusu durum tutar, kısa yaşar ve öldüğünde tek bir maç ölür. Oyun backend'i, yani hesap, envanter, ilerleme ve arkadaş listesi, durumsuzdur, uzun yaşar ve öldüğünde herkes etkilenir. Bu ikisini aynı süreçte tutmak ilk sürümde bize iki hafta kazandırdı, sonrasında iki ay kaybettirdi. Kuralı şöyle özetliyorum: bir veri maçtan uzun yaşıyorsa oyun sunucusunun işi değildir.
Ayrımı şöyle çektik: maç boyunca oyun sunucusu hiçbir kalıcı veritabanına yazmaz. Maç bitince tek bir MatchResult gövdesi backend'e POST edilir; içinde oyuncu başına skor, süre, hasar ve kullanılan eşyalar bulunur. Sekiz sunucunun maç sonunda aynı anda envanter tablosuna yazması yerine, dakikada birkaç yüz küçük istek oluyor ve bunlar kuyruğa alınabiliyor. Maç ortasında kopan oyuncular için 30 saniyede bir ara kayıt gönderiyoruz, böylece bir çökme kimsenin ilerlemesini silmiyor.
Kimlik doğrulama da backend'in işidir. Oyuncu giriş yaptığında 120 saniye ömürlü bir sessionTicket alır, oda adresiyle birlikte oyun sunucusuna verir, oyun sunucusu bileti tek bir çağrıyla backend'e doğrulatır. Oyun sunucusunun veritabanı kimlik bilgisi hiç yoktur. Ele geçirilen bir oyun sunucusu envantere ulaşamaz, en fazla o tek maçı bozar.
Matchmaking kuyruğu ve oda tahsisi nasıl işler
Matchmaking bizde tek bir servistir: MatchQueue. Oyuncu kuyruğa girerken beceri puanı, bölgesi ve parti kimliğiyle kaydedilir. Eşleştirici her 2 saniyede bir kuyruğu tarar, aynı bölgedeki oyuncuları ±100 puan bandında gruplar, her 5 saniyede bandı 50 puan genişletir. 45 saniyede bant tavana vurur ve kalite yerine bekleme süresi tercih edilir.
Sekiz oyuncu bulunduğunda iş RoomAllocator'a geçer. Alternatifini denedik: her maç için sıfırdan konteyner ayağa kaldırmak. Unity headless build'in sahneyi yükleyip hazır olması 6-9 saniye sürüyordu ve bu süre, zaten kurulmuş bir grubu dağıtmaya yetiyor. Bunun yerine bölge başına dört boş oturumu sıcak bekletiyoruz; tahsis 300 ms'nin altında bitiyor.
Sıcak havuz boşalırsa yeni oturumlar arka planda doğar ve oyuncular en kötü ihtimalle o 8 saniyeyi görür. Havuz boyutunu sabitlemeyin: bizde akşam 21.00-24.00 arası eş zamanlı oyuncu sayısı gündüze göre 6 kat artıyor, havuz da saatlik profile göre 4'ten 12'ye çıkıyor. Sabit havuzla ya gece kuyruk uzuyor ya gündüz boşuna makine ödüyorsunuz.
Konteyner başına oturum yönetimi ve ölçeklendirme
Bir oyun sunucusu süreci tam olarak bir maçı yönetir ve maç bitince ölür. Ölçtüğümüz kaynak, oturum başına 180 MB RSS ve ortalama 0,42 vCPU. 2 vCPU / 4 GB'lik bir düğüme rahatça dört, sıkıştırınca altı oturum sığıyor. Altı oturum çarpı sekiz oyuncu, düğüm başına 48 eş zamanlı oyuncu demek. Daha fazlasını sıkıştırmayı denedik; sekizinci oturumda tick süresi 33 ms'yi aşmaya başladı ve hareket gözle görülür şekilde bozuldu.
Ölçeklendirmeyi CPU yüzdesine bağlamayın. Doğru metrik boş oturum sayısıdır: freeSessions 8'in altına düşünce yeni düğüm istenir, 24'ün üstüne çıkınca bir düğüm boşaltmaya alınır. CPU'ya bağladığımız ilk sürüm, maçlar bitip yük düşünce hâlâ oyuncu barındıran düğümleri kapatmaya çalışıyordu. Bu metriği bölge başına ayrı tutun, yoksa Frankfurt'taki boşluk İstanbul'daki darlığı gizler.
Kapatma her zaman drenajla olur. Düğüm yeni tahsis almaz, üzerindeki maçlar doğal ölümünü bekler. Bizde bir maç en fazla 20 dakika sürdüğü için en kötü drenaj süresi de 20 dakikadır. Aynı sebeple spot ve preemptible makineleri yalnızca sıcak havuzda kullanıyoruz; canlı maç taşıyan düğüm ucuz olmamalı.
Loglama ve çökme toplama bu katmanda kurulur. Her süreç stdout'a satır başına bir JSON yazar ve her satırda sessionId, matchId, buildId ve tick numarası bulunur. Çökmede süreç kaybolduğu için toplayıcı süreçten bağımsız çalışmalı ve Player.log ile crash dump'ı konteyner ölmeden dışarı yazmalıdır. Bunu kurana kadar üç ayrı çökmenin sebebini hiç öğrenemedik. Log hacmi oturum başına dakikada yaklaşık 400 satır; örnekleme yapmadan saklanabilecek bir rakam.
Bölge, maliyet ve relay'in yettiği yer
Bölge seçimi oyuncunun tercihine değil, ping'e bakılarak yapılır. Bursa'dan ölçtüğümüz gidiş-dönüş süreleri: İstanbul 18-22 ms, Frankfurt 48-55 ms, Kuzey Virginia 128-140 ms. 30 Hz tick'te bir tick 33 ms sürer; Frankfurt oynanabilir, Virginia rekabetçi bir nişancı için değil. İstemci girişte üç bölgeye birer UDP yoklaması atar ve kuyruğa en iyi iki bölgeyle girer. Parti üyeleri farklı şehirlerdeyse ortak en iyi bölge seçilir ve partinin en kötü ping'i kararı belirler.
Maliyeti makine başına değil, eş zamanlı oyuncu başına hesaplayın. 2 vCPU / 4 GB düğüm bize saatte yaklaşık 0,09 dolara geliyor ve 48 oyuncu taşıyor, yani tam doluyken oyuncu saati başına 0,0019 dolar. Ama hiçbir zaman tam dolu olmazsınız: ortalama doluluğumuz %35 ve sıcak havuz ile bölge başına asgari kapasite eklenince gerçek rakam 0,006 dolara çıkıyor. 500 eş zamanlı oyuncu, günde dört saatlik yoğunluk profiliyle ayda yaklaşık 270 dolar eder. Bant genişliği faturası toplamın %8'inde kaldı; oyuncu başına yukarı akış 20 kbps civarında.
Bu rakam, üç kişilik bir ekip için asıl masrafın yanında küçük kalır. Tahsis servisi, drenaj mantığı, log hattı ve çökme toplama bize yaklaşık bir ay tam zamanlı iş çıkardı. Rekabetçi olmayan, 4-6 kişilik kooperatif bir oyun yapıyorsanız ve eş zamanlı oyuncu sayınız birkaç yüzü geçmiyorsa relay üzerinden listen server yeterlidir. Relay NAT sorununu çözer, otorite host'ta kalır ve tek bedeli host avantajıdır.
Kullandığım eşik basit: skor tablosunu, sıralamayı veya ekonomiyi etkileyen bir şey varsa dedicated server ve otoriter sunucu şarttır, yoksa relay ile başlayın. Geçişi ucuzlatan tek şey, oyun mantığını sunucu tarafında çalışabilecek biçimde ayrı tutmaktır. Bizim GameSession sınıfımız hiçbir MonoBehaviour'a bağlı değil ve aynı kod hem listen server'da hem headless build'de dönüyor. Bu ayrımı ilk günden yaparsanız kararı ertelemenin maliyeti sıfıra yakın olur.