Belakangan, pembahasan soal biaya operasional data center sering dikaitkan dengan kenaikan konsumsi listrik. Namun dalam banyak insiden backend, lonjakan tagihan cloud justru bukan berasal dari perubahan tarif atau infrastruktur fisik, melainkan dari pola aplikasi yang salah. Salah satu pola yang paling sering luput adalah worker/background job yang tetap hidup saat beban hampir nol.

Pada studi kasus debug backend: lonjakan tagihan cloud akibat worker idle boros ini, gejalanya terlihat membingungkan: CPU rendah, antrean job tipis, tetapi jumlah node tetap tinggi; pod worker tidak turun; koneksi database idle menumpuk; dan tagihan compute naik terus. Masalahnya bukan satu bug tunggal, melainkan kombinasi lifecycle worker yang buruk, polling terlalu agresif, koneksi DB yang tidak ditutup dengan benar, dan autoscaling yang menerima sinyal yang salah.

Gejala Awal yang Terlihat di Produksi

Insiden dimulai dari laporan finance: biaya compute harian naik signifikan dibanding pola normal, padahal trafik API relatif stabil. Tim infra sempat menduga ada perubahan harga dari penyedia cloud atau kenaikan biaya dasar node. Setelah dicek, tarif tidak berubah secara material. Anehya, dashboard observability menunjukkan hal-hal berikut:

  • CPU node rendah, tetapi jumlah node worker tetap banyak.
  • Queue depth rendah, bahkan sering hampir nol, tetapi pod worker tidak pernah benar-benar turun.
  • Koneksi database idle meningkat dari waktu ke waktu.
  • Tagihan compute naik, terutama dari instance yang menjalankan worker dan autoscaled node pool.

Ini adalah pola klasik ketika sistem terlihat “tenang”, tetapi sebenarnya ada aktivitas latar belakang yang terus memaksa runtime, container, atau node tetap aktif.

Hipotesis yang Muncul di Awal

Sebelum menyentuh konfigurasi, penting untuk menyusun hipotesis yang bisa diuji. Dalam kasus ini, ada beberapa kemungkinan yang masuk akal:

  1. Autoscaler salah membaca metrik, misalnya berdasarkan jumlah koneksi, jumlah pod aktif, atau metrik custom yang tidak mencerminkan beban nyata.
  2. Worker melakukan polling agresif ke queue, storage, atau database, sehingga proses tidak pernah benar-benar idle.
  3. Connection pool atau session database bocor, membuat pod terlihat “aktif” dan menahan resource.
  4. Concurrency worker terlalu tinggi atau tidak dibatasi, sehingga tiap pod membuka terlalu banyak koneksi walaupun job sedikit.
  5. Pod tidak bisa scale down ke nol karena ada liveness/readiness pattern atau minimum replica yang tidak sesuai.

Semua hipotesis ini bisa menghasilkan gejala yang mirip. Karena itu, investigasi harus bergerak dari metrik global ke bukti yang lebih spesifik: dashboard cloud, metrik aplikasi, log, lalu tracing atau inspeksi query database.

Langkah Investigasi: Mulai dari Tagihan dan Topologi Compute

1. Pecah biaya per layanan dan per node pool

Langkah pertama bukan melihat kode, tetapi memastikan sumber biaya. Di dashboard billing cloud, pecah biaya berdasarkan:

  • jenis compute (VM, managed node, serverless worker),
  • cluster atau node pool,
  • label workload jika tersedia,
  • waktu kejadian per jam.

Tujuannya adalah menjawab: biaya naik dari mana tepatnya? Dalam kasus ini, biaya terbesar berasal dari node pool worker, bukan dari API node atau database instance. Ini langsung mempersempit ruang investigasi.

2. Bandingkan jumlah node dengan utilisasi riil

Setelah itu, lihat apakah autoscaling benar-benar proporsional. Pola mencurigakan yang sering muncul:

  • Node worker bertambah atau bertahan tinggi saat CPU rata-rata rendah.
  • Memori juga tidak penuh, tetapi pod tetap tersebar di banyak node.
  • Tidak ada lonjakan antrean yang sebanding dengan jumlah pod.

Jika node banyak tetapi utilisasi rendah, ada dua kemungkinan utama: autoscaler skala berdasarkan sinyal yang salah, atau workload mendeklarasikan resource request yang terlalu besar sehingga scheduler memaksa penyebaran pod ke banyak node.

3. Cek request/limit container dan minimum replica

