RAKUS Developers Blog | ラクス エンジニアブログ

株式会社ラクスのITエンジニアによる技術ブログです。

Webアプリケーションにおけるレートリミット、サーキットブレーカー、リトライの役割を調べて実装してみた

はじめに

こんにちは!エンジニア2年目のTKDSです。
今回は、レートリミット・サーキットブレーカー・リトライについて調べた内容を紹介し、ライブラリを使ってGoで実装してみます。

Webアプリケーションにおけるレートリミット、サーキットブレーカー、リトライの役割

リトライ

リクエストが失敗した場合に再試行します。
リトライは、一時的な障害に対して効果を発揮します。
ネットワークの瞬断やサービスの一時的な過負荷など、やり直せば解決できそうな問題による失敗をシステム内のリトライでカバーすることで、ユーザーからは特に問題なく見える状態を維持したまま処理失敗のリカバリーができます。

サーキットブレーカー

サービスを監視し、設定した条件がみたされると「オープン(リクエストを受け付けない)」状態になり、しばらくすると「ハーフオープン(一部だけ受け入れ)」状態になり、システムが回復したかどうか一部のリクエストを通して確認します。
回復したことが確認できれば「クローズ(通常状態)」に、だめなら「オープン」になります。
サーキットブレーカーは、復旧に時間のかかる障害に対して効果を発揮します。
そのままリトライし続けても回復する見込みが低い、もしくは回復まで時間がかかる場合、一旦アクセスを遮断することで障害が起きたサービスへの無駄なアクセスや無駄なリソースの消費を避けることができ、サービスが回復するまでの時間を稼ぎます。

レートリミット

設定した以上のリクエストが来たときに、一時的にアクセスを制限します。
レートリミットを使うとサービスに過負荷がかかることを防ぐことができます。
また、DoS攻撃などからアプリケーションを守ることができます。

これらの機能を採用することでシステムの耐障害性、安定性、パフォーマンスを向上させられます。

レートリミット、サーキットブレーカー、リトライの実装

この節では、Goで実際にレートリミット、サーキットブレーカー、リトライの機能を実装していきます。

サンプルアプリケーションの実装

今回はライブラリを活用して実装していきます。
自分で1から実装しない理由はいくつかあります。

  • 多くの人が利用しているライブラリはバグが発見されやすく、自分で実装するより信頼性が高い
  • 採用時点でのベストプラクティスを適用できる
  • 複雑な動作の適切な処理を自身で考える必要がない

このような観点からライブラリを使います。
以下がサンプルアプリケーションです。
リクエストパラメータに指定したURLにアクセスし、存在する場合はカウントを1プラスし、ない場合はエラーを返します。

package main

import (
    "context"
    "errors"
    "fmt"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/go-redis/redis/v8"
    "golang.org/x/exp/slog"
)

var (
    rdb *redis.Client
)

func init() {
    rdb = redis.NewClient(&redis.Options{
        Addr:     "redis:6379", // Redisサーバーのアドレス
        Password: "",           // パスワードなし
        DB:       0,            // デフォルトのDB
    })
}

func hostExists(url string) bool {
    resp, err := http.Get(url)
    if err != nil {
        return false
    }
    defer resp.Body.Close()
    return resp.StatusCode == http.StatusOK
}

func handler(w http.ResponseWriter, r *http.Request) {
    host := r.URL.Query().Get("host")
    if host == "" {
        http.Error(w, "Host parameter is missing", http.StatusBadRequest)
        return
    }

    if hostExists(host) {
        count, err := rdb.Incr(context.Background(), "counter").Result()
        if err != nil {
            http.Error(w, "Could not increment counter", http.StatusInternalServerError)
            return
        }
        fmt.Fprintf(w, "Counter: %d\n", count)
    } else {
        http.Error(w, "Host not found", http.StatusNotFound)
    }
}

func main() {
    addr := ":8080"
    handler := http.HandlerFunc(handler)
    server := &http.Server{Addr: addr, Handler: handler}
    idleConnsClosed := make(chan struct{})

    go func() {
        c := make(chan os.Signal, 1)
        signal.Notify(c, os.Interrupt, syscall.SIGTERM) // SIGINT, SIGTERM を検知する
        <-c

        ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
        defer cancel()

        slog.Info("Server is shutting down...")
        if err := server.Shutdown(ctx); err != nil {
            if errors.Is(err, context.DeadlineExceeded) {
                slog.Warn("HTTP server Shutdown: timeout")
            } else {
                slog.Error("HTTP server Shutdown: ", err)
            }
            close(idleConnsClosed)
            return
        }
        slog.Info("Server is shut down")
        close(idleConnsClosed)
    }()

    slog.Info("Server is running on ", addr)
    if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
        slog.Error("HTTP server ListenAndServe: ", err)
    }

    <-idleConnsClosed
}

