Как предотвратить перегрузку сервера в Go

Как предотвратить перегрузку сервера в Go

6 января 2024 г.

Введение

При разработке крупномасштабных систем всегда важно думать об устойчивости. Нагрузка на приложение обычно меняется со временем в зависимости от количества активных пользователей или типов выполняемых ими действий.

Одной из наиболее распространенных ситуаций, с которыми вы рано или поздно столкнетесь, является перегрузка сервера, вызванная непредвиденными всплесками трафика. Это происходит потому, что каждый сервер имеет функциональные ограничения. Это означает, что сервер имеет ограниченные сетевые и вычислительные ресурсы, такие как процессор и память, поэтому сервер может эффективно обрабатывать только определенное количество запросов. Когда количество запросов достигает определенного порога и продолжает расти, это увеличивает использование ресурсов, что приводит к увеличению задержки и снижению доступности.

Существует множество стратегий, как справиться с этой ситуацией. Например, вы можете запустить несколько экземпляров своего приложения и использовать автоматическое масштабирование, чтобы в любое время привести предоставленные ресурсы в соответствие с потребностями пользователей. Однако, когда объем трафика невероятно велик, масштабирование может занять время, а серверы могут быть перегружены или выйти из строя. В этой статье я расскажу об общих стратегиях предотвращения такой ситуации.

Причины, приводящие к скачкам трафика

Причин, приводящих к внезапным скачкам трафика, может быть множество. Давайте рассмотрим некоторые общие моменты.

Приложение может испытывать внезапные скачки трафика из-за таких событий, как последние новости, запуск продукта или рекламные кампании. Если серверы не готовы справиться с таким всплеском трафика, они могут быстро перегрузиться.

Кроме того, когда по какой-либо причине трафик к приложению временно прекращается, приложение уменьшается. Затем, когда трафик возвращается, приложение не может быстро масштабироваться, что приводит к перегрузке сервера и даже сбою.

Другая распространенная причина заключается в том, что вредоносные боты или вредоносные программы могут наводнить сервер запросами, фактически перегружая его ресурсы. Это известно как распределенная атака типа «отказ в обслуживании» (DDoS).

Стратегии предотвращения перегрузки службы

Существует две основные стратегии предотвращения перегрузки сервера:

  • Регулирование.
  • Разгрузка нагрузки.

Регулирование контролирует потребление ресурсов, используемых экземпляром приложения, отдельным пользователем или всей службой. Например, сервер, испытывающий высокий спрос, может начать регулировать запросы, замедлять их или отклонять их, пока нагрузка не утихнет. Это позволяет системе обрабатывать входящий трафик без сбоев и зависаний.

Одним из распространенных методов регулирования является ограничение скорости, которое определяет максимальное количество запросов, которые пользователь, клиент или целая группа может сделать в течение определенного периода времени, гарантируя, что ни один клиент или пользователь не сможет монополизировать системные ресурсы. Это упреждающая мера, направленная в первую очередь на то, чтобы не допустить, чтобы система достигла своего потенциала. Например, ограничитель скорости может разрешать 100 запросов с определенного IP-адреса в минуту. Превышение этого ограничения может привести к тому, что пользователь будет временно заблокирован или его запросы будут поставлены в очередь до тех пор, пока они не достигнут допустимого уровня.

Сброс нагрузки — более радикальный метод. Он предполагает намеренное отбрасывание запросов, чтобы предотвратить перегрузку сервера. Это делается только в крайнем случае, когда другие меры, такие как ограничение ставок, оказались недостаточными. Отбрасывая запросы от одного экземпляра приложения, можно перераспределить нагрузку на другие экземпляры приложения в кластере, предотвращая превышение пределов любого отдельного экземпляра.

Пример ограничения скорости

Для реализации ограничителей скорости можно использовать несколько алгоритмов. Здесь давайте рассмотрим базовую реализацию ограничителя скорости с использованием алгоритма корзины токенов, при котором токены добавляются в корзину с фиксированной скоростью. Запрос разрешен, если токен доступен на момент выполнения запроса. В противном случае ему отказано. Эту реализацию можно применять для регулирования запросов от отдельных пользователей, IP-адресов, ключей API или других групп пользователей.

main.go

package main

import (
    "fmt"
    "log"
    "net/http"
    "sync"
    "time"
)

type bucket struct {
    remainingTokens int
    lastRefillTime  time.Time
}

type RateLimiter struct {
    maxTokens      int
    refillInterval time.Duration
    buckets        map[string]*bucket
    mu             sync.Mutex
}

