Let's Go Further محدودیت نرخ › محدودیت نرخ مبتنی بر IP
قبلی · فهرست مطالب · بعدی
فصل ۱۰.۲.

محدودیت نرخ مبتنی بر IP

استفاده از یک محدودکننده نرخ سراسری زمانی مفید است که بخواهید یک محدودیت دقیق روی کل نرخ درخواست‌ها به API خود اعمال کنید و برایتان مهم نباشد که درخواست‌ها از کجا می‌آیند. اما معمولاً رایج‌تر این است که برای هر کلاینت یک محدودکننده نرخ جداگانه داشته باشیم، به طوری که یک کلایtent بد که درخواست‌های زیادی ارسال می‌کند، روی بقیه تأثیر نگذارد.

یک راه ساده و منطقی برای پیاده‌سازی این کار، ایجاد یک نقشه از محدودکننده‌های نرخ در حافظه است که از آدرس IP هر کلاینت به عنوان کلید نقشه استفاده می‌کند.

هر بار که یک کلاینت جدید درخواستی به API ما ارسال می‌کند، یک محدودکننده نرخ جدید مقداردهی اولیه کرده و آن را به نقشه اضافه می‌کنیم. برای درخواست‌های بعدی، محدودکننده نرخ کلاینت را از نقشه بازیابی کرده و بررسی می‌کنیم که آیا درخواست با فراخوانی متد Allow() آن مجاز است یا خیر، دقیقاً مانند قبل.

اما یک نکته وجود دارد که باید به آن توجه کرد: به طور پیش‌فرض، نقشه‌ها برای استفاده هم‌زمان ایمن نیستند. این برای ما یک مشکل است زیرا میان‌افزار rateLimit() ما ممکن است در چندین goroutine به طور هم‌زمان اجرا شود (به یاد داشته باشید که http.Server در Go هر درخواست HTTP را در goroutine خاص خودش مدیریت می‌کند).

از وبلاگ Go:

نقشه‌ها برای استفاده هم‌زمان ایمن نیستند: تعریف نشده است که چه اتفاقی می‌افتد وقتی هم‌زمان از آن‌ها بخوانید و در آن‌ها بنویسید. اگر نیاز دارید از یک نقشه هم‌زمان از goroutine‌های مختلف بخوانید و در آن بنویسید، دسترسی‌ها باید توسط نوعی سازگاری‌سازی مدیریت شوند.

بنابراین برای حل این مشکل، باید دسترسی به نقشه محدودکننده‌های نرخ را با استفاده از یک sync.Mutex (قفل انحصاری متقابل) هماهنگ کنیم، به طوری که فقط یک goroutine بتواند در هر لحظه از نقشه بخواند یا در آن بنویسد.

حالا بیایید درباره آدرس‌های IP صحبت کنیم.

فیلد r.RemoteAddr درخواست باید حاوی آدرس IP کلاینتی باشد که درخواست را ارسال کرده است. اما… در دنیای واقعی ممکن است سرورهای پروکسی بین برنامه شما و کلاینت قرار داشته باشند، به این معنی که آدرس IP ذخیره شده در r.RemoteAddr ممکن است در واقع آدرس IP واقعی کلاینت اصلی نباشد — در عوض ممکن است آدرس IP یک پروکسی باشد.

پروکسی‌های خوش‌رفتار معمولاً هدر X-Forwarded-For یا X-Real-IP را به درخواست اضافه می‌کنند که حاوی IP کلاینت اصلی است. بنابراین می‌توانیم شانس دریافت IP واقعی کلاینت را با بررسی این هدرها و — اگر وجود داشته باشند — استفاده از آدرس IP از آن‌ها افزایش دهیم.

اگرچه می‌توانیم منطق این کار را خودمان بنویسیم، توصیه می‌کنم از پکیج realip استفاده کنیم. این پکیج بسیار کوچک است و به سادگی آدرس IP کلاینت را از هدرهای X-Forwarded-For یا X-Real-IP بازیابی می‌کند و در صورت عدم وجود هر دو، از r.RemoteAddr استفاده می‌کند.

اگر می‌خواهید همراه با ما پیش بروید، نسخه آخر realip را با دستور go get نصب کنید:

$ go get github.com/tomasen/realip@latest
go: downloading github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce
go get: added github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce

خب، با تمام شدن تنظیمات، بیایید به کد بپردازیم و میان‌افزار rateLimit() خود را برای پیاده‌سازی تغییرات به‌روزرسانی کنیم.

فایل: cmd/api/middleware.go
package main

import (
    "fmt"
    "net" // New import
    "net/http"
    "sync" // New import

    "github.com/tomasen/realip" // New import
    "golang.org/x/time/rate" 
)

...

func (app *application) rateLimit(next http.Handler) http.Handler {
    // Declare a mutex and a map to hold the clients' IP addresses and rate limiters.
    var (
        mu      sync.Mutex
        clients = make(map[string]*rate.Limiter)
    )

    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Use the realip.FromRequest() function to get the client's IP address.
        ip := realip.FromRequest(r)

        // Lock the mutex to prevent this code from being executed concurrently.
        mu.Lock()

        // Check to see if the IP address already exists in the map. If it doesn't, then
        // initialize a new rate limiter and add the IP address and limiter to the map.
        if _, found := clients[ip]; !found {
            clients[ip] = rate.NewLimiter(2, 4)
        }

        // Call the Allow() method on the rate limiter for the current IP address. If
        // the request isn't allowed, unlock the mutex and send a 429 Too Many Requests
        // response, just like before.
        if !clients[ip].Allow() {
            mu.Unlock()
            app.rateLimitExceededResponse(w, r)
            return
        }

        // Very importantly, unlock the mutex before calling the next handler in the
        // chain. Notice that we DON'T use defer to unlock the mutex, as that would mean
        // that the mutex isn't unlocked until all the handlers downstream of this 
        // middleware have also returned.
        mu.Unlock()

        next.ServeHTTP(w, r)
    })
}

