Mert Özen Her Satırda Daha İleri
DevOps Domain, DNS ve CDN — #05

CDN Nasıl Çalışır? Edge, Origin, Cache Key, s-maxage ve Angular + API için Doğru Cache Stratejisi

Mert Özen 14 Kas 2026 14 dk 2 görüntülenme
CDN Nasıl Çalışır? Edge, Origin, Cache Key, s-maxage ve Angular + API için Doğru Cache Stratejisi

Bir CDN, cevabı cache key'e göre edge'de saklar ve origin'in header'larına uyar; bu yüzden hash'li Angular dosyalarına immutable, index.html'e no-cache, katalog API'sine s-maxage ve CDN-Cache-Control, kişiye özel cevaplara private, no-store verilir. Nginx ile kurduğumuz bir edge'de public header'ın Grace'e Ada'nın siparişlerini gösterdiğini, query string'siz cache key'in sayfaları karıştırdığını ve stale yönergelerinin origin çökükken siteyi nasıl ayakta tuttuğunu gerçek isteklerle görüyoruz.

Sürüm notu: Bu yazıdaki örnekler .NET 10, Angular 22.2 (TypeScript 6.0) ve edge rolünde Nginx 1.30.6 ile Docker 29 üzerinde denenmiştir; Cloudflare davranışları 14 Kasım 2026 tarihli resmi dokümanlarından aktarılmıştır, bir Cloudflare hesabında çalıştırılmamıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) artık bir CDN (içeriği kullanıcıya yakın sunuculardan dağıtan ağ) arkasında. Bir sprint'te "API yavaş" şikâyeti üzerine Emre, ortak bir helper'a Cache-Control: public, max-age=60 ekliyor ve bu helper'ı kullanan endpoint'lerden biri de GET /api/orders/mine: giriş yapmış müşterinin kendi siparişleri. İstekler Authorization header'ı taşıyor.

Tahmin et: Ada siparişlerini açtıktan on saniye sonra Grace de kendi siparişlerini açarsa ne görür? Authorization header'ı farklı olduğuna göre CDN bunları ayrı tutmaz mı? Bu yazıda bir CDN edge'ini Nginx ile laboratuvarda kurup bu soruyu ve birkaç benzerini gerçek isteklerle cevaplıyoruz.

CDN'in Parçaları: Edge, Origin, Shield ve Hit Ratio

Bir CDN'de kullanıcının bağlandığı sunucuya edge denir ve bunlar dünyaya dağılmış PoP'larda (point of presence; CDN'in bir şehirdeki veri merkezi) durur. Cevabın asıl üretildiği yer origin: Bookstore için Bookstore.Api ve bookstore-web'in build çıktısı. Edge cevabı kendi cache'inde bulursa (HIT) origin'e hiç gitmez; bulamazsa (MISS) origin'e sorar, cevabı saklar ve döner. Arada genelde bir katman daha vardır: origin shield (Cloudflare'de Tiered Cache). Edge MISS olduğunda doğrudan origin'e değil, origin'e yakın seçilmiş bir üst katmana sorar. Böylece yüzlerce PoP'un aynı dosya için origin'e ayrı ayrı gelmesi engellenir. Cloudflare'in Smart Tiered Cache'i bütün planlarda var.

05-cdn-request-path-tr.png

İzlemen gereken temel metrik cache hit ratio: edge'in kendisi cevapladığı isteklerin toplam isteklere oranı. Statik dosyalarda %95'in üstü normaldir; API'de çok daha düşük olur ve öyle de olmalı. Hit ratio'yu belirleyen iki şey var: cache key (iki isteğin "aynı" sayılıp sayılmayacağı) ve cevabın header'ları (cevabın saklanıp saklanmayacağı ve ne kadar süre).

Laboratuvar: Bir Edge ve Bir Origin

Gerçek bir CDN hesabına girmeden davranışı görmek için Nginx'in proxy_cache özelliğiyle tek PoP'luk bir edge kurdum. Origin, makinede çalışan Bookstore.Api; edge onun önünde bir container. Edge'in config'i:

# edge.conf: Bookstore origin'inin önünde tek bir CDN edge düğümü
proxy_cache_path /var/cache/nginx/edge levels=1:2 keys_zone=edge:10m max_size=200m inactive=1d use_temp_path=off;

