GitLab CI/CD ile Otomatik Test ve Deployment Rehberi

Üretim ortamında bir şeyin bozulduğunu öğrenmek için en kötü yol, müşteri araması almaktır. Bunu birkaç kez yaşadıktan sonra insan otomatik test ve deployment pipeline’larına farklı gözlerle bakmaya başlıyor. GitLab CI/CD bu noktada gerçekten hayat kurtarıcı oluyor; ama kurulum aşamasında bazı tuzaklar var ki ilk seferinde hepsine düşmek neredeyse kaçınılmaz.

Bu yazıda sıfırdan bir GitLab CI/CD pipeline’ı kurarak otomatik test, staging deployment ve production deployment süreçlerini nasıl yapılandırdığımı paylaşacağım. Teorik anlatımdan çok, gerçek projelerde karşılaştığım sorunlar ve çözümleri üzerine yoğunlaşacağım.

GitLab CI/CD’nin Temel Yapısı

GitLab CI/CD’nin kalbi .gitlab-ci.yml dosyasıdır. Bu dosya projenin kök dizininde bulunur ve pipeline’ın nasıl çalışacağını tanımlar. GitLab Runner bu dosyayı okuyarak job’ları sırasıyla ya da paralel olarak çalıştırır.

Temel kavramları hızlıca geçelim:

  • Pipeline: Bir commit ya da merge request tetiklediğinde çalışan tüm süreç
  • Stage: Pipeline içindeki aşamalar (test, build, deploy gibi)
  • Job: Her stage içindeki görevler
  • Runner: Job’ları çalıştıran ajan (shared ya da self-hosted olabilir)
  • Artifact: Job’lar arasında geçen dosyalar
  • Cache: Tekrar kullanılabilir bağımlılıkları saklayan katman

En basit haliyle bir .gitlab-ci.yml şöyle görünür:

stages:
  - test
  - build
  - deploy

variables:
  APP_ENV: production

test:unit:
  stage: test
  image: python:3.11-slim
  script:
    - pip install -r requirements.txt
    - pytest tests/ -v --tb=short
  only:
    - merge_requests
    - main

Bu yapı anlaşılır görünüyor ama gerçek dünyada işler karmaşıklaşıyor. Özellikle birden fazla ortam (staging, production), farklı branch stratejileri ve secret yönetimi devreye girince dikkatli olmak gerekiyor.

GitLab Runner Kurulumu

Self-hosted runner kullanmak istiyorsanız, shared runner’lara güvenmek yerine kendi runner’ınızı yönetmek çok daha fazla kontrol sağlıyor. Özellikle şirket içi kaynaklara erişim gereken durumlarda bu zorunlu hale geliyor.

# Ubuntu/Debian için GitLab Runner kurulumu
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash
sudo apt-get install gitlab-runner

# Runner'ı kaydet
sudo gitlab-runner register 
  --url "https://gitlab.com/" 
  --registration-token "YOUR_REGISTRATION_TOKEN" 
  --executor "docker" 
  --docker-image "alpine:latest" 
  --description "production-runner" 
  --tag-list "docker,production" 
  --run-untagged="false" 
  --locked="false"

# Servis olarak başlat
sudo systemctl enable gitlab-runner
sudo systemctl start gitlab-runner

Runner executor seçimi kritik bir karar. Docker executor izolasyon açısından en güvenli seçenek. Shell executor ise daha hızlı ama ortam kirliliği yaratabilir. Production ortamı için her zaman Docker executor tercih ediyorum.

Runner’ın config.toml dosyasında concurrent job sayısını ve diğer ayarları düzenleyebilirsiniz:

# /etc/gitlab-runner/config.toml
sudo vim /etc/gitlab-runner/config.toml
concurrent = 4
check_interval = 0

[[runners]]
  name = "production-runner"
  url = "https://gitlab.com"
  token = "YOUR_TOKEN"
  executor = "docker"
  [runners.docker]
    tls_verify = false
    image = "alpine:latest"
    privileged = false
    disable_entrypoint_overwrite = false
    oom_kill_disable = false
    disable_cache = false
    volumes = ["/cache", "/var/run/docker.sock:/var/run/docker.sock"]
    shm_size = 0

Gerçek Bir Pipeline Yapısı

