33. DDoS, Rate Limiting и базовая защита сетевых сервисов

Введение

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

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

flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    subgraph MultiLayerDefense ["Многослойная эшелонированная оборона"]
        L3L4["L3/L4 Защита: Anycast, Cloudflare, SYN Cookies, iptables/nftables"]:::process
        L7Edge["L7 Прокси / WAF: Envoy, Nginx, фильтрация заголовков, TLS"]:::network
        DistLimit["Распределенный Rate Limiter: Redis + Lua Scripts"]:::data
        GoRuntime["Go Runtime: http.Server таймауты, MaxBytesReader, worker pools"]:::network
        AppLogic["Бизнес-логика: капчи, circuit breakers, деградация"]:::process

        L3L4 --> L7Edge --> DistLimit --> GoRuntime --> AppLogic
    end
  

Уровни DDoS-атак и векторы поражения

Атаки на отказ в обслуживании (DDoS) классифицируются по уровню сетевой модели, на котором они наносят максимальный ущерб.

L3-L4 (Сетевой и Транспортный уровни)

Включают volumetric-атаки (завал трафика) и protocol-атаки (SYN flood, UDP amplification). На этом уровне нагрузка ложится на сетевую карту ОС и стек TCP/IP. В Linux для защиты от SYN-флуда используются SYN cookies и настройка net.ipv4.tcp_syncookies.

В классическом режиме ядро выделяет память под запись полуоткрытого соединения в структуре request_sock. При SYN-флуде очередь syn_backlog переполняется за миллисекунды. При включенных SYN cookies ядро вообще не выделяет память в момент прихода SYN: оно кодирует состояние соединения в начальный порядковый номер (Initial Sequence Number, ISN) с помощью криптографического хэша от 4-tuple и секрета ядра. Если клиент легитимен, он вернет этот номер в ACK, и только тогда ядро создаст сокет. Если очередь syn_backlog переполняется, ядро начинает отбрасывать новые соединения без выделения ресурсов под полные TCP-структуры.

L7 (Прикладной уровень)

Наиболее опасны для Go-сервисов. Злоумышленники имитируют легитимный трафик: отправляют тяжелые JSON-пейлоады, запускают долгие запросы (Slowloris), или генерируют тысячи уникальных запросов, которые вынуждают сервис обращаться к БД или кэшу. В отличие от PHP, где каждый запрос изолирован в отдельном процессе и умирает после завершения, Go держит горутины и объекты в памяти до явного завершения. При атаке L7 это приводит к быстрому исчерпанию памяти (OOM) или файлового дескриптора.

При атаке Slowloris атакующий медленно отправляет HTTP-заголовки (по 1 байту каждые 10 секунд). Сервер не закрывает соединение, так как клиент формально передает данные. В результате тысячи сокетов висят открытыми, блокируя дескрипторы и пулы соединений.


Rate Limiting: Алгоритмы и реализация под капотом

Rate Limiting (ограничение скорости) — механизм контроля частоты выполнения операций. В Go его реализация требует понимания компромисса между точностью, производительностью и распределённостью.

Алгоритмы

  1. Token Bucket (Корзина токенов): Позволяет делать burst (всплески) запросов. Токены добавляются в корзину с фиксированной скоростью до максимальной емкости. Приходящий запрос забирает токен. Если очередь пуста — запрос отклоняется. Идеален для HTTP API, так как прощает естественные кратковременные всплески активности пользователей.
  2. Sliding Window Counter: Делит время на интервалы и считает запросы. Проще в реализации, но даёт ложные срабатывания на границах окон.
  3. Sliding Window Log: Хранит timestamp каждого запроса. Максимально точен, но требует O(N) памяти и медленнее работает при высоких нагрузках.
flowchart LR
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    subgraph TokenBucket ["Алгоритм Token Bucket"]
        Refill["Пополнение: R токенов/сек"]:::data --> Bucket["Корзина: Емкость B токенов"]
        Req["Входящий запрос"]:::entry --> Check{"Есть токен?"}
        Bucket -.-> Check
        Check -- "Да: -1 токен" --> Process["Обработка запроса (200 OK)"]
        Check -- "Нет токенов" --> Reject["429 Too Many Requests"]
    end
  

