Goでロードバランサーを実装する
はじめに
大学3回生のyadonです。今回はふとロードバランサーが実装したくなったので、実装した話をしようと思います。リポジトリはこちらです。
ロードバランサーのアルゴリズム
アルゴリズム自体は重み付きなどを含めるといくつかあるのですが、今回はそのなかでもいくつかだけ紹介しようと思います。詳しく知りたい方はこちらをご覧ください。
ラウンドロビン
これは非常にシンプルな方法で、リクエストを順番に各サーバーに割り当てます。すべてのサーバーが同じ能力を持っている場合にいいとされています。
最少コネクション (Least Connections)
現在のアクティブなコネクション数が最も少ないサーバーにリクエストを割り当てます。これもすべてのサーバーが同じ能力を持っている場合にいいとされています。
最短レスポンスタイム (Least Response Time)
サーバーの応答時間が最も短いものにリクエストが割り当てられます。応答時間が最も早いサーバーにトラフィックを送信することで、ユーザーへのサービスを高速化するアルゴリズムです。
しかし実際にはサーバーごとに性能が違ったりするので、重み付きラウンドロビンなどが存在しています。またここにあるアルゴリズムをいくつか合わせて実装することのほうが多いと思います。
実装
ラウンドロビンに関してはすでに実装されている方が、かなりいらっしゃると思うのでそちらの方々のリポジトリを参考にしてください。
なので今回は、最少コネクション (Least Connections)と最短レスポンスタイム (Least Response Time)について実装していこうと思います。
テストサーバー
今回は実際にどうのようにアクセスされているのかを知るために、テストサーバーをいくつか立ち上げています。
http://localhost:8081
http://localhost:8082
http://localhost:8083
http://localhost:8085
http://localhost:8086
http://localhost:8087
http://localhost:8088
http://localhost:8089
apiリストは上のとおりです。この中で8081と8082はレスポンスが遅いサーバーを表すために4秒待機するような設定になっています。
テストサーバーの実装もリポジトリに含まれています。dockerfileなどは使いまわしているので冗長なコードが含まれていて、docker-composeの立ち上げも遅いかもしれませんが、もし気になる方がいらっしゃればprいただけると幸いです。
テストサーバーの立ち上げはmakefileで行えます。
make ts-up
実装
実装はリポジトリを見てもらうのがはやいです。interface切って、2つのアルゴリズムに対応しやすいようにしています。
func main() {
backends := backend.NewDefaultBackend()
for _, b := range backends {
url, err := url.Parse(b.GetURL())
if err != nil {
utils.Error("usrl parse err",
zap.String("error", err.Error()),
zap.String("url", b.GetURL()),
)
}
b.SetReverProxy(httputil.NewSingleHostReverseProxy(url))
}
serverPool, err := utils.GetPoolType(backends)
if err != nil {
utils.Error("get pool type err",
zap.String("error", err.Error()),
)
}
go healthCheck(context.Background(), serverPool)
go benchCheck(context.Background(), serverPool)
lbHandler := NewLBHandler(serverPool)
s := http.Server{
Addr: ":" + "8080",
Handler: http.HandlerFunc(lbHandler.Serve),
}
if err := s.ListenAndServe(); err != nil {
utils.Error("listen and serve err",
zap.String("error", err.Error()),
)
}
}
リバースプロキシにはhttputilを使用しています。あとは、healthcheck(サーバーが生きているかのチェック) benchcheck(サーバーのレスポンスタイムの計測)を定期的に行っていたり、config.jsonの書き換えによって、2つのアルゴリズムを入れ替えることができるようになっています。最少コネクション (Least Connections)の実装はlcディレクトリに、最短レスポンスタイム (Least Response Time)の実装はlrディレクトリに存在します。
type lcserverPool struct {
Backends []backend.Backend `json:"backends"`
mu sync.RWMutex
}
func (s *lcserverPool) GetNextValidPeer() backend.Backend {
var leastConnectedPeer backend.Backend
for _, b := range s.Backends {
if !b.GetIsDead() {
leastConnectedPeer = b
break
}
}
for _, b := range s.Backends {
if b.GetIsDead() {
continue
}
if leastConnectedPeer.GetConnections() > b.GetConnections() {
leastConnectedPeer = b
}
}
return leastConnectedPeer
}
見てもらえば解ると思いますが、コネクション数を比較しているだけです。
結果
まずは最少コネクション (Least Connections)から見ていきましょう
make run
{"level":"info","msg":"access to endpoint","url":"http://localhost:8081/","connections":0}
{"level":"info","msg":"access to endpoint","url":"http://localhost:8081/","connections":0}
{"level":"info","msg":"access to endpoint","url":"http://localhost:8082/","connections":0}
{"level":"info","msg":"access to endpoint","url":"http://localhost:8083/","connections":0}
{"level":"info","msg":"access to endpoint","url":"http://localhost:8083/","connections":0}
{"level":"info","msg":"access to endpoint","url":"http://localhost:8081/","connections":0}
見れば解ると思いますが、コネクションが常に0になるようにそれぞれ負荷を分散させているのが解ると思います。しかし、ここではレスポンスが遅いサーバーにもアクセスしています。(8081,8082)
では次に最短レスポンスタイム (Least Response Time)の結果を見ていきます。
make run
{"level":"info","msg":"access to endpoint","url":"http://localhost:8085/","connections":0}
{"level":"info","msg":"access to endpoint","url":"http://localhost:8085/","connections":0}
{"level":"info","msg":"access to endpoint","url":"http://localhost:8085/","connections":0}
{"level":"info","msg":"access to endpoint","url":"http://localhost:8085/","connections":0}
こちらは、レスポンスの遅い8081,8082にアクセスしていないのが解ると思います。コネクションが0になっていると思いますが、これは8085のレスポンスが早いため、コネクションが増えてもすぐ元に戻るからです。
終わりに
ロードバランサーも深いですね。よかったらstarください。
参考
Creating a Load Balancer in GO
Discussion