Mert Özen Her Satırda Daha İleri
Veritabanı Redis — #14

Redis'i Production'da İşletmek: ACL, TLS, Tehlikeli Komutlar, Latency Teşhisi, OpenTelemetry ve Testcontainers

Mert Özen 14 Kas 2026 11 dk 17 görüntülenme
Redis'i Production'da İşletmek: ACL, TLS, Tehlikeli Komutlar, Latency Teşhisi, OpenTelemetry ve Testcontainers

Production'daki Redis'te her uygulama yalnız ihtiyaç duyduğu komut, key ve kanallara izinli kendi ACL kullanıcısıyla bağlanmalı; FLUSHALL, KEYS ve EVAL'a yetkisi olmayan bir kullanıcı Lua açıklarına ve sızan şifreye karşı ilk savunma. Gerçek çıktılarla ACL LOG'un ortaya çıkardıkları, Functions'a geçiş, TLS, SLOWLOG ve StackExchange.Redis timeout mesajını okumak, OpenTelemetry span'leri ve Testcontainers ile integration testi.

Sürüm notu: Bu yazıdaki örnekler Redis 8.10.3 (redis:8), Valkey 9.1.3, .NET 10, StackExchange.Redis 3.4.35, OpenTelemetry 1.21.0 (Redis instrumentation 1.20.1-beta.1), Testcontainers.Redis 4.16.0 ve xUnit v3 4.0.3 ile çalıştırılmıştır.

Önce Tahmin Et

Şimdiye kadar Bookstore.Api (sitedeki serilerin ortak örnek uygulaması Bookstore'un, bir online kitapçının API'si) Redis'e şifresiz default kullanıcıyla bağlandı; bu kullanıcı her şeyi yapabiliyor. Uygulamaya kendi kullanıcısını açalım: yalnız bookstore: ile başlayan key'ler ve kanallar, okuma-yazma, stream, pub/sub, JSON ve arama komutları; tehlikeli komutlar yok, Lua yok ama 08'deki Functions çağrılabilir:

ACL SETUSER bookstore-api on ><api-password> resetkeys ~bookstore:* resetchannels &bookstore:* -@all +@read +@write +@stream +@pubsub +@connection +@transaction +@json +@search -@dangerous +fcall +fcall_ro +function|list

Bu kullanıcıyla aşağıdaki komutlardan hangileri çalışır?

127.0.0.1:6379> FLUSHALL
(error) NOPERM User bookstore-api has no permissions to run the 'flushall' command
127.0.0.1:6379> KEYS *
(error) NOPERM User bookstore-api has no permissions to run the 'keys' command
127.0.0.1:6379> CONFIG GET maxmemory
(error) NOPERM User bookstore-api has no permissions to run the 'config|get' command
127.0.0.1:6379> EVAL 'return 1' 0
(error) NOPERM User bookstore-api has no permissions to run the 'eval' command
127.0.0.1:6379> GET session:1
(error) NOPERM No permissions to access a key
127.0.0.1:6379> GET bookstore:order:id-seq
(nil)
127.0.0.1:6379> FCALL reserve_stock 2 bookstore:stock:{9780441013593} bookstore:stock:{9780441013593}:reservations order-1 1
(integer) -1
127.0.0.1:6379> PUBLISH other-channel x
(error) NOPERM No permissions to access a channel

Uygulamanın gerçekten ihtiyaç duyduğu iki komut çalıştı; geri kalanı reddedildi. Bir uygulama açığı ya da sızan bir connection string artık FLUSHALL ile veriyi silemez, KEYS * ile sunucuyu kilitleyemez, keyfi Lua çalıştıramaz. Bu yazıda Redis'i production'da işletmenin parçalarını anlatıyoruz: ağa açmamak, ACL (Access Control List; kullanıcı bazında komut, key ve kanal izinleri), uygulamayı Functions'a taşımak ve patch disiplini, TLS (bağlantıyı şifreleyen protokol), SLOWLOG/LATENCY DOCTOR ve StackExchange.Redis'in timeout mesajı, OpenTelemetry ve Testcontainers ile integration testi.

