Autoscaling HPA Kubernetes: Bertahan dari Traffic Spike

Foto oleh Firzafp via Wikimedia Commons (CC BY-SA 4.0)
Tidak untuk bagian awalnya. Kubernetes Horizontal Pod Autoscaler adalah control loop yang bereaksi terhadap metric yang sudah ia ukur, jadi detik-detik pertama sebuah spike tetap dilayani replica yang sudah berjalan. Autoscaling melindungi kamu dari spike yang berlangsung lebih dari beberapa menit; yang melindungi kamu dari ledakan pertama adalah minReplicas.
Control loop-nya berjalan intermittent, dengan interval yang default-nya lima belas detik, jadi sebuah spike bisa menunggu hampir satu periode penuh hanya untuk disadari. Scale-up tidak punya stabilization window secara default, jadi keputusannya sendiri langsung diambil begitu metric terbaca. Setelah itu tidak ada anggaran waktu yang terdokumentasi: scheduling, image pull, boot proses, dan readiness probe semuanya bergantung pada image dan cluster kamu.
Penyebab yang paling umum adalah resources.requests. Utilisation dihitung sebagai persentase dari CPU request container, jadi request yang tidak mungkin dipenuhi aplikasi membuat persentase terukurnya rendah selamanya. Kalau CPU request tidak diset sama sekali, dokumentasi Kubernetes menyatakan CPU utilisation untuk pod itu tidak terdefinisi dan autoscaler tidak mengambil tindakan apa pun untuk metric tersebut. Penyebab umum lainnya adalah bottleneck I/O, saat aplikasi menunggu, bukan memakai CPU.
Set ke CPU yang benar-benar dipakai satu pod di bawah beban, diukur dan bukan dikira-kira, karena angka itu penyebut dari setiap keputusan scaling. Jaga limit tetap di atas request supaya utilisation terukur bisa melewati 100 persen. Kalau limit sama dengan request, throttling menahan utilisation di 100 persen, dan terhadap target 70 persen itu menahan rasio scale-up di sekitar 1.43 per tick.
Itu stabilization window untuk scale-down, yang default-nya 300 detik. Controller mengambil rekomendasi tertinggi dari mana pun di dalam window itu, sebuah rolling maximum yang mencegahnya menghapus pod yang sebentar lagi dibutuhkan lagi. Perilaku itu memang disengaja, dan memperpendeknya berarti menukar biaya dengan risiko thrashing saat spike sebenarnya belum berakhir.