func NewRateLimiter(rate int, perInterval time.Duration) *RateLimiter {
    return &RateLimiter{
        maxTokens:      rate,
        refillInterval: perInterval,
        buckets:        make(map[string]*bucket),
    }
}

func (rl *RateLimiter) IsLimitReached(id string) bool {
    rl.mu.Lock()
    defer rl.mu.Unlock()

    b, ok := rl.buckets[id]

    // If the bucket doesn't exist, it is the first request for this client.
    // Create a new bucket and allow the request.
    if !ok {
        rl.buckets[id] = &bucket{
            remainingTokens: rl.maxTokens - 1,
            lastRefillTime:  time.Now(),
        }
        return false
    }

    // Calculate the number of tokens to add to the bucket since the last
    // request.
    refillInterval := int(time.Since(b.lastRefillTime) / rl.refillInterval)
    tokensAdded := rl.maxTokens * refillInterval
    currentTokens := b.remainingTokens + tokensAdded

    // There is no tokens to serve the request for this client.
    // Reject the request.
    if currentTokens < 1 {
        return true
    }

    if currentTokens > rl.maxTokens {
        // If the number of current tokens is greater than the maximum allowed,
        // then reset the bucket and decrease the number of tokens by 1.
        b.lastRefillTime = time.Now()
        b.remainingTokens = rl.maxTokens - 1
    } else {
        // Otherwise, update the bucket and decrease the number of tokens by 1.
        deltaTokens := currentTokens - b.remainingTokens
        deltaRefills := deltaTokens / rl.maxTokens
        deltaTime := time.Duration(deltaRefills) * rl.refillInterval
        b.lastRefillTime = b.lastRefillTime.Add(deltaTime)
        b.remainingTokens = currentTokens - 1
    }

    // Allow the request.
    return false
}

type Handler struct {
    rl *RateLimiter
}

func NewHandler(rl *RateLimiter) *Handler {
    return &Handler{rl: rl}
}

func (h *Handler) Handler(w http.ResponseWriter, r *http.Request) {
    // Here should be the logic to get the client ID from the request 
    // (it could be a user ID, an IP address, an API key, etc.)
    clientID := "some-client-id"
    if h.rl.IsLimitReached(clientID) {
        w.WriteHeader(http.StatusTooManyRequests)
        fmt.Fprint(w, http.StatusText(http.StatusTooManyRequests))
        return
    }

    w.WriteHeader(http.StatusOK)
    fmt.Fprint(w, http.StatusText(http.StatusOK))
}

