CI/CD Pipeline’a Entegre Performans Testi: GitHub Actions ile Otomatik Yük Testi
Üretim ortamına deploy etmeden önce performans sorunlarını yakalamak, her sysadmin’in hayalidir. Çoğu ekip performans testlerini “bir gün yapacağız” listesine koyar ve o gün hiç gelmez. CI/CD pipeline’ına entegre edilmiş otomatik yük testleri ise bu sorunu köklüce çözer: her commit, her pull request otomatik olarak yük testinden geçer ve performans regresyonları üretime girmeden engellenir.
Bu yazıda GitHub Actions kullanarak k6, Locust ve JMeter araçlarıyla gerçek dünya senaryolarına dayalı bir yük testi pipeline’ı kuruyoruz. Teori değil, direkt uygulanabilir konfigürasyonlar.
Neden CI/CD’de Yük Testi?
Klasik yaklaşımda yük testleri ayrı bir aşamada, genellikle staging ortamında manuel olarak çalıştırılır. Bu yaklaşımın birkaç kritik sorunu var:
- Geç tespit: Performans sorunu üretimde keşfedilir, geri alma maliyeti yüksektir
- Tutarsızlık: Her test farklı parametrelerle çalıştırılır, sonuçlar karşılaştırılamaz
- İnsan hatası: Test bazen unutulur, bazen atlanır
- Bağlam kaybı: Hangi commit performansı düşürdü, kim yazdı, neden?
Pipeline’a entegre yük testi bu sorunların hepsini çözer. Her kod değişikliği aynı test senaryosundan geçer, sonuçlar otomatik olarak karşılaştırılır ve eşik değerleri aşıldığında build fail olur.
Araç Seçimi: k6, Locust, JMeter
Her aracın farklı güçlü yönleri var.
k6: Go ile yazılmış, JavaScript API’ı olan modern yük test aracı. Düşük bellek tüketimi, CI entegrasyonu için mükemmel, Docker imajı hazır. Küçük-orta ölçekli API testleri için ideal seçim.
Locust: Python tabanlı, kod olarak test senaryosu yazılır. Karmaşık kullanıcı davranışlarını simüle etmek için güçlü, dağıtık test desteği var. Python ekosistemiyle iyi entegre olur.
JMeter: Java tabanlı, kurumsal standart. Geniş protokol desteği (HTTP, JDBC, FTP, SOAP). Büyük kurumsal projelerde tercih edilir, GUI ile senaryo oluşturma imkanı sunar.
Bu yazıda ağırlıklı olarak k6 üzerinden gideceğiz, Locust ve JMeter için de çalışan örnekler vereceğiz.
k6 ile Temel Yük Testi Senaryosu
Önce basit bir k6 test scripti oluşturalım. Bir e-ticaret API’ının kritik endpoint’lerini test ediyoruz.
// tests/load/api-load-test.js
import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { Rate, Trend, Counter } from 'k6/metrics';
// Özel metrikler
const errorRate = new Rate('error_rate');
const apiTrend = new Trend('api_response_time');
const checkoutErrors = new Counter('checkout_errors');
export const options = {
stages: [
{ duration: '2m', target: 10 }, // Yavaş yükselme
{ duration: '5m', target: 50 }, // Normal yük
{ duration: '2m', target: 100 }, // Tepe yük
{ duration: '5m', target: 100 }, // Tepe yük sürekliliği
{ duration: '2m', target: 0 }, // Yavaş düşüş
],
thresholds: {
http_req_duration: ['p(95)<500', 'p(99)<1000'],
http_req_failed: ['rate<0.01'],
error_rate: ['rate<0.05'],
checkout_errors: ['count<10'],
},
};
const BASE_URL = __ENV.API_BASE_URL || 'https://api-staging.example.com';
const AUTH_TOKEN = __ENV.AUTH_TOKEN || '';
export default function () {
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${AUTH_TOKEN}`,
};
group('Product Listing', function () {
const res = http.get(`${BASE_URL}/api/v1/products?page=1&limit=20`, { headers });
const success = check(res, {
'status 200': (r) => r.status === 200,
'response time < 300ms': (r) => r.timings.duration < 300,
'has products array': (r) => JSON.parse(r.body).data !== undefined,
});
errorRate.add(!success);
apiTrend.add(res.timings.duration);
sleep(1);
});
group('Product Detail', function () {
const productId = Math.floor(Math.random() * 1000) + 1;
const res = http.get(`${BASE_URL}/api/v1/products/${productId}`, { headers });
check(res, {
'status 200 or 404': (r) => r.status === 200 || r.status === 404,
'response time < 200ms': (r) => r.timings.duration < 200,
});
sleep(0.5);
});
group('Checkout Flow', function () {
const payload = JSON.stringify({
items: [{ product_id: 1, quantity: 2 }],
shipping_address: { city: 'Istanbul', country: 'TR' },
});
const res = http.post(`${BASE_URL}/api/v1/cart/checkout`, payload, { headers });
const checkoutSuccess = check(res, {
'checkout accepted': (r) => r.status === 200 || r.status === 201,
'response time < 1000ms': (r) => r.timings.duration < 1000,
});
if (!checkoutSuccess) checkoutErrors.add(1);
sleep(2);
});
}
GitHub Actions Workflow: k6 Entegrasyonu
Şimdi bu testi her pull request’te otomatik çalıştıracak workflow’u yazıyoruz.
# .github/workflows/performance-test.yml
name: Performance Tests
on:
pull_request:
branches: [main, develop]
push:
branches: [main]
workflow_dispatch:
inputs:
target_url:
description: 'Test edilecek URL'
required: false
default: 'https://api-staging.example.com'
env:
K6_VERSION: '0.47.0'
jobs:
k6-load-test:
name: k6 Load Test
runs-on: ubuntu-latest
environment: staging
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup k6
run: |
curl -L https://github.com/grafana/k6/releases/download/v${{ env.K6_VERSION }}/k6-v${{ env.K6_VERSION }}-linux-amd64.tar.gz | tar xz
sudo mv k6-v${{ env.K6_VERSION }}-linux-amd64/k6 /usr/local/bin/k6
k6 version
- name: Wait for staging deployment
run: |
echo "Staging ortamının hazır olmasını bekliyoruz..."
for i in {1..30}; do
if curl -sf "${{ secrets.STAGING_URL }}/health" > /dev/null; then
echo "Staging hazır!"
break
fi
echo "Deneme $i/30, 10 saniye bekleniyor..."
sleep 10
done
- name: Run k6 Load Test
env:
API_BASE_URL: ${{ secrets.STAGING_URL }}
AUTH_TOKEN: ${{ secrets.STAGING_AUTH_TOKEN }}
run: |
k6 run
--out json=results/k6-results.json
--summary-export=results/k6-summary.json
tests/load/api-load-test.js
- name: Parse and Comment Results
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const summary = JSON.parse(fs.readFileSync('results/k6-summary.json', 'utf8'));
const p95 = summary.metrics.http_req_duration.values['p(95)'].toFixed(2);
const p99 = summary.metrics.http_req_duration.values['p(99)'].toFixed(2);
const errorRate = (summary.metrics.http_req_failed.values.rate * 100).toFixed(2);
const rps = summary.metrics.http_reqs.values.rate.toFixed(2);
const passed = summary.state.testRunDurationMs > 0;
const statusIcon = passed ? '✅' : '❌';
const comment = `## ${statusIcon} Performans Test Sonuçları
| Metrik | Değer |
|--------|-------|
| P95 Response Time | ${p95}ms |
| P99 Response Time | ${p99}ms |
| Hata Oranı | ${errorRate}% |
| RPS | ${rps} |
${passed ? '**Tüm eşik değerleri karşılandı.**' : '**UYARI: Bazı eşik değerleri aşıldı!**'}
`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: comment
});
- name: Upload Results
if: always()
uses: actions/upload-artifact@v3
with:
name: k6-results-${{ github.run_id }}
path: results/
retention-days: 30
Locust ile Dağıtık Yük Testi
Locust özellikle karmaşık kullanıcı davranışlarını simüle etmede güçlü. Aşağıdaki senaryo, gerçekçi bir kullanıcı oturumunu taklit ediyor.
# tests/load/locustfile.py
from locust import HttpUser, task, between, SequentialTaskSet
import random
import json
class UserBehavior(SequentialTaskSet):
def on_start(self):
"""Kullanıcı oturumu başlangıcı"""
response = self.client.post("/api/v1/auth/login", json={
"email": f"testuser{random.randint(1,100)}@example.com",
"password": "test_password_123"
})
if response.status_code == 200:
self.token = response.json().get("token")
self.client.headers.update({"Authorization": f"Bearer {self.token}"})
@task
def browse_products(self):
with self.client.get(
f"/api/v1/products?page={random.randint(1,5)}&category=electronics",
name="/api/v1/products",
catch_response=True
) as response:
if response.status_code == 200:
data = response.json()
if "data" not in data:
response.failure("Response format hatalı")
else:
response.failure(f"HTTP {response.status_code}")
@task
def view_product_detail(self):
product_id = random.randint(1, 500)
self.client.get(
f"/api/v1/products/{product_id}",
name="/api/v1/products/[id]"
)
@task
def add_to_cart(self):
self.client.post("/api/v1/cart/items", json={
"product_id": random.randint(1, 100),
"quantity": random.randint(1, 3)
})
class WebsiteUser(HttpUser):
tasks = [UserBehavior]
wait_time = between(1, 3)
host = "https://api-staging.example.com"
Locust’u GitHub Actions’ta headless modda çalıştırmak için:
# .github/workflows/locust-test.yml (ilgili step)
- name: Run Locust Headless
run: |
pip install locust
locust
--headless
--users 50
--spawn-rate 5
--run-time 5m
--host ${{ secrets.STAGING_URL }}
--csv=results/locust
--csv-full-history
--exit-code-on-error 1
-f tests/load/locustfile.py
- name: Validate Locust Results
run: |
python3 << 'EOF'
import csv
import sys
FAILURE_RATE_THRESHOLD = 0.05 # %5
P95_THRESHOLD_MS = 500
with open('results/locust_stats.csv') as f:
reader = csv.DictReader(f)
for row in reader:
if row['Name'] == 'Aggregated':
failure_rate = float(row['Failure Count']) / max(float(row['Request Count']), 1)
p95 = float(row['95%'])
print(f"Hata Oranı: {failure_rate:.2%}")
print(f"P95: {p95}ms")
if failure_rate > FAILURE_RATE_THRESHOLD:
print(f"HATA: Hata oranı eşiği aşıldı ({failure_rate:.2%} > {FAILURE_RATE_THRESHOLD:.2%})")
sys.exit(1)
if p95 > P95_THRESHOLD_MS:
print(f"HATA: P95 eşiği aşıldı ({p95}ms > {P95_THRESHOLD_MS}ms)")
sys.exit(1)
print("Tüm eşik değerleri karşılandı.")
EOF
JMeter ile Kurumsal Yük Testi
JMeter XML tabanlı test planları kullanır. Bunu CI’da çalıştırmak biraz daha fazla konfigürasyon gerektirir.
<!-- tests/load/api-test-plan.jmx - Kısaltılmış versiyon -->
<?xml version="1.0" encoding="UTF-8"?>
<jmeterTestPlan version="1.2" properties="5.0">
<hashTree>
<TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="API Load Test">
<elementProp name="TestPlan.user_defined_variables" elementType="Arguments">
<collectionProp name="Arguments.arguments">
<elementProp name="BASE_URL" elementType="Argument">
<stringProp name="Argument.name">BASE_URL</stringProp>
<stringProp name="Argument.value">${__P(base_url,https://api-staging.example.com)}</stringProp>
</elementProp>
</collectionProp>
</elementProp>
</TestPlan>
</hashTree>
</jmeterTestPlan>
# JMeter'ı GitHub Actions'ta çalıştırma scripti
#!/bin/bash
# scripts/run-jmeter.sh
JMETER_VERSION="5.6.2"
JMETER_HOME="/opt/apache-jmeter-${JMETER_VERSION}"
# JMeter kurulumu (cache'lenmiş değilse)
if [ ! -d "$JMETER_HOME" ]; then
wget -q "https://archive.apache.org/dist/jmeter/binaries/apache-jmeter-${JMETER_VERSION}.tgz"
tar -xzf "apache-jmeter-${JMETER_VERSION}.tgz" -C /opt/
fi
# Testi çalıştır
$JMETER_HOME/bin/jmeter
-n
-t tests/load/api-test-plan.jmx
-l results/jmeter-results.jtl
-e
-o results/jmeter-report
-Jbase_url="${API_BASE_URL}"
-Jthread_count="${THREAD_COUNT:-50}"
-Jramp_up="${RAMP_UP:-60}"
-Jduration="${DURATION:-300}"
# Sonuç analizi
python3 scripts/analyze-jmeter.py results/jmeter-results.jtl
Performans Baseline ve Karşılaştırma
En değerli özelliklerden biri, mevcut sonuçları geçmişle karşılaştırmaktır. Bunun için basit bir baseline mekanizması kuruyoruz.
#!/bin/bash
# scripts/compare-performance.sh
# Önceki baseline ile mevcut sonuçları karşılaştırır
CURRENT_RESULTS="results/k6-summary.json"
BASELINE_FILE="performance-baseline.json"
REGRESSION_THRESHOLD=0.15 # %15 degradasyon toleransı
if [ ! -f "$BASELINE_FILE" ]; then
echo "Baseline bulunamadı, mevcut sonuçlar baseline olarak kaydediliyor..."
cp "$CURRENT_RESULTS" "$BASELINE_FILE"
exit 0
fi
python3 << EOF
import json
import sys
with open('$CURRENT_RESULTS') as f:
current = json.load(f)
with open('$BASELINE_FILE') as f:
baseline = json.load(f)
current_p95 = current['metrics']['http_req_duration']['values']['p(95)']
baseline_p95 = baseline['metrics']['http_req_duration']['values']['p(95)']
regression = (current_p95 - baseline_p95) / baseline_p95
print(f"Baseline P95: {baseline_p95:.2f}ms")
print(f"Mevcut P95: {current_p95:.2f}ms")
print(f"Değişim: {regression:.1%}")
if regression > $REGRESSION_THRESHOLD:
print(f"PERFORMANS REGRESYONU TESPİT EDİLDİ!")
print(f"P95 response time %{regression*100:.1f} arttı (tolerans: %{$REGRESSION_THRESHOLD*100:.0f})")
sys.exit(1)
else:
print("Performans regresyonu yok.")
EOF
Çevre Bazlı Test Konfigürasyonu
Farklı ortamlar için farklı yük profilleri tanımlamak önemli. PR testleri hafif olmalı, main branch testleri daha kapsamlı.
// tests/load/config/profiles.js
export const profiles = {
// PR'lar için hızlı smoke test
smoke: {
stages: [
{ duration: '1m', target: 5 },
{ duration: '2m', target: 5 },
{ duration: '30s', target: 0 },
],
thresholds: {
http_req_duration: ['p(95)<1000'],
http_req_failed: ['rate<0.05'],
},
},
// Main branch için normal yük testi
load: {
stages: [
{ duration: '2m', target: 50 },
{ duration: '5m', target: 50 },
{ duration: '2m', target: 0 },
],
thresholds: {
http_req_duration: ['p(95)<500', 'p(99)<1000'],
http_req_failed: ['rate<0.01'],
},
},
// Haftalık stress testi
stress: {
stages: [
{ duration: '2m', target: 100 },
{ duration: '5m', target: 200 },
{ duration: '2m', target: 300 },
{ duration: '5m', target: 300 },
{ duration: '5m', target: 0 },
],
thresholds: {
http_req_duration: ['p(95)<2000'],
http_req_failed: ['rate<0.05'],
},
},
};
const profile = __ENV.TEST_PROFILE || 'smoke';
export const options = profiles[profile];
GitHub Actions workflow’da profil seçimi:
- name: Determine Test Profile
id: profile
run: |
if [ "${{ github.event_name }}" == "pull_request" ]; then
echo "profile=smoke" >> $GITHUB_OUTPUT
elif [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "profile=load" >> $GITHUB_OUTPUT
else
echo "profile=smoke" >> $GITHUB_OUTPUT
fi
- name: Run k6 with Profile
env:
TEST_PROFILE: ${{ steps.profile.outputs.profile }}
API_BASE_URL: ${{ secrets.STAGING_URL }}
run: |
echo "Test profili: $TEST_PROFILE"
k6 run
--env TEST_PROFILE=$TEST_PROFILE
--out json=results/k6-results.json
tests/load/api-load-test.js
Sonuçları Grafana Cloud’a Gönderme
Test sonuçlarını görselleştirmek için Grafana Cloud entegrasyonu ekleyebiliriz. k6 bunu native olarak destekler.
- name: Run k6 with Grafana Cloud
env:
K6_CLOUD_TOKEN: ${{ secrets.GRAFANA_CLOUD_TOKEN }}
K6_CLOUD_PROJECT_ID: ${{ secrets.GRAFANA_PROJECT_ID }}
run: |
k6 run
--out cloud
--out json=results/k6-results.json
-e API_BASE_URL=${{ secrets.STAGING_URL }}
tests/load/api-load-test.js
Pratik İpuçları ve Yaygın Hatalar
Test ortamı izolasyonu kritik: Performans testleri staging’de çalışmalı, production’a kesinlikle dokunmamalı. Ayrıca staging’in production ile aynı donanım kapasitesinde olmaması sonuçları yanıltabilir; bu durumu yorumlarınızda belirtin.
Gerçekçi veri kullanın: Test scriptlerinde her zaman aynı product_id veya user_id kullanmak cache’i yapay olarak şişirir. Random değerler veya test veri seti oluşturun.
Rate limiting’e dikkat: Agresif yük testleri WAF veya rate limiter tarafından engellenebilir. Staging ortamında bu korumaları test IP’si için gevşetin.
Paralel test çalıştırmayın: Aynı anda birden fazla PR’ın yük testi çalıştırması staging’i çökertebilir. GitHub Actions’ta concurrency grubu tanımlayın:
concurrency:
group: performance-test-${{ github.ref }}
cancel-in-progress: false
Maliyeti kontrol edin: GitHub Actions dakika tüketir. Smoke testleri 5-10 dakikada bitmeli. Uzun testleri scheduled workflow olarak çalıştırın.
Test veri temizliği: Yük testleri veritabanına binlerce kayıt yazabilir. Test sonrası temizlik scripti çalıştırmayı unutmayın ya da test verilerini işaretleyerek filtrelemek için özel bir header kullanın.
Sonuç
CI/CD pipeline’ına entegre performans testi, “performans testini bir gün yapacağız” kültüründen “her commit test edilir” kültürüne geçişi sağlar. Bu yazıda anlattığımız yaklaşımın özeti:
- k6 ile hafif, hızlı ve CI dostu yük testleri yazın
- Locust ile karmaşık kullanıcı davranışlarını Python’da modelleyin
- JMeter ile kurumsal XML tabanlı senaryoları otomatize edin
- PR’larda smoke testi, main branch’te tam yük testi çalıştırın
- Baseline karşılaştırması ile performans regresyonlarını otomatik yakalayın
- Sonuçları PR yorumu olarak paylaşın, görünürlüğü artırın
Bu sistemin kurulum maliyeti birkaç günlük iş yükü, ama ilk önlenen production incident’ta kendini katlayarak amorti eder. Üretim ortamına gittiğinde “yavaş ama çalışıyor” demek yerine, deploy öncesinde “bu değişiklik P95’i 50ms artırıyor, kabul ediyor muyuz?” sorusunu sorabilmek paha biçilemez bir kalite güvencesidir.
