GitLab CI/CD ile Otomatik Veritabanı Migration Yönetimi

Veritabanı migration’ları, çoğu ekibin “elle halledelim” diyerek başladığı ama zamanla kabusu haline gelen süreçlerden biridir. Cuma akşamı production’a çıkarken bir migration’ı unutmak, ya da yanlış sırada çalıştırmak… Bu senaryoları yaşadıysanız ne demek istediğimi anlıyorsunuzdur. GitLab CI/CD pipeline’ları ile bu süreci otomatize etmek hem güvenli hem de tekrarlanabilir hale getiriyor. Bu yazıda gerçek dünya senaryolarıyla nasıl yapılacağını ele alacağız.

Neden Otomatik Migration Yönetimi Gerekli?

Manuel migration süreçlerinin getirdiği sorunlar saymakla bitmez. Geliştirici unutur, staging’de çalışır production’da çalışmaz, rollback planı yoktur ve kim ne zaman çalıştırdı belli olmaz. Bunların yanı sıra birden fazla developer aynı anda migration yazmaya başladığında çakışmalar kaçınılmaz olur.

Otomatik migration yönetimi şu avantajları sağlar:

  • Tekrarlanabilirlik: Her ortamda aynı adımlar aynı sırayla çalışır
  • Audit trail: Kim hangi migration’ı ne zaman çalıştırdı kaydı tutulur
  • Güvenli rollback: Başarısız migration’larda otomatik geri alma
  • Environment yönetimi: Dev, staging ve production için ayrı stratejiler
  • Zero-downtime deployments: Blue-green veya rolling update stratejileriyle uyum

Proje Yapısı ve Ön Hazırlık

Örneğimizde PostgreSQL kullanan bir Rails uygulaması üzerinden gideceğiz, ancak aynı prensipler Laravel, Django veya herhangi bir framework için uygulanabilir. Önce proje dizin yapısını netleştirelim:

myapp/
├── .gitlab-ci.yml
├── db/
│   ├── migrations/
│   │   ├── 20240101_create_users.sql
│   │   ├── 20240115_add_email_index.sql
│   │   └── 20240120_create_orders.sql
│   └── seeds/
├── scripts/
│   ├── migrate.sh
│   ├── rollback.sh
│   └── check_migrations.sh
└── app/

Migration scriptleri için standart bir isimlendirme şeması kullanmak kritik önem taşıyor. Timestamp prefix ile başlamak, sıralama sorunlarını ortadan kaldırır.

Migration Script’lerinin Hazırlanması

Önce temel migration kontrol scriptini yazalım. Bu script, hangi migration’ların çalıştırıldığını takip eden bir tablo kullanıyor:

#!/bin/bash
# scripts/migrate.sh

set -euo pipefail

DB_HOST="${DB_HOST:-localhost}"
DB_PORT="${DB_PORT:-5432}"
DB_NAME="${DB_NAME:-myapp}"
DB_USER="${DB_USER:-postgres}"
MIGRATIONS_DIR="${MIGRATIONS_DIR:-db/migrations}"
LOG_FILE="/tmp/migration_$(date +%Y%m%d_%H%M%S).log"

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}

# Migration tracking tablosunu oluştur
create_migration_table() {
    psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" <<EOF
CREATE TABLE IF NOT EXISTS schema_migrations (
    id SERIAL PRIMARY KEY,
    migration_name VARCHAR(255) UNIQUE NOT NULL,
    applied_at TIMESTAMP DEFAULT NOW(),
    applied_by VARCHAR(100),
    git_commit VARCHAR(40),
    duration_ms INTEGER
);
EOF
    log "Migration tracking tablosu hazır"
}

# Migration daha önce çalıştırılmış mı kontrol et
is_migration_applied() {
    local migration_name="$1"
    local count
    count=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c 
        "SELECT COUNT(*) FROM schema_migrations WHERE migration_name='$migration_name';")
    echo "$count" | tr -d ' '
}

