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-file veya --ask-vault-pass kullanın.
  • Minimum yetki prensibi: migration_user sadece 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.
  • --check modu: Production’a geçmeden önce --check ile 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_migrations tablosunda tutarsızlık varsa ilgili kaydı silerek migration yeniden çalıştırılabilir.
  • Bağlantı zaman aşımı: migration_timeout değerini ve PostgreSQL’in statement_timeout değerini artırın.
  • Disk doldu backup sırasında: skip_backup=true ile 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.

Bir yanıt yazın

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