UNDER PRESSURE

Level 5 · Hidup-Mati Pod di OpenShift

Sesi 37: Satu Pod 10 Koneksi, 200 Pod 2.000 Koneksi: Aritmatika Redis Connection Pool

Bagaimana jika setiap pod hanya membuka 10 koneksi Redis yang terlihat sangat wajar, tetapi saat HPA mencapai batas maksimum di tengah rolling update, Redis menolak koneksi baru dengan pesan ERR max number of clients reached padahal CPU-nya baru terpakai 15%?

Sesi 37 / 387 menit baca

1. Bayangkan Jika

Sebuah kedai kopi punya satu barista super cepat. Ia hanya bisa membuat satu pesanan pada satu waktu, tetapi tiap pesanan selesai dalam 2 detik. Di depan meja barista ada 10.000 kursi antrean.

Setiap kantor di sekitar kedai mengirim kurir. Satu kantor mengirim 10 kurir "untuk jaga-jaga", walaupun pesanan mereka cukup dibawa oleh satu kurir. Ketika kantornya bertambah menjadi 200, ada 2.000 kurir yang duduk di kursi. Sebagian besar hanya duduk diam tanpa memesan apa pun.

Lalu manajemen membuka cabang kantor baru tanpa menutup cabang lama (rolling update), setiap kantor mempekerjakan 4 shift sekaligus (worker process), dan kantor tetangga ikut memakai kedai yang sama. Kursi penuh. Kurir berikutnya ditolak di pintu, padahal barista sedang santai.


2. Apa yang Sebenarnya Terjadi

Redis mengeksekusi command di satu thread utama. Sejak Redis 6 ada I/O threads opsional untuk membaca dan menulis socket, tetapi eksekusi command tetap berurutan. Artinya Redis tidak butuh banyak koneksi untuk cepat. Yang ia butuhkan adalah command yang singkat.

Setiap koneksi tetap punya biaya:

  1. Memory per client: query buffer dan output buffer. Baseline-nya puluhan KB per koneksi, dan bisa membengkak saat client membaca reply besar (misal SMEMBERS pada set berisi jutaan member).
  2. File descriptor: satu socket = satu fd. maxclients default 10000, tetapi tetap dibatasi ulimit -n proses Redis.
  3. Penolakan keras: koneksi ke-10.001 langsung ditolak dengan ERR max number of clients reached dan tercatat di rejected_connections.

Aritmatika yang sering dilupakan:

total koneksi = pod x worker per pod x client instance per worker x pool per client
Skenario Perhitungan Koneksi
Baseline 200 pod x 10 2.000
Gunicorn 4 worker per pod 200 x 4 x 10 8.000
Rolling update, maxSurge 25% 8.000 x 1,25 10.000
Service lain berbagi Redis yang sama 10.000 + 1.500 11.500 (> maxclients)

Memory-nya juga ikut dihitung: 11.500 koneksi x ~30 KB = ~345 MB hanya untuk client buffer, sebelum ada satu key pun yang disimpan.

Library client punya model berbeda:

  • Lettuce (default Spring Data Redis): multiplexed. Satu koneksi bersama melayani banyak thread lewat pipelining. Pool biasanya tidak perlu, kecuali untuk blocking command (BLPOP) atau transaksi (MULTI/EXEC).
  • Jedis: satu command per koneksi pada satu waktu, jadi wajib pool (maxTotal, maxIdle, minIdle, maxWait).
  • redis-py: ConnectionPool(max_connections=...), dan pool dibuat per process, jadi dikalikan jumlah worker.
  • ioredis: satu koneksi per instance dengan auto-pipelining. Masalah muncul saat kode membuat new Redis() di setiap request.

Berapa yang sebenarnya dibutuhkan? Pakai Little's Law dari Sesi 3 dan Sesi 6:

koneksi konkuren = throughput x waktu per command
500 ops/s x 0,002 s = 1 koneksi sibuk rata-rata
Throughput per pod Waktu command rata-rata Koneksi sibuk Pool yang masuk akal
500 ops/s 2 ms 1 4–8
2.000 ops/s 2 ms 4 8–16
2.000 ops/s 20 ms (command lambat) 40 perbaiki command-nya

