evilfactorylabs

Cover image for Membuat "waiting room" ala-ala untuk aplikasi web
Rizaldy
Rizaldy

Posted on

Membuat "waiting room" ala-ala untuk aplikasi web

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:

Image description

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:

Image description

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:

Image description

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!

Image description

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")
}
Enter fullscreen mode Exit fullscreen mode

Dan berikut tampilannya:

Image description

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
}
Enter fullscreen mode Exit fullscreen mode

Dan berikut tampilannya jika dijalankan:

Image description

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>
Enter fullscreen mode Exit fullscreen mode

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
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And here we are!

Image description

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
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

Sampai jumpa kembali kapan-kapan.

Top comments (0)