Bir Node.js uygulaması için production’da kullandığım pipeline yapısını paylaşayım. Bu yapıyı birkaç farklı projede test ettim ve köşeleri düzelttim.

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

variables:
  NODE_ENV: test
  DOCKER_DRIVER: overlay2
  DOCKER_TLS_CERTDIR: "/certs"
  IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

default:
  image: node:18-alpine
  before_script:
    - npm ci --cache .npm --prefer-offline
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - .npm/

# Kod kalite kontrolü
validate:lint:
  stage: validate
  script:
    - npm run lint
    - npm run type-check
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"
    - if: $CI_COMMIT_BRANCH == "develop"

# Unit testler
test:unit:
  stage: test
  script:
    - npm run test:unit -- --coverage
  coverage: '/Liness*:s*(d+.?d*)%/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml
    paths:
      - coverage/
    expire_in: 1 week
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

Burada birkaç önemli detay var. npm ci kullanmak npm install‘a göre çok daha güvenilir; package-lock.json’a sadık kalıyor ve daha hızlı çalışıyor. Cache yapılandırması da kritik: package-lock.json bazlı cache key kullanmak, bağımlılıklar değiştiğinde otomatik cache invalidation sağlıyor.

Docker Image Build ve Registry

Container bazlı deployment yapıyorsanız, image build süreci pipeline’ın en ağır parçalarından biri haline geliyor. Docker layer cache’ini akıllıca kullanmak build sürelerini dramatik biçimde düşürebilir.

build:docker:
  stage: build
  image: docker:24.0.5
  services:
    - docker:24.0.5-dind
  variables:
    DOCKER_BUILDKIT: 1
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - |
      docker build 
        --cache-from $CI_REGISTRY_IMAGE:latest 
        --build-arg BUILDKIT_INLINE_CACHE=1 
        --tag $IMAGE_TAG 
        --tag $CI_REGISTRY_IMAGE:latest 
        .
    - docker push $IMAGE_TAG
    - docker push $CI_REGISTRY_IMAGE:latest
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
    - if: $CI_COMMIT_BRANCH == "develop"

--cache-from parametresi burada çok önemli. Registry’deki son latest image’ı cache kaynağı olarak kullanarak build sürelerini yarı yarıya kısaltabiliyorsunuz. BUILDKIT_INLINE_CACHE=1 ise bu cache bilgisini image’ın içine gömüyor, böylece farklı runner’lar da bu cache’ten faydalanabiliyor.

Environment ve Secret Yönetimi

Bu konuyu yanlış yapmak, pipeline’ınızı güvenlik açığına dönüştürür. GitLab’ın CI/CD variables sistemi oldukça yetenekli ama doğru kullanmak gerekiyor.

GitLab UI’dan şu yola gidin: Settings > CI/CD > Variables

Variable türleri:

  • Variable: Normal çevre değişkeni, log’larda görünür
  • File: Dosya olarak mount edilir, sertifikalar için ideal
  • Masked: Log’larda yıldızlarla gizlenir
  • Protected: Sadece protected branch ve tag’lerde kullanılabilir

Production secret’ları için hem Masked hem Protected işaretlemek şart. Bir junior geliştirici yanlışlıkla echo $DATABASE_PASSWORD yazdığında masked değişken sizi kurtarır.

deploy:staging:
  stage: deploy-staging
  image: alpine:latest
  environment:
    name: staging
    url: https://staging.example.com
  before_script:
    - apk add --no-cache openssh-client
    - eval $(ssh-agent -s)
    - echo "$STAGING_SSH_PRIVATE_KEY" | tr -d 'r' | ssh-add -
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - ssh-keyscan -H $STAGING_HOST >> ~/.ssh/known_hosts
  script:
    - |
      ssh deploy@$STAGING_HOST << 'ENDSSH'
        cd /opt/app
        docker pull $IMAGE_TAG
        docker-compose up -d --no-deps app
        docker-compose exec -T app npm run migrate
      ENDSSH
  rules:
    - if: $CI_COMMIT_BRANCH == "develop"

SSH key yönetiminde dikkat edilmesi gereken bir nokta: Private key’i GitLab variable’a koyarken satır sonlarının bozulmamasına özen gösterin. tr -d 'r' komutu Windows satır sonlarını temizliyor; bu küçük detayı atlamak saatlerinizi alabilir.

Integration Test ve Quality Gate

