Menulis penyeimbang sederhana di Go


Load balancers memainkan peran penting dalam arsitektur web. Mereka memungkinkan Anda untuk mendistribusikan beban di beberapa backend, sehingga meningkatkan skalabilitas. Dan karena kami memiliki beberapa backend yang dikonfigurasi, layanan menjadi sangat tersedia, karena jika terjadi kegagalan pada satu server, penyeimbang dapat memilih server lain yang berfungsi.

Setelah bermain dengan penyeimbang profesional seperti NGINX, saya mencoba membuat penyeimbang sederhana untuk bersenang-senang. Saya menulisnya di Go, itu adalah bahasa modern yang mendukung paralelisme penuh. Pustaka standar di Go memiliki banyak fitur dan memungkinkan Anda untuk menulis aplikasi berkinerja tinggi dengan kode lebih sedikit. Selain itu, untuk kemudahan distribusi, ini menghasilkan biner tunggal yang terhubung secara statis.

Cara kerja penyeimbang kami


Algoritma yang berbeda digunakan untuk mendistribusikan beban di antara backend. Sebagai contoh:

  • Round Robin - beban didistribusikan secara merata, dengan mempertimbangkan kekuatan komputasi yang sama dari server.
  • Robin Round Tertimbang - Tergantung pada kekuatan pemrosesan, server dapat diberi bobot yang berbeda.
  • Least Connections - beban didistribusikan di seluruh server dengan paling sedikit koneksi aktif.

Di penyeimbang kami, kami menerapkan algoritma paling sederhana - Round Robin.



Seleksi di Round Robin


Algoritma Round Robin sederhana. Ini memberi semua pemain kesempatan yang sama untuk menyelesaikan tugas.


Pilih server di Round Robin untuk menangani permintaan yang masuk.

Seperti yang ditunjukkan dalam ilustrasi, algoritma memilih server dalam lingkaran, secara siklis. Tetapi kita tidak dapat memilihnya secara langsung , bukan?

Dan jika server berbohong? Kami mungkin tidak perlu mengirimkan lalu lintas ke sana. Artinya, server tidak dapat digunakan secara langsung sampai kami membawanya ke keadaan yang diinginkan. Hal ini diperlukan untuk mengarahkan lalu lintas hanya ke server-server yang aktif dan berjalan.

Tentukan strukturnya


Kita perlu melacak semua detail yang terkait dengan backend. Anda perlu tahu apakah dia masih hidup, dan melacak URL. Untuk melakukan ini, kita dapat mendefinisikan struktur berikut:

type Backend struct { URL *url.URL Alive bool mux sync.RWMutex ReverseProxy *httputil.ReverseProxy } 

Jangan khawatir, saya akan menjelaskan arti bidang di Backend.

Sekarang di balancer Anda perlu melacak semua backend. Untuk melakukan ini, Anda bisa menggunakan Slice dan penghitung variabel. Definisikan di ServerPool:

 type ServerPool struct { backends []*Backend current uint64 } 

Menggunakan ReverseProxy


Seperti yang telah kami tentukan, esensi dari penyeimbang adalah dalam mendistribusikan lalu lintas ke server yang berbeda dan mengembalikan hasil kepada klien. Seperti yang dijelaskan dalam dokumentasi Go:

ReverseProxy adalah penangan HTTP yang menerima permintaan masuk dan mengirimkannya ke server lain, mem-proxy tanggapan kembali ke klien.

Tepat seperti yang kita butuhkan. Tidak perlu menemukan kembali roda. Anda cukup mengalirkan permintaan kami melalui ReverseProxy .

 u, _ := url.Parse("http://localhost:8080") rp := httputil.NewSingleHostReverseProxy(u) // initialize your server and add this as handler http.HandlerFunc(rp.ServeHTTP) 

Menggunakan httputil.NewSingleHostReverseProxy(url) Anda dapat menginisialisasi ReverseProxy , yang akan menyiarkan permintaan ke url diteruskan. Dalam contoh di atas, semua permintaan dikirim ke localhost: 8080, dan hasilnya dikirim ke klien.

