CI/CD Entegrasyonu: Pipeline’da Docker Kullanımı

Modern yazılım geliştirme dünyasında “bende çalışıyor” problemi artık mazeret sayılmıyor. Ekipler büyüdükçe, deployment sıklığı arttıkça ve mikroservis mimarileri yaygınlaştıkça, tutarlı build ve test ortamlarına olan ihtiyaç da katlanarak artıyor. İşte tam bu noktada Docker ile CI/CD entegrasyonu devreye giriyor. Bu yazıda, production’da gerçekten kullandığım pipeline yapılarını, karşılaştığım sorunları ve çözüm yollarını paylaşacağım.

CI/CD Pipeline’ında Docker’ın Rolü

Docker’ı CI/CD süreçlerine dahil etmenin temel mantığı şu: her build, test ve deployment adımı izole, tekrarlanabilir ve taşınabilir bir ortamda çalışsın. Geliştirici makinesindeki Python versiyonu ile CI sunucusundaki Python versiyonunun farklı olması gibi klasik problemleri kalıcı olarak çözmek istiyorsanız Docker doğru araç.

Bir pipeline’da Docker’ı genellikle iki farklı şekilde kullanırsınız:

  • Docker as a build tool: Uygulamanızı Docker içinde build edip test edersiniz, çıktı olarak bir Docker image üretirsiniz.
  • Docker as a runtime: CI/CD sisteminin kendisi (runner, agent) Docker container içinde çalışır.

Çoğu gerçek dünya senaryosunda bu iki yaklaşım iç içe geçer. Hadi adım adım inceleyelim.

Temel Dockerfile Yapısı: CI Dostu Tasarım

CI/CD için optimize edilmiş bir Dockerfile yazmak, local development için yazmaktan biraz farklı. En önemli konu layer cache‘i verimli kullanmak. Gereksiz yere cache’i bozan bir Dockerfile, her commit’te sıfırdan build başlatır ve pipeline sürelerinizi katlayabilir.

İşte Node.js uygulaması için CI dostu bir Dockerfile örneği:

# Multi-stage build - production image'ı küçük tutar
FROM node:18-alpine AS dependencies
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production

FROM node:18-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:18-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=dependencies /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
EXPOSE 3000
USER node
CMD ["node", "dist/index.js"]

Bu yapının avantajları şunlar:

  • dependencies stage’i sadece production bağımlılıklarını yükler, node_modules’ün gereksiz şişmesini önler
  • build stage’i dev bağımlılıklarıyla birlikte build alır ama bu katman production image’ına geçmez
  • production stage’i minimal ve güvenli bir image üretir
  • package.json ve package-lock.json dosyaları kaynak koddan önce kopyalandığı için, sadece kod değiştiğinde npm install yeniden çalışmaz

GitLab CI ile Docker Pipeline Kurulumu

GitLab CI, Docker entegrasyonu açısından en olgun platformlardan biri. .gitlab-ci.yml dosyanızı doğru yapılandırmak kritik.

# .gitlab-ci.yml
variables:
  DOCKER_DRIVER: overlay2
  DOCKER_TLS_CERTDIR: "/certs"
  IMAGE_NAME: $CI_REGISTRY_IMAGE
  IMAGE_TAG: $CI_COMMIT_SHORT_SHA

stages:
  - build
  - test
  - security
  - deploy

# Docker-in-Docker için servis tanımı
build:
  stage: build
  image: docker:24.0.5
  services:
    - docker:24.0.5-dind
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - docker build --cache-from $IMAGE_NAME:latest
                   --build-arg BUILDKIT_INLINE_CACHE=1
                   -t $IMAGE_NAME:$IMAGE_TAG
                   -t $IMAGE_NAME:latest .
    - docker push $IMAGE_NAME:$IMAGE_TAG
    - docker push $IMAGE_NAME:latest
  only:
    - main
    - develop

Burada dikkat edilmesi gereken bir nokta var: --cache-from parametresi ile önceki build’in cache’ini kullanıyoruz. BUILDKIT_INLINE_CACHE=1 flag’i ise cache metadata’sını image içine gömiyor ki sonraki build’ler bu bilgiyi kullanabilsin.

Test Stage’i: Servis Bağımlılıklarıyla Çalışmak

Gerçek projelerde test aşaması genellikle veritabanı, cache veya message broker gibi harici servislere ihtiyaç duyar. GitLab CI’ın services özelliği burada hayat kurtarıyor.

test:unit:
  stage: test
  image: $IMAGE_NAME:$IMAGE_TAG
  services:
    - name: postgres:15-alpine
      alias: db
    - name: redis:7-alpine
      alias: cache
  variables:
    POSTGRES_DB: testdb
    POSTGRES_USER: testuser
    POSTGRES_PASSWORD: testpass
    DATABASE_URL: "postgresql://testuser:testpass@db:5432/testdb"
    REDIS_URL: "redis://cache:6379"
  script:
    - npm run test:unit
    - npm run test:integration
  coverage: '/Liness*:s*(d+.?d*)%/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml
    paths:
      - coverage/
    expire_in: 1 week

Bu yapıda testler, production image’ının kendisi üzerinde koşuyor. Bu yaklaşım şu soruyu ortadan kaldırır: “Test ortamı production ortamına ne kadar benziyor?” Cevap: Aynısı.

GitHub Actions ile Docker Pipeline

GitHub Actions kullanıyorsanız, Docker Hub veya GitHub Container Registry ile entegrasyon biraz farklı ama bir o kadar güçlü.

# .github/workflows/docker-pipeline.yml
name: Docker CI/CD Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      security-events: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata for Docker
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=sha,prefix=sha-
            type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          build-args: |
            BUILD_DATE=${{ github.event.head_commit.timestamp }}
            VCS_REF=${{ github.sha }}

GitHub Actions’ın cache-from: type=gha özelliği, GitHub’ın kendi cache altyapısını kullanıyor ve son derece etkili. Özellikle mode=max ile tüm intermediate layer’ları cache’liyorsunuz.

Docker Compose ile Entegrasyon Test Ortamı

Birden fazla mikroservisin birlikte test edilmesi gerektiğinde Docker Compose vazgeçilmez oluyor. CI ortamında Compose kullanımı için bazı püf noktalar var.

# docker-compose.test.yml
version: '3.8'

services:
  app:
    build:
      context: .
      target: build
    environment:
      - NODE_ENV=test
      - DATABASE_URL=postgresql://postgres:password@postgres:5432/testdb
      - REDIS_URL=redis://redis:6379
      - RABBITMQ_URL=amqp://guest:guest@rabbitmq:5672
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    command: npm run test:e2e

  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_PASSWORD: password
      POSTGRES_DB: testdb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

  rabbitmq:
    image: rabbitmq:3.12-management-alpine
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

Bu Compose dosyasını CI pipeline’ında şöyle kullanırsınız:

# CI script içinde
script:
  - docker compose -f docker-compose.test.yml up --build --abort-on-container-exit
  - EXIT_CODE=$(docker compose -f docker-compose.test.yml ps -q app | xargs docker inspect -f '{{ .State.ExitCode }}')
  - docker compose -f docker-compose.test.yml down -v
  - exit $EXIT_CODE

--abort-on-container-exit parametresi önemli: herhangi bir container durduğunda tüm stack’i durdurur. app container’ı testleri tamamlayıp çıktığında diğerleri de durur ve exit code’u alırsınız.

Security Scanning Pipeline’a Entegrasyon

Production’a çıkan her image’ı taramak artık bir best practice değil, zorunluluk. Trivy, açık kaynak ve son derece etkili bir araç.

security:scan:
  stage: security
  image: aquasec/trivy:latest
  variables:
    TRIVY_NO_PROGRESS: "true"
    TRIVY_CACHE_DIR: ".trivycache/"
  cache:
    paths:
      - .trivycache/
  script:
    # HIGH ve CRITICAL vulnerability varsa pipeline'ı durdur
    - trivy image
        --exit-code 1
        --severity HIGH,CRITICAL
        --ignore-unfixed
        --format sarif
        --output trivy-results.sarif
        $IMAGE_NAME:$IMAGE_TAG
  artifacts:
    when: always
    paths:
      - trivy-results.sarif
    expire_in: 30 days
  allow_failure: false

--ignore-unfixed parametresi pratikte çok önemli. Henüz fix’i olmayan zafiyetleri rapordan çıkarmak pipeline’ı gereksiz şekilde bloke etmekten kurtarır. Ancak bu parametreyi kullanıyorsanız, düzenli aralıklarla tüm zafiyetleri (unfixed dahil) listeleyen ayrı bir rapor üretmenizi öneririm.

Deployment Stage: Kubernetes’e Rolling Update

Image build ve test aşamaları tamamlandıktan sonra deployment adımına geliyoruz. Kubernetes ortamına deployment için kubectl veya helm kullanabilirsiniz.

deploy:production:
  stage: deploy
  image: bitnami/kubectl:latest
  environment:
    name: production
    url: https://app.example.com
  before_script:
    - kubectl config set-cluster production
        --server=$KUBE_URL
        --certificate-authority=$KUBE_CA_FILE
    - kubectl config set-credentials ci-user
        --token=$KUBE_TOKEN
    - kubectl config set-context production
        --cluster=production
        --user=ci-user
    - kubectl config use-context production
  script:
    # Image tag'ini güncelle
    - kubectl set image deployment/app
        app=$IMAGE_NAME:$IMAGE_TAG
        -n production
    # Rollout'u bekle, timeout: 5 dakika
    - kubectl rollout status deployment/app
        -n production
        --timeout=300s
  after_script:
    # Başarısız olursa otomatik rollback
    - |
      if [ $CI_JOB_STATUS == 'failed' ]; then
        kubectl rollout undo deployment/app -n production
        echo "Deployment başarısız, rollback yapıldı"
      fi
  only:
    - main
  when: manual

when: manual ile production deployment’ını otomatik değil, manuel onaya bağlayabilirsiniz. Bu, özellikle kritik servisler için önemli bir güvenlik katmanı.

BuildKit ve Cache Optimizasyonu

Pipeline sürelerini kısaltmak için BuildKit’i tam anlamıyla kullanmak gerekiyor. Birkaç pratik optimizasyon:

# BuildKit'i aktif et
export DOCKER_BUILDKIT=1

# Registry cache kullanımı - en etkili yöntem
docker build 
  --cache-from type=registry,ref=myregistry.com/myapp:buildcache 
  --cache-to type=registry,ref=myregistry.com/myapp:buildcache,mode=max 
  -t myregistry.com/myapp:$VERSION 
  .

# Paralel stage build için build argümanları
docker build 
  --build-arg BUILDKIT_INLINE_CACHE=1 
  --progress=plain 
  -t myapp:latest .

Registry-based cache, özellikle ephemeral runner’lar (her pipeline için temiz bir makine başlatan sistemler) kullandığınızda disk cache’e göre çok daha etkili. GitLab Runners veya GitHub Actions’ın self-hosted runner’ları ephemeral olabilir; bu durumda cache’i registry’ye gömmek pipeline sürelerinizi yarıya indirebilir.

Pratik Sorun: Flaky Tests ve Docker

Containerized test ortamlarında en sık karşılaşılan sorunlardan biri, servisler hazır olmadan testlerin başlaması. depends_on ile healthcheck kombinasyonu bu sorunu çözer ama bazen yetmez. İşte production’da kullandığım bir wait script:

#!/bin/bash
# wait-for-services.sh

set -e

TIMEOUT=${TIMEOUT:-60}
SERVICES=("$@")

wait_for_service() {
  local service=$1
  local host=$(echo $service | cut -d: -f1)
  local port=$(echo $service | cut -d: -f2)
  local elapsed=0

  echo "Bekleniyor: $host:$port"

  while ! nc -z $host $port 2>/dev/null; do
    if [ $elapsed -ge $TIMEOUT ]; then
      echo "TIMEOUT: $host:$port $TIMEOUT saniyede hazır olmadı"
      exit 1
    fi
    sleep 2
    elapsed=$((elapsed + 2))
  done

  echo "Hazır: $host:$port (${elapsed}s)"
}