Kebanyakan tim memasang 50 "untuk jaga-jaga". Hasilnya 49 koneksi idle per pod yang memakan memory dan fd di Redis.

Dua kegagalan yang berlawanan:

  • Pool terlalu kecil: request menunggu koneksi. Muncul Could not get a resource from the pool (Jedis) atau borrow timeout. Latency aplikasi naik padahal CPU Redis rendah, karena waktu habis di antrean pool, bukan di Redis.
  • Pool terlalu besar: ribuan koneksi idle, dan saat semuanya putus bersamaan, terjadi reconnect storm.

3. Bagaimana Jika Kita Coba...

"Banyak timeout ke Redis. Naikkan saja pool jadi 100 per pod!"

Hitung dulu:

200 pod x 100 = 20.000 koneksi
maxclients     = 10.000

Separuh pod tidak mendapat koneksi sama sekali dan langsung error max number of clients reached. Pod yang beruntung mendapat koneksi pun tidak jadi lebih cepat, karena Redis tetap memproses satu command pada satu waktu.

Yang lebih parah: akar masalahnya biasanya bukan ukuran pool. Ketika diselidiki lewat SLOWLOG GET, ternyata ada KEYS user:* yang dijalankan cron job setiap menit, memakan 800 ms. Selama 800 ms itu, semua client di semua pod menunggu. Pool yang lebih besar hanya menambah jumlah orang yang mengantre di belakang command lambat itu.

Pelaku umum yang menahan koneksi dan memblokir Redis:

Command Masalah
KEYS * O(N) di seluruh keyspace, gunakan SCAN
SMEMBERS / HGETALL pada key besar reply raksasa, output buffer membengkak
Lua script panjang atomik, memblokir semua client sampai selesai
BLPOP / BRPOP menahan satu koneksi pool selama menunggu

4. Nama Resminya

  • Connection Pool: kumpulan koneksi yang dipakai ulang agar tidak membuka socket baru per request.
  • Connection Multiplexing / Pipelining: banyak command dikirim lewat satu koneksi tanpa menunggu reply satu per satu (Lettuce, ioredis).
  • maxclients: batas jumlah client yang diterima Redis, default 10000, dibatasi ulimit.
  • Pool Exhaustion / Borrow Timeout: semua koneksi pool sedang dipakai, request baru menunggu hingga maxWait habis.
  • Reconnect Storm: banyak client membuka ulang koneksi pada saat yang sama setelah failover, deploy, atau scale-out.
  • Little's Law: L = λ x W, dasar sizing pool.

5. Di Dunia Kita

Contoh konfigurasi yang masuk akal untuk cache dengan target command < 5 ms:

# Spring Boot + Lettuce (multiplexed, tanpa pool kecuali butuh blocking command)
spring:
  data:
    redis:
      host: redis.cache.svc
      timeout: 300ms          # command timeout
      connect-timeout: 1s
      lettuce:
        shutdown-timeout: 2s
        # pool hanya diaktifkan jika memakai BLPOP / MULTI
        # pool:
        #   max-active: 8
        #   max-idle: 8
        #   min-idle: 0
// Jedis: wajib pool
JedisPoolConfig cfg = new JedisPoolConfig();
cfg.setMaxTotal(8);          // bukan 50
cfg.setMaxIdle(8);
cfg.setMinIdle(0);           // hindari pre-warm massal saat 200 pod start bersamaan
cfg.setMaxWait(Duration.ofMillis(200)); // gagal cepat, jangan menggantung thread
JedisPool pool = new JedisPool(cfg, "redis.cache.svc", 6379, 300 /* socket timeout ms */);
# redis-py: pool per process, ingat dikali jumlah gunicorn worker
pool = redis.ConnectionPool(
    host="redis.cache.svc", port=6379,
    max_connections=8,
    socket_connect_timeout=1, socket_timeout=0.3,
    health_check_interval=30,
)
r = redis.Redis(connection_pool=pool)
// ioredis: satu instance per process, bukan per request
const redis = new Redis({
  host: "redis.cache.svc",
  connectTimeout: 1000,
  commandTimeout: 300,
  enableAutoPipelining: true,
  retryStrategy: (times) => Math.min(times * 200, 3000) + Math.random() * 500, // jitter
});

