この記事はMakuake Advent Calendar 2021の24日目の記事です。(大遅刻しました・・)
ラウンドロビンで負荷分散するロードバランサーをGolangで自作してみるという話です。
ロードバランサーはリクエストを複数のサーバーへ振り分けて負荷分散する(ロードバランシング)機能を持ったサーバーです。
サービスの可用性を高めてくれるリバースプロキシの一種です。
ロードバランサーの種類は大きく分けて2種類あります。アプリケーション層で負荷分散するL7ロードバランサーと、トランスポート層で負荷分散するL4ロードバランサーです。
ロードバランサーは、ロードバランシングの他、パーシステンス(セッション維持)とヘルスチェックの機能を兼ね備えています。
負荷分散には静的な方式と動的な方式のものでそれぞれ種類があります。
静的なものの代表的な方式としては、リクエストを均等に振り分けるRound Robinという方式があります。
動的なものの代表的な方式としては、リクエストの未処理数が最小のサーバーに振り分けるLeast Connectionという方式があります。
パーシステンスはロードバランサーの複数の振り分け先のサーバー間でセッションを維持するための機能です。
大きく分けてSource address affinity persistenceという方式とCookie persistenceという2つの方式があります。
Source address affinity persistenceは送信元IPアドレスを見て振り分け先のサーバーを固定する方式です。
Cookie persistenceはセッション維持のためのCookieを発行して、Cookieを見て振り分け先のサーバーを固定する方式です。
ヘルスチェックはロードバランサーが振り分け先のサーバーの稼働状況を確認する機能です。
ロードバランサーから振り分け先のサーバーにヘルスチェックするアクティブ型のヘルスチェック方式と、クライアントからのリクエストに対するレスポンスを監視する方式です。
アクティブチェックは利用するプロトコルによってはL3チェック、L4チェック、L7チェックといった種類に分別することができます。
L4ロードバランサーをパッケージとして実装します。
ロードバランシングの種類はラウンドロビンで、ヘルスチェックはアクティブチェック・パッシブチェックのそれぞれ対応します。
パーシステンスはの対応はしません。
今回実装したコードはgithub.com/bmf-san/godonにあります。
ロードバランサーはリバースプロキシの一種です。まずは簡単なリバースプロキシの実装から始めます。
Golangではhttputil
を利用することで簡単に実装することができます。
package godon
import (
"log"
"net/http"
"net/http/httputil"
)
func Serve() {
director := func(request *http.Request) {
request.URL.Scheme = "http"
request.URL.Host = ":8081"
}
rp := &httputil.ReverseProxy{
Director: director,
}
s := http.Server{
Addr: ":8080",
Handler: rp,
}
if err := s.ListenAndServe(); err != nil {
log.Fatal(err.Error())
}
}
ここでは説明を省きますが、pkg.go.dev/net/http/httputil#ReverseProxyをよく読んでおくと良いかと思います。
簡単なロードバランサーなので複雑な設定を持ちませんが、jsonから設定を読み込むような設定の機能を実装しておきます。
{
"proxy": {
"port": "8080"
},
"backends": [
{
"url": "http://localhost:8081/"
},
{
"url": "http://localhost:8082/"
},
{
"url": "http://localhost:8083/"
},
{
"url": "http://localhost:8084/"
}
]
}
// ...
// Config is a configuration.
type Config struct {
Proxy Proxy `json:"proxy"`
Backends []Backend `json:"backends"`
}
// Proxy is a reverse proxy, and means load balancer.
type Proxy struct {
Port string `json:"port"`
}
// Backend is servers which load balancer is transferred.
type Backend struct {
URL string `json:"url"`
IsDead bool
mu sync.RWMutex
}
var cfg Config
// Serve serves a loadbalancer.
func Serve() {
// ...
data, err := ioutil.ReadFile("./config.json")
if err != nil {
log.Fatal(err.Error())
}
json.Unmarshal(data, &cfg)
// ...
}
次にラウンドロビンの実装をします。
均等にバックエンドのサーバーにリクエストを振り分けるのみで、バックエンドのサーバーの生死は問わない形で実装します。
// ...
var mu sync.Mutex
var idx int = 0
// lbHandler is a handler for loadbalancing
func lbHandler(w http.ResponseWriter, r *http.Request) {
maxLen := len(cfg.Backends)
// Round Robin
mu.Lock()
currentBackend := cfg.Backends[idx%maxLen]
targetURL, err := url.Parse(cfg.Backends[idx%maxLen].URL)
if err != nil {
log.Fatal(err.Error())
}
idx++
mu.Unlock()
reverseProxy := httputil.NewSingleHostReverseProxy(targetURL)
reverseProxy.ServeHTTP(w, r)
}
// ...
var cfg Config
// Serve serves a loadbalancer.
func Serve() {
data, err := ioutil.ReadFile("./config.json")
if err != nil {
log.Fatal(err.Error())
}
json.Unmarshal(data, &cfg)
s := http.Server{
Addr: ":" + cfg.Proxy.Port,
Handler: http.HandlerFunc(lbHandler),
}
if err = s.ListenAndServe(); err != nil {
log.Fatal(err.Error())
}
}
sync.Mutex
を利用しているのは、複数のGoroutineが変数にアクセスすることによる競合状態を回避するためです。
試しにsync.Mutex
を外してgo run -race server.go
でサーバー起動、複数端末から同時にリクエストするとrace conditionを確認することができます。
ここまでの実装では、ロードバランサーは異常なバックエンドに対してもリクエストを転送するようなロジックとなっています。
実際のユースケースでは異常なバックエンドにわざわざリクエストを転送してほしくはないので、異常なバックエンドを検知して、振り分け先から外れるようにします。
// Backend is servers which load balancer is transferred.
type Backend struct {
URL string `json:"url"`
IsDead bool
mu sync.RWMutex
}
// SetDead updates the value of IsDead in Backend.
func (backend *Backend) SetDead(b bool) {
backend.mu.Lock()
backend.IsDead = b
backend.mu.Unlock()
}
// GetIsDead returns the value of IsDead in Backend.
func (backend *Backend) GetIsDead() bool {
backend.mu.RLock()
isAlive := backend.IsDead
backend.mu.RUnlock()
return isAlive
}
var mu sync.Mutex
var idx int = 0
// lbHandler is a handler for loadbalancing
func lbHandler(w http.ResponseWriter, r *http.Request) {
maxLen := len(cfg.Backends)
// Round Robin
mu.Lock()
currentBackend := cfg.Backends[idx%maxLen]
if currentBackend.GetIsDead() {
idx++
}
targetURL, err := url.Parse(cfg.Backends[idx%maxLen].URL)
if err != nil {
log.Fatal(err.Error())
}
idx++
mu.Unlock()
reverseProxy := httputil.NewSingleHostReverseProxy(targetURL)
reverseProxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) {
// NOTE: It is better to implement retry.
log.Printf("%v is dead.", targetURL)
currentBackend.SetDead(true)
lbHandler(w, r)
}
reverseProxy.ServeHTTP(w, r)
}
var cfg Config
// Serve serves a loadbalancer.
func Serve() {
data, err := ioutil.ReadFile("./config.json")
if err != nil {
log.Fatal(err.Error())
}
json.Unmarshal(data, &cfg)
s := http.Server{
Addr: ":" + cfg.Proxy.Port,
Handler: http.HandlerFunc(lbHandler),
}
if err = s.ListenAndServe(); err != nil {
log.Fatal(err.Error())
}
}
ロードバランサーがバックエンドにリクエストを転送したときにエラーを検知すると呼び出されるErrorHandler
を実装します。ErrorHandler
では、正常にレスポンスを返さないバックエンドにフラグを立てて、もう一度ロードバランサーにリクエストを転送してもらうような形にしています。
ロードバランサーはフラグの立っているバックエンドにはリクエストを転送しないようにロジックを調整しています。
最後にパッシブチェックの実装をします。
パッシブチェックは、インターバルを指定してバックエンドサーバーのレスポンスを監視するだけです。
異常が検知されたバックエンドは、アクティブチェックのときと同じようにフラグが立てられます。
パッシブチェックを実装し終えた全てのコードは以下になります。
package godon
import (
"encoding/json"
"io/ioutil"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"sync"
"time"
)
// Config is a configuration.
type Config struct {
Proxy Proxy `json:"proxy"`
Backends []Backend `json:"backends"`
}
// Proxy is a reverse proxy, and means load balancer.
type Proxy struct {
Port string `json:"port"`
}
// Backend is servers which load balancer is transferred.
type Backend struct {
URL string `json:"url"`
IsDead bool
mu sync.RWMutex
}
// SetDead updates the value of IsDead in Backend.
func (backend *Backend) SetDead(b bool) {
backend.mu.Lock()
backend.IsDead = b
backend.mu.Unlock()
}
// GetIsDead returns the value of IsDead in Backend.
func (backend *Backend) GetIsDead() bool {
backend.mu.RLock()
isAlive := backend.IsDead
backend.mu.RUnlock()
return isAlive
}
var mu sync.Mutex
var idx int = 0
// lbHandler is a handler for loadbalancing
func lbHandler(w http.ResponseWriter, r *http.Request) {
maxLen := len(cfg.Backends)
// Round Robin
mu.Lock()
currentBackend := cfg.Backends[idx%maxLen]
if currentBackend.GetIsDead() {
idx++
}
targetURL, err := url.Parse(cfg.Backends[idx%maxLen].URL)
if err != nil {
log.Fatal(err.Error())
}
idx++
mu.Unlock()
reverseProxy := httputil.NewSingleHostReverseProxy(targetURL)
reverseProxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) {
// NOTE: It is better to implement retry.
log.Printf("%v is dead.", targetURL)
currentBackend.SetDead(true)
lbHandler(w, r)
}
reverseProxy.ServeHTTP(w, r)
}
// pingBackend checks if the backend is alive.
func isAlive(url *url.URL) bool {
conn, err := net.DialTimeout("tcp", url.Host, time.Minute*1)
if err != nil {
log.Printf("Unreachable to %v, error:", url.Host, err.Error())
return false
}
defer conn.Close()
return true
}
// healthCheck is a function for healthcheck
func healthCheck() {
t := time.NewTicker(time.Minute * 1)
for {
select {
case <-t.C:
for _, backend := range cfg.Backends {
pingURL, err := url.Parse(backend.URL)
if err != nil {
log.Fatal(err.Error())
}
isAlive := isAlive(pingURL)
backend.SetDead(!isAlive)
msg := "ok"
if !isAlive {
msg = "dead"
}
log.Printf("%v checked %v by healthcheck", backend.URL, msg)
}
}
}
}
var cfg Config
// Serve serves a loadbalancer.
func Serve() {
data, err := ioutil.ReadFile("./config.json")
if err != nil {
log.Fatal(err.Error())
}
json.Unmarshal(data, &cfg)
go healthCheck()
s := http.Server{
Addr: ":" + cfg.Proxy.Port,
Handler: http.HandlerFunc(lbHandler),
}
if err = s.ListenAndServe(); err != nil {
log.Fatal(err.Error())
}
}
リトライの実装やパーシステンスの対応などができていませんが、Golangでは比較的簡単にロードバランサーを実装できることが分かったかと思います。