Redis'i Ağa Açmamak

Lab'daki Redis her açılışta şu satırı yazıyor: WARNING: Redis does not require authentication and is not protected by network restrictions. Resmî image'da protected-mode kapalı (CONFIG GET protected-mode → no) ve Redis bütün arayüzleri dinliyor; compose dosyasındaki 127.0.0.1:6379:6379 port tanımı olmasa makinenin ağındaki herkes şifresiz bağlanabilirdi. protected-mode açıkken Redis, şifre tanımlanmamışsa yalnız localhost'tan bağlantı kabul eder; container'da kapatılmasının nedeni bağlantıların container ağından gelmesi. Kural basit: Redis'i asla internete açma, uygulamanın bulunduğu özel ağda tut, firewall ya da security group ile yalnız uygulama sunucularına izin ver ve şifresiz default kullanıcıyı kapat.

ACL ile Kullanıcı Başına İzin

ACL kuralı sırayla okunur: -@all her şeyi kapatıyor, +@read, +@write gibi kategoriler açıyor, -@dangerous (FLUSHALL, KEYS, CONFIG, DEBUG, RESTORE gibi komutlar) geri kapatıyor. ~bookstore:* key, &bookstore:* kanal pattern'i. Uygulama tarafında şifre koda ya da appsettings.json'a yazılmaz; ortam değişkeninden gelir:

$env:ConnectionStrings__Redis="localhost:6379,name=bookstore-api,user=bookstore-api,password=<api-password>,tiebreaker=,configChannel=,abortConnect=false"
dotnet run --project src/Bookstore.Api

API'yi bu kullanıcıyla çalıştırıp bütün endpoint'leri denedim ve ACL LOG'a baktım. İki şey çıktı. GET /catalog/categories 500 döndü: 11'de facet sayıları için kullandığımız FT.TAGVALS (karmaşıklığı O(N), bütün tag değerlerini döndürüyor) COMMAND INFO'ya göre @dangerous kategorisinde ve kural onu da kapattı; ACL SETUSER bookstore-api +ft.tagvals ile tek komut olarak açtım. İkincisi StackExchange.Redis'in bağlanırken denediği komutlar: INFO, CONFIG GET, CLUSTER NODES/SLOTS, __Booksleeve_TieBreak key'i ve __Booksleeve_MasterChanged kanalı. Paket bu reddedilmeleri tolere ediyor ve uygulama çalışıyor; connection string'deki tiebreaker= ve configChannel= son ikisini kapattı, ilk dördü ACL LOG'da kalıyor. ACL LOG'u izlemeye almak hem yanlış kurulmuş bir izni hem de birinin yasak bir şey denediğini gösterir.

ACL SETUSER ile açılan kullanıcılar bellekte; container'ı yeniden başlattıktan sonra ACL LIST yalnız user default on nopass … +@all gösterdi. Kalıcı olmaları için Redis'i aclfile ile başlatıp ACL SAVE kullan (dosyada şifrelerin SHA-256 (metinden sabit uzunlukta özet üreten hash algoritması) özeti durur, şifrenin kendisi değil) ya da kullanıcıları deploy sırasında bir secret store'dan gelen değerlerle yeniden oluştur. Son adım default kullanıcıyı kapatmak (ACL SETUSER default off) ve yönetim için ayrı, +@all yetkili bir kullanıcı tutmak. Valkey 9.1.3 aynı kuralı +@json yüzünden reddetti (Unknown command or category name in ACL): JSON ve arama modülü olmayan sunucuda bu kategoriler yok.

Uygulama Kullanıcısına EVAL Yok: Functions ve Patch Disiplini

08'de stok rezervasyonu API'den EVAL/EVALSHA ile gönderilen bir Lua script'iydi. ACL'de EVAL'ı kapatınca rezervasyon endpoint'ini 08'de yüklediğimiz reserve_stock function'ına taşıdım; uygulama artık Lua metni göndermiyor, yalnız sunucuda kayıtlı bir function'ı çağırıyor:

// FCALL reserve_stock 2 <stock> <reservations> <orderId> <quantity>
RedisResult result = await redis.GetDatabase().ExecuteAsync("FCALL", "reserve_stock", 2,
    RedisKeys.Stock(isbn), RedisKeys.StockReservations(isbn), request.OrderId, request.Quantity);

// Açılışta: kütüphane yüklü değilse rezervasyonlar hata verecek, log'a yaz
RedisResult libraries = await db.ExecuteAsync("FUNCTION", "LIST", "LIBRARYNAME", "bookstore");

Bunun bir güvenlik nedeni var. Redis'in GitHub'daki güvenlik duyuruları Ekim 2025'te dört Lua açığını birlikte yayımladı: CVE (Common Vulnerabilities and Exposures; güvenlik açıklarına verilen ortak kimlik) kayıtları CVE-2025-49844 (use-after-free, CVSS (açıkların ciddiyetini 0–10 arası puanlayan sistem) 3.1 ile 9,9), CVE-2025-46817 (integer overflow, 7,0), CVE-2025-46818 (Lua kodunu başka bir kullanıcının yetkisiyle çalıştırma, 6,0) ve CVE-2025-46819 (sınır dışı okuma, 6,3). Mayıs 2026'da Redis 8.8 ile CVE-2026-23631 (Lua, yazılabilir replica'larda use-after-free), CVE-2026-23479 (blocked client akışında use-after-free) ve CVE-2026-25243 (RESTORE ile geçersiz bellek erişimi) düzeltildi; Kasım 2025'te de Redis 8.2'deki XACKDEL için CVE-2025-62507 vardı. Hepsinin ortak noktası saldırganın kimliği doğrulanmış bir kullanıcı olması; duyuruların önerdiği geçici önlem de ACL ile EVAL/EVALSHA'yı ve ilgili komutları kapatmak. Korunma iki parçalı: patch sürümlerini takip edip hızlıca uygulamak (Redis'in GitHub'daki security advisory'lerini izle; 8.10 hattı Temmuz 2026'daki GA (General Availability; kararlı sürüm) yayınından bu yana üç kez güvenlik düzeltmesiyle güncellendi: 8.10.1, 8.10.2 ve 8.10.3) ve her uygulamaya yalnız ihtiyaç duyduğu komutları vermek.

TLS

Redis ile uygulama arasındaki trafik varsayılan olarak şifresiz; ACL şifresi de dahil. Lab için bir CA (Certificate Authority; sertifikaları imzalayan otorite) ve localhost sertifikası üreten küçük bir komut yazdım; dosyalar lab/tls/'e yazılıyor ve .gitignore'da:

dotnet run --project src/Bookstore.RedisLab -- make-certs
docker compose -f lab/compose.yaml -f lab/compose.tls.yaml up -d redis
dotnet run --project src/Bookstore.RedisLab -- tls-connect
plain               : It was not possible to connect to the redis server(s). Error connecting right now. To allow this multiplexer to continue retrying until it's able to connect, use abortConnect=false in your connection string or AbortOnConnectFail=false; in your code.
tls, CA not trusted : It was not possible to connect to the redis server(s). There was an authentication failure; check that passwords (or client certificates) are configured correctly: (AuthenticationException) The remote certificate is invalid because of errors in the certificate chain: UntrustedRoot
tls, lab CA trusted : connected, PING 0.7 ms

compose.tls.yaml Redis'i --port 0 --tls-port 6379 ile başlatıyor; şifresiz port kapalı. Şifresiz bağlantı denemesi bağlanamadı, ssl=true ile ama CA'ya güvenmeden yapılan deneme sertifika zinciri hatası aldı, CA'ya güvenen deneme bağlandı:

ConfigurationOptions options = ConfigurationOptions.Parse("localhost:6379,ssl=true");
options.TrustIssuer(caFile);                              // yalnız bu CA'nın imzaladığı sertifikaları kabul et

Sertifika doğrulamasını kapatan bir callback (her zaman true döndüren) yazmak TLS'i anlamsız kılar; araya giren biri kendi sertifikasıyla trafiği okuyabilir. Managed servislerde sertifika genel bir CA'dan geldiği için yalnız ssl=true yeterli.

Teşhis: SLOWLOG, LATENCY DOCTOR ve Timeout Mesajı

Bir rapor job'ının yanlışlıkla sunucuda 2 saniyelik bir Lua döngüsü çalıştırdığını düşün; aynı anda asyncTimeout=1000 ile çalışan API bir fiyat okuyor:

dotnet run --project src/Bookstore.RedisLab -- timeout-message
after 1900 ms: Timeout awaiting response (outbound=0KiB, inbound=0KiB, 1895ms elapsed, timeout is 1000ms), command=GET, next: GET bookstore:prices:9780441013593, inst: 0, qu: 0, qs: 0, aw: False, bw: Inactive, rs: ReadAsync, ws: Idle, in: 0, in-pipe: 0, last-in: 361, cur-in: 361, lm: 2/2/0/0, sync-ops: 0, async-ops: 1, serverEndpoint: localhost:6379, conn-sec: 2.01, aoc: 1, mc: 1/1/0, clientName: bookstore-api, IOCP: (Busy=0,Free=1000,Min=1,Max=1000), WORKER: (Busy=1,Free=32766,Min=10,Max=32767), POOL: (Threads=5,QueuedItems=0,CompletedItems=67,Timers=2), v: 3.4.35.20751 (see https://seredis.dev/Timeouts for some common client-side issues that can cause timeouts)

Mesajı okurken önce client tarafını ele: WORKER: (Busy=1,…,Min=10) thread pool'un rahat olduğunu, qu: 0 gönderilmeyi bekleyen komut olmadığını, in: 0 okunmayı bekleyen byte olmadığını, ws: Idle yazma tarafının boşta olduğunu söylüyor. Komut gönderildi, cevap gelmedi: sorun sunucu tarafında ya da ağda. 02'deki sync-over-async denemesinde ise aynı mesaj WORKER Busy=501 Min=10 gösteriyordu; orada sunucu sağlıklıydı, cevabı işleyecek thread yoktu. Bir ayrıntı daha: timeout 1.000 ms olduğu hâlde istisna 1.895 ms'de geldi; paket timeout'ları periyodik bir kontrolle yakalıyor, gerçek bekleme süresi ayardan uzun olabiliyor. Sunucu tarafında suçlu kolay bulunuyor:

127.0.0.1:6379> SLOWLOG GET 1
1) 1) (integer) 2
   2) (integer) 1794658505
   3) (integer) 2215073
   4) 1) "EVAL"
      2) "local n = 0 for i = 1, 1000000000 do n = n + 1 end return n"
      3) "0"
   5) "<client-ip:port>"
   6) "report-job"
   7) (integer) 3
