YARP ile Strangler Fig ve Canary: Eski Uygulamadan Kademeli Geçiş ve Ağırlıklı Trafik
YARP'ta strangler fig, eski uygulamaya giden host'a bağlı bir catch-all route ile başlar ve taşınan her sayfa için daha spesifik bir route eklenir; canary için iki cluster ve isteği ReassignProxyRequest ile yeni cluster'a aktaran küçük bir adım yeterlidir. Bookstore'un eski sitesinde route tablosunu faz faz değiştiriyor, %10'luk yapışkan canary'yi ölçüyor ve config reload ile 645 ms'de geri dönüyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ve Yarp.ReverseProxy 2.3 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore.Gateway'e, eski sitenin, yani bir ASP.NET MVC (Model-View-Controller; uygulamayı model, view ve controller katmanlarına ayıran pattern) 5 uygulamasının önüne geçmesi için bir catch-all route ekliyorsun: /{**catch-all}, Order: 1000, hedefi eski uygulama. 05'teki Angular sayfaları aynı gateway'den MapFallbackToFile ile sunuluyor. Catch-all route'a Hosts yazmayı unutursan http://localhost:5300/books/9780441172719 adresindeki Angular sayfasına ne olur? Fallback'ler en düşük öncelikte, YARP (Yet Another Reverse Proxy; .NET için reverse proxy library'si) route'u ise 1000. Hangisi kazanır?
İkinci soru: sepet sayfası için %10'luk bir canary açtın. Bin farklı ziyaretçi birer istek atınca yaklaşık yüzü yeni sürümü görüyor. Aynı tarayıcıdan arka arkaya on istek atan bir kullanıcı kaç kez yeni sürümü görür?
Strangler Fig'in YARP'taki Karşılığı: Catch-All ve Order
Martin Fowler'ın ilk adı Strangler Application olan, sonradan Strangler Fig Application olarak güncellediği yazısındaki fikir basit: eski sistemi bir seferde yeniden yazmak yerine, önüne bir katman koyup davranışları parça parça yeni sisteme taşımak. Microservices serisinin Monolith'ten Microservice'e yazısında yaklaşımın mimari tarafını, branch by abstraction'ı ve parallel run'ı konuştuk. ASP.NET Core MVC ve Razor Pages serisinin MVC 5'ten geçiş yazısında da yeni ASP.NET Core uygulamasının kendisini eski uygulamanın önüne koyup MapForwarder ile fallback verdik. Bu yazıda ön katman, sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için zaten çalışan gateway.
Senaryo şu: Bookstore'un eski public sitesi www.bookstore.test bir ASP.NET MVC 5 uygulaması. Sayfaları sayfa sayfa yeni bir ASP.NET Core uygulamasına, Bookstore.Web'e taşıyoruz. Repo'da gerçek bir MVC 5 projesi yerine, aynı URL'lere aynı tür HTML cevapları veren küçük bir ASP.NET Core uygulaması (Bookstore.Legacy, :5304) var; böylece örnek .NET Framework olmadan da çalışıyor. Her iki uygulama da cevabına X-Served-By header'ını koyuyor, istekleri kimin cevapladığını oradan görüyoruz.

Faz 0'da gateway hiçbir şeyi değiştirmiyor; www için gelen her istek eski siteye gidiyor. Çıktıları kısa tutmak için her isteği tek satırda path, status code, X-Served-By ve gövdenin başı olarak yazdım:
"www-legacy": {
"ClusterId": "legacy",
"AuthorizationPolicy": "Anonymous",
"Order": 1000,
"Match": {
"Path": "/{**catch-all}",
"Hosts": [ "www.bookstore.test" ]
}
}
curl --resolve www.bookstore.test:5300:127.0.0.1 http://www.bookstore.test:5300/cart
/ 200 legacy <h1>Home (legacy MVC 5)</h1>
/books 200 legacy <h1>Books (legacy MVC 5)</h1>
/books/9780441172719 200 legacy <h1>Book 9780441172719 (legacy MVC 5)</h1>
/cart 200 legacy <h1>Cart (legacy MVC 5)</h1>
/account/login 200 legacy <h1>Log in (legacy MVC 5)</h1>
--resolve curl'e www.bookstore.test'i yerel makineye çözdürüyor; tarayıcıda denemek istersen hosts dosyasına bir satır eklemen yeterli. Bu adımın değeri sıfır değişiklik değil: DNS'i (Domain Name System; alan adını IP adresine çeviren sistem) gateway'e çevirdiğin an eski site aynı davranırken artık her isteği gateway'in logunda, route ve status code ile görüyorsun. Taşıma öncesindeki trafiği ölçmek için bundan iyi bir yer yok.
Route'taki üç ayar bilinçli. AuthorizationPolicy: "Anonymous", çünkü 04'teki fallback policy kimlik istiyor; eski site kendi login'ini yönetiyor ve gateway'in onu bilmesine gerek yok. Order: 1000, catch-all'un taşınan route'lardan (varsayılan Order 0) her zaman sonra değerlendirilmesi için. 02'deki tuzak bunun tersiydi: catch-all Order 0'da kalıp diğer route'lara pozitif Order verilince catch-all her şeyi yutmuştu. Hosts ise "Önce Tahmin Et"in cevabı. Onu sildiğimde localhost'taki sonuç şu oldu:
/ HTTP/1.1 200 OK Content-Type: text/html X-Served-By: legacy
/books/9780441172719 HTTP/1.1 200 OK Content-Type: text/html X-Served-By: legacy
/api/v1/books/9780441172719 HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8
/api/v2/books HTTP/1.1 404 Not Found X-Served-By: legacy
Angular sayfası artık eski siteden geliyor, 05'teki /api 404 fallback'i de devre dışı. Sebep endpoint seçiminin önce Order'a bakması: MapFallback ve MapFallbackToFile int.MaxValue ile en sonda, 1000 onlardan önce geliyor. Spesifik route'lar (/api/v1/books/**) Order 0'da olduğu için etkilenmedi. Aynı gateway'de birden çok site varsa her catch-all route'u bir host'a bağla.
İlk Taşıma: Kitap Detay Sayfası
Faz 1'de kitap detay sayfası Bookstore.Web'e geçiyor. Bookstore.Web bir Razor Pages uygulaması; sayfanın kendisini MVC serisinin geçiş yazısındaki gibi taşıdığımızı varsayıyoruz. Gateway'de değişen tek şey route tablosuna eklenen bir satır:
"www-books": {
"ClusterId": "web",
"AuthorizationPolicy": "Anonymous",
"Match": {
"Path": "/books/{isbn}",
"Hosts": [ "www.bookstore.test" ]
}
}
Dosyayı kaydedip gateway'i yeniden başlatmadan denedim:
info: Yarp.ReverseProxy.Configuration.ConfigProvider.ConfigurationConfigProvider[1]
Loading proxy data from config.
/books 200 legacy <h1>Books (legacy MVC 5)</h1>
/books/9780441172719 200 web <h1>Book 9780441172719 (Bookstore.Web)</h1>
/cart 200 legacy <h1>Cart (legacy MVC 5)</h1>
Liste sayfası /books hâlâ eski sitede, detay sayfaları yeni uygulamada. Taşıma birimini küçük seçmek bilinçli: detay sayfası en çok ziyaret edilen ama durum tutmayan sayfa. İlk taşımanın amacı altyapıyı (deploy, log, ölçüm, geri dönüş) denemek; riskli sayfayla başlamak bu öğrenmeyi pahalı yapar.
Session ve Kimlik: Gateway'in Çözemediği Kısım
Sepet sayfası farklı. MVC 5 tarafında sepet çoğu zaman Session["Cart"] içinde, kullanıcı da Forms Authentication cookie'siyle tanınıyor. Gateway iki uygulamanın da aynı domain'de görünmesini sağlıyor, cookie'ler iki tarafa da gidiyor; ama ASP.NET Core o session'ı ve authentication cookie'sini okuyamıyor. System.Web adapters'ın remote app özellikleri bunun için var: yeni uygulama session ve kimlik bilgisini eski uygulamaya sorabiliyor. Kurulumu, HttpContext.Current kullanan library'lerin nasıl taşınacağıyla birlikte MVC serisinin geçiş yazısında anlattık; burada tekrarlamıyoruz.
Bu noktada bir mimari seçim var. Birinci seçenek Microsoft'un geçiş dokümanının önerdiği yapı: yeni ASP.NET Core uygulaması önde durur, taşınmamış her şeyi MapForwarder ile eskiye iletir. Ek bir uygulama yok ve System.Web adapters aynı uygulamada çalışıyor, ama route kararı kodda: her taşıma bir deploy. İkinci seçenek bu yazıdaki: ayrı bir gateway önde, route tablosu configuration'da. Fazladan bir uygulama işletiyorsun, ama taşıma, canary ve geri dönüş yeni uygulamayı deploy etmeden, config değişikliğiyle oluyor. Birden çok eski uygulama ya da birden çok yeni service varsa ikinci seçenek daha rahat; tek bir monolith'i tek bir yeni uygulamaya taşıyorsan birincisi yeterli.
Canary: İki Cluster ve ReassignProxyRequest
Sepeti doğrudan taşımak yerine önce ziyaretçilerin %10'una açıyoruz. YARP'ta ağırlıklı yönlendirme için hazır bir ayar yok. İki yol var. Birincisi, eski ve yeni adresleri aynı cluster'a destination olarak koyup ağırlığa göre seçen bir ILoadBalancingPolicy yazmak; 03'te kendi policy'mizi yazmıştık. Ama o zaman iki farklı uygulama tek bir cluster'ın health check'ini, HTTP ayarlarını ve log satırını paylaşıyor; hangi isteğin hangi sürüme gittiğini destination ID'sinden çıkarmak zorunda kalıyorsun. İkincisi, YARP dokümantasyonunun A/B testi için gösterdiği yol: iki ayrı cluster ve proxy pipeline'ında isteği diğer cluster'a aktaran bir adım. Biz ikincisini seçtik:
"www-cart": {
"ClusterId": "legacy",
"AuthorizationPolicy": "Anonymous",
"Match": {
"Path": "/cart",
"Hosts": [ "www.bookstore.test" ]
},
"Metadata": {
"Canary.ClusterId": "web",
"Canary.Percent": "10"
}
}
public static IReverseProxyApplicationBuilder UseCanary(this IReverseProxyApplicationBuilder proxyPipeline)
{
proxyPipeline.Use((context, next) =>
{
IReadOnlyDictionary<string, string>? metadata = context.GetReverseProxyFeature().Route.Config.Metadata;
if (metadata is not null
&& metadata.TryGetValue(ClusterKey, out string? canaryClusterId)
&& metadata.TryGetValue(PercentKey, out string? percentText)
&& int.TryParse(percentText, CultureInfo.InvariantCulture, out int percent)
&& GetOrAssignBucket(context) < percent
&& context.RequestServices.GetRequiredService<IProxyStateLookup>().TryGetCluster(canaryClusterId, out ClusterState? canary))
{
context.ReassignProxyRequest(canary);
}
return next();
});
return proxyPipeline;
}
private static int GetOrAssignBucket(HttpContext context)
{
if (int.TryParse(context.Request.Cookies[BucketCookie], CultureInfo.InvariantCulture, out int bucket) && bucket is >= 0 and < 100)
{
return bucket;
}
// ziyaretçi bir kez 0-99 arası bir bucket'a düşer ve cookie sayesinde orada kalır
bucket = Random.Shared.Next(100);
context.Response.Cookies.Append(BucketCookie, bucket.ToString(CultureInfo.InvariantCulture), new CookieOptions
{
HttpOnly = true,
IsEssential = true,
SameSite = SameSiteMode.Lax,
MaxAge = TimeSpan.FromDays(30),
});
return bucket;
}
proxyPipeline.UseCanary() pipeline'ın en başında, logging adımından ve UseLoadBalancing()'den önce. ReassignProxyRequest IReverseProxyFeature'daki cluster'ı ve kullanılabilir destination listesini değiştiriyor; load balancing, health check ve forwarder yeni cluster'la çalışıyor. Log satırındaki cluster da artık gerçek hedefi gösteriyor. Ziyaretçinin bucket'ı cookie'de duruyor, çünkü canary'nin bir şartı da kullanıcının sayfa yeniledikçe iki sürüm arasında gidip gelmemesi. Ölçüm, bin farklı ziyaretçi (cookie'siz bin istek) ve tek bir ziyaretçinin on isteği:
# bin yeni ziyaretçi
for i in $(seq 1 1000); do curl -s -o /dev/null -D - --resolve www.bookstore.test:5300:127.0.0.1 \
http://www.bookstore.test:5300/cart | grep -i "^x-served-by"; done | sort | uniq -c
899 X-Served-By: legacy
101 X-Served-By: web
# aynı ziyaretçi, cookie jar ile 10 istek
10 X-Served-By: legacy
bookstore-bucket=13
Set-Cookie: bookstore-bucket=77; max-age=2592000; path=/; samesite=lax; httponly
İkinci sorunun cevabı: bucket'ı 13 olan kullanıcı on istekte de eski sepeti gördü. Bucket 10'un altında olsaydı on istekte de yenisini görecekti. Cookie'ye dayalı bu yaklaşımın sınırı da var: kullanıcı başka bir tarayıcıdan gelirse yeni bir bucket'a düşer. Giriş yapmış kullanıcılar için bucket'ı kullanıcı ID'sinin hash'inden türetmek cihazlar arasında da tutarlı sonuç verir.
Testçilerin ve ekibin yüzdeye bakmadan yeni sepeti görmesi için ikinci bir route var. Header match'i Cookie header'ında Contains moduyla çalışıyor ve daha spesifik olduğu için www-cart'tan önce seçiliyor:
"www-cart-preview": {
"ClusterId": "web",
"AuthorizationPolicy": "Anonymous",
"Match": {
"Path": "/cart",
"Hosts": [ "www.bookstore.test" ],
"Headers": [
{ "Name": "Cookie", "Values": [ "bookstore-canary=on" ], "Mode": "Contains" }
]
}
}
/cart 200 web <h1>Cart (Bookstore.Web)</h1>
Contains bir alt metin araması; xbookstore-canary=on gibi bir cookie de eşleşir. Bir önizleme kapısı için yeterli, ama bir yetki kontrolü değil. Canary'nin hangi metriklerle büyütüleceğini, adım planını ve otomatik analizi Deployment Stratejileri serisinin Canary Release yazısında konuştuk; burada yalnız trafiğin nasıl bölündüğüne bakıyoruz.
Geri Dönüş, Feature Flag ve Parallel Run
Canary'nin değeri, yanlış giden bir şeyi küçük bir kitleyle fark edip hızla geri dönebilmekte. Yüzde route metadata'sında, metadata da configuration'da olduğu için geri dönüş bir dosya değişikliği. Bucket'ı 3 olan bir ziyaretçi yeni sepeti görürken Canary.Percent'i "0" yapıp her 50 ms'de bir istek attım:
/cart 200 web <h1>Cart (Bookstore.Web)</h1>
legacy after 645 ms
/cart 200 legacy <h1>Cart (legacy MVC 5)</h1>
Yeniden başlatma yok, deploy yok; config'in değiştiği andan 645 ms sonra aynı ziyaretçi eski sepete döndü. Production'da configuration dosyası değil, 02'de yazdığımız veritabanı provider'ı ya da bir configuration servisi olur; mantık aynı. Gateway birden çok instance'ta çalışıyorsa her instance değişikliği kendi zamanında görür; birkaç saniye boyunca iki instance farklı yüzde uygulayabilir.
Canary.Percent aslında bir feature flag. Deployment Stratejileri serisinin Feature Flag'ler yazısında Microsoft.FeatureManagement ile yüzde ve hedefleme filtrelerini gördük. Aynı kararı UseCanary adımında bir feature flag'e sordurmak mümkün: yüzde, kullanıcı grubu ya da kurum bazında açma gibi kurallar flag sisteminde yönetilir, gateway yalnız "bu istek için açık mı?" diye sorar. Bu yazıdaki metadata yaklaşımı tek bir sayı için yeterli; kurallar çoğalınca flag sistemine geçmek daha doğru.
Taşıma sırasında iki tarafın gerçekten aynı davrandığını görmek için gateway'in logu iyi bir kaynak. 01'den beri her istek için route, cluster, destination ve status yazıyoruz; canary ölçümünden sonra log'u saydım:
grep -o "Route www-cart -> cluster [a-z]*" gateway.log | sort | uniq -c
910 Route www-cart -> cluster legacy
101 Route www-cart -> cluster web
grep "Forwarded to" gateway.log | sed -E 's/.*Forwarded to ([a-z0-9-]+).*status ([0-9]+).*/\1 \2/' | sort | uniq -c
917 legacy 200
1 legacy 404
103 web 200
Her iki tarafta da 5xx yok; legacy'deki tek 404, Faz 0'da denediğim /main.js. Cluster bazında status ve süre karşılaştırması yeni sürümün kötüleşip kötüleşmediğini gösterir; cevapların içeriğini karşılaştırmak için ise trafiğin bir kopyasını yan etkisi olmayan GET istekleriyle yeni tarafa da gönderip sonuçları karşılaştıran bir parallel run gerekir. Bunun yan etkili isteklerde neden tehlikeli olduğunu Microservices serisinin strangler yazısında konuştuk. Her şey yolundaysa Faz 3'te yüzde yerine route'un hedefi değişiyor ve önizleme route'u siliniyor:
/ 200 legacy <h1>Home (legacy MVC 5)</h1>
/books 200 legacy <h1>Books (legacy MVC 5)</h1>
/books/9780441172719 200 web <h1>Book 9780441172719 (Bookstore.Web)</h1>
/cart 200 web <h1>Cart (Bookstore.Web)</h1>
/account/login 200 legacy <h1>Log in (legacy MVC 5)</h1>
Üretimde Dikkat
Strangler'ın bitişini tanımla. Catch-all route'un trafiği zamanla azalmalı; route bazında istek sayısını bir metric olarak izle ve eski siteye giden trafiğin hangi URL'lerden oluştuğunu düzenli olarak listele. Taşınmadan kalan son yüzde beşlik kısım çoğu zaman en zor sayfalardır ve eski uygulamanın kapanmasını yıllarca geciktirir.
Gateway'i DNS'in önüne koymadan önce eski sitenin forwarded header'ları doğru okuduğundan emin ol. Eski uygulama artık istemcinin IP'sini değil gateway'in IP'sini görüyor; IP'ye dayalı log, rate limit ya da coğrafi kural varsa X-Forwarded-For'u okuması gerekiyor. ASP.NET Core tarafındaki ayarları Web Server'lar ve Hosting serisinin forwarded header yazısında anlattık; MVC 5 tarafında bu kodu senin eklemen gerekiyor.
Canary'de iki sürümün aynı veriyi farklı biçimde yazmadığından emin ol. Kullanıcı sepete yeni sürümde ürün ekleyip, bucket değişince eski sürümde sepeti boş görürse sorun gateway'de değil veride. Session paylaşımı ya da ortak bir sepet service'i bu yüzden canary'den önce çözülmeli.
Canary.Percent'i değiştirmek herkesin yapabileceği bir iş olmamalı. Route tablosu veritabanındaysa değişiklikleri audit'le, kimin ne zaman yüzdeyi değiştirdiğini kaydet; geri dönüşü bir runbook adımı olarak yaz ve dene.
Sık Düşülen Tuzaklar
Birincisi, catch-all route'a host vermemek. Yukarıda gördüğün gibi aynı gateway'deki başka bir sitenin fallback'lerini sessizce yutar.
İkincisi, catch-all'un Order değerini düşünmeden bırakmak. Taşınan route'lardan büyük, fallback'lerden küçük olmalı; iki yönde de hata sessizdir ve yalnız yanlış uygulamanın cevabı olarak görünür.
Üçüncüsü, canary'yi her istekte yeniden zar atarak yapmak. Kullanıcı bir sayfada yeni, bir sonrakinde eski sürümü görür; hatalar yeniden üretilemez ve metrikler karışır. Bucket'ı bir kez seç ve sakla.
Dördüncüsü, session'ı düşünmeden durum tutan bir sayfayı taşımak. Sepet gibi sayfalarda iki uygulamanın aynı veriyi görmesi, route değişikliğinden önce gelmeli.
Beşincisi, geri dönüşü hiç denememek. Config reload'un gerçekten çalıştığını, kaç saniye sürdüğünü ve çok instance'ta nasıl davrandığını bir sorun çıkmadan önce ölç.
Küçük Bir Deneme
★☆☆ www-cart'ın Canary.Percent değerini 10'dan 50'ye çıkar ve bin ziyaretçilik ölçümü tekrarla. Sonra kendi cookie jar'ınla on istek at; bucket'ın 50'nin altındaysa yeni sepeti görmeye başladın mı?
★★☆ Bucket'ı cookie yerine giriş yapmış kullanıcının sub claim'inin hash'inden türet; kimliksiz ziyaretçiler için cookie'yi koru. 04'teki DevToken ile iki farklı kullanıcının iki farklı "tarayıcıdan" (ayrı cookie jar'larla) hep aynı sürümü gördüğünü göster.
★★★ /books/{isbn} için basit bir parallel run yaz: istek eski siteden cevaplansın, aynı GET isteği arka planda web cluster'ına da gönderilsin ve iki cevabın status code'u farklıysa bir uyarı loglansın. Kullanıcının bekleme süresinin artmadığını ve yan etkili isteklerin (POST) kopyalanmadığını ölçerek göster.
Gateway artık eski siteyi sarıyor, trafiği bölüyor ve saniyeler içinde geri dönebiliyor. Bir sonraki yazıda gateway'i production'da işletmeye geçiyoruz: telemetry ve OpenTelemetry ile trace'in gateway'den geçişi, gateway'in ne kadar gecikme eklediğini ölçen bir load test, container image'ı ve çok instance'lı çalışmanın getirdikleri.