Jika Anda melihat tanda tangan dari metode ServeHTTP, maka Anda dapat menemukan tanda tangan dari penangan HTTP di dalamnya. Oleh karena itu, Anda dapat meneruskannya ke HandlerFunc di http .

Contoh lain ada di dokumentasi .

Untuk penyeimbang kami, Anda dapat memulai ReverseProxy dengan URL terkait di Backend sehingga ReverseProxy merutekan permintaan ke URL .

Proses pemilihan server


Selama pemilihan server berikutnya, kita perlu melewati server yang mendasarinya. Tetapi Anda perlu mengatur penghitungan.

Banyak klien akan terhubung ke penyeimbang, dan ketika masing-masing dari mereka meminta node berikutnya untuk mentransfer lalu lintas, kondisi balapan dapat terjadi. Untuk mencegah hal ini, kita dapat memblokir ServerPool dengan mutex . Tapi itu akan mubazir, selain itu kita tidak ingin memblokir ServerPool . Kami hanya perlu menambah penghitung satu per satu.

Solusi terbaik untuk memenuhi persyaratan ini adalah peningkatan atom. Go mendukungnya dengan paket atomic .

 func (s *ServerPool) NextIndex() int { return int(atomic.AddUint64(&s.current, uint64(1)) % uint64(len(s.backends))) } 

Kami secara atomik meningkatkan nilai saat ini dengan satu dan mengembalikan indeks dengan mengubah panjang array. Ini berarti bahwa nilai harus selalu berada pada rentang dari 0 hingga panjang array. Pada akhirnya, kami akan tertarik pada indeks tertentu, bukan seluruh penghitung.

Memilih server langsung


Kami sudah tahu bahwa permintaan kami diputar secara siklis di semua server. Dan kita hanya perlu melewati idle.

GetNext() selalu mengembalikan nilai mulai dari 0 hingga panjang array. Kapan saja, kita bisa mendapatkan node berikutnya, dan jika tidak aktif, kita perlu mencari lebih jauh melalui array sebagai bagian dari loop.


Kami mengulang melalui array.

Seperti yang ditunjukkan dalam ilustrasi, kami ingin beralih dari simpul berikutnya ke akhir daftar. Ini bisa dilakukan menggunakan next + length . Tetapi untuk memilih indeks, Anda harus membatasi itu ke panjang array. Ini dapat dengan mudah dilakukan menggunakan operasi modifikasi.

Setelah kami menemukan server yang berfungsi selama pencarian, itu harus ditandai sebagai saat ini:

 // GetNextPeer returns next active peer to take a connection func (s *ServerPool) GetNextPeer() *Backend { // loop entire backends to find out an Alive backend next := s.NextIndex() l := len(s.backends) + next // start from next and move a full cycle for i := next; i < l; i++ { idx := i % len(s.backends) // take an index by modding with length // if we have an alive backend, use it and store if its not the original one if s.backends[idx].IsAlive() { if i != next { atomic.StoreUint64(&s.current, uint64(idx)) // mark the current one } return s.backends[idx] } } return nil } 

Menghindari kondisi balapan di struktur Backend


Di sini Anda perlu mengingat masalah penting. Struktur Backend berisi variabel yang beberapa goroutine dapat modifikasi atau kueri secara bersamaan.

Kita tahu bahwa goroutine akan membaca variabel lebih dari menulis padanya. Oleh karena itu, untuk membuat serial akses ke Alive kami memilih RWMutex .

 // SetAlive for this backend func (b *Backend) SetAlive(alive bool) { b.mux.Lock() b.Alive = alive b.mux.Unlock() } // IsAlive returns true when backend is alive func (b *Backend) IsAlive() (alive bool) { b.mux.RLock() alive = b.Alive b.mux.RUnlock() return } 

Menyeimbangkan permintaan