127.0.0.1:6379> LATENCY LATEST
1) 1) "command"
   2) (integer) 1794658505
   3) (integer) 2215
   4) (integer) 2215

SLOWLOG komutu, süresini (2.215.073 µs), client adını (report-job, multiplexer'a name= vermenin faydası) gösteriyor. LATENCY LATEST ve LATENCY DOCTOR ise yalnız latency-monitor-threshold sıfırdan büyükse kayıt tutuyor; varsayılan 0, bu denemede 100 ms yaptım. LATENCY DOCTOR'un raporunda "1. command: 1 latency spikes (average 2215ms…)" satırı ve yavaş komutlar ile büyük key silme üzerine öneriler vardı. Bunlara ek olarak INFO'da connected_clients, blocked_clients, instantaneous_ops_per_sec, used_memory, evicted_keys ve 12'deki persistence metric'leri; CLIENT LIST'te client başına omem (09'daki output buffer) izlenecek değerler.

OpenTelemetry ile Redis Span'leri

Bookstore.Api'ye OpenTelemetry tracing ekledim: her HTTP isteği bir span, içindeki her Redis komutu bir alt span. Redis instrumentation paketi hâlâ beta; sürüm numarası bunu açıkça söylüyor:

// 14. yazı: ASP.NET Core isteğinin altında her Redis komutu için bir span; konsola yalnız istenirse yaz
builder.Services.AddOpenTelemetry().WithTracing(tracing =>
{
    tracing.AddAspNetCoreInstrumentation().AddRedisInstrumentation();
    if (builder.Configuration.GetValue<bool>("Telemetry:Console"))
    {
        tracing.AddConsoleExporter();
    }
});
Activity.TraceId:            4f50a461c2936a5ba6a705c90c118cc8
Activity.ParentSpanId:       5e9527db79e128b8
Activity.DisplayName:        FCALL
Activity.Kind:               Client
Activity.Duration:           00:00:00.0019405
Activity.Tags:
    db.system: redis
    db.redis.database_index: 0
    db.statement: FCALL
    server.address: localhost
    server.port: 6379
Activity.Events:
    Enqueued [14.11.2026 12:16:35 +00:00]
    Sent [14.11.2026 12:16:35 +00:00]
    ResponseReceived [14.11.2026 12:16:35 +00:00]

Bu, --Telemetry:Console true ile çalışan API'ye gönderilen bir rezervasyon isteğinin konsol çıktısından kısaltılmış bölüm. FCALL span'i, POST /inventory/{isbn}/reservations span'inin (5e9527db79e128b8) çocuğu; komut 1,9 ms sürdü. Enqueued, Sent ve ResponseReceived event'leri komutun multiplexer'ın kuyruğunda mı yoksa ağda/sunucuda mı beklediğini ayırmaya yarıyor; timeout teşhisinde tam aradığın bilgi. db.statement yalnız komut adı, argümanlar (müşteri verisi) span'e yazılmıyor. Production'da konsol yerine OTLP (OpenTelemetry'nin veri aktarım protokolü) exporter'ı ile bir collector'a gönderirsin; collector, backend ve dashboard'lar ilerideki Observability serisinin konusu. Cache tarafındaki hit ratio ve bellek metric'leri Caching serisinin "Cache'i Ölçmek ve Test Etmek: Hit Ratio, Metric'ler, Bellek Kullanımı ve Tutarlılık Testleri" yazısında.

Testcontainers ile Integration Testi

Lua function'ı ve stream consumer'ı mock'la test edilmez; davranışları Redis'in kendisinde. tests/Bookstore.Integration.Tests her test çalıştırmasında gerçek bir redis:8.10.3 container'ı açıyor, deploy'un yüklediği aynı bookstore.lua'yı yüklüyor ve testleri ona karşı çalıştırıyor:

[assembly: AssemblyFixture(typeof(Bookstore.Integration.Tests.RedisFixture))]

// Bütün test çalıştırması için tek bir gerçek Redis 8 container'ı, bookstore Functions kütüphanesi yüklü
public sealed class RedisFixture : IAsyncLifetime
{
    private readonly RedisContainer container = new RedisBuilder("redis:8.10.3").Build();

    public IConnectionMultiplexer Redis { get; private set; } = null!;

    public async ValueTask InitializeAsync()
    {
        await container.StartAsync();
        Redis = await ConnectionMultiplexer.ConnectAsync(container.GetConnectionString());
        string library = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "bookstore.lua"));
        await Redis.GetDatabase().ExecuteAsync("FUNCTION", "LOAD", "REPLACE", library);
    }

    public async ValueTask DisposeAsync()
    {
        await Redis.DisposeAsync();
        await container.DisposeAsync();
    }
}

