Canary Release: Trafiği Yüzdeyle Açmak, Metrik ile Karar Vermek ve Otomatik Analiz
Canary yeni sürümü trafiğin küçük bir payıyla çalıştırır ve aynı anda çalışan control ile karşılaştırarak karar verir. Nginx'te weight (900/100, yapışkan değil), split_clients ile kullanıcıya yapışan canary ve boş anahtar tuzağı, 1.30'un sticky cookie'si; 4.000 istekte %4,80'e %0 fark ama 19 istekte anlamsız sonuç, WAIT/ABORT/PROMOTE veren gate, Gateway API weight'leri ve Argo Rollouts'un hatalı sürümü otomatik durdurması.
Sürüm notu: Bu yazıdaki örnekler .NET 10 (SDK 10.0.402), Nginx 1.30, Docker 29, kind 0.33 ile Kubernetes 1.37, Envoy Gateway 1.10 ve Argo Rollouts 1.10.2 ile denenmiştir.
Önce Tahmin Et
Canary sürümünde bir hata var ve isteklerin yaklaşık %5'i 500 dönüyor. Canary'ye trafiğin %10'unu veriyorsun. Toplam 200 istek geldikten sonra canary'nin metriklerine bakıyorsun. Hatayı görür müsün? Peki 4.000 istekten sonra?
İkinci soru: Nginx'te canary'yi weight=9 ve weight=1 ile kurdun. Aynı kullanıcı arka arkaya on istek atarsa kaçı yeni sürüme gider? Hep aynı sürümü mü görür, yoksa sayfalar arasında sürüm mü değiştirir?
Canary'nin Fikri
Canary, yeni sürümü önce gerçek trafiğin küçük bir parçasıyla çalıştırır, sonucu ölçer ve iyi giderse payı adım adım büyütür. Adı kömür madenlerindeki kanaryadan geliyor; Danilo Sato'nun 2014 tarihli "CanaryRelease" yazısı tekniği bu adla yaygınlaştırdı. Google SRE (Site Reliability Engineering) Workbook'un "Canarying Releases" bölümü tanımı daha keskin yapıyor: canary, yeni sürümün kısmi ve süreli bir deploy'u ve değerlendirmesidir. Değişikliği alan kısma canary, geri kalanına control denir ve karar ikisinin karşılaştırmasıyla verilir.
Workbook'taki hesap, canary'nin neden işe yaradığını tek satırda gösteriyor: isteklerin %20'sini bozan bir sürüm tüm trafiğe açılırsa hata oranı %20 olur. Aynı sürüm %5'lik bir canary'de çalışırsa toplam hata oranı %1'de kalır. Serinin ilk yazısındaki "blast radius × tespit süresi" çarpımının ilk çarpanını küçülten araç bu.
Nginx'te Weight: Basit ama Yapışkan Değil
En kısa yol upstream'de weight vermek:
upstream bookstore_api {
zone bookstore_api 64k;
server depwork-stable:8080 resolve weight=9;
server depwork-canary:8080 resolve weight=1;
}
for i in $(seq 1 1000); do
curl -s -o /dev/null -w '%header{x-app-version}\n' localhost:18600/api/version
done | sort | uniq -c
900 1.0.0
100 1.1.0
Tam olarak 900 ve 100. Nginx'in weighted round robin'i deterministik çalışıyor: her on istekten biri canary'ye gidiyor. Baştaki ikinci sorunun cevabı da burada: aynı kullanıcının on isteğinden biri yeni sürüme gider. Kullanıcı bir sayfada yeni, sonraki sayfada eski sürümü görür. Yeni sürüm bir JSON (JavaScript Object Notation) field'ı eklediyse Angular uygulaması bir istekte o field'ı bulur, sonrakinde bulamaz. Bu yüzden weight'li canary, yalnız stateless ve sürümler arası farkın görünmediği değişiklikler için uygun.
split_clients ile Kullanıcıya Yapışan Canary
Canary'yi kullanıcıya yapıştırmak için kararı istek başına değil, kullanıcı başına vermek gerekiyor. Nginx'in split_clients direktifi bir string'in MurmurHash2 hash'ini alıp yüzdelik dilimlere böler. Aynı string her zaman aynı dilime düşer:
events {}
http {
resolver 127.0.0.11 valid=1s ipv6=off;
log_format canary '$msec $status $backend $request_time $http_x_user_id';
access_log /var/log/nginx/canary.log canary;
upstream bookstore_stable {
zone bookstore_stable 64k;
server depwork-stable:8080 resolve;
}
upstream bookstore_canary {
zone bookstore_canary 64k;
server depwork-canary:8080 resolve;
}
split_clients "canary-v1.1.0:${http_x_user_id}" $canary_bucket {
10% canary;
* stable;
}
map $http_x_canary $backend {
always canary;
never stable;
default $canary_bucket;
}
server {
listen 80;
location / {
proxy_pass http://bookstore_$backend;
}
}
}
Üç ayrıntı var. Hash'lenen string'in başındaki canary-v1.1.0: bir salt; her release'te değiştirirsen canary'ye düşen kullanıcılar da değişir. Böylece hep aynı %10'luk kullanıcı grubu her yeni sürümün kobayı olmaz. map ise X-Canary: always header'ı gönderen iç kullanıcıları (QA (Quality Assurance) ekibi, şirket çalışanları) yüzdeden bağımsız olarak canary'ye yollar. never değeri de tersi için kullanılır. Son olarak proxy_pass değişkenle upstream adını seçiyor; değer tanımlı bir upstream grubunun adıyla eşleştiği sürece Nginx onu kullanır. Sonuçlar:
user 42: 1.0.0 1.0.0 1.0.0 1.0.0 1.0.0
user 7: 1.0.0 1.0.0 1.0.0 1.0.0 1.0.0
user 32: 1.1.0 1.1.0 1.1.0 1.1.0 1.1.0
== 1000 different users
902 1.0.0
98 1.1.0
== no user id, 200 requests
200 1.0.0
== X-Canary: always
{"version":"1.1.0","instance":"696fc643689a"}
Kullanıcı 32 her istekte canary'de, 42 ve 7 her istekte stable'da. 1000 farklı kullanıcının 98'i canary'ye düştü; hedef %10'du. Ama dikkat: X-User-Id göndermeyen 200 isteğin hepsi stable'a gitti. Boş string'in hash'i her seferinde aynı dilime düşer. Anonim kullanıcıların çok olduğu bir sitede bu, canary'nin trafik payını sessizce küçültür; şanssız bir salt ile tam tersi olur ve bütün anonim trafik canary'ye gider. Anonim kullanıcılar için ilk ziyarette bir cookie atayıp onu hash'lemek ya da istemci IP'sini (Internet Protocol) yedek anahtar olarak kullanmak gerekir.
Nginx 1.30'da sticky cookie
Nginx 1.29.6'dan beri açık kaynak sürümde upstream'ler için sticky direktifi var; daha önce yalnız ticari sürümdeydi. Weight ile birleşince cookie'ye yapışan bir canary elde ediliyor:
upstream bookstore_api {
zone bookstore_api 64k;
server depwork-stable:8080 resolve weight=9 route=stable;
server depwork-canary:8080 resolve weight=1 route=canary;
sticky cookie bk_route expires=1h path=/ httponly;
}
Her biri kendi cookie dosyasını kullanan 12 istemciyle dört istek attım. Bunların ilk altısının sonucu ve Nginx'in yazdığı cookie:
client 1: 1.0.0 1.0.0 1.0.0 1.0.0
client 2: 1.0.0 1.0.0 1.0.0 1.0.0
client 3: 1.0.0 1.0.0 1.0.0 1.0.0
client 4: 1.0.0 1.0.0 1.0.0 1.0.0
client 5: 1.1.0 1.1.0 1.1.0 1.1.0
client 6: 1.0.0 1.0.0 1.0.0 1.0.0
Set-Cookie: bk_route=canary; expires=Sun, 08-Nov-26 04:36:29 GMT; max-age=3600; httponly; path=/
İlk istekte Nginx weight'e göre bir sunucu seçiyor ve route değerini cookie'ye yazıyor; sonraki istekler aynı sunucuya gidiyor. Kullanıcı kimliğine bağlı olmadığı için anonim trafik için de çalışıyor. Ama cookie'yi silen ya da başka cihazdan gelen kullanıcı yeniden dağıtılır.
Karar Metrikleri: Ne Ölçülür?
Canary'nin değeri ölçtüğün şey kadardır. Üç katman var:
- Hata oranı: 5xx sayısı / toplam istek, canary ve control için ayrı ayrı. İstemci hatalarını (4xx) ayrı tut; yeni sürüm bir doğrulamayı sıkılaştırdıysa 400'ler artabilir ve bu bir hata değil, bilinçli bir değişiklik olabilir.
- Gecikme: ortalama değil p95 ya da p99. Yeni sürümün yavaşlığı ortalamada kaybolur, yüksek yüzdeliklerde görünür.
- İş metrikleri: sepete ekleme, ödeme tamamlama oranı. Teknik olarak kusursuz ama ödeme butonunu yanlış yere koyan bir sürüm yalnız burada görünür. Bu metrikler yavaş birikir, bu yüzden genelde canary'nin son adımlarında anlam kazanır.
Karşılaştırmayı neyle yaptığın da önemli. SRE Workbook, "deploy'dan önceki hafta ile sonrasını karşılaştırmak" yerine aynı anda çalışan canary ile control'ü karşılaştırmayı öneriyor. Before/after karşılaştırmasında hafta içi ile hafta sonu trafiği, bir kampanya ya da başka bir ekibin deploy'u sonucu bozar. Aynı anda çalışan iki grup aynı dış etkilere maruz kalır.
Nginx log'una hangi backend'in seçildiğini ve süreyi yazıp küçük bir rapor çıkaralım:
#!/usr/bin/env bash
# canary-report.sh LOGFILE: backend başına istek sayısı, 5xx oranı ve p95 gecikme
set -euo pipefail
for v in $(awk '$3 != "-" {print $3}' "$1" | sort -u); do
awk -v v="$v" '$3 == v {print $2, $4}' "$1" | sort -k2 -n | awk -v v="$v" '
{ n++; if ($1 >= 500) e++; t[n] = $2 }
END { i = int(n * 0.95); if (i < 1) i = 1;
printf "%-6s requests=%-5d 5xx=%-4d error_rate=%.2f%% p95=%.3fs\n", v, n, e, 100 * e / n, t[i] }'
done
Canary sürümünü BUG_FAIL_RATE=0.05 ile başlattım (Bookstore'da isteklerin %5'inde exception fırlatan bir test ayarı) ve 4.000 farklı kullanıcıdan istek gönderdim:
seq 1 4000 | xargs -P 8 -I{} curl -s -o /dev/null -H "X-User-Id: {}" localhost:18600/api/books
docker cp depwork-nginx:/var/log/nginx/canary.log canary.log
./canary-report.sh canary.log
canary requests=396 5xx=19 error_rate=4.80% p95=0.001s
stable requests=3604 5xx=0 error_rate=0.00% p95=0.001s
Fark açık: canary %4,80, stable %0. Hatalı istekler X-App-Version header'ını taşımadığı için (exception, middleware'in eklediği header'larla birlikte cevabı da siliyor) log'a sürümü değil Nginx'in verdiği routing kararını ($backend) yazıyoruz. Metriği hangi kaynaktan aldığını bil; hatalı cevapta kaybolan bir etiket, hataları sayımın dışına iter.
Düşük Trafikte İstatistiksel Tuzak
Aynı canary'ye bu kez yalnız 200 istek gönderdim:
canary requests=19 5xx=1 error_rate=5.26% p95=0.001s
stable requests=181 5xx=0 error_rate=0.00% p95=0.001s
WAIT: canary has 19 requests, need 300
exit=2
19 isteğin biri hatalı. %5,26 doğru oranın çok yakınında görünüyor, ama 19 istekte bu tamamen şans. Aynı koşu 0 hatayla da bitebilirdi, 3 hatayla da. 0 hata görsen canary'yi "temiz" ilan ederdin. Bu yüzden karar script'inin ilk sorusu "yeterli veri var mı?" olmalı:
#!/usr/bin/env bash
# canary-gate.sh LOGFILE: exit 0 = promote, 1 = abort, 2 = henüz yeterli veri yok
set -euo pipefail
min_requests=300
max_extra_error_pct=1.0
read -r cn ce sn se < <(awk '
$3 == "canary" { cn++; if ($2 >= 500) ce++ }
$3 == "stable" { sn++; if ($2 >= 500) se++ }
END { print cn + 0, ce + 0, sn + 0, se + 0 }' "$1")
if [ "$cn" -lt "$min_requests" ]; then
echo "WAIT: canary has $cn requests, need $min_requests"
exit 2
fi
verdict=$(awk -v cn="$cn" -v ce="$ce" -v sn="$sn" -v se="$se" -v max="$max_extra_error_pct" 'BEGIN {
c = 100 * ce / cn; s = (sn > 0) ? 100 * se / sn : 0
printf "%s canary=%.2f%% stable=%.2f%% limit=+%.1f", (c - s > max) ? "ABORT" : "PROMOTE", c, s, max }')
echo "$verdict"
[[ "$verdict" == PROMOTE* ]]
Script üç sonuç veriyor: PROMOTE, ABORT ve WAIT. 200 isteklik koşu WAIT ile 2 döndü. 4.000 isteklik bozuk koşu ve aynı yükteki sağlıklı bir canary koşusu ise şöyle bitti:
ABORT canary=4.80% stable=0.00% limit=+1.0
exit=1
PROMOTE canary=0.00% stable=0.00% limit=+1.0
exit=0
Eşikler (300 istek, +1 puan) örnek değerler. Gerçek bir sistemde minimum örnek sayısını beklediğin hata oranına göre seçersin: %0,1'lik bir hatayı yakalamak için binlerce, %5'lik bir hata için birkaç yüz istek gerekir. Trafiğin düşükse canary'nin süresini uzatırsın, payını büyütürsün ya da teknik metrik yerine sentetik testlerle desteklersin. Serinin son yazısındaki pipeline'da bu kuralı "sabit bir süre bekle" yerine "yeterli istek gelene kadar bekle" şeklinde kurduk, çünkü ilk denemede sabit süre yetmedi.
Adım Planı
Tipik bir plan %1, %10, %50 ve %100. Her adımda bir bekleme süresi ve bir karar var. Adımlar arasındaki fark logaritmik, çünkü ilk adımın amacı "tamamen bozuk mu?" sorusunu çok az kullanıcıyla cevaplamak. Sonraki adımlar ise yük altında ortaya çıkan sorunları (connection pool, cache hit oranı, veritabanı kilitleri) görmek için. %50'de iki sürüm eşit yük alır ve canary ile control'ü karşılaştırmak en anlamlı hale gelir. Adımın süresi trafiğine ve deploy sıklığına bağlı. SRE Workbook'un hatırlattığı gibi günde yirmi deploy yapan bir ekip bir haftalık canary çalıştıramaz.
Kubernetes Gateway API ile Weight
Kubernetes'te aynı işi Gateway API'nin (Application Programming Interface) HTTPRoute'u yapıyor. backendRefs'teki weight'ler trafiği Service'ler arasında böler, matches ise header'a göre yönlendirir:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: bookstore-api
spec:
parentRefs:
- name: bookstore
rules:
- matches:
- headers:
- name: X-Canary
value: always
backendRefs:
- name: bookstore-canary
port: 80
- backendRefs:
- name: bookstore-stable
port: 80
weight: 90
- name: bookstore-canary
port: 80
weight: 10
kind cluster'ına Envoy Gateway 1.10 kurup bir curl pod'undan 1000 istek ve bir de X-Canary: always isteği gönderdim:
901 1.0.0
99 1.1.0
{"version":"1.1.0","instance":"bookstore-green-59cffbc476-ncrqn"}
901'e 99. Bu dağılım Envoy'un kararı, Nginx'teki gibi deterministik değil ama orana yakın. HTTPRoute'un kendisi yapışkanlık sağlamıyor; kullanıcıya yapışmak gerekiyorsa header ya da cookie eşleşmesiyle ayrı bir kural yazarsın. Gateway API'nin ayrıntıları ilerideki Kubernetes serisinin dış trafik yazısında.
Argo Rollouts ile Otomatik Analiz ve Promotion
Weight'leri elle değiştirip metriğe bakmak birkaç deploy için işe yarar, sonra yorucu olur. Argo Rollouts, Deployment yerine geçen bir Rollout kaynağı ile adımları, beklemeleri ve analizi tanımlamanı sağlıyor. Flagger aynı işi mevcut Deployment'ların üzerine kurar. İkisinin GitOps ile birlikte kullanımı ilerideki GitOps ve Argo CD (Continuous Delivery) serisinin konusu; burada yalnız fikri gerçek bir koşuyla gösteriyorum:
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: bookstore-api
spec:
replicas: 5
selector:
matchLabels:
app: bookstore-rollout
template:
metadata:
labels:
app: bookstore-rollout
spec:
containers:
- name: api
image: depwork-bookstore-api:1.0.0
env:
- name: BUG_FAIL_RATE
value: "0"
readinessProbe:
httpGet:
path: /health/ready
port: 8080
strategy:
canary:
stableService: bookstore-rollout-stable
canaryService: bookstore-rollout-canary
steps:
- setWeight: 20
- pause: { duration: 10s }
- analysis:
templates:
- templateName: canary-error-rate
- setWeight: 60
- pause: { duration: 10s }
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
name: canary-error-rate
spec:
metrics:
- name: http-5xx
provider:
job:
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
containers:
- name: check
image: curlimages/curl:8.23.0
command:
- sh
- -c
- |
fails=0
for i in $(seq 1 200); do
code=$(curl -s -o /dev/null -w '%{http_code}' http://bookstore-rollout-canary/api/books)
[ "$code" -ge 500 ] && fails=$((fails + 1))
done
echo "5xx: $fails/200"
[ "$fails" -le 2 ]
Traffic routing tanımlamadığım için Argo Rollouts weight'i pod sayısıyla yaklaşık olarak uyguluyor: beş replica'da %20 bir yeni pod demek. Analizi Prometheus'suz göstermek için Job provider'ını kullandım: canary Service'ine 200 istek atan ve 2'den fazla 5xx görürse başarısız olan bir Job. Kurulumda da bir not: Argo Rollouts'un install.yaml'ındaki CRD'ler (Custom Resource Definition) düz kubectl apply ile "metadata.annotations: Too long" hatası verdi, --server-side ile uygulandı.
Sağlıklı bir sürümün rollout'u (her dört saniyede alınan durumdan seçilmiş satırlar):
06:41:44 Paused step=1 CanaryPauseStep | pods: 4 1.0.0, 1 1.1.0,
06:41:52 Progressing step=2 more replicas need to be updated | pods: 4 1.0.0, 1 1.1.0,
06:41:56 Paused step=4 CanaryPauseStep | pods: 2 1.0.0, 3 1.1.0,
06:42:08 Healthy step=5 | pods: 5 1.1.0,
Aynı Rollout'a bu kez BUG_FAIL_RATE=0.1 ile yeni bir revision verdim. Pod sütunundaki sayılar env değerini gösteriyor:
06:42:50 Paused step=1 CanaryPauseStep | pods: 4 0, 1 0.1,
06:42:58 Progressing step=2 more replicas need to be updated | pods: 4 0, 1 0.1,
06:43:02 Degraded step=0 RolloutAborted: Rollout aborted update to revision 3: Step-based analysis phase error/failed: Metric "http-5xx" assessed Failed due to failed (1) > failureLimit (0) | pods: 5 0,
NAME STATUS AGE
bookstore-api-659d7455f4-2-2 Successful 92s
bookstore-api-d9b6564bc-3-2 Failed 26s
5xx: 20/200
Analiz Job'u 200 istekte 20 hata gördü ve başarısız oldu. Argo Rollouts rollout'u durdurdu ve canary pod'unu kapattı. Beş pod da eski sürümde, durum Degraded. Bu durum bilinçli: sistem "geri döndüm ama bir insanın bakması gerekiyor" diyor. Yeni bir revision ya da kubectl argo rollouts retry gelene kadar böyle kalır.
Shadow Traffic ve Dark Launch
Canary gerçek kullanıcılara gerçek cevap verir. Bazen yeni sürümü kullanıcıya hiç göstermeden gerçek yükle denemek istersin. Nginx'in mirror direktifi her isteğin bir kopyasını başka bir yere gönderir ve o kopyanın cevabını çöpe atar:
server {
listen 80;
location /api/books {
mirror /shadow;
proxy_pass http://bookstore_stable;
}
location = /shadow {
internal;
proxy_pass http://bookstore_shadow$request_uri;
}
}
Shadow tarafı %50 hata veren bir sürüm. Kullanıcı tarafında 20 istek, Nginx log'unda (log_subrequest on ile) ise şunlar görünüyor:
client sees:
20 200 1.0.0
nginx log:
20 200 1.0.0 /api/books
11 200 1.1.0 /shadow
9 500 - /shadow
Kullanıcının hiçbir isteği etkilenmedi, ama yeni sürümün 20 kopyadan 9'unda patladığını öğrendik. Varsayılan ayarda mirror alt istekleri log'a yazılmıyor; log_subrequest on olmadan shadow'un hatalarını hiç görmezsin. Shadow'u yalnız okuma endpoint'lerinde kullan. Kopyalanan bir POST /api/orders ikinci bir sipariş, ikinci bir ödeme denemesi ya da ikinci bir e-posta demek. Yazma işlemlerini shadow'lamak için ayrı bir veritabanı ve dış servislerin taklitleri gerekir. Dark launch ise aynı fikrin feature flag'li hali: kod production'da çalışır, sonucu hesaplanır ama kullanıcıya gösterilmez.
Üretimde Dikkat
Canary kararının otomatik olması, eşiklerin doğru olduğu anlamına gelmez. İlk birkaç canary'de otomatik kararı insan kararıyla karşılaştır ve eşikleri gerçek verilere göre ayarla. Canary ile control'ün aynı altyapıyı paylaşıp paylaşmadığını da kontrol et. Aynı veritabanını yoran bir canary, control'ü de yavaşlatır ve fark kaybolur.
Canary'nin hangi kullanıcıya hangi sürümü gösterdiğini log'a yaz (bizim $backend field'ı). Bir müşteri "dün gece ödeme sayfası garipti" dediğinde, onun canary'de olup olmadığını bilmek incident'ı dakikalar içinde açıklar.
Sık Düşülen Tuzaklar
- Düşük trafikte karar vermek. 19 istekteki 0 hata hiçbir şey söylemez; minimum örnek sayısı olmadan otomatik promotion bir yazı tura.
- Canary ile feature flag'i karıştırmak. Canary sürümü (kodu) kısıtlar, flag davranışı. Canary'de olan bir kullanıcı yeni kodu çalıştırır ama flag kapalıysa yeni özelliği görmez. Hata ararken ikisini ayrı ayrı sor.
- Weight'li canary'yi stateful etkileşimlerde kullanmak. Sayfa sayfa sürüm değiştiren kullanıcı, yeni sürümün değil karışımın hatalarını görür.
- Yalnız ortalamaya bakmak. p95'teki bozulma ortalamada görünmez.
- Shadow'u yazma endpoint'lerine açmak. Kopyalanan istekler gerçek yan etkiler üretir.
Küçük Bir Deneme
★☆☆ split_clients'taki salt'ı canary-v1.2.0: yapıp aynı 1000 kullanıcıyı tekrar gönder. Kaç kullanıcı her iki release'te de canary'ye düşüyor?
★★☆ canary-gate.sh'ye p95 gecikme kontrolü ekle: canary'nin p95'i control'den %20'den fazla yüksekse ABORT. Canary'ye yapay gecikme eklemek için Bookstore'un /api/slow?ms= endpoint'ini kullan.
★★★ Argo Rollouts'taki Job analizini web metric provider'ıyla değiştir: canary'nin hata oranını JSON olarak dönen küçük bir endpoint yaz ve successCondition ile değerlendir.
Canary riskin ilk çarpanını, blast radius'u küçültüyor. Ama hepsinin altında aynı şey yatıyor: iki sürüm aynı veritabanını aynı anda kullanıyor. Bir sonraki yazıda bunun nasıl güvenle yapılacağına bakıyoruz: migration'ların deploy'daki yeri, expand-contract ve EF (Entity Framework) Core migration bundle.