Jenkins Multi-Branch Pipeline ile Kubernetes Ortamına Otomatik Deployment

Ekibiniz büyüdükçe ve proje sayısı arttıkça, her branch için ayrı Jenkins job’ı oluşturmak gerçekten baş ağrısına dönüşüyor. “Acaba bu branch’ın pipeline’ı var mıydı?” sorusunu sormak zorunda kalmak, hem zaman kaybı hem de insan hatası davet ediyor. İşte tam bu noktada Jenkins Multi-Branch Pipeline devreye giriyor ve hayatı ciddi ölçüde kolaylaştırıyor. Bu yazıda, sıfırdan bir Multi-Branch Pipeline kurarak Kubernetes ortamına otomatik deployment yapacağız. Gerçek bir projede karşılaşabileceğiniz senaryoları da ele alacağız.

Jenkins Multi-Branch Pipeline Nedir?

Tek cümleyle özetlemek gerekirse: kaynak kod deposundaki her branch için otomatik olarak ayrı bir pipeline oluşturan Jenkins özelliği. Yeni bir branch açtığınızda Jenkins bunu algılıyor, Jenkinsfile’ı buluyor ve pipeline’ı çalıştırmaya başlıyor. Branch silindiğinde ise ilgili pipeline da temizleniyor. Bu otomasyon, özellikle feature branch workflow’u kullanan ekiplerde iş yükünü dramatik biçimde azaltıyor.

Kubernetes tarafıyla birleşince tablo daha da güçleniyor. Her branch kendi namespace’ine ya da kendi deployment’ına sahip olabiliyor, main branch production’a gidiyor, develop branch staging’e, feature branch’leri ise geçici test ortamlarına deploy ediliyor.

Ön Gereksinimler

Bu rehberi takip edebilmek için aşağıdakilerin hazır olması gerekiyor:

  • Çalışan bir Jenkins kurulumu (2.387+ sürümü önerilir)
  • Kubernetes cluster’ı (EKS, GKE, bare-metal fark etmez)
  • Docker Hub veya özel container registry
  • Git deposu (GitHub, GitLab, Bitbucket)
  • kubectl ve helm araçlarına aşinalık

Gerekli Jenkins Plugin’leri

Jenkins’e şu plugin’lerin kurulu olduğundan emin olun:

  • Pipeline: Temel pipeline desteği
  • Multibranch Pipeline: Multi-branch özelliği için zorunlu
  • Kubernetes Plugin: Jenkins agent’larını Kubernetes pod olarak çalıştırır
  • Docker Pipeline: Docker image build ve push işlemleri
  • Git Plugin: Git entegrasyonu
  • Credentials Binding: Hassas bilgileri pipeline’da güvenli kullanmak için

Kubernetes’te Jenkins Agent Yapılandırması

Prodüksiyon ortamında Jenkins master’ı statik agent’larla çalıştırmak yerine, Kubernetes pod’larını dinamik agent olarak kullanmak çok daha mantıklı. Hem kaynak kullanımı optimize oluyor hem de izolasyon sağlanıyor.

Önce Jenkins’in Kubernetes cluster’ına erişmesi için gerekli RBAC ayarlarını yapıyoruz:

# jenkins-rbac.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: jenkins
  namespace: jenkins
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: jenkins-role
rules:
- apiGroups: [""]
  resources: ["pods", "pods/exec", "pods/log", "persistentvolumeclaims", "events"]
  verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["apps"]
  resources: ["deployments", "replicasets"]
  verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: [""]
  resources: ["namespaces", "services", "configmaps", "secrets"]
  verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: jenkins-binding
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: jenkins-role
subjects:
- kind: ServiceAccount
  name: jenkins
  namespace: jenkins
kubectl apply -f jenkins-rbac.yaml

Ardından Jenkins arayüzünden Manage Jenkins > Manage Nodes and Clouds > Configure Clouds yolunu izleyerek Kubernetes Cloud ekleyin. Kubernetes URL olarak cluster’ınızın API server adresini, credentials olarak az önce oluşturduğunuz service account token’ını kullanın.

Proje Yapısı

Senaryomuzda basit bir Node.js API servisi kullanacağız. Proje dizin yapısı şu şekilde:

myapp/
├── src/
│   └── index.js
├── Dockerfile
├── Jenkinsfile
├── k8s/
│   ├── deployment.yaml
│   ├── service.yaml
│   └── namespace.yaml
├── helm/
│   └── myapp/
│       ├── Chart.yaml
│       ├── values.yaml
│       └── templates/
└── package.json

Jenkinsfile Yazımı

İşte gerçek dünya senaryosunda kullanabileceğiniz, branch’e göre farklı davranışlar sergileyen bir Jenkinsfile:

pipeline {
    agent {
        kubernetes {
            yaml '''
apiVersion: v1
kind: Pod
spec:
  serviceAccountName: jenkins
  containers:
  - name: docker
    image: docker:24-dind
    securityContext:
      privileged: true
    env:
    - name: DOCKER_TLS_CERTDIR
      value: ""
  - name: kubectl
    image: bitnami/kubectl:1.28
    command: ["sleep", "infinity"]
  - name: helm
    image: alpine/helm:3.13.0
    command: ["sleep", "infinity"]
  - name: node
    image: node:20-alpine
    command: ["sleep", "infinity"]
'''
        }
    }

    environment {
        DOCKER_REGISTRY    = "registry.mycompany.com"
        APP_NAME           = "myapp"
        DOCKER_CREDENTIALS = credentials('docker-registry-creds')
        KUBECONFIG_CRED    = credentials('kubeconfig-prod')
    }

    stages {
        stage('Branch Bilgisi') {
            steps {
                script {
                    env.BRANCH_SLUG = env.BRANCH_NAME.replaceAll('[^a-zA-Z0-9]', '-').toLowerCase()
                    env.IMAGE_TAG   = "${env.BRANCH_SLUG}-${env.BUILD_NUMBER}"

                    if (env.BRANCH_NAME == 'main') {
                        env.DEPLOY_ENV       = 'production'
                        env.K8S_NAMESPACE    = 'myapp-production'
                        env.REPLICAS         = '3'
                    } else if (env.BRANCH_NAME == 'develop') {
                        env.DEPLOY_ENV       = 'staging'
                        env.K8S_NAMESPACE    = 'myapp-staging'
                        env.REPLICAS         = '2'
                    } else {
                        env.DEPLOY_ENV       = 'preview'
                        env.K8S_NAMESPACE    = "myapp-preview-${env.BRANCH_SLUG}"
                        env.REPLICAS         = '1'
                    }

                    echo "Branch: ${env.BRANCH_NAME}"
                    echo "Ortam: ${env.DEPLOY_ENV}"
                    echo "Namespace: ${env.K8S_NAMESPACE}"
                    echo "Image Tag: ${env.IMAGE_TAG}"
                }
            }
        }

        stage('Bağımlılıklar ve Test') {
            steps {
                container('node') {
                    sh '''
                        npm ci
                        npm run lint
                        npm run test -- --coverage
                    '''
                }
            }
            post {
                always {
                    junit 'test-results/**/*.xml'
                    publishHTML([
                        allowMissing: false,
                        reportDir: 'coverage',
                        reportFiles: 'index.html',
                        reportName: 'Test Coverage'
                    ])
                }
            }
        }

        stage('Docker Build') {
            steps {
                container('docker') {
                    sh """
                        docker login ${env.DOCKER_REGISTRY} 
                            -u ${DOCKER_CREDENTIALS_USR} 
                            -p ${DOCKER_CREDENTIALS_PSW}

                        docker build 
                            --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) 
                            --build-arg VCS_REF=${env.GIT_COMMIT} 
                            --build-arg VERSION=${env.IMAGE_TAG} 
                            -t ${env.DOCKER_REGISTRY}/${env.APP_NAME}:${env.IMAGE_TAG} 
                            -t ${env.DOCKER_REGISTRY}/${env.APP_NAME}:${env.BRANCH_SLUG}-latest 
                            .

                        docker push ${env.DOCKER_REGISTRY}/${env.APP_NAME}:${env.IMAGE_TAG}
                        docker push ${env.DOCKER_REGISTRY}/${env.APP_NAME}:${env.BRANCH_SLUG}-latest
                    """
                }
            }
        }

        stage('Kubernetes Namespace Hazırlık') {
            steps {
                container('kubectl') {
                    withCredentials([file(credentialsId: 'kubeconfig-prod', variable: 'KUBECONFIG')]) {
                        sh """
                            kubectl get namespace ${env.K8S_NAMESPACE} || 
                                kubectl create namespace ${env.K8S_NAMESPACE}

                            kubectl label namespace ${env.K8S_NAMESPACE} 
                                environment=${env.DEPLOY_ENV} 
                                managed-by=jenkins 
                                app=${env.APP_NAME} 
                                --overwrite
                        """
                    }
                }
            }
        }

        stage('Helm Deploy') {
            steps {
                container('helm') {
                    withCredentials([file(credentialsId: 'kubeconfig-prod', variable: 'KUBECONFIG')]) {
                        sh """
                            helm upgrade --install ${env.APP_NAME} ./helm/myapp 
                                --namespace ${env.K8S_NAMESPACE} 
                                --set image.repository=${env.DOCKER_REGISTRY}/${env.APP_NAME} 
                                --set image.tag=${env.IMAGE_TAG} 
                                --set replicaCount=${env.REPLICAS} 
                                --set environment=${env.DEPLOY_ENV} 
                                --set ingress.host=${env.BRANCH_SLUG}.preview.mycompany.com 
                                --wait 
                                --timeout 5m0s
                        """
                    }
                }
            }
        }

        stage('Smoke Test') {
            steps {
                container('node') {
                    sh """
                        sleep 10
                        APP_URL="https://${env.BRANCH_SLUG}.preview.mycompany.com"
                        curl -f -s --retry 5 --retry-delay 5 $APP_URL/health || exit 1
                        echo "Smoke test basarili: $APP_URL"
                    """
                }
            }
        }
    }

    post {
        success {
            slackSend(
                channel: '#deployments',
                color: 'good',
                message: """
                    Deployment basarili!
                    Branch: ${env.BRANCH_NAME}
                    Ortam: ${env.DEPLOY_ENV}
                    Image: ${env.DOCKER_REGISTRY}/${env.APP_NAME}:${env.IMAGE_TAG}
                    URL: https://${env.BRANCH_SLUG}.preview.mycompany.com
                """
            )
        }
        failure {
            slackSend(
                channel: '#deployments',
                color: 'danger',
                message: "HATA: ${env.BRANCH_NAME} branch deployment basarisiz! Build: ${env.BUILD_URL}"
            )
        }
        cleanup {
            cleanWs()
        }
    }
}