Üç test var: 200 eşzamanlı alıcı 100 kopyalık stoğa saldırdığında tam 100 rezervasyon (08'deki yarışın testi), aynı siparişin ikinci kez rezerve edilmemesi ve 10'daki StockConsumer'ın bozuk bir olayı üç teslimden sonra dead-letter stream'ine taşıması. Sonuncusu worker'ın kendi class'ını başlatıyor:

StockConsumer consumer = new(fixture.Redis, config, NullLogger<StockConsumer>.Instance);
await consumer.StartAsync(TestContext.Current.CancellationToken);

RedisValue poisonId = await db.StreamAddAsync(OrderEventsConsumer.Stream,
    [new("type", "order-created"), new("orderId", 9999), new("isbn", "9780451524935"), new("quantity", "two")]);
dotnet test --project tests/Bookstore.Integration.Tests
Test run summary: Passed!
  total: 3
  failed: 0
  succeeded: 3
  skipped: 0
  duration: 2s 374ms

İlk sürümde her test class'ı kendi container'ını açıyordu ve xUnit class'ları paralel çalıştırdığı için aynı anda iki Redis ayaktaydı; xUnit v3'ün assembly fixture'ı tek container'ı bütün testlere paylaştırıyor. Testler aynı Redis'i paylaştığı için her test kendi key'lerini kullanıyor (farklı ISBN (13 haneli uluslararası kitap numarası) değerleri). Testcontainers'ın yaşam döngüsü ve testler arası temizlik ilerideki Integration ve E2E (uçtan uca) Test serisinde SQL Server üzerinden anlatılıyor.

Üretimde Dikkat

Bir kontrol listesi: Redis yalnız özel ağda; default kullanıcı kapalı; her uygulamanın kendi ACL kullanıcısı (API, worker, rapor job'ı ayrı) ve kalıcı bir ACL kaynağı; şifreler secret store'da ve rotasyonu planlı (ACL bir kullanıcıya birden çok şifre tanımlamaya izin veriyor, eskiyi yenisi çalışmaya başladıktan sonra silersin); TLS; Redis ve modüllerin patch sürümleri takipte. Teşhis için multiplexer'a anlamlı bir name ver, latency-monitor-threshold'u aç, SLOWLOG'u periyodik topla ve timeout mesajını loglarda tam hâliyle sakla. Timeout'u artırmak çoğu zaman asıl sorunu (yavaş komut, thread pool açlığı, büyük value) gizler; önce mesajın hangi tarafı gösterdiğine bak.

Sık Düşülen Tuzaklar

Bütün uygulamaların şifresiz default kullanıcıyla bağlanması: bir sızıntı bütün veriyi açar.

ACL'yi kurup ACL LOG'a bakmamak: eksik izin (FT.TAGVALS) üretimde 500 olarak ortaya çıkar.

ACL SETUSER ile açılan kullanıcıların kalıcı olduğunu sanmak: restart'tan sonra yalnız default kaldı.

Uygulama kullanıcısına EVAL vermek: 2025–2026'daki Lua açıklarının hepsi script çalıştırabilen bir kullanıcı gerektiriyordu.

TLS'te sertifika doğrulamasını kapatmak.

Timeout'u artırarak sorunu "çözmek": mesajdaki WORKER, qu ve in değerleri asıl nedeni söylüyor.

Redis'e bağlı kodu mock'la test etmek: atomiklik ve consumer group davranışı ancak gerçek Redis'te görülüyor.

Küçük Bir Deneme

★☆☆ bookstore-worker adlı ikinci bir ACL kullanıcısı aç: yalnız bookstore:orders:events* key'leri ve @stream, @transaction, @connection kategorileri. Worker bu kullanıcıyla çalışıyor mu? ACL LOG ne söylüyor?

★★☆ Redis'i --aclfile /data/users.acl ile başlatacak şekilde compose dosyasını değiştir (dosya önceden var olmalı), kullanıcıları açıp ACL SAVE çalıştır ve container'ı yeniden başlat. Kullanıcılar duruyor mu, dosyada ne var?

★★★ Integration testlerine 11'deki katalog araması için bir test ekle: EnsureIndexAsync ile index'i kur, üç kitap yaz, @category:{science\-fiction} sorgusunun sonucunu doğrula. Aynı testi valkey/valkey-bundle:9 image'ıyla çalıştırınca ne oluyor?

Serinin son yazısında sürümlere bakıyoruz: Redis 7'den 8.x'e gelenler, Valkey 9 ile ayrışma noktaları ve bu uyumluluk tablosunun ölçümle çıkarılmış hâli, StackExchange.Redis 2'den 3'e geçiş ve bir sürüm yükseltme planı.