upstream origin {
    server host.docker.internal:5180;
}

server {
    listen 8080;
    server_name bookstore.example;

    proxy_cache edge;
    proxy_cache_key $scheme$host$request_uri;
    proxy_cache_lock on;
    proxy_cache_background_update on;
    proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;

    proxy_set_header Host $host;
    add_header X-Cache-Status $upstream_cache_status always;

    location / {
        proxy_pass http://origin;
    }
}

proxy_cache_key cache key'in kendisi: scheme, host ve query string dahil tam path. proxy_cache_lock, aynı anahtar için aynı anda gelen MISS'lerden yalnızca birinin origin'e gitmesini sağlar; Caching serisinde anlattığımız cache stampede'in edge'deki karşılığı. X-Cache-Status header'ı her cevabın HIT mi MISS mi olduğunu gösteriyor. Origin tarafında Bookstore.Api'nin ilk hâli:

using Bookstore.Api;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var originRequests = 0;
app.Use(async (context, next) =>
{
    // hangi cevapların gerçekten origin'e ulaştığını görmek için
    context.Response.Headers["X-Origin-Request"] = Interlocked.Increment(ref originRequests).ToString();
    await next();
});

app.UseDefaultFiles();
app.UseStaticFiles(new StaticFileOptions { OnPrepareResponse = CacheHeaders.ForStaticFile });

Book[] catalog =
[
    new("9780441172719", "Dune", 450.00m),
    new("9780141439587", "Emma", 190.00m),
    new("9780547928227", "The Hobbit", 320.00m),
    new("9780141439549", "Middlemarch", 280.00m),
];

var api = app.MapGroup("/api");

api.MapGet("/books", (HttpContext http, int page = 1) =>
{
    http.Response.Headers.CacheControl = CacheHeaders.Catalog;
    return catalog.Skip((page - 1) * 2).Take(2);
});

api.MapGet("/books/featured", (HttpContext http) =>
{
    // tarayıcılar her seferinde doğrular, CDN 5 dakika tutar (RFC 9213)
    http.Response.Headers.CacheControl = "no-cache";
    http.Response.Headers["CDN-Cache-Control"] = "max-age=300";
    return catalog[0];
});

api.MapGet("/cart", (HttpContext http) =>
{
    http.Response.Headers.CacheControl = "private, no-store";
    http.Response.Cookies.Append("cart-id", "c-1042", new CookieOptions { HttpOnly = true, Secure = true });
    return new Cart(2, 640.00m);
});

api.MapGet("/orders/mine", (HttpContext http) =>
{
    // yalnızca laboratuvar: bearer token müşterinin adı
    var customer = http.Request.Headers.Authorization.ToString().Replace("Bearer ", "");
    if (customer.Length == 0)
    {
        return Results.Unauthorized();
    }

    http.Response.Headers.CacheControl = CacheHeaders.PerUser;
    return Results.Ok(new OrderSummary(customer, customer == "ada" ? ["BK-1042"] : ["BK-2001", "BK-2002"]));
});

app.MapFallbackToFile("index.html", new StaticFileOptions { OnPrepareResponse = CacheHeaders.ForStaticFile });

app.Run();

record Book(string Isbn, string Title, decimal Price);
record Cart(int Items, decimal Total);
record OrderSummary(string Customer, string[] Orders);
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.StaticFiles;

namespace Bookstore.Api;

public static partial class CacheHeaders
{
    // tarayıcı 1 dakika, shared cache 5 dakika; sonra yenilerken veya origin çökükken eski cevap
    public const string Catalog = "public, max-age=60, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400";

    public const string PerUser = "public, max-age=60";

    public static void ForStaticFile(StaticFileResponseContext context) =>
        context.Context.Response.Headers.CacheControl = HashedAsset().IsMatch(context.File.Name)
            ? "public, max-age=31536000, immutable"
            : "no-cache";

    // Angular build çıktısı: main-STNEDLO4.js, chunk-GFHEENPR.js, styles-5INURTSO.css
    [GeneratedRegex(@"-[A-Z0-9]{8}\.(js|css)$")]
    private static partial Regex HashedAsset();
}

