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.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir