Sebelum Heroku diakuisisi SalesForce, bagi pengguna gratis Heroku mungkin masih ingat jika aplikasi yang dijalankan di Heroku tidak mendapatkan traffic selama setidaknya 30 menit, aplikasi tersebut statusnya akan dibuat menjadi "idle" dan harus menunggu beberapa detik agar aplikasi tersebut dapat diakses dengan normal.
Untuk pengguna gratis glitch.com, pasti familiar juga dengan tampilan berikut:
Di beberapa tempat lain, mungkin tampilan/kondisi seperti diatas tidak diperlukan dan proses "bangun" terasa hampir instan, yang mana menjadi cikal bakal teknologi "serverless" khususnya di Function as a Service (FaaS).
Pada suatu hari di 6 Mei 2024 saya mendapatkan halaman kurang lebih seperti berikut dari sebuah website random dengan angka 48:
Tangkapan layar diatas umum ditemukan di situs-situs yang bersifat "seasonal" seperti situs penjualan tiket dan mungkin undangan pernikahan juga (not sure, belum pernah nikah). Jika merujuk ke halaman Cloudflare Waiting Room, penjelasan fitur tersebut adalah "A virtual waiting room to manage peak traffic" yang cara kerja sekilasnya dapat dilihat disini.
Dan ini membuat saya kilas balik.
Pada waktu itu dihadapkan masalah untuk dapat mendistribusikan beban kerja secara optimal. Untuk perihal biaya... nomor sekian, seperti para peserta Google for Startups Cloud Program yang lainnya.
Teknologi yang digunakan—sebagaimana yang sebagian besar warga dapat tebak—adalah Kubernetes melalui Google Kubernetes Engine nya. Dibantu dengan Keda, beban kerja dapat disesuaikan tergantung kondisi di Redis pada saat itu. Secara otomatis. Kerjaan saya monitor dasbor Grafana sambil membuka play2048.pro dan menutup hari dengan kemenangan (look between H and L on your keyboard, qwerty users).
Salah satu benefit dari adopsi "Cloud Computing" pada umumnya adalah fleksibilitas. Yang intinya, penawaran akan "sumber daya IT" berdasarkan permintaan dengan harga bayar sesuai pemakaian, melalui internet. Satuan dari pemakaian ini biasanya dalam interval per jam, yang berarti, every hour counts.
Karena setiap jam berarti dan waktu adalah uang, penggunaan "tepat guna" adalah kewajiban untuk menghindari keborosan, karena sesungguhnya mubazir adalah sifat setan:
وَلا تُبَذِّرْ تَبْذِيرًا إِنَّ الْمُبَذِّرِينَ كَانُوا إِخْوَانَ الشَّيَاطِينِ
"Dan janganlah kamu menghambur-hamburkan (hartamu) secara boros. Sesungguhnya pemboros-pemboros itu adalah saudara-saudara syaitan." (QS. Al Isro': 26-27).
Sebagai salah satu yang bertanggung jawab dalam mengelola biaya cloud, ini menjadi tantangan tersendiri untuk saya. Disamping sudah menyewa konsultan "FinOps" yang i guess bisa membantu mengurangi biaya dengan diskon, sepertinya ada aksi lain yang bisa dilakukan juga dari sisi saya sebagai apapun itu di tempat kerja sekarang.
And here we are.
Ikhtisar
Gambaran singkat yang akan kita buat adalah seperti ini:
Cukup standar. Kita tidak akan membuat reverse proxy sendiri dan akan menggunakan Traefik karena selain itu adalah favorit saya juga karena hidup terlalu singkat untuk membuat reverse proxy sendiri from scratch.
Aplikasi "waiting room" ini bertanggung jawab untuk mengatur ketersediaan si upstream. Upstream nya bisa beragam, tapi dalam kasus ini anggap adalah sebuah VM yang menjalankan aplikasi berbasis HTTP.
Lalu kita akan membuat aturan untuk mengatur si ketersediaan tersebut:
- Interval 30 menit adalah masa hidup si upstream. Jika dalam 30 menit tidak mendapatkan traffic, maka akan dimatikan
- Aplikasi ini cenderung terkena "service abuse" jika merujuk ke kasus Heroku serta membedakan traffic bot dan manusia adalah masalah yang sampai hari ini masih menjadi misteri
- Aplikasi ini tidak dirancang untuk dapat menangani banyak permintaan per detik. Karena bagaimanapun itu tugas reverse proxy
- Pendekatan ini tidak relevan untuk aplikasi web yang selalu mendapatkan traffic. I mean, kenapa ingin on-demand jika yang dibutuhkan harus selalu ada?
Dari empat aturan diatas, yang paling menantang adalah poin nomor dua. Jika saya bisa solve ini, saya berhak mendapatkan nominasi IEEE fellow atas temuan novel saya. Jokes aside, cara paling praktis adalah mendeteksi event mouse/keyboard, melakukan fingerprinting ataupun menampilkan CAPTCHA tapi tentu saja tidak sesederhana kedengarannya.
Dengan asumsi akan terkena service abuse dan lifespan 30 menit, per-hari setidaknya akan terjadi 48x proses "wake up". Membatasi proses wakeup ini seperti maksimal 2x per-ip tentu tidak efektif karena bagaimanapun attackers gonna attack dan juga harus menyimpan PII. Tapi jika memang harus dan seniat itu, sepertinya mereka patut diberi hadiah (wadah madu).
Waktunya diagram lagi!
Hampir tergambar semua dan tersisa satu: siapa mengatur apa?
Reverse proxy umumnya menggunakan SNI atau "virtual hosts" sebagai pengidentifikasi. Singkatnya, seperti jika permintaan masuk adalah untuk xxx.com, maka teruskan ke upstream YYY:80. Di kebanyakan kasus, berarti merujuk ke nilai di Host
header. Di kasus lain, mungkin "Forwarded Host Header". It depends.
Karena untuk menyala 🔥/matikan 💀 VM di GCE menggunakan nama mesin sebagai kuncinya, tentu kita harus menyimpan nama tersebut alih-alih sebuah alamat IP. Juga, informasi terkait zona dan nama project tempat VM tersebut berjalan wajib disertakan.
Dan untuk alasan keamanan, hanya terhubung dengan satu project GCP direkomendasikan untuk membatasi resiko ancaman. Karena pada akhirnya informasi yang akan kita ambil adalah nilai hostname, untuk mengurangi kompleksitas, kita akan batasi 1 hostname hanya merujuk ke 1 VM.
Membuat PoC
Langkah pertama yang akan kita lakukan adalah:
- Membuat HTTP server
- Memeriksa apakah VM berjalan
- Memeriksa apakah peminta boleh menyalakan VM
Disini saya menggunakan Go (apakah seharusnya saya sebut di awal?) karena alasan kesederhanaan, dan kode nya adalah seperti ini:
package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
)
type IDB struct {
mu sync.RWMutex
store map[string]interface{}
}
type WaitingRoom struct {
DB *IDB
}
func NewWaitingRoom(db *IDB) *WaitingRoom {
return &WaitingRoom{
DB: db,
}
}
func NewIDB() *IDB {
return &IDB{
store: make(map[string]interface{}),
}
}
func (db *IDB) Put(key string, value interface{}) {
db.mu.Lock()
defer db.mu.Unlock()
db.store[key] = value
}
func (db *IDB) Get(key string) (interface{}, error) {
db.mu.RLock()
defer db.mu.RUnlock()
value, ok := db.store[key]
if !ok {
return nil, errors.New("key not found")
}
return value, nil
}
func getClientIP(r *http.Request) string {
clientIP := r.Header.Get("X-Real-Ip")
if clientIP == "" {
clientIP = r.Header.Get("X-Forwarded-For")
}
if clientIP == "" {
remoteAddr := r.RemoteAddr
if strings.Contains(remoteAddr, ":") {
clientIP = strings.Split(remoteAddr, ":")[0]
}
}
return clientIP
}
func (wr *WaitingRoom) RunVM(hostname string, triggerer string) error {
vmIP, err := wr.DB.Get(fmt.Sprintf("%s:upstream", hostname))
if err != nil {
log.Printf("vm %s has no ip %v", hostname, err)
}
log.Printf("running vm %s (%s) triggered by %s", hostname, vmIP, triggerer)
wr.DB.Put(fmt.Sprintf("%s:active", hostname), "true")
wr.DB.Put(fmt.Sprintf("%s:run_by", hostname), triggerer)
wr.DB.Put(fmt.Sprintf("%s:triggered_count", hostname), 1)
return nil
}
func (wr *WaitingRoom) IsVMRunning(hostname string) bool {
activeStatus, err := wr.DB.Get(fmt.Sprintf("%s:active", hostname))
if err != nil {
return false
}
return activeStatus == "true"
}
func (wr *WaitingRoom) GetTriggerer(hostname string) string {
triggerer, err := wr.DB.Get(fmt.Sprintf("%s:run_by", hostname))
if err != nil {
return "unknown"
}
return triggerer.(string)
}
func handleResponse(w http.ResponseWriter, body string) {
fmt.Fprintf(w, body)
}
func handleRequest(w http.ResponseWriter, r *http.Request, wr *WaitingRoom) {
isVMRunning := wr.IsVMRunning(r.Host)
clientIP := getClientIP(r)
if isVMRunning {
handleResponse(w, fmt.Sprintf("already running"))
} else {
if err := wr.RunVM(r.Host, clientIP); err == nil {
getTriggerer := wr.GetTriggerer(r.Host)
handleResponse(w, fmt.Sprintf("triggered by %s", getTriggerer))
}
}
}
func seed(db *IDB) {
db.Put("google.com:upstream", "8.8.8.8")
}
func main() {
srv := &http.Server{
Addr: ":8080",
}
db := NewIDB()
wr := NewWaitingRoom(db)
seed(db)
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
handleRequest(w, r, wr)
})
go func() {
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
log.Println("shutdown start")
}()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
shutdownCtx, shutdown := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdown()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Fatalf("shutdown error: %v", err)
}
log.Println("shutdown complete")
}
Dan berikut tampilannya:
Kode ini masih belum berinteraksi dengan API yang berkaitan dengan VM. Kita buat abstraksi untuk mensimulasikan kontrol VM, yang dalam langkah kedua akan kita terapkan dengan mengobrol dengan Docker Client.
Mengobrol dengan Docker API
Bagian ini tidak sekeren judulnya karena saya akan menggunakan os.Exec
alih-alih cara yang benar untuk IPC seperti menggunakan socket. Tapi mari biarkan sebagaimana adanya untuk kebutuhan PoC.
Kode yang seharusnya berubah adalah di RunVM
mari lesgo:
func (wr *WaitingRoom) RunVM(hostname string, triggerer string) error {
cmd, vmStatus := exec.Command("docker", "container", "inspect", "-f", "'{{.State.Status}}'", hostname), new(strings.Builder)
cmd.Stdout = vmStatus
if err := cmd.Run(); err != nil {
log.Fatal(err)
}
if vmStatus.String() != "running" {
runVM := exec.Command("docker", "start", hostname)
if err := runVM.Run(); err != nil {
log.Fatal(err)
}
wr.DB.Put(fmt.Sprintf("%s:active", hostname), "true")
wr.DB.Put(fmt.Sprintf("%s:run_by", hostname), triggerer)
wr.DB.Put(fmt.Sprintf("%s:triggered_count", hostname), 1)
getVMIP, vmIP := exec.Command("docker", "container", "inspect", "-f", "'{{.NetworkSettings.IPAddress}}'", hostname), new(strings.Builder)
getVMIP.Stdout = vmIP
if err := getVMIP.Run(); err != nil {
log.Fatal(err)
}
log.Printf("running vm %s (%s) triggered by %s", hostname, strings.TrimSpace(vmIP.String()), triggerer)
}
return nil
}
Dan berikut tampilannya jika dijalankan:
Kita hampir selesai untuk bagian controller!
Juga di bagian ini saya menghapus kode yang berkaitan dengan seed
karena sudah mulai berkomunikasi dengan objek nyata bukan imajinasi lagi.
Agar terlihat keren mari kita buat frontend nya dan menggunakan Angular sebagai framework.
Just kidding, mari gunakan plain ol' html.
Membuat UI enterprise-ready
Kode index.html
nya sederhana, dan hanya seperti ini:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta http-equiv="refresh" content="10">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Waiting Room</title>
<style>
body {
max-width: 960px;
width: 100%;
margin: auto;
padding: 1.666rem;
box-sizing: border-box;
font-family: sans-serif;
}
p {
line-height: 1.3rem;
}
img {
width: 100%;
}
</style>
</head>
<body>
<h1>Anda sekarang berada di antrian, terima kasih telah menunggu.</h1>
<p>Anggap situs yang sedang anda akses ({{.Hostname}}) sedang tidak aktif karena sebuah alasan, dan kami sedang mengaktifkannya untuk anda.</p>
<p>Perkiraan waktu menunggu adalah rahasia, dan halaman ini akan otomatis dimuat ulang. Sambil menunggu sekaligus membuktikan jika anda bukanlah robot, mari bermain catur dan kalahkan sistem:</p>
<img src="https://images.chesscomfiles.com/uploads/v1/images_users/tiny_mce/PedroPinhata/phpZTvydV.png">
<p>Giliran anda.</p>
</body>
</html>
Waktunya kita terapkan dengan kode yang sudah ada, yang mana mengubah si handleRequest
i guess:
func handleRequest(w http.ResponseWriter, r *http.Request, wr *WaitingRoom) {
isVMRunning := wr.IsVMRunning(r.Host)
clientIP := getClientIP(r)
file, err := os.ReadFile("index.html")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
t, err := template.New("html").Parse(string(file))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := struct {
Hostname string
}{
Hostname: r.Host,
}
if isVMRunning {
err = t.Execute(w, data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
} else {
if err := wr.RunVM(r.Host, clientIP); err == nil {
err = t.Execute(w, data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}
}
And here we are!
Jika lumayan teliti, terdapat error fatal pada kode diatas: aplikasi akan crash jika "VM" yang dimaksud tidak ada. Karena penamaan container tidak bisa menggunakan :
, di gambar diatas saya langsung menggunakan Traefik untuk demonstrasi, dan menghindari error fatal tersebut dengan menggunakan FQDN tanpa harus membawa nilai port nya (8080).
Untuk konfigurasi Traefik nya adalah seperti ini:
# traefik.yaml
global:
sendAnonymousUsage: false
checkNewVersion: false
api:
dashboard: false
entryPoints:
http:
address: ":80"
providers:
file:
filename: traefik.dynamic.yaml
# traefik.dynamic.yaml
http:
routers:
evilfactorylabs:
entrypoints:
- http
rule: Host(`evilfactorylabs.internal`)
service: evilfactorylabs-svc
services:
evilfactorylabs-svc:
failover:
service: evilfactorylabs
fallback: waitingroom
evilfactorylabs:
loadBalancer:
healthCheck:
path: /
interval: 10s
timeout: 3s
servers:
- url: http://192.168.215.2:80
waitingroom:
loadBalancer:
servers:
- url: http://127.0.0.1:8080
Ini adalah gambaran sederhana. Di beberapa kasus, mungkin konfigurasi reverse proxy yang digunakan cukup kompleks dan mengatur forwardedHeaders.trustedIPs
adalah keharusan.
Di konfigurasi Traefik diatas intinya kita menggunakan failover dengan konfigurasi healthCheck sebagaimana yang tertera. Konfigurasi tersebut dapat disesuaikan sesuai kebutuhan, dan ini hanya untuk keperluan contoh.
Sebagai demo lengkap, berikut hasil rekaman layarnya.
Lanjut part 2
Di bagian ini kita cukupkan sampai demo PoC. Di part selanjutnya kita akan bahas sampai:
- Berkomunikasi dengan GCE API
- Menerapkan validasi terhadap kontrol VM
- Menggunakan database beneran alih-alih diy in-mem
- Membuat scheduler untuk pelengkap
Untuk kode main.go
lengkapnya ini:
package main
import (
"context"
"errors"
"fmt"
"html/template"
"log"
"net/http"
"os"
"os/exec"
"os/signal"
"strings"
"sync"
"syscall"
"time"
)
type IDB struct {
mu sync.RWMutex
store map[string]interface{}
}
type WaitingRoom struct {
DB *IDB
}
func NewWaitingRoom(db *IDB) *WaitingRoom {
return &WaitingRoom{
DB: db,
}
}
func NewIDB() *IDB {
return &IDB{
store: make(map[string]interface{}),
}
}
func (db *IDB) Put(key string, value interface{}) {
db.mu.Lock()
defer db.mu.Unlock()
db.store[key] = value
}
func (db *IDB) Get(key string) (interface{}, error) {
db.mu.RLock()
defer db.mu.RUnlock()
value, ok := db.store[key]
if !ok {
return nil, errors.New("key not found")
}
return value, nil
}
func getClientIP(r *http.Request) string {
clientIP := r.Header.Get("X-Real-Ip")
if clientIP == "" {
clientIP = r.Header.Get("X-Forwarded-For")
}
if clientIP == "" {
remoteAddr := r.RemoteAddr
if strings.Contains(remoteAddr, ":") {
clientIP = strings.Split(remoteAddr, ":")[0]
}
}
return clientIP
}
func (wr *WaitingRoom) RunVM(hostname string, triggerer string) error {
cmd, vmStatus := exec.Command("docker", "container", "inspect", "-f", "'{{.State.Status}}'", hostname), new(strings.Builder)
cmd.Stdout = vmStatus
if err := cmd.Run(); err != nil {
log.Fatal(err)
}
if vmStatus.String() != "running" {
runVM := exec.Command("docker", "start", hostname)
if err := runVM.Run(); err != nil {
log.Fatal(err)
}
wr.DB.Put(fmt.Sprintf("%s:active", hostname), "true")
wr.DB.Put(fmt.Sprintf("%s:run_by", hostname), triggerer)
wr.DB.Put(fmt.Sprintf("%s:triggered_count", hostname), 1)
getVMIP, vmIP := exec.Command("docker", "container", "inspect", "-f", "'{{.NetworkSettings.IPAddress}}'", hostname), new(strings.Builder)
getVMIP.Stdout = vmIP
if err := getVMIP.Run(); err != nil {
log.Fatal(err)
}
log.Printf("running vm %s (%s) triggered by %s", hostname, strings.TrimSpace(vmIP.String()), triggerer)
}
return nil
}
func (wr *WaitingRoom) IsVMRunning(hostname string) bool {
activeStatus, err := wr.DB.Get(fmt.Sprintf("%s:active", hostname))
if err != nil {
return false
}
return activeStatus == "true"
}
func (wr *WaitingRoom) GetTriggerer(hostname string) string {
triggerer, err := wr.DB.Get(fmt.Sprintf("%s:run_by", hostname))
if err != nil {
return "unknown"
}
return triggerer.(string)
}
func handleResponse(w http.ResponseWriter, body string) {
fmt.Fprintf(w, body)
}
func handleRequest(w http.ResponseWriter, r *http.Request, wr *WaitingRoom) {
isVMRunning := wr.IsVMRunning(r.Host)
clientIP := getClientIP(r)
file, err := os.ReadFile("index.html")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
t, err := template.New("html").Parse(string(file))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := struct {
Hostname string
}{
Hostname: r.Host,
}
if isVMRunning {
err = t.Execute(w, data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
} else {
if err := wr.RunVM(r.Host, clientIP); err == nil {
err = t.Execute(w, data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}
}
func main() {
srv := &http.Server{
Addr: ":8080",
}
db := NewIDB()
wr := NewWaitingRoom(db)
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
handleRequest(w, r, wr)
})
go func() {
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
log.Println("shutdown start")
}()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
shutdownCtx, shutdown := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdown()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Fatalf("shutdown error: %v", err)
}
log.Println("shutdown complete")
}
Sampai jumpa kembali kapan-kapan.
Top comments (0)