GitLab ile Monorepo CI/CD Pipeline Yönetimi
Monorepo’lar güzel bir fikir gibi görünür. Her şey tek bir yerde, bağımlılıklar net, ekipler birbirinin değişikliklerini görüyor. Sonra ilk büyük projeyi buraya taşırsınız ve GitLab CI pipeline’ınız 45 dakika sürmeye başlar. Frontend değiştirdiniz, backend testleri de çalışıyor. Bir README.md güncellediniz, Docker image build oluyor. İşte burada monorepo yönetiminin gerçek sınavı başlıyor.
Bu yazıda production’da monorepo ile boğuşurken öğrendiğim şeyleri aktaracağım. Teorik değil, elle tutulur.
Monorepo Neden Bu Kadar Zor?
Klasik çoklu repo yapısında her servisin kendi .gitlab-ci.yml dosyası vardır. Push gelir, sadece o servisin pipeline’ı tetiklenir. Basit, temiz, anlaşılır.
Monorepo’da ise tek bir push birden fazla servisi etkileyebilir ya da hiçbirini etkilemeyebilir. GitLab bunu varsayılan olarak bilemez. Siz ona söylemek zorunda kalırsınız.
Tipik bir monorepo yapısı şöyle görünebilir:
.
├── services/
│ ├── api/
│ ├── worker/
│ └── notification/
├── frontend/
│ ├── web/
│ └── mobile/
├── packages/
│ ├── shared-utils/
│ └── db-client/
├── infra/
│ └── terraform/
└── .gitlab-ci.yml
Bu yapıda packages/shared-utils değiştiğinde api, worker ve frontend/web‘in hepsinin yeniden test edilmesi gerekebilir. Ama infra/terraform değiştiğinde sadece Terraform pipeline’ı çalışmalı.
Temel Strateji: changes ile Path Filtrelemeleri
GitLab CI’ın rules direktifindeki changes özelliği bu işin temel taşı. Fakat doğru kullanılmadığında sizi yanlış bir güvene sürükler.
Basit bir başlangıç örneği:
# .gitlab-ci.yml
stages:
- test
- build
- deploy
api:test:
stage: test
script:
- cd services/api && npm test
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
changes:
- services/api/**/*
- packages/shared-utils/**/*
- packages/db-client/**/*
api:build:
stage: build
script:
- docker build -t $CI_REGISTRY_IMAGE/api:$CI_COMMIT_SHA services/api/
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
changes:
- services/api/**/*
- packages/shared-utils/**/*
Burada kritik bir nokta var: changes kuralı sadece merge_request_event ve branch push’larında düzgün çalışır. Scheduled pipeline’larda veya manuel tetiklemelerde changes her zaman true olarak değerlendirilir. Bunu bilmeden üretim ortamına gidip “neden hep build oluyor” diye saatlerce bakabilirsiniz.
include ve extends ile Yapıyı Modülerleştirmek
Her servis için ayrı YAML dosyaları tutmak hem okunabilirliği artırır hem de bakımı kolaylaştırır. Ana .gitlab-ci.yml dosyasını bir orkestratör olarak düşünün.
# .gitlab-ci.yml
stages:
- test
- build
- deploy
include:
- local: 'services/api/.gitlab-ci.yml'
- local: 'services/worker/.gitlab-ci.yml'
- local: 'frontend/web/.gitlab-ci.yml'
- local: 'infra/terraform/.gitlab-ci.yml'
# services/api/.gitlab-ci.yml
.api-rules: &api-rules
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
changes:
- services/api/**/*
- packages/**/*
- if: '$CI_COMMIT_BRANCH == "main"'
changes:
- services/api/**/*
- packages/**/*
- if: '$CI_COMMIT_BRANCH == "main"'
when: never
api:lint:
stage: test
image: node:20-alpine
<<: *api-rules
script:
- cd services/api
- npm ci
- npm run lint
api:unit-test:
stage: test
image: node:20-alpine
<<: *api-rules
script:
- cd services/api
- npm ci
- npm run test:unit
coverage: '/Liness*:s*(d+.?d*)%/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: services/api/coverage/cobertura-coverage.xml
extends keyword’ü ise tekrar eden job tanımlarını miras almanızı sağlar. YAML anchor’lardan daha temiz bir sözdizimi sunar:
# gitlab/templates/node-base.yml
.node-base:
image: node:20-alpine
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
before_script:
- npm ci --cache .npm --prefer-offline
variables:
npm_config_cache: "$CI_PROJECT_DIR/.npm"
# services/worker/.gitlab-ci.yml
include:
- local: 'gitlab/templates/node-base.yml'
worker:test:
extends: .node-base
stage: test
script:
- cd services/worker
- npm run test
rules:
- changes:
- services/worker/**/*
- packages/**/*
Dinamik Pipeline’lar: needs ve DAG Yapısı
GitLab’ın DAG (Directed Acyclic Graph) özelliği monorepo’larda büyük bir avantaj. needs keyword’ü ile bağımlılıkları açıkça tanımlayabilirsiniz ve birbirini beklemeyen job’lar paralel çalışabilir.
api:test:
stage: test
script:
- cd services/api && npm test
frontend:test:
stage: test
script:
- cd frontend/web && npm test
api:build:
stage: build
needs:
- job: api:test
script:
- docker build -t $CI_REGISTRY_IMAGE/api:$CI_COMMIT_SHA services/api/
frontend:build:
stage: build
needs:
- job: frontend:test
script:
- docker build -t $CI_REGISTRY_IMAGE/frontend:$CI_COMMIT_SHA frontend/web/
integration:test:
stage: deploy
needs:
- job: api:build
- job: frontend:build
script:
- ./scripts/run-integration-tests.sh
Bu yapıyla api:test bittiğinde api:build başlar, frontend:test bittiğinde frontend:build başlar. İkisi de birbirini beklemez. Klasif stage tabanlı yapıya göre pipeline süresinde ciddi kazanım elde edersiniz.
Paylaşımlı Paket Değişikliklerini Tespit Etmek
En zorlu senaryo şu: packages/shared-utils‘i değiştirdiniz, bu paketi kullanan her servisin test edilmesi gerekiyor. Bunu manuel olarak her servis için tanımlamak hem hataya açık hem de bakımı ağır.
Bunun için küçük bir script yazarak hangi servislerin etkilendiğini dinamik olarak hesaplayabilirsiniz:
#!/bin/bash
# scripts/detect-changes.sh
CHANGED_FILES=$(git diff --name-only $CI_MERGE_REQUEST_DIFF_BASE_SHA $CI_COMMIT_SHA)
AFFECTED_SERVICES=()
check_service() {
local service=$1
local paths=("${@:2}")
for path in "${paths[@]}"; do
if echo "$CHANGED_FILES" | grep -q "^$path"; then
AFFECTED_SERVICES+=("$service")
return
fi
done
}
check_service "api" "services/api/" "packages/shared-utils/" "packages/db-client/"
check_service "worker" "services/worker/" "packages/shared-utils/" "packages/db-client/"
check_service "notification" "services/notification/" "packages/shared-utils/"
check_service "frontend-web" "frontend/web/" "packages/shared-utils/"
echo "AFFECTED_SERVICES=${AFFECTED_SERVICES[*]}"
for service in "${AFFECTED_SERVICES[@]}"; do
echo "NEED_BUILD_${service^^}=true"
done
Bu script’i bir detect job’ında çalıştırıp sonuçları artifact olarak saklayabilirsiniz, ardından dotenv artifact tipini kullanarak diğer job’lara geçirebilirsiniz:
detect:changes:
stage: .pre
image: alpine/git:latest
script:
- chmod +x scripts/detect-changes.sh
- ./scripts/detect-changes.sh > build.env
- cat build.env
artifacts:
reports:
dotenv: build.env
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
api:test:
stage: test
needs:
- job: detect:changes
artifacts: true
script:
- cd services/api && npm test
rules:
- if: '$NEED_BUILD_API == "true"'
Bu yaklaşım güçlüdür ama bir uyarı: dotenv artifact ile geçirilen değişkenler rules değerlendirmesinde her zaman güvenilir şekilde çalışmayabilir. GitLab’ın bu konudaki davranışı sürüme göre farklılık gösterebiliyor. Kritik servisler için changes kurallarını da yedek olarak tutmanızı öneririm.
Cache Stratejisi: Monorepo’da En Çok Zaman Kazandıran Şey
Monorepo’da her servis için ayrı cache key kullanmak işin temelidir. Aksi halde servisler birbirinin cache’ini bozar.
.cache-template:
cache:
- key:
files:
- services/api/package-lock.json
prefix: api-node-modules
paths:
- services/api/node_modules/
policy: pull-push
- key:
files:
- packages/shared-utils/package-lock.json
prefix: shared-utils
paths:
- packages/shared-utils/node_modules/
policy: pull-push
api:test:
extends: .cache-template
cache:
policy: pull
script:
- cd services/api && npm test
policy: pull kullanımına dikkat edin. Test job’larının cache’i güncellemesine gerek yok, sadece okumaları yeterli. Cache yazma işini sadece özel bir cache:warm job’ına bırakın. Bu sayede paralel çalışan job’ların cache lock sorunlarından kaçınırsınız.
Production Deployment Stratejisi
Monorepo’da deployment’lar daha dikkatli planlanmalı. Hangi servisin ne zaman deploy edileceğini net olarak tanımlamanız gerekiyor.
.deploy-template:
image: bitnami/kubectl:latest
before_script:
- echo $KUBE_CONFIG | base64 -d > ~/.kube/config
when: manual
environment:
name: production
api:deploy:production:
extends: .deploy-template
stage: deploy
needs:
- job: api:build
artifacts: true
script:
- |
kubectl set image deployment/api
api=$CI_REGISTRY_IMAGE/api:$CI_COMMIT_SHA
-n production
- kubectl rollout status deployment/api -n production --timeout=5m
environment:
name: production/api
url: https://api.example.com
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
changes:
- services/api/**/*
- packages/**/*
when: manual
worker:deploy:production:
extends: .deploy-template
stage: deploy
needs:
- job: worker:build
artifacts: true
script:
- |
kubectl set image deployment/worker
worker=$CI_REGISTRY_IMAGE/worker:$CI_COMMIT_SHA
-n production
- kubectl rollout status deployment/worker -n production --timeout=5m
environment:
name: production/worker
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
changes:
- services/worker/**/*
- packages/**/*
when: manual
when: manual kullanımı production ortamı için kritik. Her commit’te otomatik deploy yerine bilinçli bir onay mekanizması koymanızı şiddetle tavsiye ederim. Özellikle monorepo’da yanlış bir changes eşleşmesi beklenmedik servisleri tetikleyebilir.
Merge Request Pipeline’larını Optimize Etmek
MR’larda her push’ta tüm pipeline’ın tekrar başlaması gereksiz runner maliyeti ve bekleme süresi demek. GitLab’ın interruptible özelliği bu sorunu çözer:
api:test:
interruptible: true
stage: test
script:
- cd services/api && npm test
api:build:
interruptible: true
stage: build
script:
- docker build -t $CI_REGISTRY_IMAGE/api:$CI_COMMIT_SHA services/api/
interruptible: true ayarlanan job’lar, aynı branch’e yeni bir push geldiğinde GitLab tarafından otomatik olarak iptal edilir. Deploy job’larına bunu koymayın. Yarıda kalan bir deployment felaket olabilir.
Bir de workflow seviyesinde pipeline kısıtlaması ekleyin:
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main"'
- if: '$CI_COMMIT_TAG'
- if: '$CI_PIPELINE_SOURCE == "schedule"'
Bu kural, feature branch’lerde direkt push’larda pipeline oluşturulmasını engeller. Sadece MR açıldığında ya da main’e merge edildiğinde çalışır. Bir monorepo’da çok sayıda feature branch paralel ilerliyorsa bu kural runner kaynaklarınızı ciddi ölçüde korur.
Sık Karşılaşılan Tuzaklar
Birkaç yıllık monorepo deneyiminin acı birikimi:
changesboş commit’lerde çalışmaz:git commit --allow-emptyile açılan pipeline’lardachangeskuralı tüm job’ları tetikler. CI’ı boş commit ile manuel tetiklemeniz gerekiyorsa buna hazırlıklı olun.
- Nested
includederinliği: GitLab belirli birincludederinliği sınırı koyar. Çok fazla iç içe include kullanırsanız pipeline parse aşamasında başarısız olabilir. Maksimum üç seviye derinliği aşmamaya çalışın.
needsilerulesçakışması: Bir jobneedsile başka bir job’a bağlıysa ve bağımlı jobrulesnedeniyle oluşturulmadıysa pipeline hata verir. Bunu çözmek içinneedsiçindeoptional: truekullanın:
integration:test:
needs:
- job: api:build
optional: true
- job: frontend:build
optional: true
script:
- ./scripts/integration-test.sh
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
- Registry’de image tag yönetimi: Monorepo’da birden fazla servis aynı commit SHA’sını paylaşır. Image tag’larına servis adını prefix olarak eklemeyi unutmayın:
$CI_REGISTRY_IMAGE/api:$CI_COMMIT_SHAgibi. Aksi halde servisler birbirinin image’larının üzerine yazar.
- Artifact boyutları: Her servis artifact üretiyorsa ve downstream job’lar bunları çekiyorsa pipeline başlangıç süresi uzayabilir. Artifact’leri mümkün olduğunca küçük tutun, sadece gerçekten ihtiyaç duyulan dosyaları dahil edin.
Pipeline Görünürlüğü ve Debug
Monorepo pipeline’larında bir şey yanlış gittiğinde nerede baktığınızı bilmek önemlidir. Şu environment variable’ları ekleyerek job context’ini netleştirirsiniz:
.debug-info: &debug-info
before_script:
- echo "Job: $CI_JOB_NAME"
- echo "Pipeline: $CI_PIPELINE_ID"
- echo "Commit: $CI_COMMIT_SHA"
- echo "Branch: $CI_COMMIT_BRANCH"
- echo "Changed paths:"
- git diff --name-only $CI_MERGE_REQUEST_DIFF_BASE_SHA $CI_COMMIT_SHA 2>/dev/null || echo "Not a MR pipeline"
GitLab’ın Pipeline Editor‘ını aktif kullanın. CI Lint özelliği ile YAML’ı validate edebilir, hangi job’ların hangi koşullarda oluşturulduğunu simulate edebilirsiniz. Büyük bir monorepo YAML’ında syntax hatası bulmak başka türlü gerçekten eziyet.
Sonuç
Monorepo CI/CD’yi doğru yapılandırmak tek seferlik bir iş değil. Proje büyüdükçe, yeni servisler eklendikçe pipeline yapısını güncellemeniz gerekiyor. Ama doğru temeli attığınızda kazanımlar önemli: gereksiz build yok, runner maliyeti düşük, ekipler birbirini bloklamıyor.
Öncelikli adımlar:
rules+changeskombinasyonunu servis ve bağımlılık sınırlarına göre tanımlayınincludeile her servisin CI konfigürasyonunu kendi dizinine taşıyınneedsile DAG yapısı kurarak paralel çalışmayı maksimize edin- Cache’leri servis bazında ayrıştırın
- MR pipeline’larında
interruptible: truekullanın
En büyük tuzak, her şeyi mükemmel yapılandırmaya çalışırken işe başlamamak. İlk versiyonu basit tutun, changes kurallarını ekleyin, çalıştırın. Sonra profil çıkarın, neyin gereksiz çalıştığını görün, iyileştirin. Monorepo pipeline yönetimi iteratif bir süreç ve en iyi öğretmen production’da yaşanan yavaşlıklardır.