Staging’e deploy ettikten sonra oraya karşı integration test çalıştırmak, “kendi bilgisayarımda çalışıyor” sorunlarını ortadan kaldırıyor. Bu aşamayı pek çok ekip atlıyor; hata.

test:integration:
  stage: integration-test
  image: node:18-alpine
  variables:
    API_BASE_URL: https://staging.example.com/api
  before_script:
    - npm ci --cache .npm --prefer-offline
  script:
    - npm run test:integration
    - npm run test:e2e:smoke
  artifacts:
    when: always
    paths:
      - test-results/
      - screenshots/
    reports:
      junit: test-results/junit.xml
    expire_in: 3 days
  rules:
    - if: $CI_COMMIT_BRANCH == "develop"
  allow_failure: false

when: always artifact’lar için kritik; test başarısız olsa bile screenshot ve loglar artifact olarak saklanıyor. Başarısız testleri debug etmek için altın değerinde.

Production Deployment ve Manual Gate

Production deployment’ı otomatik yapmak genellikle cesaret ister. Ben çoğunlukla bir manual gate koyuyorum: staging başarılı geçtikten sonra birinin bilinçli olarak “deploy et” demesi gerekiyor.

deploy:production:
  stage: deploy-production
  image: alpine:latest
  environment:
    name: production
    url: https://example.com
  when: manual
  before_script:
    - apk add --no-cache openssh-client curl
    - eval $(ssh-agent -s)
    - echo "$PROD_SSH_PRIVATE_KEY" | tr -d 'r' | ssh-add -
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - ssh-keyscan -H $PROD_HOST >> ~/.ssh/known_hosts
  script:
    - |
      # Blue-Green deployment
      CURRENT=$(ssh deploy@$PROD_HOST "cat /opt/app/current_slot" 2>/dev/null || echo "blue")
      if [ "$CURRENT" = "blue" ]; then
        NEW_SLOT="green"
      else
        NEW_SLOT="blue"
      fi

      echo "Deploying to $NEW_SLOT slot..."
      
      ssh deploy@$PROD_HOST "
        docker pull $IMAGE_TAG
        docker stop app-$NEW_SLOT 2>/dev/null || true
        docker run -d 
          --name app-$NEW_SLOT 
          --env-file /opt/app/.env.production 
          -p 300${NEW_SLOT == 'blue' ? '1' : '2'}:3000 
          $IMAGE_TAG
        
        # Health check
        sleep 10
        curl -f http://localhost:300${NEW_SLOT == 'blue' ? '1' : '2'}/health || exit 1
        
        # Switch traffic
        ln -sf /etc/nginx/sites-available/app-$NEW_SLOT /etc/nginx/sites-enabled/app
        nginx -s reload
        
        # Save current slot
        echo $NEW_SLOT > /opt/app/current_slot
        
        # Stop old slot after successful switch
        docker stop app-$CURRENT
      "
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual

Blue-green deployment burada fazladan karmaşıklık gibi görünebilir ama bir deployment sırasında downtime yaşamamak için bunu kullanmak büyük fark yaratıyor. Özellikle iş saatlerinde deploy yapmak zorunda kaldığınızda değerini anlıyorsunuz.

Pipeline Optimizasyonu

Pipeline’ınız yavaş çalışıyorsa geliştiriciler bypass yolları aramaya başlar. Bu noktada optimizasyon hem teknik hem kültürel bir gereklilik haline geliyor.

# Paralel test çalıştırma
test:unit:parallel:
  stage: test
  parallel: 4
  script:
    - npm run test:unit -- --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
  artifacts:
    reports:
      junit: junit-$CI_NODE_INDEX.xml

# Gereksiz job'ları atlama
validate:lint:
  stage: validate
  script:
    - npm run lint
  rules:
    - changes:
        - "**/*.ts"
        - "**/*.js"
        - ".eslintrc*"
      when: always
    - when: never

parallel keyword’ü test süresini ciddi oranda düşürüyor. 4 paralel runner ile 20 dakikalık test suite’ini 6-7 dakikaya indirdiğimiz projeler oldu. changes rule’u ise lint’in sadece ilgili dosyalar değiştiğinde çalışmasını sağlıyor; bu da CI süresini kısaltıyor.

Cache stratejisi de optimizasyonda kritik rol oynuyor:

# Global cache tanımı
.node-cache: &node-cache
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - .npm/
      - node_modules/
    policy: pull-push

# Job bazlı cache (sadece okuma)
test:unit:
  <<: *node-cache
  cache:
    policy: pull
  script:
    - npm run test:unit

pull policy kullanmak test job’larında süreyi kısaltıyor çünkü cache’i güncellemelerine gerek yok. Sadece build ve install job’larında pull-push kullanmak yeterli.

Notification ve Monitoring

Pipeline başarısız olduğunda ekibin haberdar olması gerekiyor. Slack entegrasyonu için:

.notify: &notify
  after_script:
    - |
      if [ "$CI_JOB_STATUS" = "failed" ]; then
        curl -X POST $SLACK_WEBHOOK_URL 
          -H 'Content-type: application/json' 
          --data "{
            "text": "Pipeline Başarısız!",
            "attachments": [{
              "color": "danger",
              "fields": [
                {"title": "Proje", "value": "$CI_PROJECT_NAME", "short": true},
                {"title": "Branch", "value": "$CI_COMMIT_BRANCH", "short": true},
                {"title": "Job", "value": "$CI_JOB_NAME", "short": true},
                {"title": "Commit", "value": "$CI_COMMIT_SHORT_SHA", "short": true},
                {"title": "URL", "value": "$CI_JOB_URL"}
              ]
            }]
          }"
      fi

deploy:production:
  <<: *notify
  stage: deploy-production
  script:
    - echo "Deploying to production..."

YAML anchor’ları (&notify, <<: *notify) tekrar eden konfigürasyonları DRY tutmak için kullanışlı. Her job’a ayrı ayrı notification bloğu yazmaktan sizi kurtarıyor.

Sık Karşılaşılan Sorunlar

Gerçek hayatta en çok şu sorunlarla karşılaşıyorum:

  • Docker socket permission hatası: Runner container’ın /var/run/docker.sock‘a erişimi olduğundan emin olun. privileged: true set etmek gerekebilir ama güvenlik açısından dikkatli kullanın.
  • Cache invalidation sorunları: Node_modules cache’lendiğinde bazen eski bağımlılıklar kalıyor. Periyodik olarak cache’i temizlemek ve npm ci kullanmak bu sorunu çözüyor.
  • Timeout hataları: Default job timeout 1 saattir. Uzun süren build’ler için job seviyesinde timeout: 2h ayarlayabilirsiniz.
  • SSH agent forwarding: Pipeline içinde SSH üzerinden başka sunuculara erişmek gerektiğinde agent forwarding düzgün yapılandırılmadığında bağlantı sorunları çıkıyor.
  • Registry authentication: GitLab Container Registry’ye erişim için $CI_REGISTRY_USER ve $CI_REGISTRY_PASSWORD predefined variable’larını kullanın; kendi token oluşturmaya gerek yok.

Sonuç

GitLab CI/CD’yi doğru yapılandırmak başlangıçta zaman alıyor; bunu inkâr etmeyeyim. İlk kurulum, runner yönetimi, secret’ların doğru yerleştirilmesi ve pipeline optimizasyonu birkaç günlük ciddi çalışma gerektiriyor. Ama bu yatırım çok hızlı geri dönüyor.

Özellikle şunu vurgulamak isterim: Pipeline’ı sıfırdan mükemmel yapmaya çalışmayın. Önce çalışan bir şey yapın, sonra optimize edin. Lint, unit test, build, deploy: Bu dört adım bile manuel süreçlere göre inanılmaz bir iyileşme sağlıyor.

Ekibinizde CI/CD kültürü oluşturmak ise teknik kurulumdan daha zor. Geliştiricilerin pipeline’a güvenmesi, test yazmayı alışkanlık haline getirmesi ve “pipeline yeşil mi?” sorusunu rutinleştirmesi zaman istiyor. Teknik altyapıyı sağlam kurmak bu kültürün oluşmasında zemin hazırlıyor.

Son olarak: Production deployment’ları için her zaman bir geri alma planınız olsun. Blue-green deployment, feature flags ya da en basitinden bir rollback script’i. “Deploy yaptık, bir şeyler bozuldu, ne yapacağız?” sorusunun cevabını önceden bilmek, gece 2’de çalana telefonu çok daha sakin karşılamanızı sağlıyor.

Bir yanıt yazın

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