GOでのロードバランサー作成
以下の文章はLeonardo Rodrigues Martinsさんからの許可を得て翻訳したものです。
現代のウェブサイトは、1秒間に数百から数千のユーザーリクエストを処理しなければならないときがあります。そのような大量のトラフィックをすべて処理するためには、基盤となるインフラを確実に整備しておくことが重要です。リクエストの量が増加すると、利用可能なサーバーの数を増やす必要が出てきます。
ロードバランサーは、利用可能なサーバー間でトラフィックを調整するためによく使用されるツールです。
この記事では、Go言語で簡単なロードバランサーを実装する方法と、受信リクエストを処理するためのいくつかの戦略について見ていきます。
ロードバランシングって何?
ロードバランサーは、複数のサーバー(これを「サーバープール」と言います)の前に置かれます。ユーザーからのリクエストが来たとき、どのサーバーにそのリクエストを送るかを決めるのが「ロードバランシングアルゴリズム」です。このアルゴリズムが上手に働くと、すべてのサーバーが均等に仕事をすることができ、ウェブサイトやアプリの速度も落ちにくくなります。
ラウンドロビンアルゴリズム
ラウンドロビンは、サーバープール内のサーバー間で負荷を均等に分散するアルゴリズムです。単純にローテーションを行い、リクエストを特定のサーバーに転送する前に、そのサーバーが稼働中(生きている)かどうかをチェックします。
この方法は、処理能力やリソースの観点で似たような能力を持つ複数のサーバー間でリクエストを分割する必要がある場合によく使用されます。
加重ラウンドロビン
これはラウンドロビンのバリエーションの1つです。ここでは、各バックエンドのキャパシティを考慮してリクエストが転送されます。例えば、サーバー1がサーバー2の2倍の処理能力を持っているとします。
この場合、サーバー2が1つのリクエストを受け取るたびに、サーバー1はその2倍のリクエストを処理します。
リーストコネクション
この方法は、負荷を分散する際に、各サーバーが持っているアクティブな接続数を考慮します。常に現時点で最も少ない接続数を持つサーバーが選ばれます。
このプロジェクトにおいては、ラウンドロビン方式とリーストコネクション方式を使用するロードバランサを実装します。
バックエンド
まず、バックエンドとの対話のためのインターフェースを定義します。
type Backend interface {
SetAlive(bool)
IsAlive() bool
GetURL() *url.URL
GetActiveConnections() int
Serve(http.ResponseWriter, *http.Request)
}
SetAliveメソッドとIsAliveメソッドは、バックエンドのステータスをそれぞれ変更し、確認するために使用されます。Serveメソッドは、リバースプロキシを使用して、対応するURLへリクエストを中継します。
type backend struct {
url *url.URL
alive bool
mux sync.RWMutex
connections int
reverseProxy *httputil.ReverseProxy
}
ここでのmuxは、他の属性の操作を行う際の競合状態を避けるために使います。
サーバープール
次に、バックエンドサーバーのセットをまとめるサーバープールを構築します。
type ServerPool interface {
GetBackends() []Backend
GetNextValidPeer() Backend
AddBackend(Backend)
GetServerPoolSize() int
}
GetNextValidPeerメソッドは、選択された戦略に従って利用可能なサーバーを見つけるために使います。ラウンドロビンの実装については、以下のようになります:
type roundRobinServerPool struct {
backends []backend.Backend
mux sync.RWMutex
current int
}
func (s *roundRobinServerPool) Rotate() backend.Backend {
s.mux.Lock()
s.current = (s.current + 1) % s.GetServerPoolSize()
s.mux.Unlock()
return s.backends[s.current]
}
func (s *roundRobinServerPool) GetNextValidPeer() backend.Backend {
for i := 0; i < s.GetServerPoolSize(); i++ {
nextPeer := s.Rotate()
if nextPeer.IsAlive() {
return nextPeer
}
}
return nil
}
現在のサーバーのインデックスは、構造体の実装内の整数に格納されています。Rotate
メソッドは現在のカウントを増やし、次のサーバーを返します。その後、GetNextValidPeer
メソッドはサーバーが動作していてリクエストを受け取ることができるかを検証する責任があります。それができない場合、見つかるまで繰り返します。
また、リーストコネクション選択戦略のために別のサーバープールの実装も一応行いました。:
type lcServerPool struct {
backends []backend.Backend
mux sync.RWMutex
}
func (s *lcServerPool) GetNextValidPeer() backend.Backend {
var leastConnectedPeer backend.Backend
// 有効なピア(サーバー)を一つ見つける
for _, b := range s.backends {
if b.IsAlive() {
leastConnectedPeer = b
break
}
}
// アクティブな接続数が最も少ないサーバーを探す
for _, b := range s.backends {
if !b.IsAlive() {
continue
}
if leastConnectedPeer.GetActiveConnections() > b.GetActiveConnections() {
leastConnectedPeer = b
}
}
return leastConnectedPeer
}
さらに、サーバープールのラッパーとしてロードバランサーのインターフェースと基礎構造を実装しました:
type LoadBalancer interface {
Serve(http.ResponseWriter, *http.Request)
}
type loadBalancer struct {
serverPool serverpool.ServerPool
}
func (lb *loadBalancer) Serve(w http.ResponseWriter, r *http.Request) {
peer := lb.serverPool.GetNextValidPeer()
if peer != nil {
peer.Serve(w, r)
return
}
http.Error(w, "Service not available", http.StatusServiceUnavailable)
}
生存確認
裏側で特定のサーバーが応答しているかどうかを判断する方法も必要になります。バックエンドが稼働しているかどうかを判断するために、ヘルスチェックルーティンを実装しました。サーバープール内のすべてのバックエンドを継続的にチェックします:
func HealthCheck(ctx context.Context, s ServerPool) {
aliveChannel := make(chan bool, 1)
// 利用可能なバックエンドの上での繰り返し
for _, b := range s.GetBackends() {
b := b
// タイムアウト付きのコンテキストを定義
requestCtx, stop := context.WithTimeout(ctx, 10*time.Second)
defer stop()
// バックエンドのURLをチェック
go IsBackendAlive(requestCtx, aliveChannel, b.GetURL())
select {
// 親のコンテキストがキャンセルされた場合、goroutineを終了する
case <-ctx.Done():
utils.Logger.Info("ヘルスチェックを正常にシャットダウン")
return
case alive := <-aliveChannel:
b.SetAlive(alive)
}
}
}
エラー処理
選択されたサーバーの中の1つが問題を抱え、選択された後に使用できなくなることがあります。この問題を解決するために、リトライを試みるためのエラーハンドラをリバースプロキシに追加することができます:
rp.ErrorHandler = func(writer http.ResponseWriter, request *http.Request, e error) {
logger.Error("error handling the request")
if !AllowRetry(request) {
http.Error(writer, "Service not available", http.StatusServiceUnavailable)
return
}
loadBalancer.Serve(
writer,
request.WithContext(
context.WithValue(request.Context(), RETRY_ATTEMPTED, true),
),
)
}
AllowRetry
関数内では、リクエストのコンテキストにRETRY_ATTEMPTED
フラグが含まれているかどうかをチェックします。含まれていない場合、リトライが許可されます。
このコールバックハンドラは、クロージャを使用してロードバランサーの構造にアクセスし、最初に失敗した後、再度リクエストの処理を試みます。
必要に応じて、各リクエストは一度だけ再試行されます。その後、エラーハンドラは2回目に失敗した場合にエラーを投げます。
完全なコードは私(Leonardo Rodrigues Martins)のリポジトリで確認できます:https://github.com/leonardo5621/golang-load-balancer
まとめ
この記事では、GOでの基本的なロードバランサーの実装について見てきました。現代のクラウドロードバランサーは、さらに多くの機能が搭載されていますが、このプロジェクトにはロードバランサーを作成するための最低限の内容が含まれています。この記事が皆さんの役に立ったことを願っています!最後まで読んでいただき、ありがとうございます。