docker compose up --buildで起動します。
成功するリクエストと失敗するリクエストを送って動作確認をしてみましょう。

  • 成功:curl "http://localhost:8080?host=http://example.com"
  • 失敗:curl "http://localhost:8080?host=http://.com"

下の画像のように動作確認ができます。

リトライ、サーキットブレーカー、レートリミットを追加

サンプルにリトライ、サーキットブレーカー、レートリミットを追加していきます。
コードは以下の通りです。
変更が入った、initとhostExists、handlerだけ記載します。
詳細はGithubをみてください。

func init() {
    // Redisサーバーのアドレスを設定
    rdb = redis.NewClient(&redis.Options{
        Addr:     "redis:6379", // Redisサーバーのアドレス
        Password: "",           // パスワードなし
        DB:       0,            // デフォルトのDB
    })

    cb = gobreaker.NewCircuitBreaker(gobreaker.Settings{
        Name:    "Redis",
        Timeout: 30 * time.Second,
        ReadyToTrip: func(counts gobreaker.Counts) bool {
            return counts.ConsecutiveFailures >= 3 // 3回連続失敗でトリップ
        },
        OnStateChange: func(name string, from gobreaker.State, to gobreaker.State) {
            log.Println("Circuit breaker state changed", "name", name, "from", from.String(), "to", to.String()) // サーキットブレーカーの状態が変わるたびにログ出力
        },
    })

    rateLimiter = ratelimit.New(1, ratelimit.Per(10*time.Second)) // 1リクエスト/秒

    client = retryablehttp.NewClient()
    client.RetryMax = 3 // 最大3回リトライ
}

func hostExists(url string) bool {
    req, err := retryablehttp.NewRequest("GET", url, nil)
    if err != nil {
        return false
    }

    resp, err := client.Do(req)
    if err != nil {
        return false
    }
    defer resp.Body.Close()
    return resp.StatusCode == http.StatusOK
}

func handler(w http.ResponseWriter, r *http.Request) {
    // レートリミットの適用
    rateLimiter.Take()

    host := r.URL.Query().Get("host")
    if host == "" {
        http.Error(w, "Host parameter is missing", http.StatusBadRequest)
        return
    }

    if hostExists(host) {
        // サーキットブレーカーの適用
        body, err := cb.Execute(func() (interface{}, error) {
            count, err := rdb.Incr(context.Background(), "counter").Result()
            if err != nil {
                return nil, err
            }
            return fmt.Sprintf("Counter: %d\n", count), nil
        })

        if err != nil {
            http.Error(w, "Could not increment counter", http.StatusInternalServerError)
            return
        }
        fmt.Fprintf(w, body.(string))
    } else {
        http.Error(w, "Host not found", http.StatusNotFound)
    }
}

では同様に、起動して動作確認していきます。
docker compose down -vしてきれいにしておきましょう。
docker compose build --no-cacheでビルドし、 docker compose upで起動します。

  • リトライを起こすリクエス
    リトライはリクエストが失敗した場合に起こります。
    curl "http://localhost:8080?host=http://.com"を実行すると先程と違い、retryのログがサーバー側に出力されていることがわかります。

これでリトライが機能していることが確認できました。

  • レートリミットを起こすリクエス
    レートリミットの部分を以下のように変更しましょう。
    このライブラリでは、レートリミットを超えると指定した時間処理を待機するようになっています。
    10秒間に1回のリクエストに制限してあります。

レートリミットがかかると、リクエストに10秒かかっていることがわかります。
これで、レートリミットが機能していることが確認できました。

  • サーキットブレーカーを起動するリクエス
    今回のサーキットブレーカーは3回連続失敗で、30秒間タイムアウトするようにしています。
    まず、起動しているRedisを止めます。
    これでRedisへのアクセスが失敗するようになりました。

サーキットブレーカーの変化がログに出力されています。
三回失敗したあとにサーキットブレーカーの変化が起きています。
これでサーキットブレーカーの動作確認ができました!

まとめ

今回はレートリミット・サーキットブレーカー・リトライについて調べた内容を紹介し、ライブラリを使ってGoで実装しました。
ライブラリを使えば比較的簡単に実装できるので、ぜひ実装してみて下さい。
マイクロサービスには必須の機能だと思うので、記憶にとどめて置きたいと思います。
ここまで読んでいただきありがとうございました!


年に1度の技術イベント「RAKUS Tech Conference」を開催します!!

今年もラクス開発本部主催の技術カンファレンス、「RAKUS Tech Conference 2024」を開催します!

「RAKUS Tech Conference」は、SaaS開発における取り組みや知見を紹介する、ラクス開発本部主催の技術カンファレンスです。 ラクス開発本部のミッションに込めた想いをエンジニア/デザイナーが生の声でお届けします。

皆さまのご参加、お待ちしております!
techcon.rakus.co.jp


Copyright © RAKUS Co., Ltd. All rights reserved.