PerUser, Önce Tahmin Et'teki hatanın kendisi. wwwroot'ta da Angular 22'nin ng build çıktısı duruyor. Origin'i dotnet run --urls http://127.0.0.1:5180, edge'i docker run -p 127.0.0.1:8080:8080 -v ./edge.conf:/etc/nginx/conf.d/edge.conf:ro nginx:1.30-alpine ile başlattım. Header'ları kısaltmak için küçük bir shell fonksiyonu kullanıyorum:

# h <url> [curl argümanları...]: durum satırı, cache header'ları ve gövde
h() {
    local url=$1; shift
    curl -s -D /tmp/h.hdr -o /tmp/h.body -H "Host: bookstore.example" "$@" "$url"
    grep -iE "^(HTTP|cache-control|cdn-cache-control|x-origin-request|x-cache-status|set-cookie)" /tmp/h.hdr | tr -d '\r'
    head -c 160 /tmp/h.body; echo
}

Önce Tahmin Et'in cevabı:

$ h http://127.0.0.1:8080/api/orders/mine -H "Authorization: Bearer ada"
HTTP/1.1 200 OK
Cache-Control: public, max-age=60
X-Origin-Request: 8
X-Cache-Status: MISS
{"customer":"ada","orders":["BK-1042"]}
$ h http://127.0.0.1:8080/api/orders/mine -H "Authorization: Bearer grace"
HTTP/1.1 200 OK
Cache-Control: public, max-age=60
X-Origin-Request: 8
X-Cache-Status: HIT
{"customer":"ada","orders":["BK-1042"]}

Grace, Ada'nın siparişlerini görüyor. X-Origin-Request değişmemiş: istek origin'e hiç ulaşmamış. Cache key'de Authorization yok, cevap da "herkese açık, bir dakika sakla" diyor. Edge ikisine de uydu. RFC (internet standart dokümanı) 9111'e göre shared cache, Authorization taşıyan bir isteğin cevabını normalde saklamamalı; ama cevapta public, s-maxage veya must-revalidate varsa saklayabilir. public'i koyan, bu kapıyı kendisi açmış oluyor. Cloudflare de varsayılan ayarlarla aynı kuralı uyguluyor. Düzeltmeyi API bölümünde yapacağız.

Cache Key ve Vary

Cache key, "bu iki istek aynı cevabı alabilir mi?" sorusunun cevabıdır. Fazla dar bir key (her isteğe özel bir parça içeren) hit ratio'yu düşürür. Fazla geniş bir key ise farklı içerikleri birbirine karıştırır. İkincisi çok daha tehlikeli. Hit ratio'yu artırmak için query string'i key'den çıkaran bir edge'i ikinci port'ta denedim:

# yanlış yapılandırılmış bir edge: query string cache key'e girmiyor
server {
    listen 8081;
    server_name bookstore.example;

    proxy_cache edge;
    proxy_cache_key $scheme$host$uri;
    proxy_set_header Host $host;
    add_header X-Cache-Status $upstream_cache_status always;

    location / {
        proxy_pass http://origin;
    }
}
$ h "http://127.0.0.1:8081/api/books?page=1"
HTTP/1.1 200 OK
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400
X-Origin-Request: 11
X-Cache-Status: MISS
[{"isbn":"9780441172719","title":"Dune","price":450.00},{"isbn":"9780141439587","title":"Emma","price":190.00}]
$ h "http://127.0.0.1:8081/api/books?page=2"
HTTP/1.1 200 OK
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400
X-Origin-Request: 11
X-Cache-Status: HIT
[{"isbn":"9780441172719","title":"Dune","price":450.00},{"isbn":"9780141439587","title":"Emma","price":190.00}]

İkinci sayfa birinci sayfanın içeriğiyle geldi. Doğru key'li edge'de (8080) aynı iki istek iki ayrı MISS üretip Hobbit ve Middlemarch'ı döndürdü. Query string'i key'den çıkarmak yalnızca içeriği hiç değiştirmeyen parametreler için mantıklı: utm_source gibi kampanya parametreleri. CDN'lerin cache rule'larında "şu parametreleri yok say" seçeneği tam bunun için. Bütün query string'i atmak ise sayfalama, arama ve filtrenin hepsini bozar.

Key'e header eklemek için Vary var. Origin Vary: Accept-Language dönerse edge her dil için ayrı kopya tutar. Accept-Encoding gibi az değerli header'lar sorun değil. Ama Vary: User-Agent veya Vary: Cookie, her kullanıcı için ayrı bir kopya demek ve hit ratio'yu sıfıra yaklaştırır. Bazı CDN'ler Vary'yi yalnızca belirli header'lar için destekler; kişiye özel içerik için Vary'ye güvenme, o cevapları hiç cache'leme.

