CI/CD Pipeline ile Ansible Entegrasyonu

Ekibinizde birisi her deployment öncesinde “ben manuel yapayım, daha hızlı olur” diyorsa, bu yazıyı o kişiye gönderin. CI/CD pipeline ile Ansible entegrasyonu, bu tür tartışmaları tarihe gömen ve deployment süreçlerini tekrarlanabilir, denetlenebilir hale getiren en güçlü yaklaşımlardan biri.

Bu yazıda gerçek dünya senaryoları üzerinden, Jenkins ve GitLab CI kullanarak Ansible playbook’larını otomatik tetiklemeyi, inventory yönetimini, secret handling’i ve pipeline tasarım pratiklerini ele alacağız.

Neden CI/CD ve Ansible Birlikte?

Ansible tek başına harika bir araç. Playbook yazıyorsunuz, ansible-playbook komutu çalıştırıyorsunuz, sunucularınız istediğiniz state’e geliyor. Ama şu soruyu sorun kendinize: bu playbook’u kim çalıştırıyor, ne zaman, hangi versiyonuyla ve sonuç ne oldu?

Manuel çalıştırmalarda şu problemler kaçınılmaz oluyor:

  • Kimin çalıştırdığı belli değil: “Ben çalıştırdım sanıyordum” klasiği
  • Hangi branch üzerinden: Local’de değişiklik var ama push edilmemiş
  • Audit trail yok: Hangi değişiklik ne zaman uygulandı?
  • Test edilmemiş playbook: Syntax hatası canlıya mı çıktı?

CI/CD entegrasyonu bu sorunların hepsini çözüyor. Her değişiklik versiyon kontrolünde, her deployment loglanmış, her playbook çalışmadan önce test edilmiş durumda.

Mimari Genel Bakış

Tipik bir CI/CD + Ansible mimarisinde şu bileşenler var:

  • Git Repository: Playbook’lar, roller ve inventory burada
  • CI/CD Server: Jenkins, GitLab CI, GitHub Actions vb.
  • Ansible Control Node: Pipeline agent’ı bu rolü üstleniyor veya ayrı bir sunucu
  • Target Hosts: Yönetilecek sunucular
  • Vault/Secret Manager: Ansible Vault veya HashiCorp Vault

Pipeline akışı genellikle şöyle: kod push -> syntax check -> lint -> test environment’ta dry-run -> onay (staging için) -> deployment -> notification.

Proje Yapısını Düzgün Kurmak

İyi bir entegrasyon için önce repository yapısının düzgün olması lazım. Ben şu yapıyı kullanıyorum:

ansible-repo/
├── inventories/
│   ├── production/
│   │   ├── hosts.yml
│   │   └── group_vars/
│   │       ├── all.yml
│   │       └── webservers.yml
│   └── staging/
│       ├── hosts.yml
│       └── group_vars/
│           ├── all.yml
│           └── webservers.yml
├── roles/
│   ├── nginx/
│   ├── postgresql/
│   └── app_deploy/
├── playbooks/
│   ├── site.yml
│   ├── webservers.yml
│   └── databases.yml
├── .gitlab-ci.yml (veya Jenkinsfile)
├── ansible.cfg
└── requirements.yml

Bu yapı, farklı environment’lar için ayrı inventory tutmanızı ve pipeline’ın hangi environment’a deploy edeceğini parametrik olarak belirlemenizi sağlıyor.

ansible.cfg dosyasını da pipeline-friendly hale getirin:

# ansible.cfg
[defaults]
inventory = inventories/staging/hosts.yml
roles_path = roles
host_key_checking = False
retry_files_enabled = False
stdout_callback = yaml
callbacks_enabled = timer, profile_tasks

[ssh_connection]
pipelining = True
control_path = /tmp/ansible-%%h-%%p-%%r

retry_files_enabled = False özellikle önemli, CI ortamında retry dosyaları hem gereksiz hem de artifact’ları kirletiyor.

GitLab CI Entegrasyonu

