Bir messaging uygulamasında 2000 concurrent user’a kadar tek sunucuda WebSocket idare etti. 3000’e çıkınca ilk sorunlar başladı. CPU değil, file descriptor limiti tavan yaptı. Çözüm horizontal scale idi ama WebSocket’i çok sunucuda çalıştırmak düz HTTP kadar kolay değil. Öğrendiklerimi paylaşayım.
Tek sunucu modelinde ne oluyor?
WebSocket server, her client için bir TCP connection açık tutuyor. Linux default’ta kullanıcı başına 1024 file descriptor. ulimit -n 65535 ile artırılabilir, ama gerçek limit kernel parametrelerinde.
sysctl -w fs.file-max=1000000
sysctl -w net.core.somaxconn=65535
sysctl -w net.ipv4.tcp_max_syn_backlog=65535
ulimit -n 65535Tek makinede 50-70 bin connection mümkün, uygulama mantığına göre. Memory kullanımı büyür, idle connection başına 10-20 KB.
Horizontal scale’in zorluğu
HTTP’de load balancer istekleri rastgele dağıtır, her istek bağımsız. WebSocket’te ise connection kalıcı. Client A sunucu 1’e bağlı, client B sunucu 2’ye bağlı. A, B’ye mesaj göndermek istediğinde sunucu 1’in sunucu 2’ye haber vermesi lazım. İşte burada pub/sub devreye giriyor.
Çözüm 1: Redis Pub/Sub
Her sunucu Redis’e subscribe. Bir client mesaj gönderince sunucu mesajı Redis’e publish eder. Tüm sunucular mesajı alır, kendi local client’larına iletir.
// mesaj alındı
function onMessageFromClient(message) {
const envelope = { roomId: message.roomId, content: message.content };
redis.publish('chat', JSON.stringify(envelope));
}
// redis subscriber
subscriber.subscribe('chat');
subscriber.on('message', (channel, data) => {
const { roomId, content } = JSON.parse(data);
getLocalClientsInRoom(roomId).forEach(c => c.send(content));
});Kolay ama Redis throughput limiti var. 100 bin mesaj/saniye civarı başlayacak darboğaz olur. Sharded Redis veya NATS’a geçmek gerekir.
Çözüm 2: Sticky session
Load balancer client’ı her zaman aynı sunucuya yönlendirir. Cookie veya IP hash ile. Böylece aynı room’daki client’lar aynı sunucuya düşer.
Avantaj: server-to-server mesajlaşma minimize.
Dezavantaj: bir sunucu çökerse o sunucudaki client’lar hepsi düşer. Yeniden bağlanıp başka sunucuya dağılınca room hangi sunucuda sorusu yeniden.
Sticky session room-aware değilse çok mantıklı değil. Room-aware routing ise kompleks, özel load balancer logic’i gerektirir.
Çözüm 3: Messaging broker (NATS, RabbitMQ, Kafka)
Redis’ten sonraki adım. Dedicated messaging broker. Throughput yüksek, persistent, özelleştirilebilir. NATS JetStream benim tercihim, hafif ve hızlı.
Connection state yönetimi
Kullanıcı bağlandığında presence bilgisi “online” yazılmalı. Redis’e SET user:123:online "server-5". TTL ile yaşlandırın, sunucu çökerse otomatik expire olsun.
Disconnect olduğunda temiz kapatın. Ama client disconnect’i hep temiz gelmiyor. Ağ problemi olursa server connection’ı TCP timeout’a kadar açık sayar. Heartbeat ekleyin:
ws.on('pong', () => (ws.isAlive = true));
setInterval(() => {
wss.clients.forEach(ws => {
if (!ws.isAlive) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);30 saniyede bir ping, cevapsız kalan bağlantıyı kapatıyor.
Authentication
WebSocket handshake sırasında auth yapın. Query param ile token geçmek popüler ama log’larda token sızabiliyor. Subprotocol header üzerinden JWT iletmek daha güvenli.
const ws = new WebSocket('wss://host', ['token.' + jwt]);Server subprotocol’ı parse eder, token’ı doğrular. Geçersizse handshake kapat.
Rate limiting
Client başına mesaj rate’i limit. Spamlanacağını unutmayın. Token bucket mantığı yeterli:
const limiters = new Map();
function checkLimit(userId) {
if (!limiters.has(userId)) limiters.set(userId, { tokens: 10, last: Date.now() });
const l = limiters.get(userId);
const now = Date.now();
l.tokens = Math.min(10, l.tokens + (now - l.last) / 1000);
l.last = now;
if (l.tokens < 1) return false;
l.tokens -= 1;
return true;
}Saniyede 1 mesaj tokeni, burst 10’a kadar. Aşırı yükleyeni limit.
Graceful shutdown
Deploy sırasında sunucu kapanıyorsa client’lara önce “disconnect soon” sinyali gönderin. Client yeniden bağlanabilir, load balancer yeni sunucuya yönlendirir.
process.on('SIGTERM', () => {
wss.clients.forEach(ws => ws.send(JSON.stringify({ type: 'shutdown', reconnectIn: 5 })));
setTimeout(() => wss.close(() => process.exit(0)), 10000);
});10 saniye bekler, sonra kapanır. Client reconnect logic’i bu süre içinde yeni sunucuya geçer.
Monitoring
- Concurrent connections / sunucu.
- Message throughput.
- Reconnect rate.
- Memory per connection.
- Latency (ping-pong round trip).
Bu metrikler Prometheus’la toplanır, Grafana’da dashboard olur. Anomali başlarsa hemen haber verir.
Sonuç
WebSocket yatay ölçeklemesi düşünülmesi gereken bir mimari karar, default tercih değil. Pub/sub katmanı ekleyin, heartbeat’i ihmal etmeyin, auth’u handshake’te yapın, graceful shutdown’ı hazırlayın. Sonra 2000 değil 200 bin concurrent’a bile ulaşabilirsiniz.