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
mainbranch’ten manuel tetikleme zorunlu kılın - Required approvals: Production migration için en az iki onay gerektirin
- Environment variables:
DB_PASSWORD_PRODUCTIONgibi 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: manualile 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.