حذف محدودکننده‌های قدیمی

کد بالا کار می‌کند، اما یک مشکل جزئی وجود دارد — نقشه clients به طور نامحدود رشد می‌کند و با هر آدرس IP و محدودکننده نرخ جدیدی که اضافه می‌کنیم، منابع بیشتری مصرف می‌شود.

برای جلوگیری از این مشکل، بیایید کد خود را به‌روزرسانی کنیم تا زمان آخرین مشاهده هر کلاینت را نیز ثبت کنیم. سپس می‌توانیم یک goroutine پس‌زمینه اجرا کنیم که به طور دوره‌ای هر کلاینتی را که اخیراً مشاهده نشده از نقشه clients حذف می‌کند.

برای عملی کردن این کار، باید یک ساختار client سفارشی ایجاد کنیم که هم محدودکننده نرخ و هم زمان آخرین مشاهده هر کلاینت را نگه می‌دارد، و goroutine پاکسازی پس‌زمینه را هنگام مقداردهی اولیه میان‌افزار راه‌اندازی کنیم.

به این صورت:

فایل: cmd/api/middleware.go
package main

import (
    "fmt"
    "net"
    "net/http"
    "sync"
    "time" // New import

    "github.com/tomasen/realip"
    "golang.org/x/time/rate"
)

...

func (app *application) rateLimit(next http.Handler) http.Handler {
    // Define a client struct to hold the rate limiter and last seen time for each
    // client.
    type client struct {
        limiter  *rate.Limiter
        lastSeen time.Time
    }

    var (
        mu sync.Mutex
        // Update the map so the values are pointers to a client struct.
        clients = make(map[string]*client)
    )

    // Launch a background goroutine which removes old entries from the clients map once
    // every minute.
    go func() {
        for {
            time.Sleep(time.Minute)

            // Lock the mutex to prevent any rate limiter checks from happening while
            // the cleanup is taking place.
            mu.Lock()

            // Loop through all clients. If they haven't been seen within the last three
            // minutes, delete the corresponding entry from the map.
            for ip, client := range clients {
                if time.Since(client.lastSeen) > 3*time.Minute {
                    delete(clients, ip)
                }
            }

            // Importantly, unlock the mutex when the cleanup is complete.
            mu.Unlock()
        }
    }()

    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ip := realip.FromRequest(r)

        mu.Lock()

        if _, found := clients[ip]; !found {
            // Create and add a new client struct to the map if it doesn't already exist.
            clients[ip] = &client{limiter: rate.NewLimiter(2, 4)}
        }

        // Update the last seen time for the client.
        clients[ip].lastSeen = time.Now()

        if !clients[ip].limiter.Allow() {
            mu.Unlock()
            app.rateLimitExceededResponse(w, r)
            return
        }

        mu.Unlock()

        next.ServeHTTP(w, r)
    })
}

در این مرحله، اگر API را مجدداً راه‌اندازی کنید و دسته‌ای از درخواست‌ها را با سرعت زیاد مجدداً ارسال کنید، باید متوجه شوید که محدودکننده نرخ از دیدگاه یک کلاینت جداگانه همچنان به درستی کار می‌کند — دقیقاً مانند قبل.

$ for i in {1..6}; do curl  http://localhost:4000/v1/healthcheck; done
{
    "status": "available",
    "system_info": {
        "environment": "development",
        "version": "1.0.0"
    }
}
{
    "status": "available",
    "system_info": {
        "environment": "development",
        "version": "1.0.0"
    }
}
{
    "status": "available",
    "system_info": {
        "environment": "development",
        "version": "1.0.0"
    }
}
{
    "status": "available",
    "system_info": {
        "environment": "development",
        "version": "1.0.0"
    }
}
{
    "error": "rate limit exceeded"
}
{
    "error": "rate limit exceeded"
}

اطلاعات تکمیلی

برنامه‌های توزیع‌شده

استفاده از این الگو برای محدودیت نرخ فقط زمانی کار می‌کند که برنامه API شما روی یک ماشین واحد اجرا شود. اگر زیرساخت شما توزیع‌شده باشد و برنامه شما روی چندین سرور پشت یک بالانسر بار اجرا شود، به رویکرد جایگزینی نیاز خواهید داشت.

اگر از HAProxy یا Nginx به عنوان بالانسر بار یا پروکسی معکوس استفاده می‌کنید، هر دوی این‌ها قابلیت داخلی برای محدودیت نرخ دارند که می‌توانید از آن استفاده کنید. به عنوان جایگزین، می‌توانید از یک پایگاه داده سریع مانند Redis برای نگهداری شمارنده درخواست‌ها برای کلاینت‌ها استفاده کنید که روی سروری اجرا می‌شود که تمام سرورهای برنامه شما می‌توانند با آن ارتباط برقرار کنند.