CI/CD Pipeline ile Docker Compose Entegrasyonu

Üretim ortamına kod göndermek her zaman biraz stresli bir deneyimdir. “Bende çalışıyordu” cümlesini kaç kez duydunuz? Docker Compose ile CI/CD pipeline entegrasyonu tam da bu sorunu çözmek için var. Geliştirici laptop’ında çalışan ortamın, test sunucusunda ve production’da da aynı şekilde çalışmasını sağlamak artık hayal değil. Bu yazıda gerçek dünya senaryolarıyla Compose ve CI/CD entegrasyonunu tüm detaylarıyla ele alacağız.

Neden Compose ve CI/CD Birlikte?

Docker Compose tek başına güçlü bir araçtır. Ama bir CI/CD pipeline’ı olmadan Compose kullanmak, her deployment’ta elle komut çalıştırmak demektir. Bu da insan hatasına açık kapı bırakmak demektir. Birinin docker-compose up -d komutu çalıştırmayı unutması, yanlış branch’ten build alması ya da environment variable’ları eksik geçmesi gibi durumlar production’ı patlatabilir.

CI/CD ile Compose’u birleştirdiğinizde şunları elde edersiniz:

  • Tekrarlanabilir build’ler: Her push’ta aynı adımlar çalışır
  • Otomatik test entegrasyonu: Servisler ayağa kalkar, testler çalışır, her şey kapanır
  • Audit trail: Kim, ne zaman, hangi kodu deploy etti?
  • Rollback kolaylığı: Önceki image tag’lerine dönmek saniyeler alır
  • Paralel ortam yönetimi: Dev, staging, production aynı Compose dosyasından türer

Proje Yapısının Hazırlanması

Önce sağlam bir proje yapısıyla başlayalım. Bir web uygulaması senaryosu düşünelim: Frontend, backend API, PostgreSQL ve Redis.

myapp/
├── docker-compose.yml
├── docker-compose.override.yml
├── docker-compose.prod.yml
├── docker-compose.test.yml
├── .env.example
├── .gitignore
├── backend/
│   ├── Dockerfile
│   └── ...
├── frontend/
│   ├── Dockerfile
│   └── ...
└── .github/
    └── workflows/
        ├── ci.yml
        └── deploy.yml

Bu yapıda birden fazla Compose dosyası kullanmak önemli. docker-compose.yml temel yapıyı tanımlar, docker-compose.override.yml development ortamına özel ayarları barındırır (Compose bunu otomatik yükler), docker-compose.prod.yml ise production’a özel konfigürasyonları içerir.

Temel docker-compose.yml dosyamız şu şekilde görünebilir:

version: '3.9'

services:
  backend:
    image: ${REGISTRY:-ghcr.io}/myorg/myapp-backend:${IMAGE_TAG:-latest}
    build:
      context: ./backend
      dockerfile: Dockerfile
    environment:
      - DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
      - REDIS_URL=redis://redis:6379
      - APP_ENV=${APP_ENV:-development}
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    networks:
      - appnet

  frontend:
    image: ${REGISTRY:-ghcr.io}/myorg/myapp-frontend:${IMAGE_TAG:-latest}
    build:
      context: ./frontend
      dockerfile: Dockerfile
    ports:
      - "${FRONTEND_PORT:-3000}:3000"
    depends_on:
      - backend
    networks:
      - appnet

  db:
    image: postgres:15-alpine
    environment:
      - POSTGRES_USER=${DB_USER:-appuser}
      - POSTGRES_PASSWORD=${DB_PASSWORD:-changeme}
      - POSTGRES_DB=${DB_NAME:-appdb}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-appuser}"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - appnet

  redis:
    image: redis:7-alpine
    networks:
      - appnet

volumes:
  pgdata:

networks:
  appnet:
    driver: bridge

Test Ortamı için Compose Konfigürasyonu

CI ortamında testler çalışırken gerçek bir veritabanı ve cache servisi lazım. docker-compose.test.yml dosyası bu işi yapar:

version: '3.9'

services:
  backend:
    build:
      context: ./backend
      target: test
    environment:
      - DATABASE_URL=postgresql://testuser:testpass@db:5432/testdb
      - REDIS_URL=redis://redis:6379
      - APP_ENV=test
    command: ["sh", "-c", "python manage.py migrate && python manage.py test --verbosity=2"]
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:15-alpine
    environment:
      - POSTGRES_USER=testuser
      - POSTGRES_PASSWORD=testpass
      - POSTGRES_DB=testdb
    tmpfs:
      - /var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U testuser"]
      interval: 5s
      timeout: 3s
      retries: 10

  redis:
    image: redis:7-alpine