func main() {
    // We allow 1000 requests per second per client to our service.
    rl := NewRateLimiter(1000, 1*time.Second)
    h := NewHandler(rl)
    http.HandleFunc("/", h.Handler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Давайте запустим наш пример с помощью Docker Compose с настроенными ограничениями ресурсов для контейнера. Кроме того, мы будем использовать bombardier, инструмент сравнительного анализа, чтобы проверить, как наше приложение работает при различных нагрузках. Мы будем использовать следующие Dockerfile и docker-compose.yml:

Dockerfile

FROM golang:1.21 AS build-stage
WORKDIR /code
COPY main.go ./
RUN CGO_ENABLED=0 GOOS=linux go build -o /service main.go
FROM gcr.io/distroless/base AS build-release-stage
WORKDIR /
COPY --from=build-stage /service /service
EXPOSE 8080
ENTRYPOINT ["/service"]

docker-compose.yml

services:
  rate_limiting:
    build: .
    ports:
      - "8080:8080"
    deploy:
      resources:
        limits:
          cpus: '.20'
          memory: 100M
        reservations:
          cpus: '0.10'
          memory: 50M

Давайте запустим наше приложение с помощью следующей команды:

docker compose -f ./cmd/rate_limiting/docker-compose.yml up --build --force-recreate -d

Вы можете отслеживать использование ресурсов контейнера с помощью следующей команды:

dcocker stats

Давайте запустим бомбардир с разными настройками и проверим результаты в отдельном окне терминала.

Для начала давайте попробуем отправить 10 000 запросов с помощью одного одновременного соединения.

$ bombardier -c 1 -n 10000 http://127.0.0.1:8080/
Bombarding http://127.0.0.1:8080/ with 10000 request(s) using 1 connection(s)
 10000 / 10000 [===============================================================] 100.00% 859/s 11s
Done!
Statistics        Avg      Stdev        Max
  Reqs/sec       868.33     833.53    2684.45
  Latency        1.15ms     6.93ms    75.42ms
  HTTP codes:
    1xx - 0, 2xx - 10000, 3xx - 0, 4xx - 0, 5xx - 0
    others - 0
  Throughput:   151.93KB/s

Из результатов видно, что скорость запросов в среднем составляет менее 1000 запросов в секунду, что является максимально допустимой скоростью, и все запросы были успешно обработаны. Далее попробуем отправить 10 000 запросов со 100 одновременными соединениями.

$ bombardier -c 100 -n 10000 http://127.0.0.1:8080/
Bombarding http://127.0.0.1:8080/ with 10000 request(s) using 100 connection(s)
 10000 / 10000 [===============================================================] 100.00% 3320/s 3s
Done!
Statistics        Avg      Stdev        Max
  Reqs/sec      3395.87    6984.32   32322.59
  Latency       28.02ms    37.61ms   196.95ms
  HTTP codes:
    1xx - 0, 2xx - 3000, 3xx - 0, 4xx - 7000, 5xx - 0
    others - 0
  Throughput:   675.35KB/s

Вы можете видеть, что сервер начал возвращать код состояния HTTP 429, что означает, что сервер перегружен из-за большого количества запросов.

Очистите свою среду с помощью следующей команды после завершения всех тестов:

docker compose -f ./cmd/rate_limiting/docker-compose.yml down --remove-orphans --timeout 1 --volumes

Пример распределения нагрузки

В зависимости от вашего конкретного случая вы можете использовать разные подходы к сбросу нагрузки:

  • Отбрасывание случайных запросов при перегрузке системы.
  • Использование балансировщика нагрузки для равномерного распределения нагрузки.
  • Постоянный мониторинг загрузки сервера и отбрасывание или отклонение запросов при достижении определенного уровня нагрузки.
  • Отслеживание системных показателей, таких как загрузка ЦП, сетевой ввод-вывод или использование памяти, и отбрасывание запросов, когда эти показатели достигают определенных пороговых значений.
  • Настройте уровни приоритета для запросов и начните отклонять запросы с низким приоритетом, когда система находится под нагрузкой,
  • И т. д.

Распределение нагрузки может быть реализовано на различных уровнях, включая балансировщики нагрузки, серверы и библиотеки программного обеспечения. Это требует тщательного планирования и реализации, поскольку плохо реализованное распределение нагрузки может привести к отбрасыванию важных запросов, что отрицательно скажется на пользовательском опыте или функциональности системы.

Давайте реализуем простой механизм распределения нагрузки, обнаруживая условия перегрузки запроса и отвечая кодом состояния HTTP 503.

основной .go

package main

import (
    "context"
    "fmt"
    "log"
    "net/http"
    "sync/atomic"
    "time"
)

type LoadShedder struct {
    isOverloaded atomic.Bool
}

func NewLoadShedder(ctx context.Context, checkInterval, overloadFactor time.Duration) *LoadShedder {
    ls := LoadShedder{}

    go ls.runOverloadDetector(ctx, checkInterval, overloadFactor)

    return &ls
}

func (ls *LoadShedder) runOverloadDetector(ctx context.Context, checkInterval, overloadFactor time.Duration) {
    ticker := time.NewTicker(checkInterval)
    defer ticker.Stop()

    // Start with a fresh start time.
    startTime := time.Now()
    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            // Check how long it took to process the last batch of requests.
            elapsed := time.Since(startTime)
            if elapsed > overloadFactor {
                // If it took longer than the overload factor, we're overloaded.
                ls.isOverloaded.Store(true)
            } else {
                // Otherwise, we're not overloaded.
                ls.isOverloaded.Store(false)
            }
            // Reset the start time.
            startTime = time.Now()
        }
    }
}

func (ls *LoadShedder) IsOverloaded() bool {
    return ls.isOverloaded.Load()
}

type Handler struct {
    ls *LoadShedder
}

func NewHandler(ls *LoadShedder) *Handler {
    return &Handler{ls: ls}
}

func (h *Handler) Handler(w http.ResponseWriter, r *http.Request) {
    if h.ls.IsOverloaded() {
        w.WriteHeader(http.StatusServiceUnavailable)
        fmt.Fprint(w, http.StatusText(http.StatusServiceUnavailable))
        return
    }

    w.WriteHeader(http.StatusOK)
    fmt.Fprint(w, http.StatusText(http.StatusOK))
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    // The load shedder will check every 100ms if the last batch of requests
    // took longer than 200ms.
    ls := NewLoadShedder(ctx, 100*time.Millisecond, 200*time.Millisecond)

    h := NewHandler(ls)
    http.HandleFunc("/", h.Handler)

    log.Fatal(http.ListenAndServe(":8080", nil))
}