Helm Chart Yapılandırması

Helm chart’ımızın values.yaml dosyası ortam bazlı özelleştirmeye izin vermeli:

# helm/myapp/values.yaml
replicaCount: 1

image:
  repository: registry.mycompany.com/myapp
  pullPolicy: IfNotPresent
  tag: "latest"

service:
  type: ClusterIP
  port: 3000

ingress:
  enabled: true
  className: nginx
  host: myapp.preview.mycompany.com
  tls: true
  certManager: true

resources:
  limits:
    cpu: 500m
    memory: 512Mi
  requests:
    cpu: 100m
    memory: 128Mi

autoscaling:
  enabled: false
  minReplicas: 1
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70

environment: production

healthCheck:
  path: /health
  initialDelaySeconds: 15
  periodSeconds: 10

Preview Ortamları için Otomatik Temizlik

Feature branch’leri silindiğinde Kubernetes namespace’lerinin de temizlenmesi gerekiyor. Bunun için Jenkinsfile’a ayrı bir temizlik mekanizması ekliyoruz. Jenkins’in orphaned item strategy özelliğini de kullanabilirsiniz, ancak namespace temizliği için özel bir yaklaşım daha güvenilir:

// Jenkinsfile'ın sonuna eklenecek cleanup pipeline
// Bu kodu ayrı bir "cleanup" Jenkinsfile olarak da tutabilirsiniz