Test ortamında tmpfs kullanmak önemli bir detay. Veritabanı verilerini RAM’de tutarak hem hızı artırıyoruz hem de her test çalışmasında temiz bir slate elde ediyoruz.

GitHub Actions ile Entegrasyon

GitHub Actions, Docker Compose ile çalışmak için oldukça uygun. Önce CI workflow’unu yazalım:

# .github/workflows/ci.yml
name: CI Pipeline

on:
  push:
    branches: [main, develop, 'feature/**']
  pull_request:
    branches: [main, develop]

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

jobs:
  test:
    name: Run Tests
    runs-on: ubuntu-latest

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

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

      - name: Cache Docker layers
        uses: actions/cache@v4
        with:
          path: /tmp/.buildx-cache
          key: ${{ runner.os }}-buildx-${{ github.sha }}
          restore-keys: |
            ${{ runner.os }}-buildx-

      - name: Run tests with Compose
        run: |
          docker compose -f docker-compose.test.yml up 
            --build 
            --abort-on-container-exit 
            --exit-code-from backend

      - name: Cleanup test containers
        if: always()
        run: |
          docker compose -f docker-compose.test.yml down -v --remove-orphans

  build:
    name: Build and Push Images
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'

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

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

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

      - name: Generate image tags
        id: meta
        run: |
          SHORT_SHA=$(echo ${{ github.sha }} | cut -c1-7)
          BRANCH=$(echo ${{ github.ref_name }} | sed 's///-/g')
          echo "image_tag=${BRANCH}-${SHORT_SHA}" >> $GITHUB_OUTPUT
          echo "short_sha=${SHORT_SHA}" >> $GITHUB_OUTPUT

      - name: Build and push backend
        run: |
          IMAGE_TAG=${{ steps.meta.outputs.image_tag }}
          docker buildx build 
            --platform linux/amd64,linux/arm64 
            --cache-from type=gha 
            --cache-to type=gha,mode=max 
            --push 
            -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend:${IMAGE_TAG} 
            -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend:latest 
            ./backend

      - name: Build and push frontend
        run: |
          IMAGE_TAG=${{ steps.meta.outputs.image_tag }}
          docker buildx build 
            --platform linux/amd64,linux/arm64 
            --cache-from type=gha 
            --cache-to type=gha,mode=max 
            --push 
            -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend:${IMAGE_TAG} 
            -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend:latest 
            ./frontend

--abort-on-container-exit flag’i kritik. Test container’ı bittiğinde diğer tüm servisler de duruyor. --exit-code-from backend ise backend servisinin exit code’unu pipeline’a yansıtıyor. Test başarısız olursa pipeline da başarısız sayılıyor.

GitLab CI ile Entegrasyon

GitLab kullananlar için farklı bir yaklaşım gerekiyor. GitLab CI’ın Docker-in-Docker (DinD) servisiyle Compose kullanımı biraz farklı:

# .gitlab-ci.yml
stages:
  - test
  - build
  - deploy

variables:
  DOCKER_HOST: tcp://docker:2376
  DOCKER_TLS_CERTDIR: "/certs"
  DOCKER_TLS_VERIFY: 1
  DOCKER_CERT_PATH: "$DOCKER_TLS_CERTDIR/client"
  COMPOSE_PROJECT_NAME: "${CI_PROJECT_NAME}-${CI_PIPELINE_ID}"

.docker_template: &docker_template
  image: docker:24
  services:
    - docker:24-dind
  before_script:
    - docker info
    - apk add --no-cache docker-compose-plugin

test:
  <<: *docker_template
  stage: test
  script:
    - |
      docker compose -f docker-compose.test.yml up 
        --build 
        --abort-on-container-exit 
        --exit-code-from backend
  after_script:
    - docker compose -f docker-compose.test.yml down -v --remove-orphans
  coverage: '/TOTAL.+?(d+%)$/'

build_images:
  <<: *docker_template
  stage: build
  only:
    - main
    - develop
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin $CI_REGISTRY
    - IMAGE_TAG="${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHORT_SHA}"
    - |
      REGISTRY=$CI_REGISTRY_IMAGE 
      IMAGE_TAG=$IMAGE_TAG 
      docker compose build --push
  artifacts:
    reports:
      dotenv: build.env

deploy_staging:
  stage: deploy
  only:
    - develop
  environment:
    name: staging
    url: https://staging.myapp.com
  script:
    - echo "Deploy to staging..."