Sebelum menyimpulkan ada bug aplikasi, verifikasi konfigurasi dasar:

  • Apakah resource request CPU/mem worker terlalu tinggi?
  • Apakah ada min replicas yang menjaga worker tetap hidup?
  • Apakah ada PodDisruptionBudget atau constraint penjadwalan yang menghambat scale-down?
  • Apakah autoscaler menggunakan metrik CPU, queue length, atau metrik custom lain?

Pada insiden ini, request resource memang agak konservatif, tetapi belum cukup menjelaskan kenapa pod tetap aktif saat queue hampir kosong. Jadi investigasi lanjut ke level aplikasi.

Investigasi Metrik Aplikasi: Queue Tipis, Worker Tetap Sibuk

1. Lihat metrik queue, throughput, dan polling

Queue depth rendah tidak otomatis berarti worker benar-benar idle. Banyak sistem worker menggunakan loop seperti:

while true:
  job = fetch_job()
  if not job:
    sleep(1)
    continue
  process(job)

Masalah muncul ketika fetch_job() sendiri mahal atau dipanggil terlalu sering oleh ratusan worker sekaligus. Jika tiap worker melakukan polling setiap detik, maka saat tidak ada job pun sistem tetap menghasilkan:

  • query berulang ke database atau Redis,
  • aktivitas jaringan kontinu,
  • wakeup CPU periodik,
  • sinyal “masih aktif” untuk autoscaler atau platform.

Dalam kasus ini, metrik menunjukkan queue hampir kosong, tetapi jumlah poll request ke backend queue tetap tinggi sepanjang hari. Itu petunjuk kuat bahwa biaya datang dari perilaku idle yang tidak efisien, bukan dari pemrosesan job nyata.

2. Bandingkan job processed vs worker active time

Metrik penting yang sering tidak dipantau adalah rasio antara:

  • jumlah job yang benar-benar diproses,
  • waktu hidup worker,
  • jumlah iterasi polling kosong,
  • waktu koneksi DB terbuka.

Kalau worker aktif 24 jam tetapi volume job rendah, kita perlu bertanya: apa yang sebenarnya dikerjakan worker saat idle?

Validasi di Database: Query Idle Menumpuk

1. Periksa sesi aktif dan idle

Di tahap ini, dashboard database mulai memperjelas masalah. Jumlah query berat tidak naik, tetapi jumlah koneksi idle dan idle in transaction meningkat. Ini penting karena koneksi yang dibiarkan terbuka bisa:

  • menghabiskan slot koneksi,
  • menambah overhead pooler atau proxy DB,
  • membuat pod tampak aktif,
  • mengganggu autoscaling jika ada metrik custom berbasis koneksi.

Pemeriksaan yang umum dilakukan adalah melihat daftar sesi aktif, status idle, durasi idle, dan asal koneksi dari service mana. Contoh kueri generik untuk PostgreSQL:

SELECT
  application_name,
  state,
  count(*) AS total
FROM pg_stat_activity
GROUP BY application_name, state
ORDER BY total DESC;

Jika perlu, tampilkan koneksi idle yang hidup terlalu lama:

SELECT
  pid,
  application_name,
  state,
  now() - state_change AS idle_duration,
  query
FROM pg_stat_activity
WHERE state IN ('idle', 'idle in transaction')
ORDER BY idle_duration DESC;

Pada studi kasus ini, service worker mendominasi koneksi idle. Ini konsisten dengan hipotesis bahwa worker tetap membuka koneksi meskipun tidak sedang memproses job.

2. Hubungkan ke pola lifecycle worker

Setelah dicek ke implementasi, ditemukan pola seperti ini:

  • worker membuat koneksi DB saat start,
  • masuk loop polling tanpa menutup atau melepas resource saat tidak ada job,
  • beberapa path error melewati cleanup,
  • shutdown handler tidak selalu dipanggil saat pod dihentikan.

Akibatnya, setiap worker yang hidup lama menjadi sumber koneksi idle persisten. Saat jumlah worker banyak, efeknya berlipat.

Log dan Tracing: Menemukan Aktivitas Kosong yang Mahal

1. Gunakan log untuk menghitung empty poll

Bila log worker cukup baik, kita bisa mencari pola:

  • no job found berulang tiap detik,
  • retry fetch queue terlalu sering,
  • reconnect database berkala,
  • shutdown tanpa cleanup sukses.

Kalau belum ada metrik khusus, log bisa menjadi bukti awal. Contoh log yang sebaiknya ada:

{
  "component": "worker",
  "queue": "email",
  "event": "poll_empty",
  "backoff_ms": 250,
  "worker_id": "w-17"
}