Мы определяем структуру LoadShedder для запуска детектора нагрузки, используемого в нашем обработчике запросов, чтобы проверить, следует ли нам вернуть код состояния HTTP 503 или обработать запрос. Структура LoadShedder имеет атомарный флаг, указывающий, перегружена ли система. Мы используем атомарное логическое значение, чтобы сделать его потокобезопасным. У нас также есть метод IsOverloaded, который возвращает текущее значение флага. В функции NewLoadShedder мы создаем новый LoadShedder и запускаем детектор перегрузки в горутине, которая в соответствии с указанным интервалом проверяет, не перегружена ли система на основе коэффициента перегрузки. Проверяем, сколько времени прошло с момента последней проверки. Если он превышает коэффициент перегрузки, система перегружена. Это означает, что наш обработчик запросов слишком долго использовал ресурсы, и нам нужно больше возможностей для обработки запросов.

Давайте запустим наш пример, используя. Мы будем использовать следующие Dockerfile и docker-compose.yml:

Dockerfile

FROM golang:1.21 AS build-stage
WORKDIR /code
COPY main.go ./
RUN CGO_ENABLED=0 GOOS=linux go build -o /service main.go
FROM gcr.io/distroless/base AS build-release-stage
WORKDIR /
COPY --from=build-stage /service /service
EXPOSE 8080
ENTRYPOINT ["/service"]

docker-compose.yml

services:
  load_shedding:
    build: .
    ports:
      - "8080:8080"
    deploy:
      resources:
        limits:
          cpus: '.20'
          memory: 100M
        reservations:
          cpus: '0.10'
          memory: 50M

Давайте запустим наше приложение с помощью следующей команды:

docker compose -f ./cmd/load_shedding/docker-compose.yml up --build --force-recreate -d

Вы можете отслеживать использование ресурсов контейнера с помощью следующей команды:

dcocker stats

Давайте запустим бомбардир с разными настройками и проверим результаты в отдельном окне терминала.

Сначала попробуем отправить 10 000 запросов с 10 одновременными соединениями.

$ bombardier -c 10 -n 10000 http://127.0.0.1:8080/
Bombarding http://127.0.0.1:8080/ with 10000 request(s) using 10 connection(s)
 10000 / 10000 [===============================================================] 100.00% 1346/s 7s
Done!
Statistics        Avg      Stdev        Max
  Reqs/sec      1389.49    1582.00    6284.42
  Latency        7.24ms    22.41ms    98.43ms
  HTTP codes:
    1xx - 0, 2xx - 10000, 3xx - 0, 4xx - 0, 5xx - 0
    others - 0
  Throughput:   242.78KB/s

Результаты показывают, что максимальная задержка составляет менее 100 мс, и все запросы были успешно обработаны. Далее попробуем отправить 10 000 запросов с 1000 одновременными соединениями.

$ bombardier -c 1000 -n 10000 http://127.0.0.1:8080/
Bombarding http://127.0.0.1:8080/ with 10000 request(s) using 1000 connection(s)
 10000 / 10000 [===============================================================] 100.00% 3791/s 2s
Done!
Statistics        Avg      Stdev        Max
  Reqs/sec      4242.28   11985.30   58592.64
  Latency      211.67ms   210.89ms      1.54s
  HTTP codes:
    1xx - 0, 2xx - 8823, 3xx - 0, 4xx - 0, 5xx - 1177
    others - 0
  Throughput:   696.96KB/s

Вы можете видеть, что сервер начал возвращать код состояния HTTP 503, что означает, что сервер перегружен из-за большого количества запросов, и вы можете видеть, что максимальная задержка составляет более 1 секунды.

Очистите свою среду с помощью следующей команды после завершения всех тестов:

docker compose -f ./cmd/load_shedding/docker-compose.yml down --remove-orphans --timeout 1 --volumes

Заключение

В этой статье мы обсудили причины, приводящие к скачкам трафика, и стратегии предотвращения перегрузки сервера. Мы также реализовали простой ограничитель скорости и устройство сброса нагрузки, чтобы продемонстрировать, как они работают. Весь код из статьи вы можете найти здесь https://github.com/ivanlemechev/resilience.

Для производственной среды вы можете использовать готовые ограничители скорости, например https://pkg.go. dev/golang.org/x/time/rate или https://github.com/uber-go/ratelimit< /а>.


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE