コンテンツにスキップ

GoとRedisで作るIoTハートビート用レートリミッター

チェック

  • [ ] 本文を確認した
  • [ ] 概要を確認した
  • [ ] タグを確認した
  • [ ] inbox/ 直下へ移行した

概要

Medium の記事は、IoT デバイスの heartbeat を Go サービスで受け、Redis に TTL 付きで記録する実装例を紹介している。 Redis を中央の状態管理として使うことで、複数の Go インスタンスがロードバランサ配下にいても同じ制限を共有できる。 rate limiting は Redis の INCREXPIRE で簡易的に実装され、Redis 障害時は fail-open にしてサービス全体の単一障害点にしない方針。 本番では Lua script や Redis command の原子性、key 設計、監視、悪用耐性を追加で検討したい。

解説

この実装は、デバイスごとの heartbeat を「最後に来た時刻」として Redis に保存し、TTL によって自然に inactive 扱いへ落とす発想がシンプル。 IoT のように同じ種類の小さなリクエストが大量に来るシステムでは、DB に毎回書くより Redis の TTL を使うほうが軽い。

ただし記事の実装は入門レベルなので、本番では次を補う必要がある。

  • INCREXPIRE の組み合わせを Lua script などでより堅くする
  • device id の形式検証と認証を入れる
  • Redis 障害時の fail-open / fail-closed をユースケースごとに決める
  • active device の一覧取得が必要なら key scan ではなく set / sorted set を検討する
  • TTL と rate limit window を混同しないように key を分ける

本文

本文は取得できた記事内容をもとに、日本語で再構成したもの。 コードは原文の長い転載ではなく、同じ考え方を示すための短い等価例として整理している。

前提

IoT デバイスは一定間隔で heartbeat を送る。 記事では 60 秒ごとに小さな「生きている」シグナルを送る想定。

この heartbeat をサーバー側で効率的に扱いたい。 複数の Go サービスをロードバランサ配下に置く場合、各インスタンスのメモリに状態を持つだけでは制限が共有されない。 そこで Redis を中央の状態として使う。

Redis の key 設計

デバイスの最終 heartbeat は、次のような key に保存する。

heartbeat:{device_id} = last_seen_timestamp

この key に TTL を付ける。 デバイスから heartbeat が届かなくなれば、Redis が自動的に key を削除する。 そのため、存在する key を active device の近似として扱える。

rate limit 用には heartbeat 記録とは別の key を使う。

rate_limit:{device_id} = count_in_window

Go の実装イメージ

Redis client を初期化する。

func newRedisClient() *redis.Client {
    addr := os.Getenv("REDIS_ADDR")
    if addr == "" {
        addr = "localhost:6379"
    }

    return redis.NewClient(&redis.Options{
        Addr: addr,
        DB:   0,
    })
}

heartbeat handler では、JSON body から device_id を読み、rate limit を確認してから TTL 付きで最終時刻を保存する。

type heartbeatRequest struct {
    DeviceID string `json:"device_id"`
}

func heartbeatHandler(rdb *redis.Client) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        var req heartbeatRequest
        if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.DeviceID == "" {
            http.Error(w, "invalid request", http.StatusBadRequest)
            return
        }

        if limited, err := isRateLimited(r.Context(), rdb, req.DeviceID, 1, 5*time.Second); err != nil {
            log.Printf("rate limit check failed: %v", err)
        } else if limited {
            http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
            return
        }

        key := "heartbeat:" + req.DeviceID
        if err := rdb.Set(r.Context(), key, time.Now().Unix(), 2*time.Minute).Err(); err != nil {
            http.Error(w, "failed to record heartbeat", http.StatusInternalServerError)
            return
        }

        w.WriteHeader(http.StatusOK)
        _, _ = w.Write([]byte("heartbeat received"))
    }
}

簡易 rate limit は、window 内で INCR し、最初のリクエスト時に EXPIRE を設定する。

func isRateLimited(ctx context.Context, rdb *redis.Client, deviceID string, limit int64, window time.Duration) (bool, error) {
    key := "rate_limit:" + deviceID

    count, err := rdb.Incr(ctx, key).Result()
    if err != nil {
        return false, err
    }

    if count == 1 {
        if err := rdb.Expire(ctx, key, window).Err(); err != nil {
            return false, err
        }
    }

    return count > limit, nil
}

記事では、Redis 障害時に rate limit 失敗を理由として heartbeat 全体を止めない方針が示されている。 これは fail-open の設計で、rate limiter をシステムの単一障害点にしないための判断。

動作確認

/heartbeatdevice_id を送ると、正常時は heartbeat が記録される。 短時間に同じ device id で複数回送ると、rate limit により一部が 429 になる。

要点

  • Redis の TTL は heartbeat の active / inactive 判定に向いている。
  • 分散環境では、各 Go インスタンスのメモリではなく Redis のような共有ストアが必要。
  • INCR + EXPIRE で簡易 rate limit を作れる。
  • Redis 障害時に fail-open するか fail-closed するかは、保護対象とリスクで決める。
  • 本番では Lua script、認証、監視、key cardinality、active 一覧の取得方式を追加検討する。

タグ

go #redis #rate-limiting #iot #backend #distributed-system