# Tek bir migration çalıştır
run_migration() {
    local migration_file="$1"
    local migration_name
    migration_name=$(basename "$migration_file")
    local start_time
    start_time=$(date +%s%3N)

    log "Çalıştırılıyor: $migration_name"

    if psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" 
        -f "$migration_file" >> "$LOG_FILE" 2>&1; then
        local end_time
        end_time=$(date +%s%3N)
        local duration=$((end_time - start_time))

        psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c 
            "INSERT INTO schema_migrations (migration_name, applied_by, git_commit, duration_ms)
             VALUES ('$migration_name', '${GITLAB_USER_LOGIN:-ci}', '${CI_COMMIT_SHA:-local}', $duration);"

        log "Tamamlandı: $migration_name (${duration}ms)"
    else
        log "HATA: $migration_name başarısız oldu!"
        exit 1
    fi
}

# Ana fonksiyon
main() {
    log "Migration süreci başlatılıyor..."
    create_migration_table

    local pending_count=0

    for migration_file in $(ls "$MIGRATIONS_DIR"/*.sql 2>/dev/null | sort); do
        local migration_name
        migration_name=$(basename "$migration_file")
        local applied
        applied=$(is_migration_applied "$migration_name")

        if [ "$applied" -eq 0 ]; then
            run_migration "$migration_file"
            ((pending_count++))
        else
            log "Atlanıyor (zaten uygulandı): $migration_name"
        fi
    done

    log "Tamamlandı. $pending_count yeni migration uygulandı."
}

main "$@"

Rollback Mekanizması

Her migration için bir geri alma stratejisi olmalı. Down scriptlerini ayrı dosyalarda tutmak en temiz yaklaşım:

#!/bin/bash
# scripts/rollback.sh

set -euo pipefail

DB_HOST="${DB_HOST:-localhost}"
DB_PORT="${DB_PORT:-5432}"
DB_NAME="${DB_NAME:-myapp}"
DB_USER="${DB_USER:-postgres}"
MIGRATIONS_DIR="${MIGRATIONS_DIR:-db/migrations}"
ROLLBACK_COUNT="${1:-1}"

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
}

rollback_migration() {
    local migration_name="$1"
    local rollback_file="$MIGRATIONS_DIR/rollback/${migration_name%.sql}_down.sql"

    if [ ! -f "$rollback_file" ]; then
        log "UYARI: Rollback dosyası bulunamadı: $rollback_file"
        return 1
    fi

    log "Rollback uygulanıyor: $migration_name"

    if psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" 
        -f "$rollback_file"; then
        psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c 
            "DELETE FROM schema_migrations WHERE migration_name='$migration_name';"
        log "Rollback tamamlandı: $migration_name"
    else
        log "KRITIK HATA: Rollback başarısız! Manuel müdahale gerekiyor."
        exit 1
    fi
}

# Son N migration'ı geri al
main() {
    log "$ROLLBACK_COUNT migration geri alınıyor..."

    local migrations
    migrations=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c 
        "SELECT migration_name FROM schema_migrations
         ORDER BY applied_at DESC LIMIT $ROLLBACK_COUNT;")

    if [ -z "$migrations" ]; then
        log "Geri alınacak migration bulunamadı."
        exit 0
    fi

    while IFS= read -r migration; do
        migration=$(echo "$migration" | tr -d ' ')
        if [ -n "$migration" ]; then
            rollback_migration "$migration"
        fi
    done <<< "$migrations"
}

main "$@"

GitLab CI/CD Pipeline Konfigürasyonu

Asıl işin kalbi olan .gitlab-ci.yml dosyasını adım adım inşa edelim. Gerçek dünya senaryolarında birden fazla ortam ve onay mekanizması olması şart:

# .gitlab-ci.yml

variables:
  POSTGRES_IMAGE: "postgres:15-alpine"
  MIGRATION_TIMEOUT: "300"
  SLACK_CHANNEL: "#deployments"

stages:
  - validate
  - test
  - migrate-staging
  - migrate-production

# Gizli değişkenler GitLab CI/CD Variables'tan gelecek:
# DB_HOST_STAGING, DB_HOST_PRODUCTION
# DB_PASSWORD_STAGING, DB_PASSWORD_PRODUCTION
# SLACK_WEBHOOK_URL

.migration_template: &migration_defaults
  image: postgres:15-alpine
  before_script:
    - apk add --no-cache bash curl
    - export PGPASSWORD="$DB_PASSWORD"
    - chmod +x scripts/migrate.sh scripts/rollback.sh
    - pg_isready -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -t 30
  after_script:
    - |
      if [ "$CI_JOB_STATUS" == "failed" ]; then
        curl -s -X POST "$SLACK_WEBHOOK_URL" 
          -H "Content-Type: application/json" 
          -d "{"text":"Migration BAŞARISIZ: $CI_PROJECT_NAME - $CI_ENVIRONMENT_NAME - $CI_COMMIT_SHA"}"
      fi

validate-migrations:
  stage: validate
  image: postgres:15-alpine
  script:
    - apk add --no-cache bash
    - chmod +x scripts/check_migrations.sh
    - bash scripts/check_migrations.sh
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == "main"'
    - if: '$CI_COMMIT_BRANCH == "develop"'

test-migrations:
  stage: test
  image: postgres:15-alpine
  services:
    - name: postgres:15-alpine
      alias: postgres-test
      variables:
        POSTGRES_DB: myapp_test
        POSTGRES_USER: postgres
        POSTGRES_PASSWORD: testpassword
  variables:
    DB_HOST: postgres-test
    DB_PORT: "5432"
    DB_NAME: myapp_test
    DB_USER: postgres
    DB_PASSWORD: testpassword
    PGPASSWORD: testpassword
  script:
    - apk add --no-cache bash
    - chmod +x scripts/migrate.sh
    - bash scripts/migrate.sh
    - echo "Forward migration testi başarılı"
    - bash scripts/rollback.sh 3
    - echo "Rollback testi başarılı"
    - bash scripts/migrate.sh
    - echo "Re-migration testi başarılı"
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
    - if: '$CI_COMMIT_BRANCH == "develop"'

migrate-staging:
  <<: *migration_defaults
  stage: migrate-staging
  environment:
    name: staging
    url: https://staging.myapp.com
  variables:
    DB_HOST: "$DB_HOST_STAGING"
    DB_PORT: "5432"
    DB_NAME: myapp_staging
    DB_USER: myapp_user
    DB_PASSWORD: "$DB_PASSWORD_STAGING"
    PGPASSWORD: "$DB_PASSWORD_STAGING"
  script:
    - bash scripts/migrate.sh
    - echo "Staging migration tamamlandı"
  rules:
    - if: '$CI_COMMIT_BRANCH == "develop"'

migrate-production:
  <<: *migration_defaults
  stage: migrate-production
  environment:
    name: production
    url: https://myapp.com
  variables:
    DB_HOST: "$DB_HOST_PRODUCTION"
    DB_PORT: "5432"
    DB_NAME: myapp_production
    DB_USER: myapp_user
    DB_PASSWORD: "$DB_PASSWORD_PRODUCTION"
    PGPASSWORD: "$DB_PASSWORD_PRODUCTION"
  script:
    - bash scripts/migrate.sh
    - |
      curl -s -X POST "$SLACK_WEBHOOK_URL" 
        -H "Content-Type: application/json" 
        -d "{"text":"Migration başarılı: $CI_PROJECT_NAME - Production - $CI_COMMIT_SHA"}"
  when: manual
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
  allow_failure: false

Migration Doğrulama Script’i

Production’a geçmeden önce migration dosyalarının geçerli olup olmadığını kontrol eden script olmadan pipeline eksik kalır:

#!/bin/bash
# scripts/check_migrations.sh

set -euo pipefail

MIGRATIONS_DIR="${MIGRATIONS_DIR:-db/migrations}"
ERRORS=0

log_error() {
    echo "HATA: $1"
    ((ERRORS++))
}

log_ok() {
    echo "OK: $1"
}

# Dosya adı formatını kontrol et (YYYYMMDD_description.sql)
check_naming_convention() {
    for file in "$MIGRATIONS_DIR"/*.sql; do
        local filename
        filename=$(basename "$file")
        if [[ ! "$filename" =~ ^[0-9]{8}_[a-z0-9_]+.sql$ ]]; then
            log_error "Geçersiz dosya adı formatı: $filename (beklenen: YYYYMMDD_aciklama.sql)"
        else
            log_ok "Dosya adı formatı: $filename"
        fi
    done
}

# SQL syntax temel kontrolü
check_sql_syntax() {
    for file in "$MIGRATIONS_DIR"/*.sql; do
        local filename
        filename=$(basename "$file")
        # Tehlikeli operasyonları tespit et
        if grep -qi "DROP TABLE|TRUNCATE" "$file"; then
            echo "UYARI: Tehlikeli operasyon tespit edildi: $filename - manuel onay gerekebilir"
        fi
        # Eksik transaction kontrolü
        if ! grep -qi "BEGIN|START TRANSACTION" "$file"; then
            echo "UYARI: Transaction bloğu yok: $filename"
        else
            log_ok "Transaction bloğu mevcut: $filename"
        fi
    done
}

# Rollback dosyalarının varlığını kontrol et
check_rollback_files() {
    for file in "$MIGRATIONS_DIR"/*.sql; do
        local migration_name
        migration_name=$(basename "$file" .sql)
        local rollback_file="$MIGRATIONS_DIR/rollback/${migration_name}_down.sql"
        if [ ! -f "$rollback_file" ]; then
            log_error "Rollback dosyası eksik: ${migration_name}_down.sql"
        else
            log_ok "Rollback dosyası mevcut: ${migration_name}_down.sql"
        fi
    done
}

main() {
    echo "Migration doğrulaması başlatılıyor..."
    check_naming_convention
    check_sql_syntax
    check_rollback_files

    if [ "$ERRORS" -gt 0 ]; then
        echo ""
        echo "$ERRORS hata bulundu. Pipeline durduruluyor."
        exit 1
    else
        echo ""
        echo "Tüm kontroller başarılı. Migration'lar uygulamaya hazır."
    fi
}

main "$@"

Lock Mekanizması ile Eş Zamanlı Migration Sorunu

Birden fazla pipeline aynı anda tetiklenirse iki migration süreci aynı anda çalışabilir. Bu durumu önlemek için advisory lock kullanmak şart:

#!/bin/bash
# scripts/migration_lock.sh

set -euo pipefail

LOCK_ID="${1:-12345}"  # Uygulamaya özgü sabit bir sayı
DB_HOST="${DB_HOST:-localhost}"
DB_USER="${DB_USER:-postgres}"
DB_NAME="${DB_NAME:-myapp}"
PGPASSWORD="${DB_PASSWORD:-}"
export PGPASSWORD

acquire_lock() {
    log "Migration lock alınıyor (ID: $LOCK_ID)..."

    local lock_result
    lock_result=$(psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -t -c 
        "SELECT pg_try_advisory_lock($LOCK_ID);" | tr -d ' n')

    if [ "$lock_result" = "t" ]; then
        log "Lock başarıyla alındı"
        return 0
    else
        log "HATA: Başka bir migration süreci çalışıyor. Lütfen tamamlanmasını bekleyin."
        return 1
    fi
}

release_lock() {
    psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c 
        "SELECT pg_advisory_unlock($LOCK_ID);" > /dev/null 2>&1
    log "Migration lock serbest bırakıldı"
}

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
}

# Lock ile migration çalıştır
if acquire_lock; then
    trap release_lock EXIT
    bash scripts/migrate.sh
else
    exit 1
fi

Büyük Tablolarda Zero-Downtime Migration Stratejisi

Milyonlarca kayıt içeren tablolarda ALTER TABLE ADD COLUMN gibi işlemler production’ı kilitler. Bu durumda şu stratejiyi izlemek gerekir:

-- db/migrations/20240120_add_status_to_orders.sql
-- Bu migration büyük tablolar için non-blocking yaklaşım kullanır

BEGIN;

-- 1. Adım: Kolonu NULL kabul ederek ekle (hızlı)
ALTER TABLE orders ADD COLUMN IF NOT EXISTS status VARCHAR(50);

-- 2. Adım: Default değeri ayrı bir UPDATE ile set et
-- (Bunu uygulama kodu ile birlikte batch halinde yapın)

-- 3. Adım: Index'i CONCURRENTLY ile oluştur
-- NOT: CONCURRENTLY transaction içinde çalışmaz,
-- bu yüzden ayrı bir migration dosyasına taşının

COMMIT;
-- db/migrations/20240121_add_status_index_concurrently.sql
-- Bu dosya transaction dışında çalışmalı

-- CONCURRENTLY transaction gerektirmez
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_status
    ON orders(status)
    WHERE status IS NOT NULL;

Bu iki migration’ı aynı pipeline içinde ardışık çalıştırarak table lock riski minimize edilir.

Monitoring ve Alerting Entegrasyonu

Migration sürecinin ne kadar sürdüğünü ve hangi adımda takıldığını izlemek için Prometheus metriklerini besleyebilirsiniz:

#!/bin/bash
# scripts/migration_metrics.sh
# Migration sonrası Prometheus Pushgateway'e metrik gönderir

PUSHGATEWAY_URL="${PUSHGATEWAY_URL:-http://pushgateway:9091}"
APP_NAME="${CI_PROJECT_NAME:-myapp}"
ENVIRONMENT="${CI_ENVIRONMENT_NAME:-unknown}"

push_metric() {
    local metric_name="$1"
    local metric_value="$2"
    local help_text="$3"

    cat <<EOF | curl -s --data-binary @- 
        "${PUSHGATEWAY_URL}/metrics/job/db_migration/instance/${APP_NAME}_${ENVIRONMENT}"
# HELP ${metric_name} ${help_text}
# TYPE ${metric_name} gauge
${metric_name}{app="${APP_NAME}",env="${ENVIRONMENT}",commit="${CI_COMMIT_SHA:-local}"} ${metric_value}
EOF
}

# Migration sayısını ve son çalışma zamanını gönder
MIGRATION_COUNT=$(psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -t -c 
    "SELECT COUNT(*) FROM schema_migrations;" | tr -d ' ')

LAST_MIGRATION_TS=$(psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -t -c 
    "SELECT EXTRACT(EPOCH FROM MAX(applied_at)) FROM schema_migrations;" | tr -d ' ')

push_metric "db_migrations_total" "$MIGRATION_COUNT" "Toplam uygulanan migration sayisi"
push_metric "db_migration_last_applied_timestamp" "$LAST_MIGRATION_TS" "Son migration zaman damgasi"

echo "Metrikler Pushgateway'e gönderildi"

GitLab Environments ve Protected Variables

Production migration’larının güvenli olması için GitLab’ın environment protection özelliklerini kullanmak şart. Şu ayarları yapılandırın:

  • Protected environments: Production için main branch’ten manuel tetikleme zorunlu kılın
  • Required approvals: Production migration için en az iki onay gerektirin
  • Environment variables: DB_PASSWORD_PRODUCTION gibi kritik değişkenleri “Protected” ve “Masked” olarak işaretleyin
  • Deployment freeze: Mesai saatleri dışındaki deployment’ları Deployment freeze özelliğiyle engelleyin
  • Audit log: GitLab’ın yerleşik audit log özelliği sayesinde kimin ne zaman onay verdiği kayıt altına alınır

Gerçek Dünya Senaryosu: Sıfır Kesinti ile Migration

Bir e-ticaret projesinde products tablosuna yeni bir sku kolonu eklemek gerektiğini düşünelim. Tablo 50 milyon kayıt içeriyor ve 24 saat kesintisiz çalışması şart. Pipeline akışı şöyle kurgulanır:

Önce develop branch’ine push yapıldığında validate-migrations ve test-migrations stage’leri otomatik tetiklenir. Testler geçerse migrate-staging otomatik çalışır ve staging ortamında migration doğrulanır. main branch’e merge request açıldığında tekrar validasyon çalışır. Merge onaylandıktan sonra migrate-production job’ı beklemede kalır ve sorumlu mühendis GitLab arayüzünden manuel onay vererek başlatır. Başarısız olursa Slack bildirimi gelir ve rollback job’ı hazırda bekler.

Bu akış sayesinde hiçbir migration “acaba çalıştı mı” belirsizliği olmadan izlenebilir ve tekrarlanabilir hale gelir.

Sonuç

GitLab CI/CD ile otomatik database migration yönetimi kurmak ilk başta karmaşık görünüyor, ancak bir kez oturduğunda “bir daha elden nasıl yapardık” diye düşünüyorsunuz. Kritik noktalara bakacak olursak:

  • Migration tracking tablosu olmadan idempotency sağlanamaz
  • Rollback dosyaları yazmak sıkıcı ama production’da hayat kurtarır
  • when: manual ile production koruma altına alınmadan bu pipeline eksik kalır
  • Advisory lock kullanmadan paralel pipeline çakışma riski devam eder
  • Zero-downtime için büyük tablolardaki işlemleri parçalamak şarttır

Bu yapıyı kurduktan sonra her deploy öncesi “migration’ı çalıştırdık mı?” sorusu tarihe karışır. Pipeline zaten halleder.

Bir yanıt yazın

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