GitLab kullanıyorsanız .gitlab-ci.yml ile başlayalım. Gerçek bir web uygulaması deployment senaryosu:

# .gitlab-ci.yml
image: python:3.11-slim

variables:
  ANSIBLE_HOST_KEY_CHECKING: "False"
  ANSIBLE_FORCE_COLOR: "true"
  PIP_CACHE_DIR: "$CI_PROJECT_DIR/.pip-cache"

cache:
  paths:
    - .pip-cache/

stages:
  - validate
  - test
  - deploy-staging
  - deploy-production

before_script:
  - pip install ansible ansible-lint
  - ansible --version
  - mkdir -p ~/.ssh
  - echo "$SSH_PRIVATE_KEY" | tr -d 'r' > ~/.ssh/id_rsa
  - chmod 600 ~/.ssh/id_rsa
  - eval $(ssh-agent -s)
  - ssh-add ~/.ssh/id_rsa

syntax-check:
  stage: validate
  script:
    - ansible-playbook --syntax-check -i inventories/staging/hosts.yml playbooks/site.yml
    - ansible-playbook --syntax-check -i inventories/production/hosts.yml playbooks/site.yml
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == "main"'
    - if: '$CI_COMMIT_BRANCH == "develop"'

ansible-lint:
  stage: validate
  script:
    - ansible-lint playbooks/site.yml --profile=production
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == "main"'

dry-run-staging:
  stage: test
  script:
    - echo "$VAULT_PASSWORD" > .vault_pass
    - ansible-playbook -i inventories/staging/hosts.yml
        --vault-password-file .vault_pass
        --check --diff
        playbooks/site.yml
  after_script:
    - rm -f .vault_pass
  rules:
    - if: '$CI_COMMIT_BRANCH == "develop"'
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

deploy-to-staging:
  stage: deploy-staging
  script:
    - echo "$VAULT_PASSWORD" > .vault_pass
    - ansible-playbook -i inventories/staging/hosts.yml
        --vault-password-file .vault_pass
        playbooks/site.yml
        -e "app_version=$CI_COMMIT_SHORT_SHA"
  after_script:
    - rm -f .vault_pass
  environment:
    name: staging
    url: https://staging.sirketiniz.com
  rules:
    - if: '$CI_COMMIT_BRANCH == "develop"'

deploy-to-production:
  stage: deploy-production
  script:
    - echo "$VAULT_PASSWORD" > .vault_pass
    - ansible-playbook -i inventories/production/hosts.yml
        --vault-password-file .vault_pass
        playbooks/site.yml
        -e "app_version=$CI_COMMIT_SHORT_SHA"
  after_script:
    - rm -f .vault_pass
  environment:
    name: production
    url: https://sirketiniz.com
  when: manual
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

Burada dikkat edilecek birkaç nokta var. when: manual ile production deployment’ı otomatik değil, biri GitLab UI’dan onay verince çalışıyor. app_version değişkeni olarak commit SHA geçiriyoruz, böylece hangi kod versiyonunun deploy edildiğini tam olarak biliyoruz.

Jenkins ile Entegrasyon

Jenkins kullanıyorsanız Declarative Pipeline ile şöyle bir Jenkinsfile oluşturun:

// Jenkinsfile
pipeline {
    agent {
        docker {
            image 'python:3.11-slim'
            args '-u root'
        }
    }

    environment {
        ANSIBLE_HOST_KEY_CHECKING = 'False'
        ANSIBLE_FORCE_COLOR = 'true'
        VAULT_PASSWORD = credentials('ansible-vault-password')
        SSH_PRIVATE_KEY = credentials('ansible-ssh-key')
    }

    stages {
        stage('Setup') {
            steps {
                sh '''
                    pip install -q ansible ansible-lint
                    mkdir -p ~/.ssh
                    echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
                    chmod 600 ~/.ssh/id_rsa
                    ansible --version
                '''
            }
        }

        stage('Syntax Check') {
            steps {
                sh '''
                    ansible-playbook --syntax-check 
                        -i inventories/staging/hosts.yml 
                        playbooks/site.yml
                '''
            }
        }

        stage('Ansible Lint') {
            steps {
                sh 'ansible-lint playbooks/site.yml'
            }
        }

        stage('Dry Run - Staging') {
            steps {
                sh '''
                    echo "$VAULT_PASSWORD" > .vault_pass
                    ansible-playbook -i inventories/staging/hosts.yml 
                        --vault-password-file .vault_pass 
                        --check --diff 
                        playbooks/site.yml
                    rm -f .vault_pass
                '''
            }
        }

        stage('Deploy Staging') {
            when {
                branch 'develop'
            }
            steps {
                sh '''
                    echo "$VAULT_PASSWORD" > .vault_pass
                    ansible-playbook -i inventories/staging/hosts.yml 
                        --vault-password-file .vault_pass 
                        playbooks/site.yml 
                        -e "app_version=${GIT_COMMIT:0:8}"
                    rm -f .vault_pass
                '''
            }
        }

        stage('Deploy Production') {
            when {
                branch 'main'
            }
            input {
                message "Production'a deploy edilsin mi?"
                ok "Evet, deploy et"
                submitter "admin,lead-devops"
            }
            steps {
                sh '''
                    echo "$VAULT_PASSWORD" > .vault_pass
                    ansible-playbook -i inventories/production/hosts.yml 
                        --vault-password-file .vault_pass 
                        playbooks/site.yml 
                        -e "app_version=${GIT_COMMIT:0:8}"
                    rm -f .vault_pass
                '''
            }
        }
    }

    post {
        success {
            slackSend(
                channel: '#deployments',
                color: 'good',
                message: "Deployment başarılı: ${env.JOB_NAME} - ${env.BUILD_NUMBER}"
            )
        }
        failure {
            slackSend(
                channel: '#deployments',
                color: 'danger',
                message: "Deployment başarısız: ${env.JOB_NAME} - ${env.BUILD_NUMBER}"
            )
        }
        always {
            cleanWs()
        }
    }
}

Jenkins’te input direktifi harika bir özellik, production deployment öncesinde belirli kullanıcıların onayını zorunlu kılıyor.

Secret Yönetimi

Pipeline’da secret yönetimi en kritik konulardan biri. Birkaç yaklaşım var:

Ansible Vault + CI/CD Secret Store kombinasyonu en yaygın ve güvenli yaklaşım. Vault password’ünü CI/CD sisteminin secret store’unda tutuyorsunuz, runtime’da dosyaya yazıp Ansible’a geçiriyorsunuz, iş bitince siliyorsunuz.

# Vault şifrelenmiş değişken dosyası oluşturma
ansible-vault create inventories/production/group_vars/all/vault.yml

# Mevcut dosyayı şifreleme
ansible-vault encrypt inventories/production/group_vars/all/secrets.yml

# Pipeline'da kullanım
echo "$VAULT_PASSWORD" > /tmp/.vault_pass
ansible-playbook -i inventories/production/hosts.yml 
    --vault-password-file /tmp/.vault_pass 
    playbooks/site.yml
rm -f /tmp/.vault_pass

Vault dosyası örneği:

# inventories/production/group_vars/all/vault.yml (şifrelenmiş)
vault_db_password: "super_secret_password_123"
vault_api_key: "sk-prod-xxxxxxxxxxxx"
vault_ssl_cert: |
  -----BEGIN CERTIFICATE-----
  MIID...
  -----END CERTIFICATE-----

Bu değişkenleri normal group_vars dosyasında vault_ prefix’i olmadan referans edebilirsiniz:

# inventories/production/group_vars/all/main.yml
db_password: "{{ vault_db_password }}"
api_key: "{{ vault_api_key }}"

Dinamik Inventory Pipeline Entegrasyonu

Statik inventory dosyaları küçük ortamlar için işe yarıyor ama büyük ve dinamik altyapılarda AWS, GCP veya Azure’dan dinamik inventory çekmek gerekiyor.

