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:

  • changes boş commit’lerde çalışmaz: git commit --allow-empty ile açılan pipeline’larda changes kuralı tüm job’ları tetikler. CI’ı boş commit ile manuel tetiklemeniz gerekiyorsa buna hazırlıklı olun.
  • Nested include derinliği: GitLab belirli bir include derinliğ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.
  • needs ile rules çakışması: Bir job needs ile başka bir job’a bağlıysa ve bağımlı job rules nedeniyle oluşturulmadıysa pipeline hata verir. Bunu çözmek için needs içinde optional: true kullanı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_SHA gibi. 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 + changes kombinasyonunu servis ve bağımlılık sınırlarına göre tanımlayın
  • include ile her servisin CI konfigürasyonunu kendi dizinine taşıyın
  • needs ile DAG yapısı kurarak paralel çalışmayı maksimize edin
  • Cache’leri servis bazında ayrıştırın
  • MR pipeline’larında interruptible: true kullanı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.

Bir yanıt yazın

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