Mert Özen Her Satırda Daha İleri
DevOps Deployment Stratejileri — #09

Uçtan Uca Zero-Downtime: Bookstore'u GitHub Actions ile Blue-Green ve Canary Deploy Etmek

Mert Özen 8 Kas 2026 15 dk 3 görüntülenme
Uçtan Uca Zero-Downtime: Bookstore'u GitHub Actions ile Blue-Green ve Canary Deploy Etmek

Serinin bütün parçaları tek bir GitHub Actions workflow'unda: image'ı bir kez build et, migration gate'i ve expand migration bundle'ı, boştaki renge deploy, preview'den smoke test, %10 ve %50 canary gate'leri, tam geçiş ve manuel rollback job'ı. act ile yerelde koşulan pipeline başarıdan önce dört kez düştü: Down()'ı tarayan gate, act'in artifact v7 uyumsuzluğu, eski renge bakan preview ve yetersiz canary trafiği.

Sürüm notu: Bu yazıdaki örnekler act 0.2.97, actionlint 1.7.12, .NET 10 (SDK 10.0.403, runner'da), EF (Entity Framework) Core 10.0.14, Nginx 1.30, PostgreSQL 18.6 ve Docker 29 ile denenmiştir. Workflow act ile yerelde koşuldu, GitHub'da koşulmadı.

Önce Tahmin Et

Bir pipeline düşün: build, expand migration, green'e deploy, smoke test, %10 canary, %50 canary, tam geçiş. Bu pipeline'ı ilk kez çalıştırdığında hangi adımda düşer? Ya da şöyle soralım: tasarımı kâğıt üstünde doğru görünen bir deploy pipeline'ı, ilk koşusunda kaç kez hata verir?

İkinci soru: Pipeline smoke test'te düştü ama migration adımı çoktan çalışmıştı. Production şu an hangi durumda: yarım kalmış bir deploy mu, yoksa güvenli bir durum mu?

Referans Repository

Bu yazıda serinin bütün parçalarını tek bir GitHub Actions workflow'unda birleştiriyoruz. Repository'nin yapısı:

.
├── .config/dotnet-tools.json
├── .github/workflows/deploy.yml
├── deploy/
│   ├── nginx/nginx.conf
│   ├── canary-gate.sh
│   ├── deploy-idle.sh
│   ├── migration-gate.sh
│   ├── smoke.sh
│   └── traffic.sh
└── src/Bookstore.Api/
    ├── Migrations/
    ├── BookstoreDbContext.cs
    ├── Dockerfile
    ├── Lifecycle.cs
    └── Program.cs

src/Bookstore.Api serinin önceki yazılarındaki API (Application Programming Interface): readiness, drain ve graceful shutdown (ikinci yazı), sürüm header'ı ve hata enjekte etmek için BUG_FAIL_RATE (beşinci yazı), EF Core migration'ları (altıncı yazı). Bir fark var: sürüm header'ı artık Response.OnStarting ile ekleniyor ve UseExceptionHandler kullanılıyor, böylece 500 cevapları da X-App-Version taşıyor. Canary metriği sürüme göre sayabilmek için bu şart; beşinci yazıda bu header hatalı cevaplarda kayboluyordu.

deploy/ altındaki script'ler pipeline'ın kasları. Workflow'u ince tutup asıl işi script'lere vermek, aynı adımları bir incident sırasında elle de çalıştırabilmeyi sağlıyor. GitHub Actions'ın syntax'ı ve özellikleri ilerideki GitHub Actions serisinin konusu; özellikle Environment'lar, SSH (Secure Shell) ile kendi sunucuna deploy ve reusable workflow yazıları burada yaptığımızın üretim karşılığını anlatacak.

Workflow

name: deploy-production

on:
  workflow_dispatch:
    inputs:
      action:
        description: deploy or rollback
        type: choice
        options: [deploy, rollback]
        default: deploy
      allow_destructive:
        description: approve destructive migrations after review
        type: boolean
        default: false

concurrency:
  group: production
  cancel-in-progress: false

permissions:
  contents: read

jobs:
  build:
    if: inputs.action == 'deploy'
    runs-on: ubuntu-latest
    outputs:
      tag: ${{ steps.meta.outputs.tag }}
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
      - uses: actions/setup-dotnet@v6
        with:
          dotnet-version: 10.0.x
      - id: meta
        run: echo "tag=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
      - name: Migration review gate
        env:
          ALLOW_DESTRUCTIVE: ${{ inputs.allow_destructive }}
        run: deploy/migration-gate.sh
      - name: Build the image once
        env:
          TAG: ${{ steps.meta.outputs.tag }}
        run: |
          dotnet publish src/Bookstore.Api -c Release -o src/Bookstore.Api/publish
          docker build -t "depwork-bookstore-api:$TAG" --build-arg APP_VERSION="$TAG" src/Bookstore.Api
      - name: Build the migration bundle
        env:
          RID: linux-${{ runner.arch == 'ARM64' && 'arm64' || 'x64' }}
        run: |
          dotnet tool restore
          dotnet ef migrations bundle --project src/Bookstore.Api --self-contained -r "$RID" -o artifacts/efbundle
      - uses: actions/upload-artifact@v4
        with:
          name: efbundle
          path: artifacts/efbundle

  migrate:
    needs: build
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: efbundle
      - name: Apply expand migrations before the new code
        env:
          DB_CONNECTION: ${{ secrets.DB_CONNECTION }}
        run: |
          chmod +x efbundle
          ./efbundle --connection "$DB_CONNECTION"

  deploy:
    needs: [build, migrate]
    runs-on: ubuntu-latest
    environment: production
    env:
      TAG: ${{ needs.build.outputs.tag }}
      DB_CONNECTION: ${{ secrets.DB_CONNECTION }}
    steps:
      - uses: actions/checkout@v7
      - id: idle
        name: Deploy to the idle color
        run: deploy/deploy-idle.sh "$TAG"
      - name: Smoke test without user traffic
        run: |
          deploy/traffic.sh "${{ steps.idle.outputs.color }}" 0
          deploy/smoke.sh "$TAG"
      - name: Canary 10%
        run: |
          deploy/traffic.sh "${{ steps.idle.outputs.color }}" 10
          deploy/canary-gate.sh "$TAG" 100 120
      - name: Canary 50%
        run: |
          deploy/traffic.sh "${{ steps.idle.outputs.color }}" 50
          deploy/canary-gate.sh "$TAG" 100 120
      - name: Switch all traffic
        run: deploy/traffic.sh "${{ steps.idle.outputs.color }}" 100
      - name: Send traffic back on failure
        if: failure() && steps.idle.outputs.color != ''
        run: deploy/traffic.sh "${{ steps.idle.outputs.color == 'blue' && 'green' || 'blue' }}" 100

  rollback:
    if: inputs.action == 'rollback'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v7
      - name: Send all traffic to the previous color
        run: |
          live=$(docker exec depwork-nginx cat /etc/nginx/bookstore/live-color)
          if [ "$live" = blue ]; then previous=green; else previous=blue; fi
          deploy/traffic.sh "$previous" 100

Workflow dört job'dan oluşuyor ve sırası bu serinin özeti. build image'ı bir kez build ediyor, tag'i commit'in kısa SHA'sı (Secure Hash Algorithm) oluyor. Aynı job migration bundle'ını da üretip artifact olarak yüklüyor. Ondan önce bir migration review gate'i çalışıyor. migrate bundle'ı indirip veritabanına uyguluyor; kod deploy'undan önce, çünkü expand migration eski kodu bozmaz. deploy yeni sürümü boştaki renge kuruyor, preview portundan smoke test yapıyor, trafiği %10 ve %50 adımlarıyla açıyor ve her adımda canary'yi control'le karşılaştırıyor. Herhangi bir adım düşerse son adım (if: failure()) trafiği eski renge geri veriyor. rollback job'ı ise elle tetiklenen bir geri dönüş: workflow_dispatch ile action=rollback seçildiğinde trafiği önceki renge çeviriyor.

Birkaç tasarım kararı daha var. concurrency: production aynı anda iki deploy'un çalışmasını engelliyor. Altıncı yazıda gördüğümüz gibi transaction dışı migration adımlarının tek koruması bu. permissions: contents: read workflow token'ına en az yetkiyi veriyor. Secret'lar run satırına ${{ }} ile gömülmek yerine env üzerinden geçiyor. Bundle'ın runtime'ı runner'ın mimarisine göre seçiliyor (runner.arch).

GitHub'da environment: production, environment protection rule'larıyla (onay, bekleme süresi, branch kısıtı) birlikte çalışır. act bu kuralları uygulamaz, bu yüzden onay adımını burada test etmedim. Gerçek bir kurulumda migrate job'ının önüne bir insan onayı koymak, özellikle contract migration'ların olduğu release'lerde mantıklı.

Script'ler: Trafik, Deploy, Gate'ler

Trafiği Nginx'te weight'lerle bölen script, serinin dördüncü ve beşinci yazılarındaki fikirlerin birleşimi:

#!/usr/bin/env bash
# traffic.sh NEW_COLOR PERCENT: canlı trafiğin PERCENT kadarını NEW_COLOR'a, kalanını diğer renge gönderir
set -euo pipefail
new=$1
percent=$2
if [ "$new" = blue ]; then old=green; else old=blue; fi
if [ "$percent" -ge 100 ]; then
    live="    server depwork-$new:8080 resolve;"
    preview=$old
elif [ "$percent" -le 0 ]; then
    live="    server depwork-$old:8080 resolve;"
    preview=$new
else
    live="    server depwork-$old:8080 resolve weight=$((100 - percent));
    server depwork-$new:8080 resolve weight=$percent;"
    preview=$new
fi
docker exec -i depwork-nginx sh -c 'cat > /etc/nginx/bookstore/upstreams.conf' <<CONF
upstream bookstore_live {
    zone bookstore_live 64k;
$live
}

upstream bookstore_preview {
    zone bookstore_preview 64k;
    server depwork-$preview:8080 resolve;
}
CONF
docker exec depwork-nginx nginx -t -q
docker exec depwork-nginx nginx -s reload 2>/dev/null
if [ "$percent" -ge 100 ]; then
    docker exec depwork-nginx sh -c "echo $new > /etc/nginx/bookstore/live-color"
fi
echo "$(date +%T) $new=$percent% $old=$((100 - percent))%"

Yüzde 0, yeni rengi yalnız preview'e bağlıyor; 100 ise canlı trafiğin tamamını ona veriyor ve eski rengi preview'e alıyor. Ara değerler iki rengi weight'le birlikte canlıya koyuyor. Nginx konfigürasyonu bir Docker volume'unda duruyor ve script onu docker exec -i ile yazıyor. Böylece runner container'ı, Nginx'in dosya sistemini bilmeden onunla konuşabiliyor. Gerçek bir sunucuda bu satırlar SSH üzerinden çalışır.

#!/usr/bin/env bash
# deploy-idle.sh IMAGE_TAG: yeni sürümü trafik almayan renkte başlatır
set -euo pipefail
tag=$1
live=$(docker exec depwork-nginx cat /etc/nginx/bookstore/live-color)
if [ "$live" = blue ]; then idle=green; else idle=blue; fi
docker rm -f "depwork-$idle" >/dev/null 2>&1 || true
docker run -d --name "depwork-$idle" --network depwork-net --stop-timeout 40 \
    -e ConnectionStrings__Bookstore="$DB_CONNECTION" \
    -e Shutdown__DrainSeconds=5 \
    "depwork-bookstore-api:$tag" >/dev/null
for _ in $(seq 1 30); do
    if docker exec depwork-nginx wget -q -O /dev/null "http://depwork-$idle:8080/health/ready" 2>/dev/null; then
        echo "color=$idle" >> "${GITHUB_OUTPUT:-/dev/null}"
        echo "depwork-$idle is ready with $tag"
        exit 0
    fi
    sleep 1
done
echo "depwork-$idle did not become ready" >&2
exit 1
#!/usr/bin/env bash
# canary-gate.sh NEW_VERSION MIN_REQUESTS MAX_SECONDS: yeterli canary trafiği bekler, sonra 5xx oranlarını karşılaştırır
set -euo pipefail
new=$1
min=$2
end=$((SECONDS + $3))
docker exec depwork-nginx sh -c ': > /var/log/nginx/bookstore.log'
while true; do
    sleep 5
    read -r cn ce sn se < <(docker exec depwork-nginx cat /var/log/nginx/bookstore.log | awk -v new="$new" '
        $3 == new { cn++; if ($2 >= 500) ce++; next }
        { sn++; if ($2 >= 500) se++ }
        END { print cn + 0, ce + 0, sn + 0, se + 0 }')
    if [ "$cn" -ge "$min" ]; then
        break
    fi
    if [ $SECONDS -ge $end ]; then
        echo "only $cn canary requests in $3 s, not enough to decide"
        exit 1
    fi
done
awk -v cn="$cn" -v ce="$ce" -v sn="$sn" -v se="$se" 'BEGIN {
    c = 100 * ce / cn; s = sn ? 100 * se / sn : 0
    printf "new: %d requests, %.2f%% 5xx | old: %d requests, %.2f%% 5xx\n", cn, c, sn, s
    if (c - s > 1.0) { print "canary is worse than baseline"; exit 1 }
    print "canary looks healthy"
}'
#!/usr/bin/env bash
# migration-gate.sh: son release'ten beri eklenen migration'lar bir şey siliyor ya da yeniden adlandırıyorsa durur
set -euo pipefail
last=$(git describe --tags --abbrev=0 --match 'release-*' 2>/dev/null || git rev-list --max-parents=0 HEAD)
files=$(git diff --name-only --diff-filter=A "$last" HEAD -- 'src/*/Migrations/*.cs' | grep -v '\.Designer\.cs$' || true)
echo "migrations since $last: ${files:-none}"
[ -z "$files" ] && exit 0
# yalnız Up() önemli; ekleme yapan bir migration'ın Down()'ında her zaman Drop* vardır
if awk '/override void Up\(/,/override void Down\(/' $files | grep -nE 'Drop(Column|Table|Index)|Rename(Column|Table)'; then
    if [ "${ALLOW_DESTRUCTIVE:-false}" != "true" ]; then
        echo "::error::Destructive migration found; run again with allow_destructive after review"
        exit 1
    fi
    echo "Destructive migration approved by the run input."
fi

act ile Yerelde Koşturmak

Pipeline'ı GitHub'a göndermeden yerelde act ile koşturdum. Ortam: depwork-net Docker ağında PostgreSQL, Nginx ve ilk sürümü çalıştıran depwork-blue container'ı. Bir de saniyede yaklaşık 18 istek gönderen bir yük container'ı. Bu gerçek kullanıcıları taklit ediyor, çünkü canary gate'inin karar verebilmesi için trafik gerekiyor. Komut:

act workflow_dispatch -W .github/workflows/deploy.yml --input action=deploy \
    -P ubuntu-latest=ghcr.io/catthehacker/ubuntu:act-latest \
    --network depwork-net \
    --container-daemon-socket unix:///var/run/docker.sock \
    --secret-file act.secrets \
    --artifact-server-path act-artifacts \
    --pull=false

--network job container'larını aynı Docker ağına bağlıyor, --container-daemon-socket ise job'ların Docker'ı kullanabilmesi için soketi içeri veriyor. Bu makinede Docker Desktop kullanıldığı için act'e DOCKER_HOST ile soketin yerini de söyledim. Production'da runner'ın Docker soketine erişmesi yerine image bir registry'ye (örneğin GHCR (GitHub Container Registry)) push edilir ve sunucu oradan çeker. Burada build ile deploy aynı Docker daemon'ını paylaştığı için "build once" ilkesi registry olmadan da korunuyor: deploy job'ı build job'ının ürettiği image'ı kullanıyor.

İlk Koşular: Pipeline Neden Dört Kez Düştü?

Baştaki ilk sorunun cevabı: bu pipeline başarılı koşudan önce dört kez düştü ve her biri bu serinin bir dersini tekrar ettirdi.

Birinci düşüş migration gate'indeydi:

[deploy-production/build   ]   | migrations since release-1: src/Bookstore.Api/Migrations/20261108041219_AddCustomerNameParts.cs
[deploy-production/build   ]   | 29:            migrationBuilder.DropColumn(
[deploy-production/build   ]   | 33:            migrationBuilder.DropColumn(
[deploy-production/build   ]   ❗  ::error::Destructive migration found; run again with allow_destructive after review
[deploy-production/build   ]   ❌  Failure - Main Migration review gate [77.9335ms]

AddCustomerNameParts yalnız kolon ekliyor, ama gate'in ilk sürümü bütün dosyayı tarıyordu. Ekleme yapan her migration'ın Down() metodu DropColumn içerir. Düzeltme, yalnız Up() bloğunu taramaktı (yukarıdaki awk satırı). Bir gate'in yanlış alarmı, onun kısa sürede kapatılmasına yol açar. Bu yüzden gate'i gerçek migration'larla test etmek gerekiyor.

İkinci düşüş artifact yüklemesindeydi:

[deploy-production/build   ] ⭐ Run Main actions/upload-artifact@v7
time="2026-11-08T07:15:11+03:00" level=error msg="Error decode request body: proto: (line 1:96): unknown field \"mime_type\""
[deploy-production/build   ]   | Attempt 2 of 5 failed with error: Unexpected end of JSON input. Retrying request in 16970 ms...

act 0.2.97'nin yerel artifact sunucusu, actions/upload-artifact@v7'nin gönderdiği mime_type field'ını tanımıyor ve adım beş denemeden sonra düşüyor. Workflow'da artifact action'larını v4'e sabitledim. GitHub'ın kendi altyapısında v7 sorunsuz çalışır; bu, act ile GitHub arasındaki farkın somut bir örneği.

Üçüncü düşüş en öğreticisiydi:

[deploy-production/migrate ]   | Applying migration '20261108041219_AddCustomerNameParts'.
[deploy-production/deploy  ]   | depwork-green is ready with f266e33
[deploy-production/deploy  ]   | /health/ready -> 200
[deploy-production/deploy  ]   | /api/books -> 200
[deploy-production/deploy  ]   | /api/customers/1 -> 200
[deploy-production/deploy  ]   | version -> c5a0dfb
[deploy-production/deploy  ]   ❌  Failure - Main Smoke test without user traffic [278.967084ms]
[deploy-production/deploy  ] ⭐ Run Main Send traffic back on failure
[deploy-production/deploy  ]   | 04:17:51 blue=100% green=0%

Smoke test sağlık kontrollerinden geçti ama sürüm kontrolünde düştü: preview portu hâlâ eski blue'yu gösteriyordu. Script yeni rengi ayağa kaldırmış, ama Nginx'in preview upstream'ini ona çevirmeyi unutmuştu. Dördüncü yazıda "sürüm kontrolünü atlama" demiştik; tam bu yüzden. Düzeltme, smoke test'ten önce traffic.sh <renk> 0 çağırmak oldu. Baştaki ikinci sorunun cevabı da bu çıktıda: migration (AddCustomerNameParts) uygulanmıştı ve kod deploy'u düştü. Ama production güvendeydi, çünkü expand migration'dan sonra blue'daki eski kod sorunsuz çalışmaya devam etti. Yarım kalan şey yalnız deploy'du, sistem değil. Expand-contract'ın değeri tam olarak bu.

Dördüncü düşüş canary gate'indeydi:

[deploy-production/deploy  ] ⭐ Run Main Canary 10%
[deploy-production/deploy  ]   | 04:19:34 green=10% blue=90%
[deploy-production/deploy  ]   | new: 36 requests, 0.00% 5xx | old: 324 requests, 0.00% 5xx
[deploy-production/deploy  ]   | not enough canary traffic
[deploy-production/deploy  ]   ❌  Failure - Main Canary 10% [20.362683833s]

Gate yirmi saniyelik sabit bir pencerede bekliyordu ve %10'luk canary yalnız 36 istek gördü. Beşinci yazıdaki istatistik tuzağı burada gerçek oldu: düşük örnekle karar verilmez. Gate'i "en az 100 canary isteği gelene kadar bekle, en fazla 120 saniye" şeklinde değiştirdim. Az trafik varsa pipeline yavaşlıyor ama yanlış karar vermiyor.

Başarılı Koşu

Dört düzeltmeden sonraki koşunun özeti:

[deploy-production/build   ]   ✅  Success - Main Migration review gate [69.789667ms]
[deploy-production/build   ]   ✅  Success - Main Build the image once [14.92709125s]
[deploy-production/build   ]   ✅  Success - Main Build the migration bundle [14.985959459s]
[deploy-production/build   ]   ✅  Success - Main actions/upload-artifact@v4 [4.200684875s]
[deploy-production/migrate ]   | No migrations were applied. The database is already up to date.
[deploy-production/migrate ]   ✅  Success - Main Apply expand migrations before the new code [548.045625ms]
[deploy-production/deploy  ]   | depwork-green is ready with 7b2c3f6
[deploy-production/deploy  ]   | 04:21:19 green=0% blue=100%
[deploy-production/deploy  ]   | /health/ready -> 200
[deploy-production/deploy  ]   | /api/books -> 200
[deploy-production/deploy  ]   | /api/customers/1 -> 200
[deploy-production/deploy  ]   | version -> 7b2c3f6
[deploy-production/deploy  ]   | 04:21:20 green=10% blue=90%
[deploy-production/deploy  ]   | new: 103 requests, 0.00% 5xx | old: 922 requests, 0.00% 5xx
[deploy-production/deploy  ]   | canary looks healthy
[deploy-production/deploy  ]   | 04:22:16 green=50% blue=50%
[deploy-production/deploy  ]   | new: 138 requests, 0.00% 5xx | old: 139 requests, 0.00% 5xx
[deploy-production/deploy  ]   | canary looks healthy
[deploy-production/deploy  ]   | 04:22:36 green=100% blue=0%
[deploy-production/deploy  ] 🏁  Job succeeded

Migration adımı "zaten güncel" dedi, çünkü expand migration üçüncü koşuda uygulanmıştı. Bundle'ın tekrar çalıştırılabilir olması burada işe yarıyor. %10 adımı 103 canary isteğine ulaşmak için yaklaşık bir dakika bekledi; %50 adımında iki renk neredeyse eşit trafik aldı. Pipeline bittikten sonra:

curl -s localhost:18600/api/version
curl -s localhost:18600/api/customers/1
{"version":"7b2c3f6","instance":"269aa4811c9d"}
{"id":1,"firstName":"Alan","lastName":"Lovelace","email":"user1@example.com"}

Yeni sürüm canlıda ve müşteri verisini yeni kolonlardan okuyor.

Manuel Rollback Job'ı

Pipeline bittikten sonra bir sorun fark ettiğini düşün. Geri dönüş aynı workflow'un rollback aksiyonu:

act workflow_dispatch -W .github/workflows/deploy.yml --input action=rollback \
    -P ubuntu-latest=ghcr.io/catthehacker/ubuntu:act-latest \
    --network depwork-net \
    --container-daemon-socket unix:///var/run/docker.sock \
    --secret-file act.secrets \
    --artifact-server-path act-artifacts \
    --pull=false
[deploy-production/rollback] ⭐ Run Main Send all traffic to the previous color
[deploy-production/rollback]   | 04:24:18 blue=100% green=0%
[deploy-production/rollback]   ✅  Success - Main Send all traffic to the previous color [3.50226225s]
[deploy-production/rollback] 🏁  Job succeeded
{"version":"c5a0dfb","instance":"a132bfe009c8"}
{"id":1,"fullName":"Alan Lovelace","email":"user1@example.com"}

Trafik 3,5 saniyede eski renge döndü ve eski kod, expand edilmiş şemada müşteriyi fullName ile döndürüyor. Rollback'in bu kadar sıkıcı olabilmesinin sebebi, contract migration'ın bu release'te olmaması.

Contract'ı bir sonraki release'e koyduğumuzda ne olduğunu da denedim. FullName'i silen migration'ı ayrı bir branch'te ekleyip workflow'u çalıştırdım:

[deploy-production/build   ]   | migrations since release-2: src/Bookstore.Api/Migrations/20261108042437_ContractCustomerFullName.cs
[deploy-production/build   ]   | 3:            migrationBuilder.DropColumn(
[deploy-production/build   ]   ❗  ::error::Destructive migration found; run again with allow_destructive after review
[deploy-production/build   ]   ❌  Failure - Main Migration review gate [130.3645ms]
[deploy-production/build   ] 🏁  Job failed

Gate pipeline'ı build adımında durdurdu. Bu release ancak bir insan migration'ı inceleyip allow_destructive girdisiyle tekrar başlattığında ilerler. O inceleme, altıncı yazıdaki ön koşulları sorar: bütün instance'lar FullName'i kullanmayan sürümde mi? Backfill tamamlandı mı? Az önce yaptığımız rollback gibi bir geri dönüş ihtimali kaldı mı? Rollback sonrası blue hâlâ FullName okuyor, yani bu koşulda contract'ı onaylamak production'ı kırardı.

Üretimde Dikkat

Bu pipeline'ı gerçek bir ortama taşırken üç şey değişir. Image registry'ye push edilir ve deploy adımı sunucuda (SSH ile ya da bir agent ile) çalışır. Smoke test ve canary gate'i Nginx log'u yerine gerçek bir metrik sisteminden (Prometheus, Azure Monitor) beslenir. Bu konu ilerideki Observability serisinin. Onay adımları da GitHub environment'larına taşınır. Jenkins kullanan ekipler için eşleme doğrudan: her job bir stage olur, concurrency grubunun karşılığı lock adımıdır, if: failure() ise post { failure { ... } } bloğuna denk düşer. Script'ler aynen kalır; pipeline'ı script'lere yaslamanın bir faydası da bu.

Pipeline'ın kendisini de ölç: her koşunun süresi, hangi adımda düştüğü ve kaç kez manuel rollback yapıldığı. Bu, serinin sekizinci yazısındaki DORA (DevOps Research and Assessment) metriklerinin ham verisi.

Sık Düşülen Tuzaklar

Unutulan cache invalidation. Yeni sürüm bir cache key'inin formatını değiştirdiyse eski sürümün yazdığı değerler hâlâ orada. Key'e bir sürüm öneki koymak (v2:book:42) ya da deploy sırasında ilgili key'leri temizlemek gerekir. Yoksa canary'de görünmeyen hata %100'de patlar.

Eski Angular chunk'ları. API'yi blue-green ile çevirmek frontend'in eski sekmelerini kurtarmaz. Serinin ikinci yazısındaki gibi hash'li dosyaları bir süre tutmak ve chunk hatasında sayfayı yeniden yüklemek, pipeline'ın frontend yayın adımının parçası olmalı.

Migration sırası. Contract migration'ı kodla aynı release'e koymak, rollback'i imkânsız kılar. Bu pipeline'da gate onu durduruyor, ama asıl koruma release planında: expand bu release'te, contract bir sonrakinde.

Pipeline'ın yarım kalabileceğini unutmak. Her adım tekrar çalıştırılabilir (idempotent) olmalı. Bundle "zaten güncel" diyebiliyor, traffic.sh aynı yüzdeyi tekrar yazabiliyor, deploy-idle.sh eski boş container'ı silip yeniden başlatıyor. Bu sayede düşen bir pipeline'ı baştan başlatmak güvenli.

Küçük Bir Deneme

★☆☆ Workflow'a bir workflow_dispatch girdisi daha ekle: canary_steps (örneğin 10,50). Adımları bu girdiden okuyan bir döngü yaz ve 5,25,50 ile koştur.

★★☆ BUG_FAIL_RATE=0.05 ile bir sürüm hazırla (Dockerfile'a ENV olarak ekle), workflow'u çalıştır ve %10 adımında gate'in düşüp Send traffic back on failure adımının trafiği eski renge verdiğini gözle.

★★★ Contract release'ini güvenle çıkaracak ikinci bir workflow tasarla: önce "bütün instance'lar yeni sürümde mi?" ve "boş satır sayısı 0 mı?" kontrollerini yapan bir job, sonra onaylı contract migration'ı. Gate'i bu kontrollere bağla.

Seri burada bitiyor. Recreate'in ölçülen iki buçuk saniyelik kesintisinden, hatayı on saniyede geri alan ve contract migration'ı bir insanın onayına bırakan bir pipeline'a geldik. Araçlar değişir; Nginx yerine bir cloud load balancer, act yerine GitHub'ın kendi runner'ları gelir. Ama soruların sırası aynı kalır: uygulama hazır mı, iki sürüm aynı anda yaşayabilir mi, şema geriye uyumlu mu, hata kaç kullanıcıya ne kadar süre dokunur ve geri dönüş yolu gerçekten açık mı?