Hal-hal yang sering muncul di OpenShift:

  • Connection storm saat deploy dan failover: 200 pod dengan minIdle: 10 yang start bersamaan membuka 2.000 koneksi dalam beberapa detik, ditambah biaya TLS handshake per koneksi. Saat Redis failover, semua client reconnect serentak. Gunakan lazy init, minIdle kecil, dan backoff dengan jitter (Sesi 26).
  • Timeout wajib: tanpa command timeout, Redis yang lambat membuat thread aplikasi menggantung, pool habis, dan kegagalan merambat ke upstream (Sesi 25). Untuk cache, 200–500 ms sudah longgar.
  • Fail open dengan hati-hati: jika Redis down dan semua request langsung dialihkan ke database, itu thundering herd (Sesi 19) yang memindahkan masalah ke Sesi 16.
  • Graceful shutdown harus menutup pool (Sesi 36): tanpa pool.close() di shutdown hook, Redis menyimpan client setengah mati sampai timeout atau tcp-keepalive membersihkannya. Selama rolling update, koneksi zombie ini ikut menghabiskan jatah maxclients.
  • Redis Cluster: client membuka pool per node. 6 shard x 200 pod x 8 = 9.600 koneksi total, tersebar ke beberapa node tetapi tetap harus dihitung.
  • Proxy untuk client yang sangat banyak: Twemproxy atau Envoy Redis proxy sebagai sidecar/gateway, mirip peran PgBouncer di Sesi 17.

6. Lensa Performance Tester

Metrik di sisi Redis:

redis-cli INFO clients
# connected_clients:8123
# blocked_clients:12
# maxclients:10000

redis-cli INFO stats | grep rejected_connections
# rejected_connections:347

redis-cli CLIENT LIST | awk '{print $2}' | cut -d= -f2 | cut -d: -f1 | sort | uniq -c | sort -rn | head
# hitung koneksi per IP pod

redis-cli SLOWLOG GET 10
redis-cli LATENCY LATEST
Metrik Arti Tanda bahaya
connected_clients koneksi aktif saat ini > 70% dari maxclients
blocked_clients client di blocking command naik terus tanpa turun
rejected_connections ditolak karena penuh lebih dari 0
Pool active / idle / waiting kondisi pool di aplikasi waiting > 0 terus-menerus
Borrow wait time p99 waktu menunggu koneksi mendekati maxWait
Reconnect rate koneksi baru per detik lonjakan saat rolling restart

Skenario uji:

  1. Scale ke HPA max: naikkan replica ke batas HPA, hitung connected_clients dan bandingkan dengan rumus di bagian 2. Lakukan sambil rolling update agar maxSurge ikut terhitung.
  2. Redis failover di tengah beban: matikan primary, ukur durasi error, puncak reconnect rate, dan apakah backoff memiliki jitter.
  3. Latency injection: tambahkan 50–200 ms delay ke Redis (Toxiproxy atau network chaos). Amati apakah command timeout bekerja atau thread aplikasi menggantung dan pool habis.
  4. Slow command test: jalankan KEYS * atau Lua script berat di staging, lihat efeknya ke p99 semua service yang berbagi Redis.
  5. Shutdown check: setelah rolling restart, apakah jumlah client kembali ke baseline, atau ada client zombie dengan idle tinggi di CLIENT LIST?

7. Pertanyaan untuk Ronde Berikutnya

Anggap pool sudah dihitung dengan benar, timeout sudah dipasang, dan shutdown sudah menutup koneksi dengan rapi. 200 pod, 8 koneksi masing-masing, aman di bawah maxclients.

Tetapi bagaimana jika scheduler OpenShift menaruh 150 dari 200 pod itu di node yang sama, atau di zona yang sama? Satu node mati, dan 75% kapasitas hilang dalam satu detik, lalu 150 pod pengganti reconnect ke Redis bersamaan.

Jawabannya ada di Sesi 38: Semua Telur di Satu Keranjang, tentang topology spread constraints dan maxSkew.