Foto oleh Firzafp via Wikimedia Commons (CC BY-SA 4.0)
Ringkasan Utama
Autoscaling HPA Kubernetes di managed PaaS tidak melayani awal sebuah traffic spike. Control loop-nya berjalan setiap lima belas detik secara default, scale-up masih menunggu pod scheduling dan readiness probe, dan CPU utilisation diukur terhadap resources.requests, jadi pod yang sudah berjalanlah yang menyerap ledakan pertama.
Panel autoscaling Helipod hanya tiga field: jumlah replica minimum, maksimum, dan target CPU. Screenshot di halaman depan mereka sendiri menunjukkan min 1, max 10, dan target 70 persen, dan janji di sebelahnya akurat sejauh yang dijanjikan — kamu menentukan batasnya, HPA mengurus sisanya. Yang disembunyikan tiga field itu adalah bahwa semua yang terjadi antara saat traffic datang dan saat pod baru melayani request pertamanya ditentukan oleh default Kubernetes yang tidak pernah dipilih siapa pun di panel itu.
Jadi saya membaca dokumentasi Kubernetes Horizontal Pod Autoscaler sambil membayangkan stopwatch, lalu mencatat setiap default yang aktif selama sebuah spike: sync period, scale-up policy, tolerance, dan dua delay readiness. Tulisan ini adalah timeline itu, ditambah empat kenyataan di level aplikasi yang tidak bisa diperbaiki platform mana pun, sebagus apa pun dashboard-nya.
Tidak ada autoscaler yang menambah kapasitas sebelum beban yang membenarkannya muncul. Horizontal Pod Autoscaler adalah sebuah control loop: ia membaca metric, membandingkannya dengan target, lalu meminta Deployment mengubah jumlah replica. Setiap langkah itu terjadi setelah traffic kamu datang dan sudah dilayani — atau sudah menumpuk di queue — oleh pod yang sudah ada. Tidak ada jalur prediktif di konfigurasi berbasis CPU yang dibuka sebuah PaaS.
Dokumentasi Kubernetes menyebutnya terang-terangan: horizontal pod autoscaling diimplementasikan sebagai control loop yang berjalan intermittent, bukan proses yang kontinu. Menganggapnya asuransi terhadap traffic spike adalah kesalahan kategori. Ia adalah asuransi terhadap spike yang berlangsung lebih dari beberapa menit. Ledakan di detik-detik awal adalah persoalan capacity planning, dan satu-satunya setting yang menyentuhnya adalah jumlah replica minimum yang kamu pilih sebelum apa pun terjadi.
Tiga field itu dipetakan ke sebuah object HorizontalPodAutoscaler, dan object itu punya bagian behavior yang tidak pernah ditampilkan dashboard. Kubernetes mendokumentasikan default-nya secara eksplisit, jadi kamu bisa menuliskan persis apa yang kamu setujui saat menyalakan switch itu.
# What "min 1, max 10, CPU target 70%" expands into.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: web
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: web
minReplicas: 1 # the ONLY field that helps before a spike starts
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70 # 70% of resources.requests.cpu, not of the node
# Nothing below is in the dashboard. These are the defaults you inherit.
behavior:
scaleUp:
stabilizationWindowSeconds: 0 # no smoothing: act on the first reading
policies:
- type: Percent
value: 100 # at most double, per 15s tick
periodSeconds: 15
- type: Pods
value: 4 # or add 4 pods, per 15s tick
periodSeconds: 15
selectPolicy: Max # whichever of the two allows more
scaleDown:
stabilizationWindowSeconds: 300 # 5 minutes of rolling maximum
policies:
- type: Percent
value: 100
periodSeconds: 15Bagian yang layak dihafal adalah asimetrinya. Scale-up punya stabilization window nol detik, jadi controller bertindak pada pembacaan pertama yang melewati target. Scale-down punya window 300 detik dan mengambil rekomendasi tertinggi dari mana pun di dalamnya — sebuah rolling maximum, dirancang supaya controller tidak menghapus pod yang sebentar lagi dibutuhkan lagi. Platformnya berpihak pada mempertahankan kapasitas, dan itu memang pilihan yang benar, tapi konsekuensinya aplikasi yang over-scaled tetap over-scaled selama lima menit setelah spike berakhir. Pada billing harian per resource, itu masuk tagihan, bukan pembulatan.
Seluruh keputusan controller adalah satu rasio: desiredReplicas adalah currentReplicas dikali current metric value dibagi desired metric value, dibulatkan ke atas. Ia melewatkan perubahan sama sekali ketika rasionya cukup dekat ke 1.0, di dalam tolerance yang default-nya 0.1. Dengan target CPU 70 persen, artinya apa pun di antara sekitar 63 dan 77 persen average utilisation dianggap steady state dan diabaikan.
desiredReplicas = ceil( currentReplicas * currentMetricValue / desiredMetricValue )
# One pod. resources.requests.cpu = 500m. Target utilisation = 70%.
# The HPA is therefore aiming at 350m of measured usage per pod.
# Quiet, 160m per pod:
# 160 / 350 = 0.46 -> ceil(1 * 0.46) = 1
# Saturated. If the platform sets limits equal to requests, which is the usual
# choice when a plan sells a fixed vCPU count, usage cannot pass 500m, so
# measured utilisation cannot pass 100% and the ratio cannot pass 1.43:
#
# 500 / 350 = 1.43 -> ceil(1 * 1.43) = 2
# ceil(2 * 1.43) = 3
# ceil(3 * 1.43) = 5
# ceil(5 * 1.43) = 8
# ceil(8 * 1.43) = 12 -> clamped to maxReplicas: 10
#
# Five decisions to reach the ceiling. One decision per sync period is
# 75 seconds of arithmetic, and that is the optimistic case: a not-yet-Ready
# pod is assumed to be consuming 0% of the target, which dampens the next
# scale-up rather than accelerating it.Dua hal dalam aritmetika itu mengejutkan saya. Pertama, yang membatasi kecepatan ramp biasanya metric-nya, bukan policy-nya: default scale-up policy sebenarnya mengizinkan penambahan empat pod atau penggandaan setiap lima belas detik, tapi pod yang CPU limit-nya sama dengan CPU request-nya tidak pernah bisa melaporkan lebih dari 100 persen utilisation, jadi rasionya tertutup dan controller meminta lebih sedikit dari yang sebenarnya diizinkan. Kedua, efek dampening-nya. Ketika ada pod yang belum Ready, controller menghitung ulang dengan asumsi pod itu tidak mengonsumsi apa pun, justru supaya tidak overshoot — jadi saat kamu paling ingin ia agresif adalah saat ia sengaja konservatif.
Interval loop-nya ditentukan flag sync period di kube-controller-manager, dan default-nya lima belas detik. Di platform managed itu setting cluster-wide, artinya bukan milik kamu. Spike yang mulai satu detik setelah sebuah tick harus menunggu hampir satu periode penuh hanya untuk disadari, dan tidak ada apa pun di aplikasi atau konfigurasi kamu yang mengubahnya.
Setelah keputusan diambil, jumlah replica di Deployment berubah seketika dan kerja yang berguna belum dimulai. Scheduler masih harus menemukan node yang punya ruang untuk CPU request pod itu, container image masih harus tersedia atau di-pull, prosesnya masih harus boot, dan readiness probe masih harus lulus sebelum Service mau mengarahkan satu request pun ke sana. Tidak satu pun dari empat langkah itu punya anggaran waktu yang terdokumentasi, karena keempatnya bergantung pada image, node pool, dan aplikasi kamu. Empat langkah itu juga satu-satunya bagian timeline yang benar-benar bisa kamu perpendek.

