Golangでロードバランサーを実装する

アプリケーション

概要

この記事はMakuake Advent Calendar 2021の24日目の記事です。(大遅刻しました・・)
ラウンドロビンで負荷分散するロードバランサーをGolangで自作してみるという話です。

ロードバランサーとは何か

ロードバランサーはリクエストを複数のサーバーへ振り分けて負荷分散する(ロードバランシング)機能を持ったサーバーです。

スクリーンショット 2022-01-01 23 05 20

サービスの可用性を高めてくれるリバースプロキシの一種です。
ロードバランサーの種類は大きく分けて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をよく読んでおくと良いかと思います。

Configの実装

簡単なロードバランサーなので複雑な設定を持ちませんが、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では比較的簡単にロードバランサーを実装できることが分かったかと思います。

参考