Anatomi Masalah: Cache Stampede pada Endpoint /.well-known/
Endpoint metadata seperti /.well-known/openid-configuration dan /.well-known/jwks.json adalah target kritis pada arsitektur berbasis token (OAuth2/OIDC). Saat ribuan request memverifikasi signature JWT secara paralel dan cache JWKS kedaluwarsa secara bersamaan, terjadi fenomena cache stampede (thundering herd).
Karakteristik masalah ini mencakup:
- Cold Start & Key Rotation: Saat service baru deployment atau IdP merotasi public key, ribuan worker mendapati cache kosong dan serempak menghubungi IdP upstream.
- Lock Contention: Implementasi muteks yang tidak efisien dapat membuat koneksi worker habis menunggu antrean lock, memicu cascading failure.
- Dependency Timeout: Identity Provider (IdP) menerapkan rate limiting (HTTP 429) atau mengalami crash karena lonjakan beban mendadak, menggagalkan proses validasi token pada seluruh ekosistem backend.
Strategi 1: Distributed Lock Menggunakan Redis
Pola paling umum untuk menahan thundering herd adalah distributed lock: hanya satu thread/worker yang boleh melakukan komputasi ulang atau fetch ke upstream. Worker lain menunggu hasil cache diperbarui.
Implementasi Lock Aman: Set NX PX & Atomic Release Lua
Jangan gunakan SETNX terpisah dari EXPIRE. Gunakan parameter atomik SET key val NX PX ttl. Untuk rilis kunci, gunakan skrip Lua agar worker tidak menghapus lock milik worker lain jika proses fetch melebihi TTL lock.
// Go: Implementasi Single-Flight Lock via Redis
package jwks
import (
"context"
"crypto/rand"
"encoding/hex"
"time"
"github.com/redis/go-redis/v9"
)
const releaseLockLua = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`
type LockManager struct {
client *redis.Client
}
func (m *LockManager) Acquire(ctx context.Context, key string, ttl time.Duration) (string, bool, error) {
b := make([]byte, 16)
_, _ = rand.Read(b)
token := hex.EncodeToString(b)
ok, err := m.client.SetNX(ctx, key, token, ttl).Result()
if err != nil || !ok {
return "", false, err
}
return token, true, nil
}
func (m *LockManager) Release(ctx context.Context, key, token string) error {
return m.client.Eval(ctx, releaseLockLua, []string{key}, token).Err()
}
Strategi 2: Probabilistic Early Expiration (Algoritma XFetch)
Distributed locking memiliki kelemahan: worker yang tidak mendapatkan lock harus memblokir thread atau melakukan polling. Pendekatan non-blocking yang superior adalah XFetch (Vattani dkk., 2015). Algoritma ini memicu background refresh secara probabilistik sebelum cache benar-benar kedaluwarsa, di mana probabilitas meningkat seiring mendekatnya waktu kedaluwarsa dan tingginya read latency.
Formula komputasi XFetch:
// Refresh jika: -beta * delta * ln(random()) > (ttl - time_remaining)
// delta: waktu komputasi fetch ke IdP (detik)
// beta: agresivitas refresh (> 0, default 1.0)
Implementasi In-Memory Cache dengan Pola XFetch
package jwks
import (
"context"
"math"
"math/rand"
"sync"
"time"
)
type CacheItem struct {
Data []byte
ExpiresAt time.Time
Delta time.Duration // Durasi fetch upstream terakhir
}
type XFetchCache struct {
mu sync.RWMutex
store map[string]CacheItem
beta float64
}
func NewXFetchCache(beta float64) *XFetchCache {
return &XFetchCache{
store: make(map[string]CacheItem),
beta: beta,
}
}
func (c *XFetchCache) Get(key string) (CacheItem, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
item, ok := c.store[key]
return item, ok
}
func (c *XFetchCache) Set(key string, data []byte, ttl, delta time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.store[key] = CacheItem{
Data: data,
ExpiresAt: time.Now().Add(ttl),
Delta: delta,
}
}
// ShouldRefresh menentukan apakah worker harus fetch ulang secara probabilistik
func (c *XFetchCache) ShouldRefresh(item CacheItem) bool {
now := time.Now()
if now.After(item.ExpiresAt) {
return true // Hard expired
}
timeRemaining := item.ExpiresAt.Sub(now).Seconds()
deltaSeconds := item.Delta.Seconds()
// -beta * delta * ln(U)
r := rand.Float64()
if r == 0 {
return true
}
probabilityThreshold := -c.beta * deltaSeconds * math.Log(r)
return probabilityThreshold > timeRemaining
}
Strategi 3: Reverse Proxy & Stale-While-Revalidate
Untuk memangkas traffic sebelum menyentuh layer worker aplikasi, posisikan reverse proxy (seperti NGINX, Envoy, atau Cloudflare Worker) di depan endpoint downstream dengan direktif stale-while-revalidate dan stale-if-error.
Konfigurasi NGINX untuk endpoint internal proxy cache JWKS:
proxy_cache_path /var/cache/nginx/jwks levels=1:2 keys_zone=jwks_cache:10m max_size=100m inactive=24h use_temp_path=off;
server {
listen 8080;
location /jwks-proxy {
proxy_pass https://idp.internal.domain/.well-known/jwks.json;
proxy_cache jwks_cache;
# Cache data valid selama 1 jam
proxy_cache_valid 200 1h;
# Izinkan respon stale jika data sedang di-fetch di background atau IdP error
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
# Pastikan hanya 1 request yang dialirkan ke IdP saat cache expired
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
# Background revalidation
proxy_cache_background_update on;
proxy_set_header Host idp.internal.domain;
add_header X-Cache-Status $upstream_cache_status;
}
}
Pola Multi-Layer Caching yang Direkomendasikan
Gunakan arsitektur pertahanan berlapis (defense in depth):
- L1 - In-Memory Go/Node.js/JVM Cache: Validasi
kid(Key ID). Jikakidada, verifikasi token tanpa I/O network. Cek probabilitas refresh via XFetch. - L2 - Distributed Redis: Shared cache lintas replica. Mengurangi beban saat penambahan pod/instance baru (cold replica).
- Lock Fallback: Jika
kidtidak ditemukan pada L1 dan L2 (indikasi rotasi kunci aktif oleh IdP), peroleh distributed lock via Redis sebelum menembak IdP. Jika gagal memperoleh lock, lakukan sleep 50-100ms dan baca kembali L2 cache. - Stale Cache Fallback: Jika IdP mengembalikan response 5xx atau timeout, gunakan payload kunci JWKS lama sebagai fallback darurat daripada menggagalkan seluruh flow autentikasi.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!