Load Balancer на Go
Введение
Когда приложение начинает получать много запросов, одного сервера может стать недостаточно. В таком случае можно заменить текущий сервер на более производительный, но рано или поздно и этого может стать недостаточно. Второй вариант — запустить несколько экземпляров приложения и распределять запросы между ними. Для этого используется балансировщик нагрузки(load balancer).
В этой статье я хочу разобрать базовые принципы работы балансировщиков нагрузок, а так же реализовать свою, минимальную, версию балансировщика.
О балансировщике нагрузок
Load Balancer — это промежуточный компонент между клиентом и несколькими серверами. Он принимает входящий запрос, выбирает один из доступных серверов и передаёт ему запрос.
Упрощённо это выглядит так:
Основная задача балансировщика — распределять нагрузку между серверами. Это позволяет лучше использовать ресурсы, увеличивать пропускную способность и повышать доступность приложения.
Балансировщики бывают как аппаратными, так и программными. В нашем случае это будет обычное Go-приложение.
Виды балансировки
Существует много алгоритмов выбора сервера. Самые распространённые из них:
- Round Robin — запросы последовательно отправляются на каждый сервер.
- Random — сервер выбирается случайным образом.
- Least Connections — запрос отправляется серверу, у которого сейчас меньше всего активных соединений.
- Weighted Round Robin — похож на обычный Round Robin, но серверам назначается вес. Более мощный сервер получает больше запросов.
Кроме того, алгоритмы можно условно разделить на статические и динамические. Статические принимают решение без учёта текущего состояния серверов, а динамические используют информацию о доступности или текущей нагрузке. Round Robin относится к простым статическим алгоритмам.
Для демонстрации работы балансировщика в нашем примере Round Robin подходёт лучше всего.
Реализация
Для начала опишем структуру сервера, на который будет распределяться нагрузка:
type Backend struct {
URL *url.URL
Alive bool
}
URL хранит адрес сервера, а Alive показывает, доступен ли он для обработки запросов.
Создадим сам балансировщик:
type LoadBalancer struct {
backends []*Backend
current int
mu sync.Mutex
}
current — индекс backend'а, которому должен достаться следующий запрос.
Мьютекс необходим, потому что HTTP-сервер Go обрабатывает запросы конкурентно. Несколько горутин могут одновременно обращаться к current.
Теперь реализуем алгоритм Round Robin:
func (lb *LoadBalancer) nextBackend() *Backend {
lb.mu.Lock()
defer lb.mu.Unlock()
for i := 0; i < len(lb.backends); i++ {
backend := lb.backends[lb.current]
lb.current = (lb.current + 1) % len(lb.backends)
if backend.Alive {
return backend
}
}
return nil
}
Самая важная строка в данном коде:
lb.current = (lb.current + 1) % len(lb.backends)
Она позволяет после вызова последнего сервера снова перейти к первому:
0 → 1 → 2 → 0 → 1 → 2 → ...
Цикл дополнительно проверяет Alive, поэтому недоступный backend можно пропустить.
Проксирование запросов
Теперь необходимо отправить запрос выбранному серверу.
В стандартной библиотеке Go уже есть готовый httputil.ReverseProxy, поэтому писать HTTP-прокси самостоятельно не придётся.
func (lb *LoadBalancer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
backend := lb.nextBackend()
if backend == nil {
http.Error(
w,
"no available backends",
http.StatusServiceUnavailable,
)
return
}
proxy := httputil.NewSingleHostReverseProxy(
backend.URL,
)
proxy.ServeHTTP(w, r)
}
Теперь наш балансировщик реализует интерфейс http.Handler, поэтому его можно передать непосредственно в http.ListenAndServe.
func main() {
lb := &LoadBalancer{
backends: []*Backend{
{
URL: mustParseURL("http://localhost:8081"),
Alive: true,
},
{
URL: mustParseURL("http://localhost:8082"),
Alive: true,
},
{
URL: mustParseURL("http://localhost:8083"),
Alive: true,
},
},
}
log.Fatal(http.ListenAndServe(":8080", lb))
}
Функцию mustParseURL я не буду приводить в данном примере.
Получаем следующую схему:
Проверяем работу
Чтобы увидеть балансировку, достаточно поднять три простых HTTP-сервера.
func main() {
port := os.Getenv("PORT")
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello from %s", port)
})
log.Fatal(http.ListenAndServe(":"+port, nil))
}
Запускаем их на разных портах:
PORT=8081 go run server.go
PORT=8082 go run server.go
PORT=8083 go run server.go
Теперь отправим несколько запросов:
curl localhost:8080
curl localhost:8080
curl localhost:8080
curl localhost:8080
Получим:
Hello from 8081
Hello from 8082
Hello from 8083
Hello from 8081
Балансировщик последовательно отправляет запросы каждому backend'у и после последнего возвращается к первому.
О сложностях реальных балансировщиков
Наш пример намеренно минималистичный. В реальном балансировщике недостаточно просто отправлять запросы по кругу.
Например, сервер может перестать отвечать. Тогда балансировщику необходимо понять, что backend недоступен, и перестать отправлять ему запросы. Для этого используются health checks.
Также понадобятся таймауты, обработка ошибок, graceful shutdown и, возможно, другие алгоритмы распределения нагрузки.
Есть и ещё одна важная проблема: сам балансировщик становится точкой входа для всего трафика. Если он упадёт, приложение перестанет отвечать на запросы. Поэтому необходимо заботиться о высокой доступности самих балансировщиков.
Заключение
Мы написали простой HTTP балансировщик на Go, который принимает запросы и распределяет их между несколькими backend-серверами с помощью Round Robin.
При этом большая часть работы легла на стандартную библиотеку Go: net/http отвечает за HTTP-сервер, а httputil.ReverseProxy позволяет проксировать запросы.
“Round-robin is about as simple as a load balancing strategy can get.” — Spotify Engineering