Satu-satunya setting yang mengubah apa yang terjadi di lima belas detik pertama adalah minReplicas. Tentukan dari ledakan awal yang kamu perkirakan, bukan dari beban rata-rata, terima bahwa kamu membayar pod idle di antara spike, dan anggap itu harga dari jendela waktu yang tidak bisa ditutup autoscaler mana pun. Knob lainnya hanya memengaruhi apa yang terjadi setelah kerusakannya terjadi.
Target CPU bukan persentase dari node atau dari plan kamu. Controller menghitung utilisation sebagai persentase dari resource request yang setara pada container di dalam pod, jadi resources.requests.cpu adalah penyebut dari setiap keputusan scaling yang akan pernah diambil platform tentang aplikasi kamu. Kubernetes mendefinisikan satu CPU unit sebagai satu core fisik dan 100m sebagai seratus millicpu, jadi ini kuantitas absolut: angka yang salah tidak menggeser target sedikit, ia mengubah arti target itu.
# Wrong: no CPU request. The HPA has no denominator.
resources:
limits:
cpu: "1"
memory: "1Gi"
# The docs are blunt: if a container has no relevant resource request set, CPU
# utilisation for the Pod is undefined and the autoscaler takes no action for
# that metric. The dashboard still says autoscaling is enabled.
# Also wrong: a request the app can never saturate.
resources:
requests:
cpu: "2" # a single Node process runs your JS on one thread
limits:
cpu: "2"
# Target 70% now means 1400m per pod. One busy Node process gets nowhere near
# that, so the HPA reads a low utilisation and never scales. Nothing errors.
# Right: request what one pod's share of the work actually costs.
resources:
requests:
cpu: "500m" # measured under load, not guessed
memory: "512Mi"
limits:
cpu: "1" # headroom above the request, so utilisation CAN exceed 100%
memory: "512Mi"Kegagalan yang tidak saya duga adalah kegagalan yang sunyi. Request yang jauh lebih besar dari yang bisa dikonsumsi aplikasi membuat targetnya tidak pernah tercapai, jadi HPA melaporkan utilisation yang nyaman sementara request pengguna timeout, dan semua dashboard tetap hijau. Request yang tidak diset sama sekali lebih buruk: dokumentasinya menyatakan bahwa jika sebuah container tidak punya resource request yang relevan, CPU utilisation untuk pod itu tidak terdefinisi dan autoscaler tidak mengambil tindakan apa pun untuk metric tersebut. Autoscaling menyala, terkonfigurasi, tampil di layar, dan tidak melakukan apa-apa.
Menyetel requests sama dengan limits adalah default yang aman untuk scheduling sekaligus musuh diam-diam dari scaling yang cepat. CPU limit ditegakkan lewat throttling, jadi pod yang mentok di limit-nya tidak pernah bisa melaporkan lebih dari 100 persen utilisation, dan terhadap target 70 persen rasionya tidak bisa melewati sekitar 1.43 — kira-kira satu pod tambahan per pod yang ada, per tick. Sisakan headroom antara request dan limit kalau kamu ingin controller bisa meminta lompatan besar.
Yang memasukkan sebuah pod ke dalam layanan adalah readiness, bukan keberadaannya. Kalau readiness probe mengembalikan status gagal, EndpointSlice controller mencabut IP address pod itu dari EndpointSlices setiap Service yang cocok dengannya, dan probe itu terus berjalan sepanjang siklus hidup container, bukan hanya saat startup. Jadi probe itulah gerbang masuk bagi seluruh kapasitas baru kamu, dan sejujur apa isinya menentukan apakah spike benar-benar terlayani atau hanya tercatat.
# A readiness probe is not a health check. It is the switch that puts this
# pod's IP into the Service's EndpointSlices. Until it passes, the pod exists,
# is billed, and serves nothing.
readinessProbe:
httpGet:
path: /api/ready # must touch what a request needs: DB pool, cache client
port: 3000
periodSeconds: 5 # a pod servable now still waits up to 5s to be asked
failureThreshold: 2
# No initialDelaySeconds. It is a fixed tax on every scale-up you will
# ever do; use a startupProbe if warm-up is genuinely slow.
startupProbe:
httpGet:
path: /api/ready
port: 3000
periodSeconds: 3
failureThreshold: 20 # up to 60s to warm up, then readiness takes over
livenessProbe:
httpGet:
path: /api/live # process-only: never checks the database
port: 3000
periodSeconds: 15HPA punya pendapatnya sendiri soal pod yang benar-benar baru, dan pendapatnya konservatif. Pod yang belum ready sesaat setelah start dianggap masih initialising selama initial readiness delay yang default-nya tiga puluh detik, dan CPU-nya diabaikan selama cpu initialization period yang default-nya lima menit kecuali sample-nya diambil sepenuhnya saat pod itu Ready. Di atas itu, pod Next.js yang baru dingin dengan cara yang tidak bisa dilihat Kubernetes: panduan self-hosting Next.js mencatat bahwa cache asset yang dihasilkan disimpan di memory dan di disk, dan bahwa di platform container orchestration seperti Kubernetes setiap pod memegang copy cache-nya sendiri. Naik dari satu pod ke sepuluh mengalikan kerja origin kamu sepuluh kali sampai cache itu terisi.
Bottleneck yang tidak bisa dilihat autoscaler berbasis CPU adalah bottleneck di mana aplikasi kamu menunggu, bukan bekerja. Pod yang tertahan menunggu koneksi database hampir tidak memakai CPU, jadi utilisation justru turun tepat ketika pengalaman pengguna memburuk, dan bacaan jujur dari metric itu adalah bahwa aplikasi butuh lebih sedikit pod. Sementara itu konfigurasi pool yang kamu tulis saat aplikasi masih satu pod sekarang dikalikan jumlah replica.
// One pod's pool, written when the app ran as one pod.
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10, // 10 sockets per pod
idleTimeoutMillis: 30000,
});
// The HPA scales 1 -> 10 pods on CPU. This file did not change.
// 10 pods * 10 sockets = 100 connections demanded of one Postgres server,
// whose own ceiling is set by max_connections, at server start, by someone
// who was never told your maxReplicas.
//
// Worse: pods 8, 9 and 10 fail their first query, so they never pass the
// readiness probe, so they are set aside as not-yet-Ready, so the HPA
// assumes they consume nothing and asks for more pods.
// The fix is division, done before the spike:
const pool = new Pool({
max: Math.max(2, Math.floor(POOL_BUDGET / MAX_REPLICAS)),
});| Yang kamu lihat saat spike | Yang dilihat target CPU | Yang benar-benar menolong |
|---|---|---|
| Request menumpuk padahal CPU stabil di sekitar target | Rasio di dalam tolerance 0.1, jadi tidak ada tindakan sama sekali | Batasnya concurrency, bukan CPU — scale pada queue depth atau request in-flight |
| Latency naik sementara CPU turun | Kandidat untuk scale down | Periksa I/O: pod yang menunggu database tidak memakai CPU selama menunggu |
| Setiap pod baru error di query pertamanya | Pod belum Ready, diasumsikan tidak mengonsumsi apa pun, jadi scale-up justru teredam | Bagi connection pool dengan maxReplicas sebelum spike, bukan sesudahnya |
| Request pertama ke setiap pod baru terasa lambat | Tidak ada — cache miss bukan metric yang ia kumpulkan | Cache handler bersama, atau keputusan sadar menerima cold tail di setiap scale-up |
| Replica bertahan di batas atas lama setelah traffic turun | Rekomendasi tertinggi dari 300 detik terakhir | Tidak ada, dan itu benar — perpendek stabilization window hanya kalau kamu bisa membuktikan spike sudah selesai |
Semua baris punya bentuk yang sama: metric-nya berperilaku benar dan menjawab pertanyaan yang berbeda dari yang kamu ajukan. Itu bukan argumen menentang autoscaling berbasis CPU, yang murah, tidak butuh komponen tambahan, dan menangani kasus umum dengan baik. Itu argumen supaya kamu tahu kegagalan mana saja yang bisa ia lihat.

Tidak ada satu pun dari ini yang butuh platform lain. Yang dibutuhkan adalah lima keputusan yang diambil sebelum traffic datang, bukan saat traffic sudah datang.
Log streaming real-time Helipod menampilkan semua pod sekaligus di browser, dan browser terminal-nya membuka shell ke pod production yang sedang berjalan tanpa SSH key atau VPN. Kombinasi itulah yang mengubah timeline ini dari teori menjadi sesuatu yang bisa diamati: kamu melihat baris log pertama pod baru, readiness check pertamanya yang gagal, dan slow query pertamanya, dalam urutan yang benar-benar terjadi.
Autoscaling berhenti jadi sebuah switch bagi saya dan berubah menjadi empat angka yang harus bisa saya pertahankan: request, target, minimum, dan maksimum. Managed PaaS menjalankan loop-nya dengan andal, dan itu memang bagian terbesar dari pekerjaannya. Menentukan apa yang diukur loop itu, dan memastikan sudah ada cukup kapasitas berjalan untuk bertahan lima belas detik sebelum ia menyadari apa pun, tetap tugas saya.
Sumber