Shared Cache Yönergeleri: s-maxage, stale-while-revalidate, stale-if-error

HTTP caching header'larının anlamını Networking ve HTTP serisinin "HTTP Caching ve Conditional Request'ler" yazısında gördük. Burada CDN'i ilgilendiren yönergelere bakıyoruz. Kataloğun ilk header'ı: tarayıcıya 60 saniye (max-age), shared cache'e 300 saniye (s-maxage); süre dolduktan sonraki 60 saniyede edge eski cevabı verip arka planda yenileyebilir (stale-while-revalidate, RFC 5861); origin hata verirse bir gün boyunca eski cevabı verebilir (stale-if-error). Dört davranışı da sırayla ölçtüm:

[02:26:03]
HTTP/1.1 200 OK
X-Origin-Request: 12
X-Cache-Status: HIT
[02:27:09]
HTTP/1.1 200 OK
X-Origin-Request: 12
X-Cache-Status: HIT
[02:31:06] page=1, s-maxage expired, origin up
HTTP/1.1 200 OK
X-Origin-Request: 12
X-Cache-Status: STALE
[02:31:07] page=1 again
HTTP/1.1 200 OK
X-Origin-Request: 16
X-Cache-Status: HIT
[02:32:07] origin stopped, page=2 expired
HTTP/1.1 200 OK
X-Origin-Request: 13
X-Cache-Status: STALE
[02:32:07] page=3, never cached
HTTP/1.1 502 Bad Gateway
X-Cache-Status: MISS

Cevap 02:25'in sonunda cache'lendi. 02:27:09'da max-age=60 çoktan geçmişti, ama edge HIT veriyor, çünkü shared cache için geçerli olan s-maxage=300. 02:31:06'da 300 saniye de dolmuştu: edge eski cevabı STALE olarak hemen döndü ve arka planda origin'e gitti, bir saniye sonraki istek yeni cevapla (X-Origin-Request: 16) HIT. 02:32'de origin'i durdurdum. Süresi dolmuş ikinci sayfa yine STALE olarak geldi, hiç cache'lenmemiş üçüncü sayfa ise 502. stale-if-error, origin'in kötü bir deploy'u sırasında katalog sayfalarını ayakta tutar.

Ama bu header gerçek bir CDN'de aynı sonucu vermeyebilir. RFC 9111'e göre s-maxage, shared cache için proxy-revalidate anlamını da taşır, yani "süresi dolmuşsa doğrulamadan verme" demektir. Cloudflare dokümanı bunu açıkça yazıyor: s-maxage varken stale-while-revalidate devre dışı ve stale-if-error yok sayılıyor. Nginx bu ayrıntıyı uygulamıyor, Cloudflare uyguluyor; iki "CDN" aynı header'la farklı davranıyor. Bu yüzden CDN'e özel davranışı ayrı bir header'a taşımak daha doğru: RFC 9213'teki CDN-Cache-Control. Bu header'ı destekleyen CDN'ler onu kullanır ve Cache-Control'e bakmaz; diğer cache'ler ve tarayıcılar onu yok sayar. Cloudflare CDN-Cache-Control'ü destekliyor ve aşağı yöndeki cache'lere iletiyor; yalnızca Cloudflare'in okumasını istediğin değerler için Cloudflare-CDN-Cache-Control da var. Laboratuvardaki Nginx ise RFC 9213'ü uygulamıyor:

$ h http://127.0.0.1:8080/api/books/featured
HTTP/1.1 200 OK
Cache-Control: no-cache
X-Origin-Request: 9
CDN-Cache-Control: max-age=300
X-Cache-Status: MISS
{"isbn":"9780441172719","title":"Dune","price":450.00}
$ h http://127.0.0.1:8080/api/books/featured
HTTP/1.1 200 OK
Cache-Control: no-cache
X-Origin-Request: 10
CDN-Cache-Control: max-age=300
X-Cache-Status: MISS
{"isbn":"9780441172719","title":"Dune","price":450.00}

Her istek origin'e gidiyor. Kataloğun son hâli iki dünyaya da uyacak şekilde iki header gönderiyor:

public static void ForCatalog(HttpResponse response)
{
    // tarayıcı: 1 dakika; herhangi bir shared cache: 5 dakika
    response.Headers.CacheControl = "public, max-age=60, s-maxage=300";
    // RFC 9213'ü uygulayan CDN'ler: 5 dakika, sonra yenilerken veya origin çökükken eski cevap
    response.Headers["CDN-Cache-Control"] = "max-age=300, stale-while-revalidate=60, stale-if-error=86400";
}
$ curl -s -D - -o /dev/null http://127.0.0.1:5180/api/books | grep -i cache-control
Cache-Control: public, max-age=60, s-maxage=300
CDN-Cache-Control: max-age=300, stale-while-revalidate=60, stale-if-error=86400

Sonuç: kullandığın CDN'in dokümanında header tablosunu oku ve davranışı kendi edge'inde ölç. Bir CDN'de çalışan header, diğerinde çalışmayabilir.

Purge mi, Versiyonlu URL mi?

Cache'teki bir içeriği değiştirmenin iki yolu var. Purge, CDN'e "şunu sil" demek. Cloudflare'de bütün planlarda URL, cache tag, hostname, prefix ve "her şey" ile purge var; ama rate limit'ler plana göre değişiyor, örneğin Free planda tag/hostname/prefix/her şey purge'ü dakikada 5 istek. Purge birden fazla katmanda çalışmalıdır: CDN'i temizlersin, ama kullanıcının tarayıcısındaki max-age=31536000'lik kopyaya dokunamazsın.

Versiyonlu URL ise içeriği değiştirmek yerine adını değiştirmek: main-STNEDLO4.js değişince main-ZVLSC526.js olur. Eski ad cache'te kalır ama artık kimse onu istemez; yeni ad ilk istekte MISS olur ve cache'e girer. Purge'e, sıraya ve rate limit'e gerek kalmaz; tarayıcı cache'i de sorun olmaz. Bu yüzden her değişen statik dosya için versiyonlu URL, purge'den daha güvenilir. Purge'ü versiyonlanamayan şeyler için sakla: index.html, API cevapları, bir görselin aynı adla yeniden yüklenmesi.

Angular: Hash'li Bundle'lar immutable, index.html no-cache

Angular CLI build çıktısındaki dosya adlarına içeriğin hash'ini ekler:

Lazy chunk files    | Names         |  Raw size | Estimated transfer size
chunk-GFHEENPR.js   | book-list     | 590 bytes |               590 bytes

CacheHeaders.ForStaticFile bu deseni tanıyıp hash'li dosyalara public, max-age=31536000, immutable, diğer her şeye (index.html dahil) no-cache veriyor. immutable, tarayıcıya "sayfa yenilense bile bunu doğrulamaya kalkma" der. Edge'de:

$ curl -s -o /dev/null -D - -H "Host: bookstore.example" http://127.0.0.1:8080/main-STNEDLO4.js | grep -iE "^(HTTP|cache-control|x-origin|x-cache)"
HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, immutable
X-Origin-Request: 5
X-Cache-Status: HIT
$ h http://127.0.0.1:8080/ | head -4
HTTP/1.1 200 OK
Cache-Control: no-cache
X-Origin-Request: 4
X-Cache-Status: MISS

Nginx no-cache'li cevabı hiç saklamıyor ve her seferinde origin'e gidiyor. RFC 9111'e göre no-cache "saklayabilirsin ama her kullanımda doğrula" demek; Cloudflare'in dokümanına göre Origin Cache Control açıkken (Free, Pro ve Business planlarda hep açık) Cloudflare da saklayıp her seferinde doğruluyor. İki davranışta da sonuç aynı: index.html her zaman güncel. Küçük bir dosya için origin'e gitmenin maliyeti düşük.

Deploy anında ise klasik bir sorun var. Kullanıcı eski index.html'i yüklemiş, sayfa açık duruyor. Biz deploy ettik ve eski dosyaları silen bir kopyalama yaptık. Kullanıcı /books'a tıkladığında tarayıcı eski main'in bildiği lazy chunk'ı ister:

$ curl -s -o /dev/null -D - -H "Host: bookstore.example" http://127.0.0.1:8080/chunk-GFHEENPR.js | grep -iE "^(HTTP|content-type|x-cache|cache-control)"
HTTP/1.1 404 Not Found
X-Cache-Status: MISS

Edge'de yok (o güne kadar kimse istememiş), origin'de de yok. Tarayıcıda bu bir ChunkLoadError. Deployment Stratejileri serisinin "Zero-Downtime'ın Ön Koşulları" yazısında anlattığımız çözüm burada da geçerli: deploy'da eski hash'li dosyaları silme, en az bir önceki sürümünkileri tut. Edge'deki immutable kopya bazen seni kurtarır ama ona güvenemezsin; her PoP'ta her dosya yoktur. Olumlu bir ayrıntı: ASP.NET Core'un MapFallbackToFile'ı varsayılan olarak yalnızca uzantısız path'leri yakalıyor; bu yüzden eksik .js için index.html yerine 404 döndü. SPA (tek sayfalık uygulama) fallback'i try_files $uri /index.html gibi her şeyi yakalayacak şekilde yazarsan, eksik bir JavaScript dosyası için 200 text/html döner ve no-cache olmayan bir yapılandırmada bu HTML, JavaScript adresiyle edge'e yerleşir.

API: Neyi Edge'de Cache'leyebilirsin?

Bookstore'un API'sinde üç grup var. Herkese aynı dönen ve birkaç dakika eski olması sorun olmayan cevaplar: kitap kataloğu, kategori listesi, kitap detayı. Bunlar edge'e çok uygun. Kişiye özel cevaplar: sepet, siparişler, profil. Bunlar asla shared cache'e girmemeli. Yazma işlemleri: POST/PUT/DELETE zaten cache'lenmez. Sepetin header'ları doğru:

$ h http://127.0.0.1:8080/api/cart
HTTP/1.1 200 OK
Cache-Control: private, no-store
Set-Cookie: cart-id=c-1042; path=/; secure; httponly
X-Origin-Request: 6
X-Cache-Status: MISS
{"items":2,"total":640.00}

Her istek MISS. private shared cache'lere "saklama" der. Ayrıca hem Nginx hem Cloudflare (varsayılan ayarlarla) Set-Cookie içeren cevabı cache'lemez. Bu ikinci kural bir güvenlik ağı, ama ona yaslanma: Cloudflare'in dokümanına göre bazı ayarlarla cevap Set-Cookie çıkarılarak cache'lenebiliyor.

Siparişler için düzeltme iki katmanlı. Birincisi origin'de, doğru header:

public const string PerUser = "private, no-store";
$ h http://127.0.0.1:8081/api/orders/mine -H "Authorization: Bearer ada"
HTTP/1.1 200 OK
Cache-Control: private, no-store
X-Origin-Request: 7
X-Cache-Status: MISS
{"customer":"ada","orders":["BK-1042"]}
$ h http://127.0.0.1:8081/api/orders/mine -H "Authorization: Bearer grace"
HTTP/1.1 200 OK
Cache-Control: private, no-store
X-Origin-Request: 8
X-Cache-Status: MISS
{"customer":"grace","orders":["BK-2001","BK-2002"]}

İkincisi edge'de, origin bir gün yine hata yaparsa diye bir kural: kimlik bilgisi taşıyan istek ne cache'ten cevaplanır ne de cache'e yazılır.

    location /api/ {
        # kimlik bilgisi taşıyan bir istek için cache'ten cevap verme ve cache'e yazma
        proxy_cache_bypass $http_authorization;
        proxy_no_cache $http_authorization;
        proxy_pass http://origin;
    }

Bu kuralı origin'deki hata hâlâ dururken ekleyip denedim; iki istek de BYPASS oldu ve Grace kendi siparişlerini gördü:

$ h http://127.0.0.1:8080/api/orders/mine -H "Authorization: Bearer grace"
HTTP/1.1 200 OK
Cache-Control: public, max-age=60
X-Origin-Request: 15
X-Cache-Status: BYPASS
{"customer":"grace","orders":["BK-2001","BK-2002"]}