properties([
    pipelineTriggers([
        // Branch silindiğinde tetikle
        [$class: 'GitHubPushTrigger']
    ])
])

// Branch silindiğinde namespace'i temizle
if (currentBuild.getBuildCauses().toString().contains('BranchEventCause')) {
    stage('Preview Namespace Temizlik') {
        if (env.BRANCH_NAME != 'main' && env.BRANCH_NAME != 'develop') {
            container('kubectl') {
                withCredentials([file(credentialsId: 'kubeconfig-prod', variable: 'KUBECONFIG')]) {
                    sh """
                        NAMESPACE="myapp-preview-${env.BRANCH_SLUG}"
                        if kubectl get namespace $NAMESPACE 2>/dev/null; then
                            kubectl delete namespace $NAMESPACE --grace-period=30
                            echo "Namespace temizlendi: $NAMESPACE"
                        fi
                    """
                }
            }
        }
    }
}

Alternatif olarak, bir CronJob ile eski preview namespace’lerini periyodik temizleyebilirsiniz:

#!/bin/bash
# cleanup-preview-namespaces.sh
# Bu scripti bir Kubernetes CronJob içinde çalıştırabilirsiniz

CUTOFF_HOURS=48
CURRENT_TIME=$(date +%s)

kubectl get namespaces -l managed-by=jenkins,environment=preview 
    --no-headers -o custom-columns="NAME:.metadata.name,CREATED:.metadata.creationTimestamp" | 