GitLab CI’da COMPOSE_PROJECT_NAME değişkenini pipeline ID’siyle birleştirmek önemli. Paralel çalışan pipeline’ların container’ları birbirine karışmasını engeller.

Production Deploy Script’i

CI/CD’nin build aşaması tamamlandıktan sonra production’a deploy etmek için bir script lazım. Bu script SSH üzerinden çalışabilir ya da doğrudan server üzerinde tetiklenebilir:

#!/bin/bash
# deploy.sh

set -euo pipefail

# Konfigurasyon
DEPLOY_DIR="/opt/myapp"
COMPOSE_FILE="docker-compose.prod.yml"
BACKUP_IMAGES=3
LOG_FILE="/var/log/myapp/deploy.log"

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}

check_health() {
    local service=$1
    local max_attempts=30
    local attempt=0

    while [ $attempt -lt $max_attempts ]; do
        if docker compose -f "$COMPOSE_FILE" ps "$service" | grep -q "healthy|running"; then
            log "Service $service is healthy"
            return 0
        fi
        attempt=$((attempt + 1))
        log "Waiting for $service... ($attempt/$max_attempts)"
        sleep 5
    done

    log "ERROR: Service $service did not become healthy"
    return 1
}

rollback() {
    log "ROLLBACK initiated..."
    export IMAGE_TAG="$PREVIOUS_TAG"
    docker compose -f "$COMPOSE_FILE" up -d --no-build
    log "Rollback to $PREVIOUS_TAG completed"
}

# Ortam degiskenlerini yukle
source "$DEPLOY_DIR/.env"

# Yeni image tag'ini al
NEW_TAG=${1:-latest}
PREVIOUS_TAG=$(grep IMAGE_TAG "$DEPLOY_DIR/.env" | cut -d= -f2)

log "Starting deployment: $PREVIOUS_TAG -> $NEW_TAG"

# .env dosyasini guncelle
sed -i "s/IMAGE_TAG=.*/IMAGE_TAG=${NEW_TAG}/" "$DEPLOY_DIR/.env"

# Yeni image'lari cek
export IMAGE_TAG="$NEW_TAG"
docker compose -f "$COMPOSE_FILE" pull

# Zero-downtime deployment
log "Updating services..."
if ! docker compose -f "$COMPOSE_FILE" up -d --no-build --remove-orphans; then
    log "Deployment failed, initiating rollback..."
    rollback
    exit 1
fi

# Health check
if ! check_health backend; then
    log "Health check failed, initiating rollback..."
    rollback
    exit 1
fi

# Eski image'lari temizle
docker image prune -f --filter "until=24h"

log "Deployment successful: $NEW_TAG"

Bu script’in set -euo pipefail ile başlaması önemli. Herhangi bir komut hata verirse script durur, tanımsız değişken kullanılırsa hata verir ve pipe chain’de hata olursa yakalar.

Sırların Yönetimi: Secrets Handling

CI/CD pipeline’larında en kritik konu güvenlik. Environment variable’ları doğrudan kod içine yazmak ya da Git’e commitleyen biri mutlaka çıkar. Bunun yerine:

GitHub Actions için şu yaklaşım çalışır:

- name: Create production .env file
  run: |
    cat > .env.prod << EOF
    DB_USER=${{ secrets.PROD_DB_USER }}
    DB_PASSWORD=${{ secrets.PROD_DB_PASSWORD }}
    DB_NAME=${{ secrets.PROD_DB_NAME }}
    SECRET_KEY=${{ secrets.APP_SECRET_KEY }}
    IMAGE_TAG=${{ steps.meta.outputs.image_tag }}
    REGISTRY=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
    EOF

- name: Deploy to production
  uses: appleboy/ssh-action@v1
  with:
    host: ${{ secrets.PROD_HOST }}
    username: ${{ secrets.PROD_USER }}
    key: ${{ secrets.PROD_SSH_KEY }}
    script: |
      cd /opt/myapp
      cat > .env < /dev/stdin
    script_stop: true

.env dosyalarını asla Git’e eklemeyin. .gitignore dosyanızda mutlaka şunlar olsun:

.env
.env.prod
.env.staging
.env.local
*.env
!.env.example

Çoklu Ortam Yönetimi

Gerçek dünyada dev, staging, production en az üç ortam oluyor. Compose override sistemi bunu zarif bir şekilde çözüyor:

# Development
docker compose up

# Staging
docker compose -f docker-compose.yml -f docker-compose.staging.yml up

# Production
docker compose -f docker-compose.yml -f docker-compose.prod.yml up

docker-compose.prod.yml örneği:

version: '3.9'

services:
  backend:
    restart: always
    deploy:
      resources:
        limits:
          cpus: '1'
          memory: 512M
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "5"

  frontend:
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/prod.conf:/etc/nginx/nginx.conf:ro
      - /etc/letsencrypt:/etc/letsencrypt:ro

  db:
    restart: always
    volumes:
      - /data/postgres:/var/lib/postgresql/data
    deploy:
      resources:
        limits:
          memory: 1G

Pipeline’da Image Tagging Stratejisi

Hangi image’ın nerede çalıştığını bilmek istersiniz. Bunun için tutarlı bir tagging stratejisi şart:

  • main-a1b2c3d: main branch’ten gelen commit hash’i ile tag
  • develop-x9y8z7w: develop branch’ten gelen tag
  • latest: Her zaman main’deki son stable versiyonu gösterir
  • v1.2.3: Release tag’leri için semantic versioning
# CI script'inde tag oluşturma
SHORT_SHA=$(git rev-parse --short HEAD)
BRANCH=$(git rev-parse --abbrev-ref HEAD | sed 's///-/g')
TIMESTAMP=$(date +%Y%m%d%H%M%S)

# Ana tag
IMAGE_TAG="${BRANCH}-${SHORT_SHA}"

# Release ise
if git describe --exact-match --tags HEAD 2>/dev/null; then
    RELEASE_TAG=$(git describe --exact-match --tags HEAD)
    echo "release_tag=${RELEASE_TAG}" >> $GITHUB_OUTPUT
fi

echo "image_tag=${IMAGE_TAG}" >> $GITHUB_OUTPUT

Monitoring ve Alerting Entegrasyonu

Deploy pipeline’ı başarıyla tamamlandı, ama iş bitmedi. Deployment sonrası monitoring de pipeline’ın parçası olmalı:

notify_deployment:
  name: Notify Deployment Status
  runs-on: ubuntu-latest
  needs: [deploy]
  if: always()

  steps:
    - name: Send Slack notification
      uses: 8398a7/action-slack@v3
      with:
        status: ${{ needs.deploy.result }}
        fields: repo,message,commit,author,action,eventName,ref,workflow
        text: |
          Deployment ${{ needs.deploy.result == 'success' && 'basarili' || 'BASARISIZ' }}
          Image: ${{ env.IMAGE_TAG }}
          Ortam: Production
      env:
        SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

Yaygın Sorunlar ve Çözümleri

CI ortamında karşılaşılan bazı klasik sorunlar var:

Container başlamadan test koşmaya çalışmak: depends_on ile healthcheck birleşimi bu sorunu çözer. Ama bazı CI sistemlerinde yine de sorun çıkar. wait-for-it.sh gibi araçlar kullanabilirsiniz.

Port çakışmaları: Paralel pipeline’lar aynı host port’u kullanmaya çalışabilir. Çözüm: Production dışı ortamlarda ports tanımlamayın ya da dinamik port ataması kullanın.

Disk dolması: CI runner’larında build cache ve eski image’lar birikir. Her job sonunda temizlik şart:

# Cleanup job
cleanup:
  runs-on: ubuntu-latest
  if: always()
  steps:
    - name: Docker cleanup
      run: |
        docker system prune -f
        docker volume prune -f
        docker image prune -a -f --filter "until=24h"

Build cache’in doğru kullanılmaması: --cache-from ve --cache-to flag’lerini doğru kullanmak build sürelerini dramatik şekilde düşürür. GitHub Actions’ta type=gha cache en iyi seçenek.

Sonuç

CI/CD pipeline ile Docker Compose entegrasyonu ilk bakışta karmaşık görünebilir. Ama katmanlı bir yaklaşımla ilerlerseniz, her adım bir öncekinin üzerine oturur. Temel Compose konfigürasyonunu hazırladıktan sonra test ortamını ekleyin, ardından pipeline’ı yazın, en son production deploy mekanizmasını oturtun.

Burada anlattığımız yaklaşımların tamamını tek seferde kurmaya çalışmayın. Kendi projenize uygun olanları seçin. Küçük bir ekip için basit bir CI testi bile production krizlerini önlemek açısından devasa bir fark yaratıyor. Environment variable yönetimini düzgün yaparsanız, aynı Compose yapısıyla dev’den production’a sorunsuz geçiş yapabilirsiniz.

En önemli nokta: Pipeline’ınız yetersizse daha fazla manuel test yapmanız gerekir. Pipeline ne kadar güçlüyse, geceleri o kadar rahat uyursunuz.

Yorum yapın