Cloudflare'de aynı kuralın karşılığı, /api/ path'leri için bir Cache Rule; altıncı yazıda kuracağız. Bir karışıklığı da burada netleştirelim: ASP.NET Core'un Output Cache'i sunucunun kendi belleğinde (veya Redis'te) tutulan bir cache; cevabın Cache-Control header'ını kendiliğinden ayarlamaz. Output Cache'te bir policy tanımlamak, CDN'in o cevabı saklayacağı anlamına gelmez; tersi de geçerli. Output Cache'in ayrıntıları Caching serisinin "ASP.NET Core Output Cache Derinlemesine" yazısında. CDN'e ne söyleyeceğini her zaman header'larla ayrıca belirle.

Üretimde Dikkat

CDN'in ne yaptığını header'larından oku. Her CDN bir cache durum header'ı döner (Cloudflare'de CF-Cache-Status, Azure Front Door'da X-Cache). Deploy sonrası smoke test'ine birkaç curl -I ekle: hash'li dosya HIT mi, index.html her zaman güncel mi, /api/orders/mine asla HIT değil mi?

Hit ratio'yu ve origin trafiğini izle. Hit ratio birden düşerse genelde bir cache key değişikliği (yeni bir query parametresi, bir Vary header'ı) veya kısa bir TTL (cache süresi) vardır. Origin trafiği birden artarsa, belki bir purge ya da yayılmış bir cache kuralı hatası. CDN'in analytics ekranındaki bu iki grafik, Observability serisinde kuracağımız alarmların ilk adayları.

Kişiye özel cevapları test et. Önce Tahmin Et'teki hata tipik olarak bir refactoring'de gelir: ortak bir helper'a eklenen bir header bütün endpoint'lere yayılır. İki farklı kullanıcıyla aynı endpoint'i çağırıp cevapların farklı olduğunu, cache durumunun da HIT olmadığını kontrol eden bir integration test, bu hatayı production'a çıkmadan yakalar.

Stale yönergelerini bilinçli kullan. stale-if-error origin kesintisinde siteyi ayakta tutar, ama yanlış fiyatı bir gün boyunca göstermek de demek olabilir. Fiyat gibi kritik veriler için süreyi kısa tut veya bu yönergeyi kullanma.

Sık Düşülen Tuzaklar

Kişiye özel cevaba public yazmak: Önce Tahmin Et'teki durum. public, Authorization taşıyan isteğin cevabının shared cache'e girmesine açıkça izin verir.

Query string'i cache key'den atmak: Sayfalama, arama ve filtre birbirine karışır. Yalnızca içeriği etkilemeyen parametreleri (kampanya parametreleri gibi) key'den çıkar.

Bir CDN'in header davranışını diğerine taşımak: Nginx'te çalışan s-maxage + stale-while-revalidate kombinasyonu Cloudflare'de aynı sonucu vermez. Her CDN'in tablosunu oku, ölç.

Deploy'da eski chunk'ları silmek: Açık sekmeler eski main'in istediği dosyaları aramaya devam eder ve 404 alır. En az bir önceki sürümün hash'li dosyalarını tut.

Purge'e güvenip versiyonlamamak: Tarayıcıdaki bir yıllık immutable kopyayı hiçbir purge silemez. Değişen her statik dosyanın adı da değişmeli.

Küçük Bir Deneme

★☆☆ İyi bildiğin bir sitenin ana sayfasını, bir CSS dosyasını ve bir API isteğini curl -I ile iste. Hangi cache durum header'ları dönüyor? Cache-Control değerleri bu yazıdaki önerilerle uyumlu mu?

★★☆ Laboratuvardaki edge'e utm_source ve utm_campaign parametrelerini cache key'den çıkaran, ama diğer bütün query string'i koruyan bir key yaz (ipucu: map ile $args'ı temizle). ?page=2&utm_source=mail ile ?page=2 aynı kopyayı mı alıyor?

★★★ Bookstore.Api için bir integration test yaz: /api/orders/mine'ı iki farklı kullanıcıyla çağırsın ve hem cevapların farklı olduğunu hem de Cache-Control'ün private veya no-store içerdiğini doğrulasın. Sonra CacheHeaders.PerUser'ı bilerek bozup testin kırıldığını gör.

Bookstore'un cevapları artık bir CDN'e ne yapacağını doğru söylüyor. Bir sonraki yazıda gerçek bir CDN'e geçiyoruz: Cloudflare'de nameserver'ı taşımak, proxied ve DNS-only kayıtlar, SSL (bugünkü adıyla TLS; şifreli bağlantı protokolü) modları, Page Rules'un yerini alan Cache Rules ve Redirect Rules, ve origin'de gerçek client IP'yi doğru okumak.