k6 ile WebSocket ve gRPC Performans Testi
WebSocket ve gRPC testleri çoğu zaman sysadmin’lerin kör noktasıdır. HTTP yük testini herkes yapabilir, Postman’den birkaç istek atarsın, k6 ile basit bir script yazarsın, biter. Ama modern uygulamaların çok büyük bir kısmı artık WebSocket üzerinden gerçek zamanlı iletişim kuruyor, gRPC ile mikroservisler arası konuşuyor. Bu protokolleri yük testine sokmak ise bambaşka bir dünya. Bugün bu iki protokolü k6 ile nasıl test edeceğimizi, gerçek hayatta karşılaştığım senaryolarla birlikte ele alacağım.
k6 Neden Bu İkisi İçin İyi Bir Seçim?
JMeter ile WebSocket testi yapmaya çalıştıysanız ne demek istediğimi anlarsınız. Plugin kurmak, XML konfigürasyonu, hafıza sorunları… k6, Go tabanlı mimarisi sayesinde düşük kaynak tüketimiyle yüksek concurrent bağlantı açabiliyor. WebSocket desteği natifte geliyor, gRPC desteği ise 1.32 sürümünden itibaren stabil.
Bir proje sırasında 5000 eş zamanlı WebSocket bağlantısını JMeter ile test etmeye çalıştık. 16GB RAM’li bir makinede JMeter kendisi çöküyordu. Aynı testi k6 ile 4GB RAM’de rahatlıkla koşturduk. Bu fark, büyük ölçekli testlerde kritik.
WebSocket Testine Giriş
Temel WebSocket Bağlantısı
k6’nın ws modülü ile WebSocket testleri yazmak oldukça sezgisel. İlk örneğe bakalım:
import ws from 'k6/ws';
import { check } from 'k6';
export const options = {
vus: 50,
duration: '30s',
};
export default function () {
const url = 'ws://uygulama.example.com:8080/ws';
const res = ws.connect(url, {}, function (socket) {
socket.on('open', () => {
console.log('Bağlantı açıldı');
socket.send(JSON.stringify({ type: 'ping', data: 'merhaba' }));
});
socket.on('message', (data) => {
const msg = JSON.parse(data);
check(msg, {
'pong alındı': (m) => m.type === 'pong',
'data boş değil': (m) => m.data !== undefined,
});
});
socket.on('error', (e) => {
console.error('Hata:', e);
});
socket.setTimeout(() => {
socket.close();
}, 10000);
});
check(res, {
'bağlantı kuruldu': (r) => r && r.status === 101,
});
}
Bu script 50 sanal kullanıcıyla 30 saniye boyunca WebSocket bağlantısı açıp mesaj alışverişi yapıyor. status === 101 kontrolü HTTP Upgrade handshake’in başarılı olduğunu doğruluyor.
Gerçek Dünya Senaryosu: Chat Uygulaması Yük Testi
Bir fintech müşterisinde destek chat sistemi kuruyorduk. Aynı anda 2000 kullanıcının chat yapabileceği söylendi. Önce sistemi gerçekçi bir senaryoyla test etmemiz gerekiyordu.
import ws from 'k6/ws';
import { check, sleep } from 'k6';
import { Counter, Trend } from 'k6/metrics';
const messageLatency = new Trend('ws_mesaj_gecikmesi');
const messageCount = new Counter('ws_mesaj_sayisi');
export const options = {
stages: [
{ duration: '2m', target: 500 }, // Yavaşça yükselt
{ duration: '5m', target: 2000 }, // Hedef yüke ulaş
{ duration: '3m', target: 2000 }, // Sabit tut
{ duration: '2m', target: 0 }, // Yavaşça düşür
],
thresholds: {
'ws_mesaj_gecikmesi': ['p(95)<500'], // %95 mesaj 500ms altında
'ws_mesaj_sayisi': ['count>10000'],
},
};
export default function () {
const userId = `user_${Math.random().toString(36).substr(2, 9)}`;
const roomId = `room_${Math.floor(Math.random() * 10) + 1}`;
const url = `ws://chat.example.com/ws?userId=${userId}&roomId=${roomId}`;
const headers = {
'Authorization': `Bearer ${__ENV.AUTH_TOKEN}`,
};
ws.connect(url, { headers }, function (socket) {
let gonderimZamani;
socket.on('open', () => {
// Odaya katılım mesajı
socket.send(JSON.stringify({
type: 'join',
roomId: roomId,
userId: userId,
}));
});
socket.on('message', (data) => {
const msg = JSON.parse(data);
if (msg.type === 'join_ack') {
// Katılım onaylandı, mesaj göndermeye başla
const mesajInterval = setInterval(() => {
gonderimZamani = Date.now();
socket.send(JSON.stringify({
type: 'message',
content: 'Test mesajı ' + Date.now(),
roomId: roomId,
}));
messageCount.add(1);
}, 3000); // Her 3 saniyede bir mesaj
socket.setTimeout(() => {
clearInterval(mesajInterval);
socket.close();
}, 60000);
}
if (msg.type === 'message_ack' && gonderimZamani) {
messageLatency.add(Date.now() - gonderimZamani);
}
});
socket.on('close', () => {
console.log(`${userId} bağlantısı kapandı`);
});
});
}
Bu senaryoda özel metrikler kullanmak kritik. Trend ile gecikme dağılımını, Counter ile toplam mesaj sayısını takip ediyoruz. Threshold’lar ise kabul kriterlerimizi belirliyor.
WebSocket’te Ping/Pong ve Bağlantı Sağlığı
Uzun süreli bağlantılarda sunucu tarafındaki timeout politikalarını test etmek de önemli. Şöyle bir senaryo çok işe yarıyor:
import ws from 'k6/ws';
import { check } from 'k6';
export const options = {
vus: 100,
duration: '10m',
};
export default function () {
const url = 'ws://uygulama.example.com/ws';
ws.connect(url, {}, function (socket) {
let pingCount = 0;
let pongCount = 0;
// Uygulama seviyesi ping (WebSocket protocol ping'inden farklı)
const pingInterval = setInterval(() => {
socket.send(JSON.stringify({ type: 'heartbeat', seq: pingCount }));
pingCount++;
}, 30000);
socket.on('ping', () => {
// Sunucu WebSocket protocol ping gönderdiyse
socket.pong();
});
socket.on('message', (data) => {
const msg = JSON.parse(data);
if (msg.type === 'heartbeat_ack') {
pongCount++;
}
});
socket.on('close', (code, reason) => {
check(pongCount, {
'heartbeat kayıp oranı kabul edilebilir': (p) =>
p >= pingCount * 0.95, // En az %95 heartbeat yanıtı
});
clearInterval(pingInterval);
});
socket.setTimeout(() => {
socket.close(1000, 'Test tamamlandı');
}, 580000); // 9.5 dakika bağlı kal
});
}
gRPC Performans Testi
gRPC Modülünü Anlamak
k6’nın gRPC modülü biraz farklı çalışıyor. Önce proto tanımını yüklüyorsunuz, sonra istemci oluşturuyorsunuz. Dikkat edilmesi gereken nokta: proto dosyasını her VU için değil, init context’inde bir kez yüklemeniz gerekiyor.
import grpc from 'k6/net/grpc';
import { check, sleep } from 'k6';
const client = new grpc.Client();
// Proto dosyasını bir kez yükle
client.load(['proto/'], 'urun.proto');
export const options = {
vus: 20,
duration: '1m',
};
export default function () {
client.connect('grpc-sunucu.example.com:50051', {
plaintext: true, // TLS yoksa
});
const payload = {
urun_id: Math.floor(Math.random() * 1000),
kategori: 'elektronik',
};
const response = client.invoke('UrunServisi/UrunGetir', payload);
check(response, {
'status OK': (r) => r && r.status === grpc.StatusOK,
'urun adi var': (r) => r.message.urun_adi !== '',
'fiyat pozitif': (r) => r.message.fiyat > 0,
});
client.close();
sleep(1);
}
Gerçek Dünya: Mikroservis Yük Testi
E-ticaret projesinde ürün kataloğu servisi gRPC üzerinden çalışıyordu. Black Friday öncesi stres testi yapmamız gerekti. İşte o testin sadeleştirilmiş versiyonu:
import grpc from 'k6/net/grpc';
import { check, sleep } from 'k6';
import { Trend, Rate } from 'k6/metrics';
const client = new grpc.Client();
client.load(['./proto'], 'katalog.proto', 'siparis.proto');
const katalogLatency = new Trend('katalog_gecikme');
const siparisLatency = new Trend('siparis_gecikme');
const hataOrani = new Rate('grpc_hata_orani');
export const options = {
scenarios: {
katalog_okuma: {
executor: 'constant-arrival-rate',
rate: 500, // Saniyede 500 istek
timeUnit: '1s',
duration: '5m',
preAllocatedVUs: 100,
maxVUs: 300,
exec: 'katalogTest',
},
siparis_yazma: {
executor: 'ramping-arrival-rate',
startRate: 10,
timeUnit: '1s',
stages: [
{ duration: '2m', target: 100 },
{ duration: '3m', target: 100 },
],
preAllocatedVUs: 50,
maxVUs: 200,
exec: 'siparisTest',
},
},
thresholds: {
'katalog_gecikme': ['p(99)<200'],
'siparis_gecikme': ['p(95)<1000'],
'grpc_hata_orani': ['rate<0.01'],
},
};
export function katalogTest() {
client.connect('katalog-servis:50051', { plaintext: true });
const baslangic = Date.now();
const res = client.invoke('KatalogServisi/UrunListele', {
sayfa: Math.floor(Math.random() * 100),
sayfa_boyutu: 20,
kategori_filtre: ['elektronik', 'giyim', 'kitap'][Math.floor(Math.random() * 3)],
});
katalogLatency.add(Date.now() - baslangic);
const basarili = check(res, {
'katalog OK': (r) => r.status === grpc.StatusOK,
'urun listesi dolu': (r) => r.message.urunler && r.message.urunler.length > 0,
});
hataOrani.add(!basarili);
client.close();
}
export function siparisTest() {
client.connect('siparis-servis:50051', { plaintext: true });
const baslangic = Date.now();
const res = client.invoke('SiparisServisi/SiparisOlustur', {
kullanici_id: `user_${Math.floor(Math.random() * 10000)}`,
urun_id: `prod_${Math.floor(Math.random() * 1000)}`,
adet: Math.floor(Math.random() * 5) + 1,
adres_id: `addr_${Math.floor(Math.random() * 100)}`,
});
siparisLatency.add(Date.now() - baslangic);
const basarili = check(res, {
'siparis OK': (r) => r.status === grpc.StatusOK,
'siparis_id var': (r) => r.message.siparis_id !== '',
});
hataOrani.add(!basarili);
if (!basarili && res) {
console.error(`Siparis hatası: ${res.status} - ${JSON.stringify(res.error)}`);
}
client.close();
sleep(0.5);
}
Burada scenarios kullanımı kritik. Okuma ve yazma workload’larını ayrı senaryolara bölmek, gerçek üretim trafiğini simüle etmek açısından çok daha doğru sonuçlar veriyor.
gRPC TLS ve Metadata Yönetimi
Üretim ortamlarında genellikle TLS ve authentication metadata’sı gerekiyor:
import grpc from 'k6/net/grpc';
import { check } from 'k6';
const client = new grpc.Client();
client.load(['./proto'], 'servis.proto');
export const options = {
vus: 50,
duration: '2m',
};
export default function () {
// TLS ile bağlantı
client.connect('guvenli-servis.example.com:443', {
tls: {
insecureSkipVerify: false, // Prod'da mutlaka false
},
});
// gRPC metadata (HTTP header'ına benzer)
const metadata = {
'authorization': `Bearer ${__ENV.GRPC_TOKEN}`,
'x-request-id': `req_${Date.now()}_${Math.random()}`,
'x-client-version': '2.1.0',
};
const res = client.invoke(
'GüvenliServis/KullaniciBilgisi',
{ kullanici_id: 'test_user_123' },
{ metadata }
);
check(res, {
'auth başarılı': (r) => r.status !== grpc.StatusUnauthenticated,
'veri geldi': (r) => r.status === grpc.StatusOK,
});
// Farklı hata durumlarını yakala
if (res.status === grpc.StatusResourceExhausted) {
console.warn('Rate limit aşıldı, yavaşlıyoruz...');
}
client.close();
}
Test Sonuçlarını Analiz Etmek
Grafana + InfluxDB ile Görselleştirme
k6 çıktısını doğrudan InfluxDB’ye göndermek ve Grafana’da izlemek hem WebSocket hem de gRPC testleri için çok değerli:
# InfluxDB'ye gönder
k6 run --out influxdb=http://localhost:8086/k6 websocket-test.js
# Birden fazla çıktı
k6 run
--out influxdb=http://localhost:8086/k6
--out json=sonuclar.json
grpc-test.js
# Ortam değişkeniyle çalıştır
AUTH_TOKEN=abc123 GRPC_TOKEN=xyz789 k6 run
--vus 100
--duration 5m
--out influxdb=http://localhost:8086/k6
grpc-stres-test.js
Karşılaştırmalı Test Yapısı
Birden fazla endpoint veya servis versiyonunu karşılaştırmak için basit bir yapı:
import grpc from 'k6/net/grpc';
import { check } from 'k6';
import { Trend } from 'k6/metrics';
const clientV1 = new grpc.Client();
const clientV2 = new grpc.Client();
clientV1.load(['./proto/v1'], 'arama.proto');
clientV2.load(['./proto/v2'], 'arama.proto');
const v1Latency = new Trend('arama_v1_gecikme');
const v2Latency = new Trend('arama_v2_gecikme');
export const options = {
vus: 50,
duration: '3m',
};
export default function () {
const aramaTermi = ['laptop', 'telefon', 'klavye', 'monitor'][
Math.floor(Math.random() * 4)
];
// V1 testi
clientV1.connect('arama-v1.internal:50051', { plaintext: true });
const baslangicV1 = Date.now();
const resV1 = clientV1.invoke('AramaServisi/Ara', { sorgu: aramaTermi, limit: 10 });
v1Latency.add(Date.now() - baslangicV1);
check(resV1, { 'v1 OK': (r) => r.status === grpc.StatusOK });
clientV1.close();
// V2 testi
clientV2.connect('arama-v2.internal:50051', { plaintext: true });
const baslangicV2 = Date.now();
const resV2 = clientV2.invoke('AramaServisi/Ara', { sorgu: aramaTermi, limit: 10 });
v2Latency.add(Date.now() - baslangicV2);
check(resV2, { 'v2 OK': (r) => r.status === grpc.StatusOK });
clientV2.close();
}
Yaygın Sorunlar ve Çözümleri
WebSocket testlerinde en çok karşılaştığım sorun, sunucunun idle bağlantıları erken kapatması. Bunu test scriptinizde socket.on('close') ile yakalayıp loglayabilirsiniz. Eğer bağlantılar beklenenden erken kapanıyorsa, sunucu tarafında nginx veya haproxy timeout ayarlarına bakmanız gerekiyor.
gRPC tarafında ise client.connect()‘i her iteration’da çağırmak ciddi performans kaybına yol açıyor. Eğer bağlantıyı tekrar kullanmak istiyorsanız, bağlantıyı setup fonksiyonunda açıp teardown’da kapatmayı düşünebilirsiniz. Ancak k6’da her VU’nun kendi execution context’i olduğunu unutmayın, global bağlantı paylaşımı doğrudan mümkün değil.
- WebSocket mesaj sırası garantisi: WebSocket mesajları sıralı gelir ancak test scriptinizde seq numaraları kullanarak kayıp mesajları tespit edin
- gRPC deadline:
client.invoke()çağrısına{ timeout: '5s' }ekleyin, aksi halde askıda kalan istekler VU’ları bloke eder - Proto import hataları: Proto dosyasındaki
importpath’lericlient.load()‘ın ilk parametresine göre relative olmalı - Memory leak riski: WebSocket testinde setInterval kullandıysanız, socket close event’inde mutlaka clearInterval çağırın
- k6 cloud ile distributed test: Lokalde yapamadığınız ölçekte testler için k6 Cloud veya k6 operator kullanarak Kubernetes üzerinde dağıtık test koşturabilirsiniz
Sonuç
WebSocket ve gRPC testleri, modern uygulamaların performansını doğrulamak için artık opsiyonel değil, zorunlu. k6 bu iki protokol için de olgun bir destek sunuyor ve özellikle yüksek eş zamanlılık gerektiren senaryolarda kaynak verimliliğiyle öne çıkıyor.
Kendi deneyimimden en önemli tavsiyem şu: Önce gerçekçi bir kullanıcı davranışı modeli çıkarın. Kullanıcılar WebSocket üzerinden kaç saniyede bir mesaj gönderiyor? gRPC istekleri hangi dağılımda geliyor? Bu soruları yanıtlamadan yazılan yük testi, size yanıltıcı sonuçlar verir. Üretim loglarını analiz edin, p50/p95/p99 dağılımlarına bakın, sonra test senaryonuzu buna göre şekillendirin.
Threshold’ları da ihmal etmeyin. Bir yük testi threshold olmadan sadece grafik üretir. p(95)<500 gibi somut kriterler koyduğunuzda, testin başarılı mı başarısız mı olduğunu otomatik olarak belirleyebilir ve CI/CD pipeline’ınıza entegre edebilirsiniz. Bu sayede her deployment öncesi performans regresyonlarını erkenden yakalarsınız.
