Membangun Autoscaler CPU Kustom untuk Docker Swarm

Foto oleh M.Minderhoud at Dutch Wikipedia via Openverse (CC BY-SA 3.0)
Tidak. Docker Swarm hanya menyediakan perintah imperatif docker service scale tanpa controller yang memutuskan kapan perintah itu dipanggil berdasarkan metrik. HPA di Kubernetes membaca CPU, memori, atau metrik kustom lalu menyesuaikan jumlah replika secara otomatis melalui control loop; di Swarm, logika keputusan itu harus dibangun terpisah, dan itulah yang dilakukan oleh daemon Bash kustom ini.
Kekurangan kapasitas saat lonjakan nyata langsung menurunkan kualitas setiap permintaan yang sedang berjalan, jadi daemon harus bereaksi cepat: dua sampel berturut-turut di atas 70% CPU memicu scale-out. Kelebihan kapasitas hanya memboroskan sumber daya komputasi, jadi aman untuk bersikap konservatif dan mensyaratkan tiga sampel berturut-turut di bawah 30% CPU sebelum scale-in. Membuat kedua arah sama sensitifnya akan membuat reaksi terlalu lambat terhadap beban nyata atau terlalu cepat menskala masuk hanya karena penurunan sementara.
Cooldown mencegah daemon bereaksi terhadap konsekuensi dari tindakan penskalaannya sendiri sebelumnya. Tepat setelah scale-out, CPU per replika secara alami turun, yang bisa memenuhi syarat streak scale-in beberapa polling kemudian padahal beban sesungguhnya belum surut. Cooldown 90 detik memblokir keputusan penskalaan lebih lanjut setelah suatu tindakan, apa pun kondisi streak-nya, sampai jumlah replika baru punya cukup waktu mencapai kondisi stabil yang sesungguhnya.
Diuji melawan profil beban k6 bertahap yang naik dari 1 ke 5 ke 20 virtual user selama 9 menit. Daemon menskalakan dari satu ke dua replika pada +52 detik sejak awal ramp, masih di dalam target 60 detik, dan CPU per replika menetap di kisaran sekitar 60% seperti yang diprediksi. Sebuah lonjakan CPU palsu sebesar 92% yang muncul selama cooldown pasca scale-out berhasil ditekan dengan benar, mengonfirmasi mekanisme anti-flapping bekerja sesuai desain.
Tidak. Dalam pekerjaan skripsi yang lebih luas di balik daemon ini, ternyata batas throughput sesungguhnya dari platform ini adalah row lock di level database pada tabel konfigurasi bersama yang tidak bisa dihilangkan dengan penskalaan replika berapa pun, karena setiap permintaan memperbarui baris yang sama. Autoscaler berbasis CPU mengatasi saturasi CPU tetapi tidak bisa melihat melewati lapisan yang ia ukur; kendala di level database atau transaksi aplikasi membutuhkan solusi yang sama sekali berbeda.

Foto oleh M.Minderhoud at Dutch Wikipedia via Openverse (CC BY-SA 3.0)
Ringkasan Utama
Docker Swarm tidak punya autoscaler bawaan seperti HPA Kubernetes, jadi tulisan ini membangunnya sendiri dari nol: daemon Bash kurang dari 80 baris yang memantau CPU tiap 10 detik, membutuhkan dua pembacaan berturut-turut di atas 70% untuk scale-out namun tiga di bawah 30% untuk scale-in, serta menerapkan cooldown 90 detik setelah tiap aksi agar tidak terjadi flapping.
Selama skripsi sarjana saya di Swiss German University, yang dikerjakan bersama Commsult Indonesia, saya melakukan benchmarking terhadap platform logistik Java Spring Boot bernama Ontego Traces yang berjalan sepenuhnya di atas Docker Swarm. Satu kesenjangan langsung terlihat jelas di minggu pertama desain: Docker Swarm tidak memiliki padanan bawaan dari Horizontal Pod Autoscaler milik Kubernetes. Tidak ada controller yang memantau sebuah metrik lalu menyesuaikan jumlah replika secara otomatis. Jika sebuah service di Swarm perlu diskalakan mengikuti beban, seseorang harus membangun logika itu secara manual. Maka saya membangunnya. Tulisan ini membahas desain autoscaler tersebut, alasan saya memilih ambang batas asimetris alih-alih satu ambang simetris, serta angka nyata dari uji beban bertahap di mana daemon ini menangkap satu peristiwa penskalaan nyata pada detik ke-52 dan dengan benar mengabaikan satu peristiwa palsu.
Kubernetes menyediakan Horizontal Pod Autoscaler yang membaca metrik dari metrics-server atau adapter kustom, lalu menyesuaikan jumlah replika sebuah Deployment melalui control loop, biasanya setiap 15 detik. Docker Swarm dirancang untuk ceruk yang berbeda: kesederhanaan satu binary, terintegrasi langsung ke dalam Docker Engine, tanpa control plane terpisah yang perlu dioperasikan. Kesederhanaan itulah yang membuat banyak tim memilih Swarm untuk platform berskala lebih kecil, tetapi konsekuensinya Swarm hanya menyediakan primitif statis, docker service scale, tanpa mekanisme apa pun yang memutuskan kapan primitif itu dipanggil. Untuk Ontego Traces, setiap service berjalan sebagai satu replika tanpa logika penskalaan sama sekali sebelum pekerjaan ini. Itu tidak masalah saat traffic rendah, tetapi riwayat health-check platform itu sendiri menunjukkan lonjakan waktu respons di atas 18 detik saat beban tidak terkendali, yang justru merupakan skenario kegagalan yang seharusnya dicegah oleh sebuah autoscaler.
Autoscaler ini dikemas sebagai service Docker Swarm yang berjalan di manager node, dengan Docker socket di-mount agar bisa memanggil docker stats dan docker service scale secara langsung. Daemon ini memeriksa penggunaan CPU setiap task yang berjalan milik service target setiap 10 detik, merata-ratakan hasilnya, lalu memasukkannya ke dalam loop keputusan. Interval polling ini harus menyeimbangkan dua biaya: terlalu sering, daemon sendiri menambah overhead yang terasa; terlalu jarang, waktu reaksi terhadap lonjakan nyata bisa meleset dari target. Sepuluh detik berada di titik tengah dan selaras dengan granularitas yang secara alami dilaporkan oleh docker stats.
Keputusan desain intinya adalah bahwa scale-out dan scale-in bukan masalah yang simetris. Kekurangan kapasitas saat lonjakan traffic akan menurunkan kualitas setiap permintaan yang sedang berjalan, sehingga daemon harus bereaksi begitu ada kepercayaan yang wajar bahwa ini lonjakan nyata, bukan satu pembacaan yang berisik. Sebaliknya, kelebihan kapasitas hanya memboroskan sumber daya komputasi tanpa merusak apa pun, sehingga daemon bisa bersikap konservatif dan menunggu tren yang berkelanjutan sebelum menskala masuk. Saya mengkodekan asimetri ini langsung ke dalam penghitung streak: dua sampel berturut-turut selama 10 detik di atas 70% CPU memicu scale-out, tetapi tiga sampel berturut-turut di bawah 30% CPU diperlukan sebelum scale-in dijalankan. Satu pembacaan tinggi saja, yang bisa jadi jeda garbage collection atau lonjakan sesaat, tidak cukup untuk memicu apa pun dengan sendirinya.
Konsensus streak saja belum sepenuhnya mencegah osilasi, karena sebuah service yang baru saja discale-out biasanya akan langsung melihat penurunan CPU per replika, yang bisa memenuhi syarat streak scale-in beberapa polling kemudian padahal beban sesungguhnya belum benar-benar surut. Untuk menghentikan itu, setiap tindakan penskalaan, ke arah mana pun, memulai cooldown 90 detik di mana tidak ada keputusan penskalaan lain yang dieksekusi apa pun kata penghitung streak. Sembilan puluh detik dipilih karena secara nyaman melebihi waktu yang dibutuhkan sebuah task Swarm baru untuk lolos health check dan mulai menyerap traffic sungguhan, sehingga daemon selalu mendapat pembacaan yang adil atas kondisi stabil yang baru sebelum diizinkan bertindak lagi.
Jika Anda mengadaptasi pola ini, tahan godaan untuk membuat cooldown simetris dengan syarat streak. Cooldown melindungi dari daemon yang bereaksi terhadap tindakannya sendiri sebelumnya; jumlah streak melindungi dari reaksi terhadap noise. Keduanya menyelesaikan masalah yang berbeda dan keduanya sama-sama penting.
Daemon lengkapnya kurang dari 80 baris Bash. Ia menjalankan docker stats dengan flag --no-stream sehingga setiap pemanggilan mengembalikan satu snapshot alih-alih stream langsung, merata-ratakan persentase CPU di semua kontainer task milik service tersebut, lalu menjalankan rata-rata itu melalui logika streak-dan-cooldown sebelum memanggil docker service scale hanya ketika sebuah keputusan benar-benar terpicu. Cuplikan di bawah ini adalah inti nyata dari loop tersebut, dipangkas dari logging dan penanganan kasus tepi demi keterbacaan.
#!/usr/bin/env bash
# autoscaler.sh — polls a Swarm service's CPU and scales with
# asymmetric streak consensus + cooldown. Runs as a Swarm service
# on the manager node (needs the manager socket mounted read-only).
SERVICE="tdp_tour-data-provider"
SCALE_OUT_THRESHOLD=70 # % CPU
SCALE_IN_THRESHOLD=30 # % CPU
SCALE_OUT_STREAK_NEEDED=2 # fast to react
SCALE_IN_STREAK_NEEDED=3 # slow to react (avoid flapping)
COOLDOWN_SECONDS=90
POLL_INTERVAL=10
MAX_REPLICAS=4
MIN_REPLICAS=1
out_streak=0
in_streak=0
last_scale_ts=0
while true; do
now=$(date +%s)
# Average CPU % across every running task of the service, this poll
cpu=$(docker stats --no-stream --format '{{.CPUPerc}}' \
$(docker ps --filter "label=com.docker.swarm.service.name=${SERVICE}" -q) \
| tr -d '%' | awk '{sum+=$1; n++} END {if (n>0) print sum/n; else print 0}')
cooling_down=$(( now - last_scale_ts < COOLDOWN_SECONDS ))
if (( $(echo "$cpu > $SCALE_OUT_THRESHOLD" | bc -l) )); then
out_streak=$((out_streak + 1))
in_streak=0
elif (( $(echo "$cpu < $SCALE_IN_THRESHOLD" | bc -l) )); then
in_streak=$((in_streak + 1))
out_streak=0
else
out_streak=0
in_streak=0
fi
current=$(docker service inspect "$SERVICE" \
--format '{{.Spec.Mode.Replicated.Replicas}}')
if (( out_streak >= SCALE_OUT_STREAK_NEEDED && !cooling_down \
&& current < MAX_REPLICAS )); then
new=$((current + 1))
echo "[scale-out] cpu=${cpu}% streak=${out_streak} ${current}->${new}"
docker service scale "${SERVICE}=${new}"
last_scale_ts=$now
out_streak=0
elif (( in_streak >= SCALE_IN_STREAK_NEEDED && !cooling_down \
&& current > MIN_REPLICAS )); then
new=$((current - 1))
echo "[scale-in] cpu=${cpu}% streak=${in_streak} ${current}->${new}"
docker service scale "${SERVICE}=${new}"
last_scale_ts=$now
in_streak=0
fi
sleep "$POLL_INTERVAL"
done
Satu detail yang perlu disebutkan: docker stats --no-stream tetap membutuhkan daftar ID kontainer yang hidup pada setiap polling, karena Swarm menjadwal ulang task dan ID kontainer tidak stabil di seluruh peristiwa penskalaan atau kegagalan node. Daemon ini menyusun ulang daftar ID kontainer dari docker ps dengan filter label nama service Swarm pada setiap iterasi, alih-alih menyimpannya dalam cache, yang memakan sedikit CPU tambahan di manager node tetapi menghindari query stats untuk kontainer yang sudah tidak ada.
docker stats melaporkan CPU sebagai persentase dari satu core secara default, sehingga di host multi-core sebuah kontainer bisa secara sah melaporkan lebih dari 100%. Rata-ratakan pembacaan di seluruh kontainer, bukan terhadap total jumlah core host, atau perbandingan ambang batas Anda akan diam-diam salah begitu sebuah kontainer menyebar pekerjaannya ke lebih dari satu core.
Untuk memvalidasi daemon secara end-to-end alih-alih hanya menguji logikanya secara unit, saya menjalankannya melawan profil beban k6 bertahap yang naik dari 1 ke 5 ke 20 virtual user selama 9 menit, dimulai dari satu replika service Tour Data Provider. Ini merefleksikan ramp traffic yang realistis alih-alih lonjakan instan, yang merupakan kasus lebih sulit bagi autoscaler mana pun karena sinyalnya terbangun secara bertahap alih-alih langsung mengumumkan dirinya.
CPU satu replika mencapai 97% lalu 79% pada dua sampel berturut-turut selama 10 detik, memenuhi syarat streak scale-out. Daemon menskalakan service dari satu replika menjadi dua pada +52 detik sejak awal ramp, masih di dalam target 60 detik yang saya tetapkan untuk desain ini. CPU per replika kemudian menetap di 62% dan 59%, sesuai dengan perkiraan beban per replika sekitar 60% yang saya prediksi untuk dua replika yang membagi traffic yang sama. Ketika ramp surut di akhir profil, tiga sampel berturut-turut pada 31%, 23%, dan 15% dengan benar memicu scale-in kembali ke satu replika.
Hasil yang paling saya pedulikan: selama cooldown 90 detik setelah scale-out, sebuah lonjakan CPU palsu sebesar 92% muncul dan berhasil ditekan dengan benar. Satu peristiwa yang berhasil ditekan itu adalah keseluruhan inti dari jendela cooldown. Tanpanya, lonjakan itu akan memicu scale-out kedua murni berdasarkan satu pembacaan sesaat, yang justru merupakan perilaku flapping yang ingin dicegah oleh desain asimetris ini.
Autoscaler ini mengatasi saturasi CPU secara andal dan dalam jendela reaksi targetnya, tetapi penting untuk jujur soal batasannya. Dalam pekerjaan skripsi yang lebih luas, ternyata batas throughput platform yang sama disebabkan oleh kontensi row lock di level database yang tidak bisa diperbaiki dengan penskalaan replika berapa pun, karena setiap permintaan memperbarui baris konfigurasi tenant yang sama. Autoscaler ini akan konvergen ke batas yang sama begitu CPU berhenti menjadi kendala utama; ia tidak bisa melihat melewati lapisan yang ia ukur. Autoscaler yang hanya berbasis CPU juga merupakan penyederhanaan yang diketahui dibandingkan pendekatan bi-metrik yang memperhitungkan memori atau latensi permintaan, yang secara eksplisit saya sebut sebagai arah untuk pekerjaan lanjutan, bukan masalah yang sudah selesai.
Jika Anda membangun sesuatu yang serupa untuk deployment Swarm Anda sendiri, catat setiap transisi streak dan setiap keputusan yang ditekan oleh cooldown ke sebuah file yang bisa diserahkan daemon ke stack observability Anda. Kemampuan untuk menunjukkan, setelah kejadian, lonjakan mana yang diabaikan dan mengapa, adalah yang membuat mekanisme anti-flapping bisa dipercaya oleh tim lainnya.
Jika Anda menjalankan beban kerja produksi di Docker Swarm alih-alih Kubernetes, autoscaling bukan sebuah checkbox yang tinggal dinyalakan, melainkan sepotong kecil infrastruktur yang harus Anda desain dan miliki sendiri. Ide-ide inti di sini, ambang batas asimetris, konsensus berbasis streak alih-alih pemicu satu sampel, dan cooldown eksplisit, tidak spesifik untuk Docker atau platform ini. Ide-ide ini berlaku untuk controller penskalaan buatan sendiri apa pun yang Anda bangun di atas API scale imperatif orchestrator mana pun. Trade-off-nya jujur: Anda mendapatkan persis perilaku penskalaan yang Anda desain, dengan konsekuensi harus membangun dan mengujinya sendiri.