Mert Özen Her Satırda Daha İleri
Backend API Gateway ve YARP — #02

YARP Konfigürasyonu: Route Match'leri, Transform'lar, Koddan Config ve Canlı Reload

Mert Özen 14 Kas 2026 15 dk 11 görüntülenme
YARP Konfigürasyonu: Route Match'leri, Transform'lar, Koddan Config ve Canlı Reload

YARP'ta route sırasını önce Order, sonra path, method, host, header ve query spesifikliği belirler; path transform'ları dış URL'yi iç URL'ye çevirir, X-Forwarded varsayılan olarak istemcinin değerini ezer ama iç header'ları gelişte senin silmen gerekir. Bookstore.Gateway'de catch-all tuzağını, canlı reload'da hatalı config'in ne yaptığını, LoadFromMemory'yi, SQL Server'dan okuyan IProxyConfigProvider'ı ve environment'a göre adres veren IProxyConfigFilter'ı gerçek loglarla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Yarp.ReverseProxy 2.3, Microsoft.Data.SqlClient 7.1 ve SQL Server 2025 ile derlenip denenmiştir.

Önce Tahmin Et

YARP (Yet Another Reverse Proxy; .NET için reverse proxy library'si) ile kurduğumuz Bookstore.Gateway'in route tablosunu düzenli görünsün diye elden geçiren bir arkadaşın iki değişiklik yapıyor: catalog-v1 route'una "Order": 10 yazıyor ve eski uygulamaya giden istekler için en alta bir route daha ekliyor:

"legacy-fallback": {
  "ClusterId": "ordering",
  "Match": {
    "Path": "{**catch-all}"
  }
}

GET /api/v1/books/9780547928227 isteği nereye gider? catalog-v1 path olarak çok daha spesifik, catch-all ise "ne kalırsa" diye yazılmış. Bir de ikinci soru: bir istemci isteğe kendisi X-Forwarded-For: 203.0.113.66 header'ı ekliyor. Catalog service'i bu isteği aldığında X-Forwarded-For değeri ne olur? Cevaplar bu yazıda, gerçek loglarla.

Route Match: Path, Method, Host, Header ve Query

Önceki yazıda sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için kurduğumuz gateway'de route'lar yalnız path'e bakıyordu ve dış URL ile iç URL aynıydı. Şimdi dış dünyaya sürümlü bir URL veriyoruz: istemci /api/v1/books çağırıyor, Catalog service'i ise kendi içinde sade /books dinliyor. Bir de mobil uygulama için ayrı bir davranış istiyoruz: mobil istemci kitap listesinde fiyat ve yazar istemiyor, sadece ISBN (kitabın uluslararası standart numarası) ve başlık yeterli. Route tanımı şöyle:

"catalog-v1": {
  "ClusterId": "catalog",
  "Match": {
    "Path": "/api/v1/books/{**rest}"
  },
  "Transforms": [
    { "PathPattern": "/books/{**rest}" }
  ]
},
"catalog-v1-mobile": {
  "ClusterId": "catalog",
  "Match": {
    "Path": "/api/v1/books",
    "Methods": [ "GET" ],
    "Headers": [
      {
        "Name": "X-Client-Platform",
        "Values": [ "mobile" ],
        "Mode": "ExactHeader"
      }
    ]
  },
  "Transforms": [
    { "PathSet": "/books/summary" }
  ]
}

Match içindeki her koşul AND ile bağlanıyor: catalog-v1-mobile yalnız path /api/v1/books olduğunda, method GET olduğunda ve X-Client-Platform header'ı tam olarak mobile olduğunda eşleşir. Header eşleşmesinde ExactHeader dışında HeaderPrefix, Contains, NotContains, Exists ve NotExists modları var; query parametreleri için QueryParameters bölümü aynı mantıkla Exact, Prefix, Contains, NotContains ve Exists modlarını kullanıyor. Host eşleşmesi ise bir dizi: "Hosts": [ "partner.bookstore.test" ] gibi. Dört isteği atalım:

curl http://localhost:5300/api/v1/books
[{"isbn":"9780441172719","title":"Dune","author":"Frank Herbert","price":420.00},...]

curl -H "X-Client-Platform: mobile" http://localhost:5300/api/v1/books
[{"isbn":"9780441172719","title":"Dune"},{"isbn":"9780141439587","title":"Emma"},{"isbn":"9780547928227","title":"The Hobbit"},{"isbn":"9780141439549","title":"Middlemarch"}]

curl -X POST -H "X-Client-Platform: mobile" -w "%{http_code}\n" http://localhost:5300/api/v1/books
405

curl http://localhost:5300/api/v1/books/9780547928227
{"isbn":"9780547928227","title":"The Hobbit","author":"J. R. R. Tolkien","price":310.00}

Üçüncü istek ilginç. Method POST olduğu için mobil route eşleşmedi, ama istek boşa düşmedi: catalog-v1'in catch-all parametresi boş path'i de kabul ettiği için istek oraya gitti ve Catalog'a /books olarak iletildi. 405'i gateway değil, POST endpoint'i olmayan Catalog döndü. Bir route'tan düşen isteğin başka bir route'a yakalanması, route tablosu büyüdükçe en sık şaşırtan davranış.

Order ve Catch-all: Route Tablosu Hangi Sırayla Okunur?

YARP route'ları ASP.NET Core routing'ine endpoint olarak eklendiği için sıralamayı da onun kuralları belirliyor. YARP dokümantasyonuna göre eşleşme sırasında önce path, sonra method, host, header ve en son query değerlendirilir; daha spesifik olan kazanır. Order verdiğinde ise bu spesifiklik karşılaştırmasından önce Order'a bakılır ve küçük değer kazanır. Order yazmadığın route'un değeri 0'dır. YARP'ın kaynak kodunda bu, config.Order.GetValueOrDefault() olarak duruyor.

"Önce Tahmin Et"teki değişikliği gateway çalışırken appsettings.json'a yazdım. Gateway dosyayı izliyor ve log'a hemen şunu yazıyor:

info: Yarp.ReverseProxy.Configuration.ConfigProvider.ConfigurationConfigProvider[1]
      Loading proxy data from config.

Sonra iki istek:

curl -w "  [%{http_code}]\n" http://localhost:5300/api/v1/books/9780547928227
  [404]
curl -w "  [%{http_code}]\n" http://localhost:5300/api/v1/orders/1001
{"id":1001,"customerEmail":"ada@example.com","total":605.50,"status":"Shipped"}  [200]

info: Bookstore.Gateway[0]
      Route legacy-fallback -> cluster ordering, 1 available destination(s)
info: Yarp.ReverseProxy.Forwarder.HttpForwarder[9]
      Proxying to http://localhost:5302/api/v1/books/9780547928227 HTTP/2 RequestVersionOrLower
info: Bookstore.Gateway[0]
      Forwarded to ordering-1, status 404
info: Bookstore.Gateway[0]
      Route orders-v1 -> cluster ordering, 1 available destination(s)

Kitap isteği catch-all'a, yani Ordering service'ine gitti. Sebep: catalog-v1'in Order'ı 10, catch-all'unki varsayılan 0. Spesifiklik hiç devreye girmeden catch-all kazandı. Sipariş isteği ise doğru yere gitti, çünkü orders-v1'in de Order'ı 0 ve eşitlik durumunda spesifik path kazanıyor. Yani hata tablonun yarısında görünüyor, yarısında görünmüyor; testte sadece sipariş akışını denediysen fark etmezsin.

Kuralım şu: Order'ı "düzen" için kullanma. Varsayılan sıralama çoğu durumda doğru çalışır. Gerçekten ihtiyaç olduğunda Order'ı yalnız istisna route'a ver; fallback route'a da büyük bir değer ver (ör. "Order": 1000), böylece başkası bir gün diğer route'lara pozitif değer yazsa bile fallback hep en sonda kalır.

Transform'lar: İsteği Backend'e Göre Biçimlendirmek

Path transform'ları

Yukarıdaki route'larda üç farklı path transform'u kullandık. Sırasıyla /api/v1/books, /api/v1/books/9780547928227, /api/v1/orders/1001, /api/catalog/books/9780141439587, /api/orders/1001, mobil header'lı /api/v1/books ve mobil header'lı POST isteklerini attım. Gateway'in backend'e gönderdiği URL'ler Proxying to satırlarında:

Proxying to http://localhost:5301/books HTTP/2 RequestVersionOrLower
Proxying to http://localhost:5301/books/9780547928227 HTTP/2 RequestVersionOrLower
Proxying to http://localhost:5302/orders/1001 HTTP/2 RequestVersionOrLower
Proxying to http://localhost:5301/books/9780141439587 HTTP/2 RequestVersionOrLower
Proxying to http://localhost:5302/orders/1001 HTTP/2 RequestVersionOrLower
Proxying to http://localhost:5301/books/summary HTTP/2 RequestVersionOrLower
Proxying to http://localhost:5301/books HTTP/2 RequestVersionOrLower

PathPattern, route'taki parametreleri yeni bir path template'ine yerleştiriyor; {**rest} boş geldiğinde sondaki slash'ı da düşürüp /books üretmesi işimize geliyor. PathSet path'i tamamen değiştiriyor, PathRemovePrefix baştaki parçayı siliyor, PathPrefix de başa ekliyor. Query string bu transform'lardan etkilenmiyor ve olduğu gibi gidiyor; değiştirmek istersen QueryValueParameter ve QueryRemoveParameter var. Eski /api/catalog/** URL'lerini de silmedik: catalog-legacy route'u aynı cluster'a PathRemovePrefix ile gidiyor. Bir dış URL'yi emekliye ayırırken bu, istemcilere geçiş süresi tanımanın en ucuz yolu.

Header transform'ları ve X-Forwarded

Backend'in gerçekte ne aldığını görmek için Catalog'a yalnız Development ortamında açılan bir endpoint ekledim. Path'i, query'yi, Host'u ve X- ile başlayan header'ları JSON olarak geri döndürüyor. Gateway tarafında bu endpoint'e giden route da yalnız appsettings.Development.json'da tanımlı:

"catalog-debug": {
  "ClusterId": "catalog",
  "Match": {
    "Path": "/debug/catalog"
  },
  "Transforms": [
    { "PathSet": "/debug/request" },
    { "RequestHeaderOriginalHost": "true" }
  ]
}

Şimdi kötü niyetli bir istemci gibi davranalım ve sahte header'lar gönderelim:

curl "http://localhost:5300/debug/catalog?lang=tr" \
  -H "X-Forwarded-For: 203.0.113.66" -H "X-Forwarded-Host: evil.example" \
  -H "Forwarded: for=203.0.113.66" -H "X-Bookstore-User-Id: 1" \
  -H "X-Client-Platform: web"
{
    "path": "/debug/request",
    "query": "?lang=tr",
    "host": "localhost:5300",
    "headers": {
        "X-Bookstore-Route": "catalog-debug",
        "X-Client-Platform": "web",
        "X-Forwarded-For": "::1",
        "X-Forwarded-Host": "localhost:5300",
        "X-Forwarded-Proto": "http"
    }
}

"Önce Tahmin Et"teki ikinci sorunun cevabı burada. YARP her route'a varsayılan olarak X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host ve X-Forwarded-Prefix transform'larını Set modunda ekliyor: istemcinin gönderdiği değeri siliyor, kendi gördüğünü yazıyor. X-Forwarded-For'da curl'ün bağlandığı adres var (::1, IPv6 loopback), istemcinin uydurduğu IP yok. İstemcinin gönderdiği Forwarded header'ı da tamamen kaldırılmış; 2.3 sürümünde AddXForwarded'a bu davranışı kapatmak için bir parametre eklendi, varsayılan davranış silmek. RequestHeaderOriginalHost sayesinde Catalog Host olarak localhost:5301 değil, istemcinin kullandığı localhost:5300'ü görüyor; mutlak URL üreten ya da host'a göre davranan backend'ler için önemli.

Peki Set yerine Append kullansaydık? Önünde başka bir proxy olan zincirlerde bu gerekli olabilir, çünkü her proxy kendi adresini listeye ekler. Ama gateway internete doğrudan açıksa tehlikeli. Aynı route'a { "X-Forwarded": "Append" } ekleyip özel transform'umuzu kapattığımda Catalog'un gördüğü:

"headers": {
    "X-Bookstore-User-Id": "1",
    "X-Forwarded-For": "203.0.113.66, ::1",
    "X-Forwarded-Host": "localhost:5310",
    "X-Forwarded-Proto": "http"
}

Sahte IP listenin başına yerleşti. Backend'de ForwardedHeaders middleware'i yanlış ayarlıysa rate limit'i, audit log'u ve IP kısıtlarını bu sahte adrese göre yapar. Backend'in hangi proxy'lere güveneceğini nasıl ayarladığımızı Web Server'lar ve Hosting serisinin forwarded header yazısında anlattık. X-Bookstore-User-Id ise daha da kötü: YARP istemcinin header'larını varsayılan olarak olduğu gibi kopyalıyor, yani gateway'in kendi koyacağı bir iç header'ı istemci önceden doldurabiliyor. Bunu bir sonraki bölümdeki transform kapatıyor.

Koddan Transform: Bütün Route'lar İçin Tek Kural

JSON'daki transform'lar route başına. Bütün route'lara uygulanacak bir kural için AddTransforms kullanıyoruz. Bookstore'da bir kural koyduk: X-Bookstore- ile başlayan header'ları yalnız gateway yazar. Gelen istekte bu önekle ne varsa silinir, ardından gateway isteğin hangi route'tan geçtiğini X-Bookstore-Route ile ekler. 04'te kimliği doğrulanmış kullanıcının ID'sini de aynı önekle taşıyacağız; o yüzden bu kuralın şimdiden yerinde olması gerekiyor.

using Yarp.ReverseProxy.Transforms;
using Yarp.ReverseProxy.Transforms.Builder;

namespace Bookstore.Gateway.Transforms;

public static class InternalHeaderTransforms
{
    // her istekte değil, config her yüklendiğinde route başına bir kez çalışır
    public static void Apply(TransformBuilderContext context)
    {
        string routeId = context.Route.RouteId;

        context.AddRequestTransform(transformContext =>
        {
            foreach (string name in transformContext.HttpContext.Request.Headers.Keys)
            {
                if (name.StartsWith(InternalHeaders.Prefix, StringComparison.OrdinalIgnoreCase))
                {
                    transformContext.ProxyRequest.Headers.Remove(name);
                }
            }

            transformContext.ProxyRequest.Headers.TryAddWithoutValidation(InternalHeaders.Route, routeId);
            return ValueTask.CompletedTask;
        });
    }
}

// Program.cs
builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
    .AddTransforms(InternalHeaderTransforms.Apply)
    .AddConfigFilter<DestinationAddressFilter>();

İki ayrıntıya dikkat. Birincisi, Apply metodunun kendisi istek başına çalışmıyor; config yüklendiğinde her route için bir kez çalışıp o route'un transform listesini kuruyor. Bu yüzden route'a göre karar (bu route'a ekle, şuna ekleme) burada verilir; istek başına iş AddRequestTransform'a verdiğin delegate'in içinde yapılır. İkincisi, transform'lar header'la, path'le ve query ile çalışmak için var; request body'ye dokunma. YARP 2.3'ün release notlarına göre request transform'unda HttpRequestMessage.Content'i değiştirmek artık exception fırlatıyor, çünkü YARP içeride bu property'nin değişmeyeceğine güveniyor. Body'yi değiştirmen gerekiyorsa bu genelde gateway'in işi değildir; gerçekten gerekiyorsa YARP pipeline'ından önce çalışan bir middleware'de, boyut sınırlarıyla birlikte düşün.

Config'i Doğrulamak ve Canlı Reload

LoadFromConfig configuration değişikliklerini izliyor; appsettings.json'ı kaydettiğinde yeni route tablosu birkaç saniye içinde devreye giriyor, uygulama yeniden başlamıyor. Bu güçlü bir özellik ama yanlış bir değişiklik de aynı hızla canlıya çıkıyor. YARP 2.3 ile config için bir JSON schema geldi: paket, Visual Studio'ya appsettings*.json dosyalarındaki ReverseProxy bölümü için schema'yı kendisi tanıtıyor ve yanlış alan adlarında, geçersiz enum değerlerinde editör uyarı veriyor. Release notları, paketi güncellemeden en güncel schema'yı kullanmak isteyenler için GitHub'daki adresini de veriyor. Ama schema yalnız editör yardımı; çalışma anındaki davranışı değiştirmiyor. Çalışma anında ne olduğunu üç denemeyle gördüm.

Header modunu yanlış yazıp ("Mode": "Exact"; header'da doğrusu ExactHeader) uygulamayı başlattığımda:

fail: Yarp.ReverseProxy.Configuration.ConfigProvider.ConfigurationConfigProvider[47]
      Configuration data conversion failed.
      System.ArgumentException: Requested value 'Exact' was not found.
Unhandled exception. System.InvalidOperationException: Unable to load or apply the proxy configuration.

Uygulama hiç ayağa kalkmıyor. Bu iyi bir davranış: hatalı config ile deploy pipeline'ı kırmızıya döner. Aynı hatayı uygulama çalışırken dosyaya yazdığımda ise aynı fail satırı loglandı ama gateway çalışmaya devam etti ve son geçerli config ile istekleri karşılamayı sürdürdü. Yani canlı reload'da hatalı bir değişiklik trafiği kesmiyor, ama uygulanmadığını da sana söylemiyor; logdaki bu satırı alarm olarak izlemen gerekiyor.

Üçüncü deneme daha sinsi: route'un ClusterId'sini var olmayan bir cluster'a (orders) çevirdim. Uygulama hatasız başladı. O route'a gelen istek ise şu cevabı aldı:

HTTP/1.1 503 Service Unavailable
Content-Length: 0

info: Yarp.ReverseProxy.Model.ProxyPipelineInitializerMiddleware[4]
      Route 'orders-v1' has no cluster information.

Başlangıçta yakalanmayan bu tür hatalar için en iyi koruma, config'i deploy etmeden önce gateway'i bir integration test'te ayağa kaldırıp her route'a bir istek atmak.

Koddan ve Veritabanından Config

LoadFromMemory ve InMemoryConfigProvider.Update

Route tablosu her zaman bir dosyada durmak zorunda değil. LoadFromMemory route ve cluster'ları koddan alıyor ve DI (Dependency Injection; bağımlılıkların dışarıdan verilmesi) container'ına bir InMemoryConfigProvider kaydediyor. Bu provider'ın Update metoduyla tabloyu çalışma anında değiştirebilirsin. Repo'daki samples/Bookstore.Gateway.InMemory projesi bunu Catalog cluster'ının adresini değiştiren bir endpoint ile gösteriyor:

builder.Services.AddReverseProxy()
    .LoadFromMemory(
        [
            new RouteConfig
            {
                RouteId = "catalog-v1",
                ClusterId = "catalog",
                Match = new RouteMatch { Path = "/api/v1/books/{**rest}" },
                Transforms = [new Dictionary<string, string> { ["PathPattern"] = "/books/{**rest}" }],
            },
        ],
        [CatalogCluster("http://localhost:5301/")]);

var app = builder.Build();

app.MapReverseProxy();

if (app.Environment.IsDevelopment())
{
    // cluster'ı yeniden başlatmadan başka bir adrese yönlendirir
    app.MapPost("/admin/catalog-address", (string address, InMemoryConfigProvider provider) =>
    {
        IProxyConfig current = provider.GetConfig();
        provider.Update(current.Routes, [CatalogCluster(address)]);
        return Results.Ok(new { previousRevision = current.RevisionId, address });
    });
}
curl -X POST "http://localhost:5309/admin/catalog-address?address=http://localhost:5311/"
{"previousRevision":"9f727934-0c1d-45a3-a72b-8138156fca75","address":"http://localhost:5311/"}

      Proxying to http://localhost:5301/books/9780141439549 HTTP/2 RequestVersionOrLower
      Proxying to http://localhost:5311/books/9780141439549 HTTP/2 RequestVersionOrLower

Güncellemeden önceki istek 5301'e, sonraki 5311'e gitti. Bu endpoint yalnız Development'ta açık ve kimlik kontrolü yok; gerçek bir admin API'si yazacaksan 04'teki authorization'ı mutlaka önüne koy.

Veritabanından okuyan IProxyConfigProvider

Route tablosunu operasyon ekibinin yönettiği bir yerde tutmak istiyorsan kendi IProxyConfigProvider'ını yazarsın. Sözleşme küçük: GetConfig() o anki IProxyConfig'i döner; config'in ChangeToken'ı tetiklendiğinde YARP GetConfig()'i yeniden çağırır. Bookstore'da ek route'ları SQL Server'daki BookstoreGatewayDb veritabanında, dbo.GatewayRoutes tablosunda tutuyoruz (tabloyu oluşturan script repo'da db/gateway-config.sql). Cluster'lar appsettings.json'da kalıyor; YARP birden fazla config kaynağını birleştiriyor ve bir kaynaktaki route başka kaynaktaki cluster'ı kullanabiliyor.

public sealed class SqlProxyConfigProvider(
    string connectionString,
    TimeSpan pollInterval,
    ILogger<SqlProxyConfigProvider> logger) : BackgroundService, IProxyConfigProvider
{
    private volatile SqlProxyConfig _config = new([], "empty");

    public IProxyConfig GetConfig() => _config;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using PeriodicTimer timer = new(pollInterval);
        do
        {
            try
            {
                await ReloadAsync(stoppingToken);
            }
            catch (SqlException ex)
            {
                // veritabanına ulaşılamazsa son geçerli config ile devam
                logger.LogWarning(ex, "Could not read gateway routes, keeping revision {Revision}", _config.RevisionId);
            }
        }
        while (await timer.WaitForNextTickAsync(stoppingToken));
    }

    private async Task ReloadAsync(CancellationToken cancellationToken)
    {
        List<RouteConfig> routes = [];
        StringBuilder fingerprint = new();

        await using SqlConnection connection = new(connectionString);
        await connection.OpenAsync(cancellationToken);
        await using SqlCommand command = new(
            """
            SELECT RouteId, ClusterId, Path, [Order], PathPattern
            FROM dbo.GatewayRoutes
            WHERE IsEnabled = 1
            ORDER BY RouteId
            """, connection);
        await using SqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken);

        while (await reader.ReadAsync(cancellationToken))
        {
            RouteConfig route = new()
            {
                RouteId = reader.GetString(0),
                ClusterId = reader.GetString(1),
                Match = new RouteMatch { Path = reader.GetString(2) },
                Order = reader.IsDBNull(3) ? null : reader.GetInt32(3),
                Transforms = reader.IsDBNull(4)
                    ? null
                    : [new Dictionary<string, string> { ["PathPattern"] = reader.GetString(4) }],
            };
            routes.Add(route);
            fingerprint.Append(route.RouteId).Append('|').Append(route.ClusterId).Append('|')
                .Append(route.Match.Path).Append('|').Append(route.Order).Append('|')
                .Append(reader.IsDBNull(4) ? "" : reader.GetString(4)).Append(';');
        }

        string revision = Convert.ToHexString(
            System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(fingerprint.ToString())))[..12];
        if (revision == _config.RevisionId)
        {
            return;
        }

        SqlProxyConfig old = _config;
        _config = new SqlProxyConfig(routes, revision);
        old.SignalChange();
        logger.LogInformation("Loaded {Count} route(s) from SQL, revision {Revision}", routes.Count, revision);
    }

    private sealed class SqlProxyConfig : IProxyConfig
    {
        private readonly CancellationTokenSource _changed = new();

        public SqlProxyConfig(IReadOnlyList<RouteConfig> routes, string revisionId)
        {
            Routes = routes;
            RevisionId = revisionId;
            ChangeToken = new CancellationChangeToken(_changed.Token);
        }

        public string RevisionId { get; }
        public IReadOnlyList<RouteConfig> Routes { get; }
        public IReadOnlyList<ClusterConfig> Clusters { get; } = [];
        public IChangeToken ChangeToken { get; }

        public void SignalChange() => _changed.Cancel();
    }
}

Provider her 10 saniyede tabloyu okuyor, satırlardan bir parmak izi çıkarıyor ve yalnız içerik değiştiyse yeni config'i yayımlayıp eskisinin token'ını tetikliyor. Tablo birkaç satırlık olduğu için her seferinde tamamını okumak, UpdatedAt kolonuna ve trigger'a güvenmekten daha basit ve daha az hata çıkarıyor. Kayıt, connection string verilmişse yapılıyor; connection string'i dosyaya değil environment variable'a koy (ConnectionStrings__GatewayConfig):

string? gatewayDb = builder.Configuration.GetConnectionString("GatewayConfig");
if (!string.IsNullOrEmpty(gatewayDb))
{
    builder.Services.AddSingleton(sp => new SqlProxyConfigProvider(
        gatewayDb,
        TimeSpan.FromSeconds(10),
        sp.GetRequiredService<ILogger<SqlProxyConfigProvider>>()));
    builder.Services.AddSingleton<IProxyConfigProvider>(sp => sp.GetRequiredService<SqlProxyConfigProvider>());
    builder.Services.AddHostedService(sp => sp.GetRequiredService<SqlProxyConfigProvider>());
}

Gateway çalışırken tabloya bir v2 route'u ekleyelim:

curl -o /dev/null -w "%{http_code}\n" http://localhost:5300/api/v2/books/9780441172719
404

INSERT dbo.GatewayRoutes (RouteId, ClusterId, Path, PathPattern)
VALUES (N'catalog-v2', N'catalog', N'/api/v2/books/{**rest}', N'/books/{**rest}');

info: Bookstore.Gateway.Configuration.SqlProxyConfigProvider[0]
      Loaded 1 route(s) from SQL, revision 16D84FEED90D

curl -w "  [%{http_code}]\n" http://localhost:5300/api/v2/books/9780441172719
{"isbn":"9780441172719","title":"Dune","author":"Frank Herbert","price":420.00}  [200]

Satırı IsEnabled = 0 yaptığımda bir sonraki okumada Loaded 0 route(s) logu geldi ve aynı istek tekrar 404 döndü. Veritabanını bilerek dondurduğumda (container'ı docker pause ile durdurdum) ise sorgu command timeout'a kadar bekledi, ardından provider şu uyarıyı yazdı ve v2 route'u çalışmaya devam etti:

warn: Bookstore.Gateway.Configuration.SqlProxyConfigProvider[0]
      Could not read gateway routes, keeping revision 16D84FEED90D

Bu davranış bilinçli: veritabanı düştü diye gateway'in route kaybetmesi, veritabanı arızasını bütün sitenin arızasına çevirir.

IProxyConfigFilter: Environment'a göre adres

Route tablosu her ortamda aynı, ama adresler değişiyor: geliştirmede localhost:5301, container ortamında catalog:8080. Bütün config'i her ortam için kopyalamak yerine YARP'ın config filter'ını kullanıyoruz. Filter, hangi kaynaktan gelirse gelsin her route ve cluster YARP'a verilmeden önce çalışıyor:

// DestinationAddresses:catalog-1 değeri varsa destination adresini onunla değiştirir
public sealed class DestinationAddressFilter(IConfiguration configuration) : IProxyConfigFilter
{
    public ValueTask<ClusterConfig> ConfigureClusterAsync(ClusterConfig cluster, CancellationToken cancel)
    {
        if (cluster.Destinations is null)
        {
            return new(cluster);
        }

        Dictionary<string, DestinationConfig> destinations = new(StringComparer.OrdinalIgnoreCase);
        foreach ((string id, DestinationConfig destination) in cluster.Destinations)
        {
            string? address = configuration[$"DestinationAddresses:{id}"];
            destinations[id] = string.IsNullOrEmpty(address)
                ? destination
                : destination with { Address = address };
        }

        return new(cluster with { Destinations = destinations });
    }

    public ValueTask<RouteConfig> ConfigureRouteAsync(
        RouteConfig route, ClusterConfig? cluster, CancellationToken cancel) => new(route);
}
dotnet run --project src/Bookstore.Gateway -- --DestinationAddresses:catalog-1=http://localhost:5311/
curl -o /dev/null -w "%{http_code}\n" http://localhost:5300/api/v1/books
200
      Proxying to http://localhost:5311/books HTTP/2 RequestVersionOrLower

Değeri burada command line argümanıyla verdim. Destination ID'sinde tire olduğu için bash ve zsh bu anahtarı environment variable olarak export edemiyor; appsettings.Production.json, command line ya da container platformunun environment ayarı kullanılabilir. ClusterConfig ve DestinationConfig record olduğu için with ile kopyalayıp değiştirmek yeterli; YARP'ın config nesneleri immutable.

Üretimde Dikkat

Canlı reload'u bir değişiklik kanalı olarak gör ve ona göre koru. Dosyadan okuyorsan dosyayı kimin değiştirebildiğini, veritabanından okuyorsan tabloya kimin yazabildiğini kontrol et; gateway'in route tablosuna yazabilen kişi bütün trafiği istediği yere gönderebilir. Her config yüklemesini (Loading proxy data from config, bizim Loaded N route(s) from SQL satırımız) ve özellikle Configuration data conversion failed hatasını izle; ikincisi "değişikliğin uygulanmadı" demek.

Çok instance'lı bir gateway'de dosyadan reload, her instance'ın kendi dosyasını görmesi demek. Container ortamında config'i image'a gömdüysen reload zaten anlamsız, değişiklik yeni deploy ile gelir; config'i paylaşılan bir kaynaktan (veritabanı, config service) okuyorsan instance'lar birkaç saniye farkla güncellenir. Bu arada aynı istek iki instance'ta farklı route'a gidebilir; geçişleri buna göre planla. Konuyu 07'de ayrıntılı ele alacağız.

Header transform'larında boyut ve sayıya da dikkat et. Gateway'in eklediği her header backend'in header limitine sayılıyor. İstemcinin gönderdiği bütün header'ları körü körüne iletmek yerine, gerekirse RequestHeadersAllowed ile yalnız izin verilen header'ların geçmesini sağlayabilirsin.

Sık Düşülen Tuzaklar

Birincisi, istemciden gelen iç header'lara güvenmek. YARP varsayılan olarak bütün header'ları kopyalıyor; X-User-Id, X-Tenant ya da X-Internal-* gibi gateway'in koyacağı her header'ı gelişte silmezsen istemci onları kendisi doldurur. Bookstore'da bunu X-Bookstore- önekiyle tek kuralda çözdük.

İkincisi, X-Forwarded'ı internete açık gateway'de Append yapmak. Zincirde gerçekten güvenilir bir proxy yoksa ilk adres her zaman istemcinin yazdığı adres olur.

Üçüncüsü, Order'ı route'ları "sıralamak" için kullanmak. Yukarıda gördüğün gibi, varsayılanı 0 olan bir catch-all, pozitif Order verdiğin bütün route'ları yutar.

Dördüncüsü, transform'u istek başına çalışıyor sanmak. AddTransforms'a verdiğin delegate'in gövdesinde istek bilgisine erişmeye çalışırsan istek yok; o kod config yüklenirken route başına bir kez çalışır.

Beşincisi, veritabanı tabanlı provider'da her hata için boş config yayımlamak. Okuma başarısızsa son geçerli config'i koru; "route sayısı 0" sonucunu da gerçekten bilinçli bir durum mu diye kontrol etmek isteyebilirsin.

Küçük Bir Deneme

★☆☆ catalog-v1-mobile route'unu X-Client-Platform header'ı yerine ?client=mobile query parametresiyle eşleşecek şekilde değiştir ve /api/v1/books?client=mobile isteğinin Catalog'a hangi path ve query ile gittiğini /debug/catalog benzeri bir route ile kontrol et.

★★☆ legacy-fallback route'unu geri ekle, ama bu sefer doğru sırayla: catch-all'a "Order": 1000 ver ve catalog-v1'deki Order'ı da bırak. /api/v1/books/9780547928227 ve /api/unknown isteklerinin hangi route'tan geçtiğini logdan doğrula.

★★★ SqlProxyConfigProvider'a dbo.GatewayRoutes için bir Hosts kolonu ekle (virgülle ayrılmış) ve provider'da oku. Ardından provider'ın okuduğu route'ları YARP'a vermeden önce doğrulayan bir adım yaz: ClusterId'si bilinen cluster'lardan biri değilse o satırı atla ve Warning logla. Böylece yukarıda gördüğümüz 503 "no cluster information" durumu veritabanından gelen route'larda hiç oluşmasın.

Route tablosu artık esnek ama her cluster'da hâlâ tek destination var. Bir sonraki yazıda Catalog'u üç instance'a çıkarıyoruz: load balancing policy'lerini karşılaştıracak, bir instance'ı öldürüp active ve passive health check'in tepkisini ölçecek ve session affinity'nin ne zaman gerçekten gerektiğini konuşacağız.