Veritabanı Migration Otomasyonu: Ansible ile Adım Adım Rehber
Veritabanı migration’ları her sysadmin’in kabusu olabilir. Özellikle production ortamında, gece yarısı, bir şeyler ters gittiğinde. Yıllar içinde onlarca migration felaketi gördüm: yanlış sırayla çalıştırılan scriptler, yarım kalan transaction’lar, rollback yapmayı unutmuş mühendisler. Ansible bu süreci otomatize ettiğinde hayat gerçekten kolaylaşıyor. Bu yazıda sıfırdan production-ready bir veritabanı migration otomasyonu kuracağız.
Neden Migration Otomasyonu?
Manuel migration süreçleri şu sorunları beraberinde getirir:
- İnsan hatası: Adımları atlama, yanlış ortama bağlanma, scripti iki kez çalıştırma
- Belgeleme eksikliği: “Bu migration’ı kim yazdı, ne zaman çalıştı?” sorusuna cevap bulamama
- Tekrarlanabilirlik sorunu: Staging’de çalışan şeyin production’da çalışacağının garantisi yok
- Rollback karmaşası: Bir şeyler bozulduğunda geri dönmek için panikle script yazmak zorunda kalmak
Ansible ile bu süreçleri idempotent, versiyonlanmış ve otomatik hale getiriyoruz. Bir migration sadece bir kez çalışır, sonraki playbook çalıştırmalarında atlanır. Rollback adımları önceden tanımlanmıştır. Her şey git’te versiyonlanmıştır.
Proje Yapısı
Önce klasör yapımızı oluşturalım. Ben gerçek projelerde bu yapıyı kullanıyorum ve oldukça sağlam çalışıyor:
db-migrations/
├── ansible.cfg
├── inventory/
│ ├── production/
│ │ ├── hosts.yml
│ │ └── group_vars/
│ │ └── db_servers.yml
│ └── staging/
│ ├── hosts.yml
│ └── group_vars/
│ └── db_servers.yml
├── playbooks/
│ ├── migrate.yml
│ ├── rollback.yml
│ └── status.yml
├── roles/
│ └── db_migration/
│ ├── tasks/
│ │ ├── main.yml
│ │ ├── pre_checks.yml
│ │ ├── backup.yml
│ │ ├── apply_migrations.yml
│ │ └── verify.yml
│ ├── templates/
│ │ └── migration_log.j2
│ └── vars/
│ └── main.yml
└── migrations/
├── V001__create_users_table.sql
├── V002__add_email_column.sql
└── V003__create_orders_table.sql
Inventory ve Konfigürasyon
ansible.cfg dosyasını yapılandıralım:
[defaults]
inventory = inventory/
roles_path = roles/
remote_user = ansible
private_key_file = ~/.ssh/ansible_key
host_key_checking = False
retry_files_enabled = False
stdout_callback = yaml
log_path = /var/log/ansible/migrations.log
[privilege_escalation]
become = True
become_method = sudo
become_user = root
[ssh_connection]
ssh_args = -o ControlMaster=auto -o ControlPersist=60s
pipelining = True
Production inventory dosyası inventory/production/hosts.yml:
all:
children:
db_servers:
hosts:
db-master-01:
ansible_host: 10.0.1.10
db_role: master
db-replica-01:
ansible_host: 10.0.1.11
db_role: replica
vars:
ansible_user: ansible
ansible_port: 22
Group variables için inventory/production/group_vars/db_servers.yml:
# Veritabanı bağlantı bilgileri (vault ile şifrelenmiş olmalı)
db_host: "localhost"
db_port: 5432
db_name: "myapp_production"
db_user: "migration_user"
db_password: "{{ vault_db_password }}"
# Migration ayarları
migration_dir: "/opt/migrations"
migration_log_dir: "/var/log/db_migrations"
migration_backup_dir: "/var/backups/db_migrations"
migration_lock_file: "/tmp/db_migration.lock"
migration_timeout: 3600
max_backup_age_days: 30
# Bildirim ayarları
slack_webhook_url: "{{ vault_slack_webhook }}"
notification_enabled: true
Ana Playbook
playbooks/migrate.yml dosyamız:
---
- name: Veritabanı Migration Otomasyonu
hosts: db_servers
serial: 1
gather_facts: yes
vars_files:
- "../vault/secrets.yml"
pre_tasks:
- name: Migration başlangıç zamanını kaydet
set_fact:
migration_start_time: "{{ ansible_date_time.iso8601 }}"
- name: Sadece master node üzerinde çalış
fail:
msg: "Migration sadece master DB node üzerinde çalıştırılır!"
when: db_role != "master"
- name: Lock dosyası kontrolü
stat:
path: "{{ migration_lock_file }}"
register: lock_file_stat
- name: Paralel migration'ı engelle
fail:
msg: "Başka bir migration süreci devam ediyor! Lock dosyası mevcut: {{ migration_lock_file }}"
when: lock_file_stat.stat.exists
- name: Lock dosyası oluştur
file:
path: "{{ migration_lock_file }}"
state: touch
mode: '0644'
roles:
- role: db_migration
post_tasks:
- name: Lock dosyasını temizle
file:
path: "{{ migration_lock_file }}"
state: absent
when: always
- name: Migration tamamlandı bildirimi gönder
uri:
url: "{{ slack_webhook_url }}"
method: POST
body_format: json
body:
text: "✅ Migration tamamlandı: {{ db_name }} - {{ migration_start_time }}"
when: notification_enabled
ignore_errors: yes
handlers:
- name: lock dosyasini temizle
file:
path: "{{ migration_lock_file }}"
state: absent
Migration Role’ün İçi
Şimdi işin asıl kısmına gelelim. roles/db_migration/tasks/main.yml:
---
- name: Pre-check adımlarını çalıştır
import_tasks: pre_checks.yml
tags: [always, pre_checks]
- name: Migration öncesi backup al
import_tasks: backup.yml
tags: [always, backup]
when: skip_backup is not defined or not skip_backup
- name: Migration'ları uygula
import_tasks: apply_migrations.yml
tags: [migrate]
- name: Migration sonrası doğrulama
import_tasks: verify.yml
tags: [always, verify]
roles/db_migration/tasks/pre_checks.yml dosyasında kontrolleri yapalım:
---
- name: PostgreSQL servisinin çalıştığını doğrula
systemd:
name: postgresql
register: pg_status
failed_when: pg_status.status.ActiveState != "active"
- name: Disk alanı kontrolü (en az 10GB boş alan)
shell: df -BG {{ migration_backup_dir }} | awk 'NR==2 {print $4}' | tr -d 'G'
register: free_disk_gb
changed_when: false
- name: Yetersiz disk alanı kontrolü
fail:
msg: "Yetersiz disk alanı! Mevcut: {{ free_disk_gb.stdout }}GB, Gerekli: 10GB"
when: free_disk_gb.stdout | int < 10
- name: Migration klasörünün var olduğunu kontrol et
file:
path: "{{ migration_dir }}"
state: directory
owner: postgres
group: postgres
mode: '0750'
- name: Migration log klasörünü oluştur
file:
path: "{{ migration_log_dir }}"
state: directory
owner: postgres
group: postgres
mode: '0750'
- name: Migration tracking tablosunu oluştur (yoksa)
become_user: postgres
postgresql_query:
db: "{{ db_name }}"
login_user: "{{ db_user }}"
query: |
CREATE TABLE IF NOT EXISTS schema_migrations (
id SERIAL PRIMARY KEY,
version VARCHAR(50) NOT NULL UNIQUE,
filename VARCHAR(255) NOT NULL,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
applied_by VARCHAR(100),
checksum VARCHAR(64),
execution_time_ms INTEGER
);
- name: Bekleyen migration'ları listele
find:
paths: "{{ migration_dir }}"
patterns: "V*.sql"
file_type: file
register: migration_files
- name: Migration dosyalarını sırala
set_fact:
sorted_migrations: "{{ migration_files.files | map(attribute='path') | sort | list }}"
- name: Pre-check özeti
debug:
msg: |
Disk Alanı: {{ free_disk_gb.stdout }}GB
Toplam Migration Dosyası: {{ sorted_migrations | length }}
DB Adı: {{ db_name }}
Migration Uygulama Adımları
roles/db_migration/tasks/apply_migrations.yml:
---
- name: Her migration dosyası için işlem yap
include_tasks: run_single_migration.yml
loop: "{{ sorted_migrations }}"
loop_control:
loop_var: migration_file
label: "{{ migration_file | basename }}"
roles/db_migration/tasks/run_single_migration.yml adında yeni bir dosya oluşturalım, bu critical kısım:
---
- name: Migration version'ını çıkar
set_fact:
migration_version: "{{ migration_file | basename | regex_replace('^(V[0-9]+)__.*\.sql$', '\1') }}"
migration_filename: "{{ migration_file | basename }}"
- name: Migration daha önce çalıştırıldı mı kontrol et
become_user: postgres
postgresql_query:
db: "{{ db_name }}"
login_user: "{{ db_user }}"
query: "SELECT COUNT(*) as cnt FROM schema_migrations WHERE version = %s"
positional_args:
- "{{ migration_version }}"
register: migration_check
- name: Migration'ı atla (zaten uygulanmış)
debug:
msg: "⏭️ {{ migration_filename }} zaten uygulanmış, atlanıyor..."
when: migration_check.query_result[0].cnt | int > 0
- name: Migration checksum hesapla
stat:
path: "{{ migration_file }}"
checksum_algorithm: sha256
register: migration_stat
when: migration_check.query_result[0].cnt | int == 0
- name: Migration dosyasını hedefe kopyala
copy:
src: "{{ migration_file }}"
dest: "/tmp/{{ migration_filename }}"
remote_src: yes
owner: postgres
mode: '0600'
when: migration_check.query_result[0].cnt | int == 0
- name: Migration başlangıç zamanını kaydet
set_fact:
migration_exec_start: "{{ ansible_date_time.epoch }}"
when: migration_check.query_result[0].cnt | int == 0
- name: Migration SQL'ini çalıştır
become_user: postgres
shell: |
psql -U {{ db_user }} -d {{ db_name }}
--single-transaction
--set ON_ERROR_STOP=on
-f /tmp/{{ migration_filename }}
2>&1
register: migration_result
failed_when: migration_result.rc != 0
when: migration_check.query_result[0].cnt | int == 0
- name: Execution time hesapla
set_fact:
exec_time_ms: "{{ ((ansible_date_time.epoch | int) - (migration_exec_start | int)) * 1000 }}"
when: migration_check.query_result[0].cnt | int == 0
- name: Migration tracking tablosuna kaydet
become_user: postgres
postgresql_query:
db: "{{ db_name }}"
login_user: "{{ db_user }}"
query: |
INSERT INTO schema_migrations (version, filename, applied_by, checksum, execution_time_ms)
VALUES (%s, %s, %s, %s, %s)
positional_args:
- "{{ migration_version }}"
- "{{ migration_filename }}"
- "{{ ansible_user }}"
- "{{ migration_stat.stat.checksum }}"
- "{{ exec_time_ms | int }}"
when: migration_check.query_result[0].cnt | int == 0
- name: Migration log yaz
lineinfile:
path: "{{ migration_log_dir }}/migrations.log"
line: "{{ ansible_date_time.iso8601 }} | SUCCESS | {{ migration_filename }} | {{ exec_time_ms }}ms | {{ ansible_user }}"
create: yes
when: migration_check.query_result[0].cnt | int == 0
- name: Geçici dosyayı temizle
file:
path: "/tmp/{{ migration_filename }}"
state: absent
when: migration_check.query_result[0].cnt | int == 0
- name: Migration başarı mesajı
debug:
msg: "✅ {{ migration_filename }} başarıyla uygulandı ({{ exec_time_ms }}ms)"
when: migration_check.query_result[0].cnt | int == 0
Backup Adımı
roles/db_migration/tasks/backup.yml:
---
- name: Backup klasörünü oluştur
file:
path: "{{ migration_backup_dir }}"
state: directory
owner: postgres
group: postgres
mode: '0750'
- name: Backup dosya adını belirle
set_fact:
backup_filename: "{{ db_name }}_pre_migration_{{ ansible_date_time.epoch }}.sql.gz"
- name: PostgreSQL dump al
become_user: postgres
shell: |
pg_dump -U {{ db_user }}
--format=custom
--compress=9
--file={{ migration_backup_dir }}/{{ backup_filename }}
{{ db_name }}
register: backup_result
failed_when: backup_result.rc != 0
- name: Backup dosyasının oluşturulduğunu doğrula
stat:
path: "{{ migration_backup_dir }}/{{ backup_filename }}"
register: backup_stat
failed_when: not backup_stat.stat.exists
- name: Backup boyutunu raporla
debug:
msg: "Backup alındı: {{ backup_filename }} ({{ (backup_stat.stat.size / 1024 / 1024) | round(2) }} MB)"
- name: Eski backup dosyalarını temizle
find:
paths: "{{ migration_backup_dir }}"
age: "{{ max_backup_age_days }}d"
patterns: "*.sql.gz"
register: old_backups
- name: Eski backup'ları sil
file:
path: "{{ item.path }}"
state: absent
loop: "{{ old_backups.files }}"
when: old_backups.files | length > 0
Rollback Playbook’u
playbooks/rollback.yml ile bir şeyler ters gittiğinde geri dönebiliriz:
---
- name: Veritabanı Migration Rollback
hosts: db_servers
gather_facts: yes
vars_files:
- "../vault/secrets.yml"
vars_prompt:
- name: target_version
prompt: "Hangi versiyona geri dönmek istiyorsunuz? (örn: V003)"
private: no
- name: confirm_rollback
prompt: "Production'da rollback yapacaksınız! DEVAM ET yaz"
private: no
tasks:
- name: Rollback onayını kontrol et
fail:
msg: "Rollback iptal edildi."
when: confirm_rollback != "DEVAM ET"
- name: En son backup'ı bul
find:
paths: "{{ migration_backup_dir }}"
patterns: "{{ db_name }}_pre_migration_*.sql.gz"
file_type: file
register: available_backups
- name: En güncel backup'ı seç
set_fact:
latest_backup: "{{ available_backups.files | sort(attribute='mtime') | last }}"
- name: Backup bilgisini göster
debug:
msg: "Rollback için kullanılacak backup: {{ latest_backup.path }}"
- name: Mevcut veritabanını yedekle (güvenlik için)
become_user: postgres
shell: |
pg_dump -U {{ db_user }}
--format=custom
--compress=9
--file={{ migration_backup_dir }}/{{ db_name }}_before_rollback_{{ ansible_date_time.epoch }}.sql.gz
{{ db_name }}
- name: Backup'tan restore et
become_user: postgres
shell: |
pg_restore -U {{ db_user }}
--clean
--if-exists
--dbname={{ db_name }}
{{ latest_backup.path }}
register: restore_result
- name: Rollback sonucu
debug:
msg: "Rollback tamamlandı. Hedef versiyon: {{ target_version }}"
- name: Slack bildirimi gönder
uri:
url: "{{ slack_webhook_url }}"
method: POST
body_format: json
body:
text: "⚠️ ROLLBACK yapıldı: {{ db_name }} -> {{ target_version }} - Kullanıcı: {{ ansible_user }}"
when: notification_enabled
ignore_errors: yes
Status Playbook’u
Migration durumunu sorgulamak için playbooks/status.yml:
---
- name: Migration Status Kontrolü
hosts: db_servers
gather_facts: no
vars_files:
- "../vault/secrets.yml"
tasks:
- name: Uygulanan migration'ları listele
become_user: postgres
postgresql_query:
db: "{{ db_name }}"
login_user: "{{ db_user }}"
query: |
SELECT
version,
filename,
applied_at,
applied_by,
execution_time_ms
FROM schema_migrations
ORDER BY applied_at DESC
LIMIT 20
register: migration_history
- name: Migration geçmişini göster
debug:
msg: "{{ item.version }} | {{ item.filename }} | {{ item.applied_at }} | {{ item.applied_by }} | {{ item.execution_time_ms }}ms"
loop: "{{ migration_history.query_result }}"
- name: Bekleyen migration dosyalarını kontrol et
find:
paths: "{{ migration_dir }}"
patterns: "V*.sql"
register: all_migration_files
- name: Bekleyen migration sayısını hesapla
debug:
msg: "Toplam {{ all_migration_files.files | length }} migration dosyası, {{ migration_history.query_result | length }} tanesi uygulanmış."
CI/CD Pipeline Entegrasyonu
GitLab CI ile entegrasyon için .gitlab-ci.yml:
stages:
- validate
- staging_migrate
- production_migrate
variables:
ANSIBLE_FORCE_COLOR: "true"
ANSIBLE_CONFIG: "ansible.cfg"
validate_migrations:
stage: validate
image: cytopia/ansible:latest
script:
- ansible-playbook playbooks/migrate.yml --check --diff -i inventory/staging/
- ansible-lint playbooks/migrate.yml
only:
- merge_requests
staging_migration:
stage: staging_migrate
image: cytopia/ansible:latest
environment:
name: staging
script:
- ansible-vault decrypt vault/secrets.yml --vault-password-file $VAULT_PASS_FILE
- ansible-playbook playbooks/migrate.yml -i inventory/staging/ -v
only:
- develop
artifacts:
paths:
- /var/log/db_migrations/
expire_in: 1 week
production_migration:
stage: production_migrate
image: cytopia/ansible:latest
environment:
name: production
script:
- ansible-vault decrypt vault/secrets.yml --vault-password-file $VAULT_PASS_FILE
- ansible-playbook playbooks/migrate.yml -i inventory/production/ -v
only:
- main
when: manual
allow_failure: false
Gerçek Dünya Senaryoları
Senaryo 1: Büyük Tablo Migration’ı
Milyonlarca satırlı bir tabloya index eklemek saatler sürebilir ve production’ı kilitleyebilir. Bunun için migration dosyanızda CONCURRENTLY kullanın:
-- V010__add_index_orders_user_id.sql
-- Bu migration uzun sürebilir, timeout artırılmış durumda
SET statement_timeout = '0';
SET lock_timeout = '30s';
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_user_id
ON orders(user_id);
Senaryo 2: Zero-Downtime Column Rename
Column rename işlemlerini direkt yapmak yerine şöyle yapıyoruz:
- V020’de yeni column ekle ve eski column’dan veriyi kopyala
- V021’de uygulamayı yeni column’u kullanacak şekilde deploy et
- V022’de eski column’u kaldır
Bu yaklaşım sayesinde hiç downtime olmadan migration yapabilirsiniz.
Senaryo 3: Büyük Veri Batch Güncelleme
Milyonlarca satırı tek seferde güncellemek transaction log’unu patlatır. Migration dosyanızda batch işlem yapın:
-- V015__update_user_status.sql
DO $$
DECLARE
batch_size INT := 10000;
offset_val INT := 0;
rows_updated INT;
BEGIN
LOOP
UPDATE users
SET status = 'active'
WHERE id IN (
SELECT id FROM users
WHERE status IS NULL
LIMIT batch_size OFFSET offset_val
);
GET DIAGNOSTICS rows_updated = ROW_COUNT;
EXIT WHEN rows_updated = 0;
offset_val := offset_val + batch_size;
PERFORM pg_sleep(0.1); -- Rate limiting
COMMIT;
END LOOP;
END $$;
Güvenlik ve Best Practice’ler
Ansible ile migration yaparken mutlaka dikkat edilmesi gerekenler:
- Ansible Vault kullanın: Tüm şifreler, connection string’ler vault’ta şifrelenmiş olmalı. Playbook’u çalıştırırken
--vault-password-fileveya--ask-vault-passkullanın. - Minimum yetki prensibi:
migration_usersadece gerekli tablolar üzerinde yetkiye sahip olmalı. DBA yetkisi vermeyin. - Lock mekanizması: Paralel migration’ları engellemek için lock dosyası veya veritabanı advisory lock kullanın.
- Idempotency: Her migration sadece bir kez çalışmalı. Tracking tablosu bu işi yapıyor.
- Checksum doğrulama: Migration dosyası değiştirilmişse uyarı verin. Çalıştırılmış bir migration asla değiştirilmemeli.
--checkmodu: Production’a geçmeden önce--checkile dry-run yapın.serial: 1: Cluster ortamında aynı anda birden fazla node’da migration çalışmasın.- Timeout değerleri: Uzun süren migration’lar için uygun timeout değerleri set edin.
Sorun Giderme
Sık karşılaşılan durumlar ve çözümleri:
- Lock dosyası kaldı: Önceki migration yarım kalmışsa lock dosyası manuel silinmeli:
rm /tmp/db_migration.lock - Migration tracking tablosu bozuldu:
schema_migrationstablosunda tutarsızlık varsa ilgili kaydı silerek migration yeniden çalıştırılabilir. - Bağlantı zaman aşımı:
migration_timeoutdeğerini ve PostgreSQL’instatement_timeoutdeğerini artırın. - Disk doldu backup sırasında:
skip_backup=trueile migration çalıştırılabilir, ancak önerilmez.
Sonuç
Ansible ile veritabanı migration otomasyonu kurduğunuzda gece yarısı panikle çalışma dönemini bitiriyorsunuz. Artık her migration takip edilebilir, tekrarlanabilir ve geri alınabilir durumda. Ekip içindeki “bu migration çalıştı mı?” sorusu status.yml playbook’u ile saniyeler içinde yanıtlanıyor.
Bu yapının en güçlü yanı idempotency. Playbook’u on kez çalıştırabilirsiniz, her seferinde sadece uygulanmamış migration’lar çalışır. CI/CD pipeline’ınıza entegre ettikten sonra migration’lar kod deploy’unun doğal bir parçası haline gelir.
Kendi ortamınıza uyarlarken dikkat etmeniz gereken tek şey: tracking tablosu yapısını tüm ortamlarda senkronize tutun ve vault şifrelerinizi rotate etmeyi unutmayın. Gerisi otomatik.
