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: ¬ify
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ı (¬ify, <<: *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: trueset 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 cikullanmak bu sorunu çözüyor. - Timeout hataları: Default job timeout 1 saattir. Uzun süren build’ler için job seviyesinde
timeout: 2hayarlayabilirsiniz. - 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_USERve$CI_REGISTRY_PASSWORDpredefined 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.