Идиоматичная реализация в Go

Для production-систем рекомендуется использовать пакет golang.org/x/time/rate или кастомные реализации на atomic для снижения давления на sync.Mutex. Ниже пример высокопроизводительного лимитера с поддержкой burst:

Go
package ratelimit

import (
    "sync"
    "time"

    "golang.org/x/time/rate"
)

type ipEntry struct {
    lim      *rate.Limiter
    lastSeen time.Time
}

// Limiter объединяет глобальный лимитер и лимитеры по IP.
type Limiter struct {
    global     *rate.Limiter
    perIPLimit rate.Limit
    perIPBurst int
    mu         sync.Mutex
    limiters   map[string]*ipEntry
}

// NewLimiter создает глобальный и per-IP лимитеры.
func NewLimiter(globalLimit rate.Limit, globalBurst int, perIPLimit rate.Limit, perIPBurst int) *Limiter {
    return &Limiter{
        global:     rate.NewLimiter(globalLimit, globalBurst),
        perIPLimit: perIPLimit,
        perIPBurst: perIPBurst,
        limiters:   make(map[string]*ipEntry),
    }
}

// Allow проверяет доступность токенов. Возвращает false, если лимит исчерпан.
func (rl *Limiter) Allow(ip string) bool {
    // Сначала per-IP: один злоупотребляющий адрес не должен тратить глобальный бюджет.
    if !rl.ipLimiter(ip).Allow() {
        return false
    }
    // rate.Limiter внутренне защищен мьютексом
    return rl.global.Allow()
}

func (rl *Limiter) ipLimiter(ip string) *rate.Limiter {
    rl.mu.Lock()
    defer rl.mu.Unlock()

    e, ok := rl.limiters[ip]
    if !ok {
        e = &ipEntry{lim: rate.NewLimiter(rl.perIPLimit, rl.perIPBurst)}
        rl.limiters[ip] = e
    }
    e.lastSeen = time.Now()
    return e.lim
}

// Cleanup удаляет лимитеры адресов, неактивных дольше ttl.
// Вызывайте периодически (например, из time.Ticker), иначе карта растёт без границ.
func (rl *Limiter) Cleanup(ttl time.Duration) {
    cutoff := time.Now().Add(-ttl)

    rl.mu.Lock()
    defer rl.mu.Unlock()
    for ip, e := range rl.limiters {
        if e.lastSeen.Before(cutoff) {
            delete(rl.limiters, ip)
        }
    }
}

Распределенный Rate Limiting и проблемы синхронизации

В кластере из N инстансов в-memory лимитеры бесполезны. Суммарный лимит будет в N раз больше заданного. Решение — распределённое состояние (Redis, Memcached).

flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    Client["Клиент"]:::entry --> LB["Load Balancer"]
    LB --> Pod1["Go Pod 1"]
    LB --> Pod2["Go Pod 2"]
    LB --> PodN["Go Pod N"]

    Pod1 -->|"EVAL ratelimit.lua"| Redis["Централизованный Redis / Cluster"]
    Pod2 -->|"EVAL ratelimit.lua"| Redis
    PodN -->|"EVAL ratelimit.lua"| Redis
  

Ключевые проблемы:

  1. Сетевая задержка: Каждый вызов rateLimit.Check() добавляет RTT (round-trip time). При 1000 RPS это значит 1000 сетевых вызовов в секунду.
  2. Атомарность: Чтение-модификация-запись в Redis должно быть атомарным. Используется Lua-скрипты (EVAL), которые выполняются в одном потоке Redis, исключая race conditions.
  3. Синхронизация часов: Если инстансы используют локальное время для window-алгоритмов, границы окон рассинхронизируются. В Redis-решениях обычно используется время сервера или логика "last access + TTL".