Sekarang kita dapat merumuskan metode sederhana untuk menyeimbangkan permintaan kita. Ini akan gagal hanya jika semua server jatuh.

 // lb load balances the incoming request func lb(w http.ResponseWriter, r *http.Request) { peer := serverPool.GetNextPeer() if peer != nil { peer.ReverseProxy.ServeHTTP(w, r) return } http.Error(w, "Service not available", http.StatusServiceUnavailable) } 

Metode ini dapat diteruskan ke server HTTP hanya sebagai HandlerFunc .

 server := http.Server{ Addr: fmt.Sprintf(":%d", port), Handler: http.HandlerFunc(lb), } 

Kami merutekan lalu lintas hanya ke server yang berjalan


Penyeimbang kami memiliki masalah serius. Kami tidak tahu apakah server sedang berjalan. Untuk mengetahuinya, Anda perlu memeriksa server. Ada dua cara untuk melakukan ini:

  • Aktif: menjalankan permintaan saat ini, kami menemukan bahwa server yang dipilih tidak merespons, dan menandainya sebagai siaga.
  • Pasif: Anda dapat melakukan ping server pada beberapa interval dan memeriksa status.

Secara aktif memeriksa server yang sedang berjalan


Jika ada kesalahan ReverseProxy memulai fungsi callback ErrorHandler . Ini dapat digunakan untuk mendeteksi kegagalan:

 proxy.ErrorHandler = func(writer http.ResponseWriter, request *http.Request, e error) { log.Printf("[%s] %s\n", serverUrl.Host, e.Error()) retries := GetRetryFromContext(request) if retries < 3 { select { case <-time.After(10 * time.Millisecond): ctx := context.WithValue(request.Context(), Retry, retries+1) proxy.ServeHTTP(writer, request.WithContext(ctx)) } return } // after 3 retries, mark this backend as down serverPool.MarkBackendStatus(serverUrl, false) // if the same request routing for few attempts with different backends, increase the count attempts := GetAttemptsFromContext(request) log.Printf("%s(%s) Attempting retry %d\n", request.RemoteAddr, request.URL.Path, attempts) ctx := context.WithValue(request.Context(), Attempts, attempts+1) lb(writer, request.WithContext(ctx)) } 

Dalam mengembangkan penangan kesalahan ini, kami menggunakan kemampuan penutupan. Ini memungkinkan kami untuk menangkap variabel eksternal seperti URL server ke dalam metode kami. Pawang memeriksa retry counter, dan jika kurang dari 3, maka kami kembali mengirim permintaan yang sama ke server yang sama. Ini karena, karena kesalahan sementara, server dapat membatalkan permintaan kami, tetapi segera menjadi tersedia (server mungkin tidak memiliki soket gratis untuk klien baru). Jadi, Anda perlu mengatur timer tunda untuk upaya baru setelah sekitar 10 ms. Dengan setiap permintaan, kami meningkatkan upaya balasan.

Setelah kegagalan setiap upaya, kami menandai server sebagai siaga.

Sekarang Anda perlu menetapkan server baru untuk permintaan yang sama. Kami akan melakukan ini menggunakan penghitung upaya menggunakan paket context . Setelah meningkatkan penghitungan upaya, kami meneruskannya ke lb untuk memilih server baru untuk memproses permintaan.

Kami tidak dapat melakukan ini tanpa batas, jadi kami akan memeriksa di lb apakah jumlah upaya maksimum telah tercapai sebelum melanjutkan dengan pemrosesan permintaan.

Anda bisa mendapatkan counter upaya dari permintaan, jika itu mencapai maksimum, maka kami mengganggu permintaan.

 // lb load balances the incoming request func lb(w http.ResponseWriter, r *http.Request) { attempts := GetAttemptsFromContext(r) if attempts > 3 { log.Printf("%s(%s) Max attempts reached, terminating\n", r.RemoteAddr, r.URL.Path) http.Error(w, "Service not available", http.StatusServiceUnavailable) return } peer := serverPool.GetNextPeer() if peer != nil { peer.ReverseProxy.ServeHTTP(w, r) return } http.Error(w, "Service not available", http.StatusServiceUnavailable) } 

Ini adalah implementasi rekursif.

Menggunakan paket konteks


Paket context memungkinkan Anda untuk menyimpan data yang berguna dalam permintaan HTTP. Kami akan secara aktif menggunakan ini untuk melacak data yang terkait dengan permintaan - Attempt dan Retry penghitung.

Pertama, Anda perlu mengatur kunci untuk konteksnya. Disarankan untuk tidak menggunakan string, tetapi nilai numerik yang unik. Go memiliki kata kunci iota untuk implementasi konstanta tambahan, yang masing-masing berisi nilai unik. Ini adalah solusi yang bagus untuk mendefinisikan kunci numerik.

 const ( Attempts int = iota Retry ) 

Anda kemudian dapat mengekstrak nilainya, seperti yang biasa kita lakukan dengan HashMap . Nilai default dapat bergantung pada situasi saat ini.

 // GetAttemptsFromContext returns the attempts for request func GetRetryFromContext(r *http.Request) int { if retry, ok := r.Context().Value(Retry).(int); ok { return retry } return 0 } 

Validasi Server Pasif


Pemeriksaan pasif mengidentifikasi dan memulihkan server yang jatuh. Kami melakukan ping pada interval tertentu untuk menentukan status mereka.

Untuk melakukan ping, cobalah membuat koneksi TCP. Jika server merespons, kami menandainya berfungsi. Metode ini dapat diadaptasi untuk memanggil titik akhir tertentu seperti /status . Pastikan untuk menutup koneksi setelah dibuat untuk mengurangi beban tambahan di server. Jika tidak, ia akan mencoba mempertahankan koneksi ini dan pada akhirnya akan menghabiskan sumber dayanya.

 // isAlive checks whether a backend is Alive by establishing a TCP connection func isBackendAlive(u *url.URL) bool { timeout := 2 * time.Second conn, err := net.DialTimeout("tcp", u.Host, timeout) if err != nil { log.Println("Site unreachable, error: ", err) return false } _ = conn.Close() // close it, we dont need to maintain this connection return true } 

Sekarang Anda dapat mengulangi server dan menandai statusnya:

 // HealthCheck pings the backends and update the status func (s *ServerPool) HealthCheck() { for _, b := range s.backends { status := "up" alive := isBackendAlive(b.URL) b.SetAlive(alive) if !alive { status = "down" } log.Printf("%s [%s]\n", b.URL, status) } } 

Untuk menjalankan kode ini secara berkala, Anda dapat menjalankan timer di Go. Ini akan memungkinkan Anda untuk mendengarkan acara di saluran.

 // healthCheck runs a routine for check status of the backends every 2 mins func healthCheck() { t := time.NewTicker(time.Second * 20) for { select { case <-tC: log.Println("Starting health check...") serverPool.HealthCheck() log.Println("Health check completed") } } } 

Dalam kode ini, saluran <-tC akan mengembalikan nilai setiap 20 detik. select memungkinkan Anda untuk menentukan acara ini. Dengan tidak adanya situasi default , menunggu sampai setidaknya satu kasus dapat dieksekusi.

Sekarang jalankan kodenya di goroutine terpisah:

 go healthCheck() 

Kesimpulan


Dalam artikel ini, kami memeriksa banyak pertanyaan:

  • Algoritma Round Robin
  • ReverseProxy dari perpustakaan standar
  • Mutex
  • Operasi atom
  • Sirkuit pendek
  • Telepon balik
  • Operasi pemilihan

Ada banyak cara untuk meningkatkan penyeimbang kita. Sebagai contoh:

  • Gunakan heap untuk mengurutkan server langsung untuk mengurangi cakupan pencarian.
  • Kumpulkan statistik.
  • Menerapkan algoritma round-robin tertimbang dengan jumlah koneksi paling sedikit.
  • Tambahkan dukungan untuk file konfigurasi.

Dan sebagainya.

Kode sumber ada di sini .

Source: https://habr.com/ru/post/id476276/


All Articles