Dari log semacam ini, tim bisa menghitung jumlah polling kosong per menit dan melihat apakah backoff bekerja atau tidak.

2. Tracing untuk membedakan kerja nyata vs overhead idle

Tracing membantu saat metrik terlalu agregat. Pada trace, operasi berikut sering terlihat mendominasi:

  • fetch queue,
  • open connection / checkout from pool,
  • heartbeat internal,
  • query sederhana berulang yang tidak menghasilkan kerja nyata.

Jika sebagian besar span berasal dari aktivitas menunggu atau polling kosong, maka jelas biaya compute bukan karena job berat, melainkan karena desain idle loop yang boros.

Root Cause yang Tervalidasi

Setelah metrik, log, tracing, dan dashboard cloud dikorelasikan, root cause-nya adalah kombinasi empat hal:

  1. Worker tetap aktif terus-menerus walau antrean tipis, karena lifecycle-nya didesain sebagai proses panjang tanpa mekanisme parkir atau scale-to-zero.
  2. Polling agresif ke queue/backend storage, sehingga ratusan worker menghasilkan aktivitas kosong yang konstan.
  3. Koneksi database tidak ditutup atau tidak dilepas dengan disiplin saat worker idle, error, atau shutdown.
  4. Autoscaling memakai sinyal yang salah atau tidak lengkap, sehingga keberadaan pod aktif dan metrik residual dianggap sebagai kebutuhan skala nyata.

Inilah kenapa gejalanya terasa kontradiktif: CPU rendah tetapi node banyak. Beban tidak datang dari komputasi intensif, melainkan dari banyak proses ringan yang tidak pernah benar-benar berhenti.

Perbaikan yang Bisa Langsung Diterapkan

1. Benahi lifecycle worker

Worker harus punya perilaku idle yang eksplisit, bukan sekadar loop tanpa akhir. Prinsipnya:

  • buka koneksi saat perlu,
  • lepaskan resource setelah job selesai,
  • tangani sinyal shutdown dengan cleanup yang pasti,
  • hindari menyimpan transaksi atau cursor terlalu lama.

Contoh pseudocode yang lebih aman:

running = true

on_shutdown_signal():
  running = false

while running:
  job = queue.fetch(timeout=long_poll_timeout)
  if not job:
    continue

  db = null
  try:
    db = db_pool.acquire()
    process(job, db)
    ack(job)
  except Exception as err:
    retry_or_nack(job, err)
  finally:
    if db is not null:
      db_pool.release(db)

Yang penting bukan sintaks, melainkan disiplin acquire-use-release dan shutdown yang bersih.

2. Ganti polling agresif dengan backoff atau long polling

Jika backend queue mendukung long polling atau mekanisme blocking read, gunakan itu. Jika tidak, terapkan exponential backoff dengan jitter saat antrean kosong. Tujuannya mengurangi sinkronisasi polling massal dan mengurangi request sia-sia.

Contoh pendekatan backoff:

empty_count = 0

while running:
  job = fetch_job()
  if job is None:
    empty_count += 1
    delay = min(30, 2 ** min(empty_count, 5))
    sleep(delay + random_jitter())
    continue

  empty_count = 0
  process(job)

Kenapa ini efektif? Karena saat sistem sepi, frekuensi polling turun drastis. Saat job muncul lagi, worker tetap bisa kembali aktif tanpa menahan resource konstan sepanjang waktu.

3. Batasi concurrency secara realistis

Concurrency yang terlalu tinggi sering terlihat aman saat load test, tetapi merusak efisiensi saat produksi sepi. Jika satu pod membuka terlalu banyak goroutine, thread, atau koneksi pool, maka jejak resource minimum per pod menjadi besar.

Langkah praktis:

  • set batas concurrency per worker sesuai kapasitas downstream,
  • batasi ukuran connection pool database,
  • pastikan jumlah pod x concurrency tidak melebihi kebutuhan riil.

Trade-off-nya jelas: concurrency rendah bisa menurunkan burst handling. Karena itu, tuning harus melihat pola trafik dan SLA job, bukan hanya mengejar throughput maksimum.

4. Perbaiki sinyal autoscaling

Autoscaling worker idealnya mengikuti indikator kerja nyata, bukan hanya keberadaan proses. Metrik yang lebih tepat biasanya:

  • queue depth,
  • oldest message age,
  • job processing latency,
  • throughput vs backlog growth.

Hindari mengandalkan sinyal yang mudah bias, seperti:

  • jumlah koneksi idle,
  • heartbeat internal,
  • CPU rendah tetapi non-zero dari polling kosong,
  • metric custom yang tidak membedakan idle dan active work.

