GOでのロードバランサー作成

 
0
このエントリーをはてなブックマークに追加
Daichi Takayama
Daichi Takayama (高山 大地)

以下の文章はLeonardo Rodrigues Martinsさんからの許可を得て翻訳したものです。

https://medium.com/@leonardo5621_66451/building-a-load-balancer-in-go-1c68131dc0ef


現代のウェブサイトは、1秒間に数百から数千のユーザーリクエストを処理しなければならないときがあります。そのような大量のトラフィックをすべて処理するためには、基盤となるインフラを確実に整備しておくことが重要です。リクエストの量が増加すると、利用可能なサーバーの数を増やす必要が出てきます。

ロードバランサーは、利用可能なサーバー間でトラフィックを調整するためによく使用されるツールです。

この記事では、Go言語で簡単なロードバランサーを実装する方法と、受信リクエストを処理するためのいくつかの戦略について見ていきます。


ロードバランシングって何?

ロードバランサーは、複数のサーバー(これを「サーバープール」と言います)の前に置かれます。ユーザーからのリクエストが来たとき、どのサーバーにそのリクエストを送るかを決めるのが「ロードバランシングアルゴリズム」です。このアルゴリズムが上手に働くと、すべてのサーバーが均等に仕事をすることができ、ウェブサイトやアプリの速度も落ちにくくなります。


ラウンドロビンアルゴリズム

ラウンドロビンは、サーバープール内のサーバー間で負荷を均等に分散するアルゴリズムです。単純にローテーションを行い、リクエストを特定のサーバーに転送する前に、そのサーバーが稼働中(生きている)かどうかをチェックします。
Round Robin Algorithm

この方法は、処理能力やリソースの観点で似たような能力を持つ複数のサーバー間でリクエストを分割する必要がある場合によく使用されます。


加重ラウンドロビン

これはラウンドロビンのバリエーションの1つです。ここでは、各バックエンドのキャパシティを考慮してリクエストが転送されます。例えば、サーバー1がサーバー2の2倍の処理能力を持っているとします。

この場合、サーバー2が1つのリクエストを受け取るたびに、サーバー1はその2倍のリクエストを処理します。
Weighted Round Robin


リーストコネクション

この方法は、負荷を分散する際に、各サーバーが持っているアクティブな接続数を考慮します。常に現時点で最も少ない接続数を持つサーバーが選ばれます。
Least Connections

このプロジェクトにおいては、ラウンドロビン方式とリーストコネクション方式を使用するロードバランサを実装します。


バックエンド

まず、バックエンドとの対話のためのインターフェースを定義します。

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での基本的なロードバランサーの実装について見てきました。現代のクラウドロードバランサーは、さらに多くの機能が搭載されていますが、このプロジェクトにはロードバランサーを作成するための最低限の内容が含まれています。この記事が皆さんの役に立ったことを願っています!最後まで読んでいただき、ありがとうございます。

参考リンク

info-outline

お知らせ

K.DEVは株式会社KDOTにより運営されています。記事の内容や会社でのITに関わる一般的なご相談に専門の社員がお答えしております。ぜひお気軽にご連絡ください。