# requirements.yml - gerekli koleksiyonları tanımlayın
---
collections:
  - name: amazon.aws
    version: ">=6.0.0"
  - name: community.general
    version: ">=7.0.0"
  - name: ansible.posix
    version: ">=1.5.0"

Pipeline başında koleksiyonları kurun:

# CI script içinde
pip install ansible boto3 botocore
ansible-galaxy collection install -r requirements.yml

# AWS dynamic inventory ile çalıştırma
ansible-playbook -i inventories/aws_ec2.yml 
    --vault-password-file .vault_pass 
    playbooks/webservers.yml 
    --limit "tag_Environment_production"

AWS EC2 dynamic inventory dosyası:

# inventories/aws_ec2.yml
plugin: amazon.aws.aws_ec2
regions:
  - eu-central-1
  - eu-west-1
filters:
  instance-state-name: running
  "tag:ManagedBy": ansible
keyed_groups:
  - prefix: env
    key: tags.Environment
  - prefix: role
    key: tags.Role
hostnames:
  - private-ip-address
compose:
  ansible_host: private_ip_address

Pipeline’da Test ve Validasyon Stratejisi

Sadece syntax check yetmez, playbook’larınızı gerçekten test etmeniz lazım. Molecule ile role testing’i pipeline’a entegre edin:

# .gitlab-ci.yml'a eklenecek molecule test stage'i
molecule-test:
  stage: test
  image: python:3.11-slim
  before_script:
    - pip install ansible molecule molecule-docker docker
  script:
    - cd roles/nginx
    - molecule test
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - roles/nginx/**/*

Role içinde molecule/default/molecule.yml:

---
dependency:
  name: galaxy
driver:
  name: docker
platforms:
  - name: ubuntu-test
    image: "geerlingguy/docker-ubuntu2204-ansible:latest"
    pre_build_image: true
  - name: centos-test
    image: "geerlingguy/docker-rockylinux9-ansible:latest"
    pre_build_image: true
provisioner:
  name: ansible
  config_options:
    defaults:
      callbacks_enabled: "profile_tasks"
verifier:
  name: ansible

Rollback Mekanizması

Deployment başarısız olursa ne yapacaksınız? Pipeline’a rollback capability ekleyin:

# GitLab CI rollback job'u
rollback-production:
  stage: deploy-production
  script:
    - echo "$VAULT_PASSWORD" > .vault_pass
    - ansible-playbook -i inventories/production/hosts.yml 
        --vault-password-file .vault_pass 
        playbooks/rollback.yml 
        -e "rollback_version=$ROLLBACK_VERSION"
    - rm -f .vault_pass
  environment:
    name: production
  when: manual
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

Rollback playbook’u basit ama etkili:

# playbooks/rollback.yml
---
- name: Application Rollback
  hosts: webservers
  become: yes
  vars:
    rollback_version: "{{ rollback_version | mandatory }}"

  tasks:
    - name: Mevcut versiyonu kaydet
      command: readlink /var/www/app/current
      register: current_version
      changed_when: false

    - name: Rollback versiyonuna geç
      file:
        src: "/var/www/app/releases/{{ rollback_version }}"
        dest: "/var/www/app/current"
        state: link
      notify: restart application

    - name: Rollback logla
      lineinfile:
        path: /var/log/deployments.log
        line: "{{ ansible_date_time.iso8601 }} - ROLLBACK from {{ current_version.stdout }} to {{ rollback_version }}"
        create: yes

  handlers:
    - name: restart application
      systemd:
        name: myapp
        state: restarted

Paralel Deployment ve Performans Optimizasyonu

Çok sayıda sunucunuz varsa deployment süresini kısaltmak için paralel çalıştırma stratejileri kullanın:

# playbooks/webservers.yml - rolling update örneği
---
- name: Web Sunucuları Rolling Update
  hosts: webservers
  serial: "25%"  # Bir seferde %25'ini güncelle
  max_fail_percentage: 10  # %10'dan fazla fail olursa dur
  become: yes

  pre_tasks:
    - name: Load balancer'dan çıkar
      uri:
        url: "http://lb.internal/api/servers/{{ inventory_hostname }}/disable"
        method: POST
        status_code: 200
      delegate_to: localhost

    - name: Aktif bağlantıların bitmesini bekle
      wait_for:
        host: "{{ ansible_host }}"
        port: 8080
        state: drained
        timeout: 30

  tasks:
    - name: Uygulamayı güncelle
      include_role:
        name: app_deploy
      vars:
        deploy_version: "{{ app_version }}"

  post_tasks:
    - name: Health check
      uri:
        url: "http://{{ ansible_host }}:8080/health"
        status_code: 200
      retries: 5
      delay: 10

    - name: Load balancer'a geri ekle
      uri:
        url: "http://lb.internal/api/servers/{{ inventory_hostname }}/enable"
        method: POST
        status_code: 200
      delegate_to: localhost

Pipeline Logları ve Audit Trail

Deployment’ları takip etmek için callback plugin’i veya basit bir loglama mekanizması kullanın:

# ansible.cfg'e ekle
[defaults]
log_path = /var/log/ansible/ansible.log
callbacks_enabled = timer, profile_tasks, log_plays

GitLab ve Jenkins’te deployment geçmişi zaten tutulduğu için, Ansible log’larını artifact olarak saklamak yeterli oluyor:

# GitLab CI artifact tanımı
deploy-to-production:
  stage: deploy-production
  script:
    - ansible-playbook ... 2>&1 | tee deployment.log
  artifacts:
    paths:
      - deployment.log
    expire_in: 30 days
    when: always

Yaygın Hatalar ve Çözümleri

SSH key permission sorunları: CI ortamında SSH key’i dosyaya yazarken rn satır sonu karakterleri sorun çıkarıyor.

# Doğru yöntem
echo "$SSH_PRIVATE_KEY" | tr -d 'r' > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa

Vault password dosyası temizlenmeden kalıyor: after_script veya post bloğunda mutlaka temizleyin:

# GitLab CI
after_script:
  - rm -f .vault_pass

# Jenkins
post {
  always {
    sh 'rm -f .vault_pass'
    cleanWs()
  }
}

Timeout sorunları: Uzun süren deployment’larda CI/CD sisteminin job’ı timeout ile öldürmesi. GitLab CI’da:

deploy-to-production:
  timeout: 2 hours
  script:
    - ansible-playbook ...

Facts caching: Her pipeline run’da facts toplamak zaman alıyor. Cache kullanın:

# ansible.cfg
[defaults]
fact_caching = redis
fact_caching_connection = redis://localhost:6379
fact_caching_timeout = 3600

Sonuç

CI/CD pipeline ile Ansible entegrasyonu, başlangıçta biraz setup gerektiriyor ama uzun vadede kazanım inanılmaz büyük. Artık deployment’larınızın her birinde kimin ne zaman hangi versiyonu nereye kurduğunu tam olarak biliyorsunuz. Syntax hatası olan playbook canlıya çıkmıyor. Rolling update stratejisiyle downtime olmadan güncelleme yapabiliyorsunuz.

Pratik öneriler şunlar: Önce staging ortamında her şeyi otomatik, production’ı manual approval ile başlatın. Zamanla ekibinizin güveni arttıkça production’ı da otomasyona taşıyabilirsiniz. Rollback mekanizmasını kurmadan deployment otomasyonunu canlıya almayın, çünkü bir gün mutlaka lazım olacak. Molecule ile role testlerini pipeline’a eklemek ise code review kalitesini dramatik biçimde artırıyor.

En büyük tuzak, her şeyi mükemmel yapmaya çalışmak. Basit bir syntax check ve staging deployment ile başlayın, çalışır duruma getirin, sonra üzerine ekleyin. “Sonra yapacağım” dediğiniz manuel deployment’lar ne kadar uzun sürerse, otomasyon o kadar değerli oluyor.

Yorum yapın