Jika platform mendukung, pertimbangkan scale-to-zero untuk worker non-kritis atau queue yang sporadis. Ini sangat efektif untuk menekan biaya beban rendah, tetapi perlu perhatian pada:

  • cold start,
  • waktu boot worker,
  • idempotensi job pertama setelah scale-up,
  • timeout readiness dan drain saat shutdown.

5. Tutup dan daur ulang koneksi DB dengan benar

Beberapa perbaikan yang layak diterapkan sekaligus:

  • gunakan pool dengan batas koneksi yang masuk akal,
  • pastikan setiap checkout di-release di blok finally atau mekanisme setara,
  • hindari transaksi yang dibuka terlalu awal,
  • tutup resource pada path error dan shutdown,
  • monitor rasio active vs idle connections per service.

Kesalahan umum adalah menganggap garbage collector atau runtime akan membereskan koneksi. Di proses panjang seperti worker, asumsi itu berbahaya.

Contoh Sinyal Observability yang Perlu Ditambahkan

Insiden seperti ini sering lama terdeteksi karena metrik yang tersedia hanya CPU dan memory. Padahal masalah utamanya ada di perilaku idle. Tambahkan metrik berikut:

  • empty poll rate per queue dan per worker,
  • job processed rate,
  • worker active count,
  • DB connections active/idle per service,
  • queue oldest message age,
  • cost anomaly by node pool atau label workload.

Dengan metrik ini, tim bisa membedakan kondisi:

  • worker benar-benar sibuk,
  • worker idle efisien,
  • worker idle tapi boros.

Alert yang Sebaiknya Dipasang Setelah Perbaikan

Alert operasional tidak cukup jika hanya berbasis error rate. Untuk kasus biaya, tambahkan alert yang lebih dekat ke akar masalah:

  • queue depth rendah tetapi replica worker tinggi selama periode tertentu,
  • CPU rendah tetapi node pool worker tidak turun,
  • idle DB connections dari service worker melewati ambang normal,
  • empty poll rate melonjak,
  • biaya harian atau per jam menyimpang dari baseline.

Catatan: alert biaya tidak harus menunggu invoice bulanan. Banyak platform cloud menyediakan data biaya harian atau near-real-time yang cukup untuk mendeteksi anomali lebih cepat.

Checklist Pasca-Inisiden

Setelah masalah diperbaiki, jangan berhenti di patch kode. Gunakan checklist pasca-insiden berikut:

  1. Dokumentasikan timeline: kapan biaya mulai naik, kapan gejala muncul, kapan root cause tervalidasi.
  2. Catat sinyal yang menyesatkan: misalnya CPU rendah yang membuat tim awalnya menyingkirkan worker sebagai tersangka.
  3. Perbarui runbook debugging untuk insiden biaya compute yang tidak sejalan dengan load.
  4. Tambah dashboard gabungan yang menampilkan biaya, node count, queue depth, replica worker, dan idle DB connections.
  5. Lakukan game day atau load simulation ringan untuk menguji perilaku saat antrean kosong.
  6. Review konfigurasi autoscaling dan pool koneksi setiap ada perubahan arsitektur worker.
  7. Tetapkan owner untuk metrik biaya aplikasi, bukan hanya tim infra.

Kesalahan Umum yang Membuat Masalah Ini Berulang

  • Menganggap CPU rendah berarti aman. Biaya cloud bisa tetap tinggi karena banyak node hidup tanpa kerja berarti.
  • Hanya memantau queue depth tanpa melihat empty polling dan worker uptime.
  • Menggunakan autoscaling default tanpa memverifikasi kecocokan metrik untuk workload worker.
  • Mengabaikan koneksi idle database karena tidak memicu error langsung.
  • Tidak menguji perilaku sistem pada kondisi beban sangat rendah, padahal justru di sanalah pemborosan sering terjadi.

Penutup

Dalam kasus debug backend: lonjakan tagihan cloud akibat worker idle boros, masalah utamanya bukan kenaikan tarif infrastruktur, melainkan desain aplikasi yang membuat worker terus hidup dan terus menghasilkan aktivitas kosong. CPU bisa tetap rendah, tetapi biaya compute naik karena pod, node, koneksi DB, dan autoscaling semua terjebak pada sinyal idle yang salah.

Pelajaran pentingnya: saat tagihan cloud naik tanpa lonjakan trafik yang jelas, jangan hanya mencari query berat atau proses CPU-intensif. Periksa juga perilaku saat sistem tidak sibuk. Worker idle yang salah desain sering lebih mahal daripada job yang benar-benar diproses.