while read -r name created; do
    CREATED_TIME=$(date -d "$created" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$created" +%s)
    AGE_HOURS=$(( (CURRENT_TIME - CREATED_TIME) / 3600 ))

    if [ "$AGE_HOURS" -gt "$CUTOFF_HOURS" ]; then
        echo "$name namespace'i $AGE_HOURS saatlik, siliniyor..."
        kubectl delete namespace "$name"
    fi
done

Multi-Branch Pipeline’ı Jenkins’te Oluşturmak

Jenkins arayüzünde şu adımları izleyin:

  • New Item tıklayın
  • İsim olarak projenin adını girin
  • Multibranch Pipeline seçin
  • Branch Sources bölümünde Git veya GitHub ekleyin
  • Repository URL’ini ve credentials’ı girin
  • Discover branches davranışını ayarlayın (All branches önerilir)
  • Build Configuration altında Jenkinsfile yolunu belirtin (varsayılan: Jenkinsfile)
  • Scan Multibranch Pipeline Triggers altında webhook veya periyodik tarama ayarlayın
  • Kaydedin ve Scan Multibranch Pipeline Now butonuna tıklayın

GitHub webhook için şu URL formatını kullanın:

https://jenkins.mycompany.com/multibranch-webhook-trigger/invoke?token=MY_SECRET_TOKEN

Credentials Yönetimi

Hassas bilgileri asla Jenkinsfile’a doğrudan yazmayın. Jenkins Credentials Store’u doğru kullanmak şart:

# kubectl ile secret oluşturup Jenkins'e aktarma
kubectl config view --raw | base64 | tr -d 'n' > kubeconfig-base64.txt

# Sonra bu değeri Jenkins'te:
# Manage Jenkins > Credentials > System > Global credentials
# Kind: Secret file
# ID: kubeconfig-prod
# File: kubeconfig.yaml

Pipeline içinde credentials kullanımı için standart yöntem:

// Birden fazla credential aynı anda kullanmak için
withCredentials([
    usernamePassword(
        credentialsId: 'docker-registry-creds',
        usernameVariable: 'REGISTRY_USER',
        passwordVariable: 'REGISTRY_PASS'
    ),
    file(
        credentialsId: 'kubeconfig-prod',
        variable: 'KUBECONFIG'
    ),
    string(
        credentialsId: 'slack-token',
        variable: 'SLACK_TOKEN'
    )
]) {
    sh """
        docker login -u $REGISTRY_USER -p $REGISTRY_PASS registry.mycompany.com
        kubectl --kubeconfig=$KUBECONFIG apply -f k8s/
    """
}

Sık Karşılaşılan Sorunlar ve Çözümleri

Docker-in-Docker izin hatası: Kubernetes pod’unda Docker build yaparken permission denied hatası alıyorsanız, pod spec’ine securityContext.privileged: true ekleyin. Ama prodüksiyonda bu yaklaşım güvenlik riski taşıyor. Kaniko veya Buildah gibi daemon-less alternatifleri değerlendirin.

Helm timeout sorunu: --wait flag’i ile birlikte uzun süren deployment’larda timeout alabilirsiniz. --timeout değerini artırın ya da uygulamanızın readiness probe’larını gözden geçirin.

Branch adından kaynaklanan Kubernetes kaynak adı hataları: Kubernetes kaynak adları yalnızca lowercase harf, rakam ve tire kabul ediyor. Branch adını replaceAll('[^a-zA-Z0-9]', '-').toLowerCase() gibi bir regex ile temizlemeyi unutmayın.

Image pull hatası: Private registry kullanıyorsanız namespace’e imagePullSecrets eklemeniz gerekiyor:

kubectl create secret docker-registry regcred 
    --docker-server=registry.mycompany.com 
    --docker-username=myuser 
    --docker-password=mypassword 
    --namespace=myapp-production

Performans İpuçları

Pipeline hızını artırmak için birkaç pratik öneri:

  • Paralel stage’ler kullanın: Test ve lint işlemlerini paralel çalıştırabilirsiniz.
  • Docker layer cache’ini akıllıca kullanın: COPY package*.json satırını uygulama kodundan önce yazarak npm install katmanını cache’leyin.
  • Workspace temizliğini kontrol edin: Her build’de cleanWs() çağırmak yerine, sadece gerekli dosyaları temizleyin.
  • Kubernetes agent pod startup süresini azaltın: Sık kullandığınız image’ları node’lara önceden çekin ya da image pull policy’yi IfNotPresent olarak ayarlayın.

Sonuç

Jenkins Multi-Branch Pipeline ile Kubernetes deployment kombinasyonu, özellikle birden fazla ekibin aynı repository üzerinde çalıştığı ortamlarda gerçek anlamda hayat kurtarıyor. Her branch’in kendi test ortamına sahip olması, “benim bilgisayarımda çalışıyor” problemini büyük ölçüde ortadan kaldırıyor. Pull request açılır açılmaz otomatik bir preview URL’inin oluşması, QA süreçlerini dramatik biçimde hızlandırıyor.

Bu yazıda ele aldığımız yapıyı kendi projenize uyarlarken aşamalar halinde ilerlemenizi öneririm. Önce tek bir branch için basit bir pipeline kurun, çalıştığına emin olun, sonra multi-branch yapısına geçin. Helm chart’ınızı olgunlaştırın ve ancak ondan sonra preview ortamı otomasyonunu devreye alın. Bir anda her şeyi kurmaya çalışmak, debug sürecini gereksiz yere zorlaştırıyor.

Son olarak şunu söylemek isterim: bu tür CI/CD altyapısına yapılan yatırım, ilk haftada karmaşık gelse de birkaç ay içinde kendini birkaç kez amorti ediyor. Ekibin deployment korkusu azalıyor, release sıklığı artıyor ve gece yarısı acil müdahaleleri belirgin biçimde düşüyor. Bundan daha iyi ROI bulmak zor.

Bir yanıt yazın

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