for service in "${SERVICES[@]}"; do
  wait_for_service $service
done

echo "Tüm servisler hazır, testler başlıyor..."
exec "$@"

Bu scripti Dockerfile’ınıza kopyalayıp test command’inizde kullanabilirsiniz:

COPY wait-for-services.sh /usr/local/bin/wait-for-services
RUN chmod +x /usr/local/bin/wait-for-services

# Kullanım
CMD ["wait-for-services", "db:5432", "cache:6379", "--", "npm", "run", "test"]

Image Tagging Stratejisi

Production ortamında image tagging karmaşık bir konu. Önerdiğim strateji:

  • latest: Sadece main/master branch’ten üretilen son stable image
  • develop: Develop branch’inin son hali, QA ortamı için
  • sha-abc1234: Her commit için unique tag, traceability için kritik
  • v1.2.3: Semantic versioning tag’leri, release süreçleri için
  • main-20240115: Branch + tarih kombinasyonu, rollback senaryoları için

latest tag’ini production deployment’ında asla kullanmayın. Deployment sırasında image değişmiş olabilir ve tam olarak neyi deploy ettiğinizi bilmezsiniz. Her zaman commit SHA veya semantic version ile tag’lenmiş bir image kullanın.

Monitoring ve Observability

Pipeline’larınızın sağlığını izlemek de en az pipeline’ı kurmak kadar önemli. Birkaç pratik metrik takip etmenizi öneririm:

  • Pipeline duration: Build süresi trend olarak artıyorsa Dockerfile veya cache optimizasyonu gerekiyor
  • Failure rate by stage: Hangi stage en çok başarısız oluyor? Flaky test mi, environment sorunu mu?
  • Image size over time: Image boyutu commit’ler arasında dramatik değişiyor mu?

GitLab CI için pipeline analytics built-in geliyor. GitHub Actions için ise Datadog veya custom metrics with Prometheus kullanabilirsiniz.

Güvenlik: Secret Yönetimi

CI/CD pipeline’ında Docker kullanırken en kritik konulardan biri secret yönetimi. Dockerfile’a asla secret gömmeyln. ARG ile build-time secret geçmek de log’larda görünebilir.

BuildKit’in secret mounting özelliği bu sorunu çözüyor:

# Dockerfile içinde
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc 
    npm install

# Build sırasında
docker build 
  --secret id=npmrc,src=$HOME/.npmrc 
  -t myapp:latest .

Bu yöntemle .npmrc dosyası final image’a gömülmez, sadece RUN komutunun çalışması sırasında kullanılabilir durumda olur.

Sonuç

Docker ile CI/CD entegrasyonu, başta karmaşık görünse de doğru temeller atıldığında son derece güçlü ve güvenilir bir yapı ortaya çıkıyor. Bu yazıda ele aldığım konuları özetlemek gerekirse:

  • Multi-stage build ile hem build sürelerini hem de final image boyutunu optimize edebilirsiniz
  • Layer cache’i doğru kullanmak, pipeline sürelerinizi dramatik ölçüde kısaltır
  • Test aşamasında servis bağımlılıkları için healthcheck ve wait script kombinasyonu şart
  • Security scanning pipeline’ın bir parçası olmalı, sonradan eklenen bir adım değil
  • Image tagging stratejisi traceability için kritik, latest tag’ini production deployment’ında kullanmayın
  • Secret yönetimi için BuildKit’in mount özelliğini kullanın

En sık yapılan hata, bu kurulumları bir kere yapıp unutmak. Pipeline’larınızı düzenli olarak gözden geçirin, build sürelerini takip edin ve image boyutlarını monitör edin. CI/CD pipeline’ı da bir uygulama gibi bakım istiyor.

Sorularınız veya farklı yaklaşımlarınız varsa yorumlarda buluşalım.

Yorum yapın