Базовая защита сетевых сервисов в Go

Помимо rate limiting, необходимо жестко ограничивать ресурсы на уровне net/http:

Go
srv := &http.Server{
    Addr:           ":8080",
    Handler:        middleware.Chain(router),
    MaxHeaderBytes: 1 << 20, // 1 MB заголовков. Защита от Slowloris и buffer overflow.
    IdleTimeout:    120 * time.Second,
    ReadTimeout:    10 * time.Second,
    WriteTimeout:   30 * time.Second,
}

Критические настройки:

  • MaxConnsPerHost и MaxIdleConns: Ограничивают количество соединений в пуле http.Client (для исходящих запросов).
  • http.MaxBytesReader: Ограничивает тело запроса. Без этого атакующий может отправить 10 ГБ JSON, убив память сервиса.
  • tls.Config: Отключение слабых шифров (TLS_RSA_WITH_RC4_128_SHA), принуждение к TLS 1.3, настройка MinVersion.
Go
// Защита обработчика от гигантских пейлоадов
func protectedHandler(w http.ResponseWriter, r *http.Request) {
    // Ограничиваем чтение тела максимум 1 мегабайтом
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20)

    var data RequestPayload
    if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
        http.Error(w, "Payload too large or malformed", http.StatusRequestEntityTooLarge)
        return
    }
}

Mechanical Sympathy и влияние на рантайм Go

Защита от DDoS напрямую влияет на работу рантайма Go и ОС:

  1. GC Pressure: Высокая частота запросов генерирует аллокации (новые context, map, []byte). При атаке GC переходит в режим "stop-the-world" чаще, увеличивая p99 latency. Решение: sync.Pool для повторного использования буферов, избежание аллокаций в hot path.
  2. Goroutine Scheduling: Каждая горутина требует стека. При исчерпании лимита дескрипторов (ulimit -n по умолчанию 1024 в Docker) net.Listen начнёт падать с too many open files. Число P (processor) в планировщике Go задаётся GOMAXPROCS и к дескрипторам не относится; нехватка файловых дескрипторов проявляется ошибками accept/dial (too many open files). С Go 1.19 рантайм при старте сам поднимает мягкий лимит RLIMIT_NOFILE до жёсткого.
  3. TCP Backlog: При SYN-флуде net.Listen(backlog) упирается в net.core.somaxconn. Если очередь заполнена, ядро начинает отбрасывать SYN-пакеты. Go не может это обойти на уровне приложения.
  4. Context Propagation: В Go контекст передается по ссылке. При атаке с миллионами запросов создание context.WithTimeout для каждого соединения создает нагрузку на GC. Пула контекстов в Go нет, а убирать таймаут нельзя: без него запрос может висеть бесконечно. Создание context.WithTimeout дёшево; если профиль показывает, что это узкое место, ограничивайте число одновременных запросов (семафор), а не отказывайтесь от дедлайнов.

Вопросы на собеседовании (Middle+/Senior)


Итоги

Защита сетевых сервисов в Go строится на принципах ограничения ресурсов на каждом уровне:

  • OS/Network: SYN cookies, somaxconn, лимиты дескрипторов.
  • Proxy/WAF: Фильтрация L3-L4, DDoS scrubbing, IP reputation.
  • Go Runtime: http.Server таймауты, MaxHeaderBytes, http.MaxBytesReader, ограничение горутин через worker pools или semaphore.
  • Application: Распределённый rate limiting (Redis + Lua), валидация входных данных, пулинг объектов для снижения GC pressure.

Go не защищает вас от атак автоматически. Его мощь в эффективности требует дисциплины в управлении ресурсами. Без явного ограничения соединений, размеров тел запросов и частоты вызовов, высокопроизводительный сервис станет мишенью №1 для исчерпания ресурсов.

В следующей статье мы разберем инструменты, которые позволят вам видеть то, что скрывает Go: 34. Наблюдаемость сети. tcpdump, Wireshark, netstat, ss.