omeryanbas.com

Ömer Yanbaş

Genel Müdür, Ticofab Yazılım

OperasyonYöntem

Blue green dağıtım: yeniden başlatmadan önce build çıkış kodunu kontrol edin

Build hata verdiği hâlde süreci yeniden başlatan deploy scripti yarım dizini yayına alır. Çıkış kodu, atomik geçiş, smoke test ve çalışan bir geri alma.

Deploy çalışır, log akar ve son satır sitenin yayında olduğunu söyler. Değildir. Ortalarda bir yerde build tip hatası vermiştir, script aldırmadan devam etmiştir, süreç yeniden başlatılmıştır ve çalışan uygulama artık yarısı dünkü çıktı, yarısı hiç bitmemiş bir build olan bir dizini okumaktadır. Sayfa ya hata verir ya da stilleri olmadan açılır. Sağlıklı görünen tek şey, deploy scriptinin kendi son mesajıdır.

Gerçekte ne oluyor

Üst üste binen üç ayrı hata var ve her biri tek başına zararsız.

Birincisi yerinde build almak. Build, sunucunun okuduğu dizine yazıyorsa, build süresince site bir karışımdır. Başarılı bir build bu pencereyi kısa tutar. Başarısız bir build onu kalıcı hâle getirir.

İkincisi, shell scriptinin bir şey hata verdiğinde durmaması. Her satır çalışır ve çıkış kodu, kimse bakmadıkça çöpe gider. Şöyle biten bir scriptin build'in çalışıp çalışmadığı hakkında hiçbir fikri yoktur:

cd /srv/app
git pull
npm run build
systemctl restart app

Üçüncüsü pipeline'lar. Log tutmak için eklenen | tee, scriptin bildiği şeyi sessizce değiştirir, çünkü bir pipeline'ın çıkış kodu son komutunun çıkış kodudur. npm run build | tee build.log size tee'nin fikrini söyler ve tee neredeyse her zaman başarılı olur:

false | tee /dev/null; echo "cikis=$?"
# cikis=0
set -o pipefail
false | tee /dev/null; echo "cikis=$?"
# cikis=1

Üçü birleşince build hatası, kimsenin doğrulamadığı bir dizine yapılan restart'a dönüşür. Süreç yöneticisi kendisine söyleneni yapar, ayağa kalkar ve kendini sağlıklı bildirir. Çünkü başlayan bir süreçle çalışan bir uygulama aynı şey değildir.

Dördüncü katkı izleme tarafından gelir. Süreç yöneticisi çalışıyor der, port dinlenir, health ucu büyük ihtimalle 200 döner, çünkü çoğu health ucu yalnızca sürecin ayakta olduğunu söyler. Yarım kalmış bir build'de sunucu tarafı genelde ayağa kalkar; eksik olan istemciye giden varlıklardır. Yani gösterge paneli yeşil kalırken sayfa stilsiz açılır ve durumu ilk fark eden kişi bir kullanıcı olur.

Nasıl görülür

Bir şey değiştirmeden önce mevcut scriptinizin neyi bildiğine bakın. Build'i bilerek bozup deploy'u çalıştırın ve restart'ın olup olmadığını izleyin:

bash -x deploy.sh 2>&1 | tail -20

-x izleme çıktısı her komutu çalışırken basar, yani build hatasından sonra gelen bir restart ekranda açık yazıyla görünür. Sonra scriptlerin genelde atladığı iki şeye bakın:

grep -n 'set -' deploy.sh
# (hiç çıktı olmaması en sık karşılaşılan cevap)

grep -n '|' deploy.sh
# 14: npm run build | tee /var/log/deploy-build.log

Bir scriptte set satırı yoksa ve build bir yerden geçiriliyorsa, o script iyi dağıtımla kötüsünü ayırt edemez. Yayındaki sürecin ne sunduğunu log'a değil dosya sistemine sorarak her an öğrenebilirsiniz:

ls -l /srv/app/current
# current -> /srv/app/releases/20251024T081140Z

O yol build'in yazdığı dizinle aynıysa ortada ne geçiş yapılacak bir şey vardır ne de geri dönülecek.

Bir soru daha var: script hata verdiğinde ne döndürüyor. Deploy'u bilerek bozun ve ardından echo $? çalıştırın. Sıfır dönüyorsa onu çağıran her otomasyon işi başarılı sanıp bir sonraki adıma geçer; bildirim gönderir, cache temizler, belki başka bir servisi de yeniden başlatır. Bozuk bir dağıtımın sessiz kalmasının sebebi çoğu zaman scriptin kendi çıkış kodunu yanlış söylemesidir.

Çözüm

Yeni bir yere build alın, çalıştığını kanıtlayın, sonra geçiş yapın. Bütün kalıp bir sayfa shell'e sığar:

#!/usr/bin/env bash
set -Eeuo pipefail

APP=/srv/app
REL="$APP/releases"
REF="${1:-origin/main}"
TARGET="$REL/$(date -u +%Y%m%dT%H%M%SZ)"
trap 'echo "deploy $LINENO satirinda basarisiz, yayindaki siteye dokunulmadi" >&2' ERR

mkdir -p "$TARGET"
git -C "$APP/repo" fetch --quiet origin
git -C "$APP/repo" archive "$REF" | tar -x -C "$TARGET"

cd "$TARGET"
npm ci --omit=dev --no-audit --no-fund
npm run build                       # hata olursa set -e scripti burada durdurur
[ -s dist/index.html ]              # build basarili olup hicbir sey uretmemis olabilir

# gecisten once yeni release'i bos bir portta smoke testten gecir
PORT=8081 node server.js & NEW=$!
trap 'kill "$NEW" 2>/dev/null || true' EXIT
for i in $(seq 30); do
  curl -fsS -o /dev/null "http://127.0.0.1:8081/healthz" && break
  [ "$i" -eq 30 ] && { echo "yeni release hic saglikli olmadi" >&2; exit 1; }
  sleep 1
done
curl -fsS "http://127.0.0.1:8081/" | grep -q '</html>' || { echo "ana sayfa bos" >&2; exit 1; }
kill "$NEW"; wait "$NEW" 2>/dev/null || true; trap - EXIT

# atomik gecis, sonra yeniden baslat
ln -sfn "$TARGET" "$APP/next"
mv -T "$APP/next" "$APP/current"
systemctl restart app
curl -fsS -o /dev/null "https://app.ornek.com/healthz"

ls -1dt "$REL"/*/ | tail -n +6 | xargs -r rm -rf
echo "yayinda: $(readlink -f "$APP/current")"

İşi yapan beş şey var:

  1. set -Eeuo pipefail ilk hatada durur, tanımsız değişkeni hata sayar ve bir pipeline'ın kötü çıkış kodunu saklamasına izin vermez. E harfi ERR trap'inin fonksiyonların içinde de geçerli olmasını sağlar.
  2. Build hiçbir şeyin sunmadığı bir dizine yazar, yani bir hata size boşa gitmiş bir dizinden başka hiçbir şeye mal olmaz.
  3. [ -s dist/index.html ] sadece çıkış kodunu değil çıktıyı da kontrol eder. Sıfırla çıkıp boş bir dizin bırakan build aracı gerçek bir hata biçimidir ve genelde bir config değişikliğinden sonra görülür.
  4. Smoke test yeni release'i başka bir portta süreç olarak çalıştırır ve ondan bir sayfa ister. Kusursuz derlenip ayağa kalkamayan bir build'i yakalayan tek adım budur: eksik kalmış bir bağımlılık, tanımsız kalmış bir environment değişkeni.
  5. Canlı bağlantının yanına ln -sfn, ardından üstüne mv -T, bir rename işlemidir ve çekirdek bunu tek adımda yapar. Eski symlink'i silip yenisini oluşturmak ise size yolun hiç var olmadığı bir boşluk bırakır.

Smoke testin hangi adresi çağırdığı da önemlidir. Yalnızca health ucuna bakan bir test uygulamanın ayakta olduğunu söyler; gerçek bir sayfa isteyen test build çıktısının da yerinde olduğunu söyler. İkisini birden isteyin, çünkü yarım kalmış bir dağıtımda ayakta olan taraf genelde sunucudur ve eksik olan taraf istemciye giden dosyalardır.

Bir release dizini çalışmak için gereken her şeyi tutmalı, bir release'den uzun yaşaması gereken hiçbir şeyi tutmamalıdır. Config, yüklemeler ve bir sürecin çalışma anında yazdığı her şey dizinin dışında durur ve mutlak yolla erişilir, çünkü bunların geçişten iki yönde de sağ çıkması gerekir. Bedeli disktir: her release kendi build çıktısını ve bağımlılıklarını taşır, yani orta boy bir uygulamanın beş release'i birkaç yüz megabayt eder. Budamayı disk dolduğunda değil her deploy'da yapın, çünkü dolu bir disk sıradan bir dağıtımı olaya çevirir.

Geri alma, aynı geçişin bir öncekini göstermesidir:

PREV=$(ls -1dt /srv/app/releases/*/ | sed -n 2p)
ln -sfn "$PREV" /srv/app/next && mv -T /srv/app/next /srv/app/current
systemctl restart app

Bunun çözmediği şey: veritabanı migration'ları. Symlink takası kodu saniyeler içinde geri alır, düşürülmüş bir kolonu hiç geri getirmez. Yani migration'ların en az bir sürüm boyunca geriye dönük uyumlu olması gerekir. Bu ayrı bir disiplindir ve uzun işleri küçük ve geri alınabilir adımlara bölmekle aynı disiplindir.

Çalıştığını nasıl doğrularsınız

Önemli olan kontrol iyi senaryo değil. Build'i bilerek bozun ve sitenin hiç kıpırdamadığını kanıtlayın:

echo 'syntax error here' >> src/app.js
./deploy.sh; echo "deploy cikis=$?"
# deploy 17 satirinda basarisiz, yayindaki siteye dokunulmadi
# deploy cikis=1

readlink -f /srv/app/current
# /srv/app/releases/20251024T081140Z   (degismedi)

curl -fsS -o /dev/null -w '%{http_code}\n' https://app.ornek.com/
# 200

Bozuk build'de sıfırdan farklı bir çıkış kodu dönen deploy scripti, aynı zamanda bir otomasyonun bir sonraki adıma geçmek yerine durmasını sağlayan şeydir. Ardından iyi bir deploy çalıştırın ve current'ın taşındığını, releases altında en fazla beş dizin kaldığını doğrulayın.

Bozuk build'i bir fixture olarak saklayın. Deploy scripti de koddur, baskı altında düzenlenir ve onu dürüst tutan tek şey, birisi her dokunduğunda hata senaryosunun bilerek çalıştırılmasıdır.

Nelere dikkat etmeli

  • set -e, çıkış durumu zaten test edilen komutlarda devreye girmez. if içindeki, while koşulundaki, && veya || işaretinin solundaki, ünlemle olumsuzlanan her şey scripti durdurmadan çalışır. O noktalarda $? değerini kendiniz okuyun.
  • Açılışta gerçek yolu çözmüş bir süreç yöneticisi, symlink taşındıktan sonra da eski release'i sunmaya devam eder. Geçişin parçası olarak yeniden başlatın ve sunucu yeniden başladığında da ayağa kalktığından emin olun: bu başlı başına ayrı bir kesinti sınıfıdır.
  • mv -T bir GNU coreutils seçeneğidir ve bazı sistemlerde yoktur. Deploy'un ortasında keşfetmek yerine script içinde bir kez kontrol edin.
  • Smoke testi yayındaki portta çalıştırmayın. Aynı portu kullanan bir test, sınamak istediğiniz sürümü ayağa kaldırmak için çalışan sürümü düşürür ve geçişten önce kesinti yaratmış olursunuz.
  • Build'in geçici alanı paylaşılan bir temp yolunda değil release dizininin içinde olmalı. Sebebi paylaşımlı sunucuda /tmp'ye genel adla yazmanın tuzağıyla aynı.
  • ls | xargs rm -rf ile budama, dizin adları sizin ürettiğiniz zaman damgaları olduğu sürece güvenlidir; biri adında boşluk olan bir dizin yarattığı gün tehlikelidir. Adlandırmayı makineye bırakın.
  • Release adlarını UTC ile üretin. Yerel saatle üretilen iki dizin adı, saat değişiminin olduğu gece aynı olabilir ya da ters sıralanabilir; geri alma anında bakılan ilk şey de o listedir.

Blue green'in işe yarayan tarafı renkler değil, yayına alma kararının her şey zaten geçtikten sonra gerçekleşen tek ve geri alınabilir bir işleme dönüşmesidir. Geçişten önceki her şeyin hata verme hakkı vardır, çünkü orada hata bir dizine mal olur. Geçişten sonraki her şey pahalıdır, o yüzden sonrasında mümkün olduğunca az şey kalmalıdır. Bir deploy scripti güveni yapmayı reddettiği şeylerle kazanır ve sizinkinin neyi reddettiğini öğrenmenin yolu build'i bozup izlemektir.

Sorular ve cevaplar

Build hata verdiği hâlde deploy scripti neden devam ediyor?
Çünkü aksini söylemediğiniz sürece shell böyle çalışır. Her komut çalışır ve çıkış kodu, birileri okumadıkça atılır; dolayısıyla hata veren bir build ile onu izleyen restart birbirinden bağımsız iki cümledir. İlk hatada durması için set -e ekleyin, pipeline içindeki hatanın son komutun çıkış koduyla gizlenmemesi için set -o pipefail ekleyin.
set -e her hatayı yakalar mı?
Hayır, ve onu garanti saymak scriptlere fazla güvenmenin yoludur. Çıkış durumu zaten test edilen komutlarda devreye girmez: if ve while koşulları, && veya || işaretinin solundaki her şey, ünlem ile olumsuzlanan her şey bunun kapsamındadır. Oralarda çıkış kodunu kendiniz okuyup karar verirsiniz, ki asıl önemli noktalarda zaten daha okunaklı olan budur.
Release'ler arası geçişi atomik yapan şey nedir?
Symlink'in rename ile değiştirilmesi. Çekirdek bunu tek bir işlem olarak yapar, yani gelen istek ya eski hedefi ya yeni hedefi görür, hiçbir zaman boş bir yol görmez. Yeni bağlantıyı mevcut olanın yanında oluşturup mv -T ile üstüne taşımak bunu sağlar; eski bağlantıyı silip yenisini oluşturmak ise arada birkaç milisaniyelik boşluk bırakır. mv -T olmayan sistemlerde symlink'i tutan bir dizin takas edilebilir.
Kaç eski release saklamalıyım?
Bozan sürümün ötesine geri dönmeye yetecek kadar, pratikte üç ile beş arası. Her biri build çıktınız artı bağımlılıkları kadar yer kaplar, o yüzden disk dolduğunda değil her deploy'da budayın. Adlandırmayı zaman damgasıyla yapın ki gece üçte dizin listesine bakan kişi sıralamayı düşünmek zorunda kalmasın.