Load Balancer на Go

Введение

Когда приложение начинает получать много запросов, одного сервера может стать недостаточно. В таком случае можно заменить текущий сервер на более производительный, но рано или поздно и этого может стать недостаточно. Второй вариант — запустить несколько экземпляров приложения и распределять запросы между ними. Для этого используется балансировщик нагрузки(load balancer).

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

О балансировщике нагрузок

Load Balancer — это промежуточный компонент между клиентом и несколькими серверами. Он принимает входящий запрос, выбирает один из доступных серверов и передаёт ему запрос.

Упрощённо это выглядит так:

┌───────────┐ │ Client │ └─────┬─────┘ │ ▼ ┌──────────────┐ │ Load Balancer│ └───────┬──────┘ │ ┌────────────┼────────────┐ ▼ ▼ ▼ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ Server 1│ │ Server 2│ │ Server 3│ └─────────┘ └─────────┘ └─────────┘

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

Балансировщики бывают как аппаратными, так и программными. В нашем случае это будет обычное 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 я не буду приводить в данном примере.

Получаем следующую схему:

localhost:8080 │ ▼ Load Balancer │ │ │ ▼ ▼ ▼ 8081 8082 8083

Проверяем работу

Чтобы увидеть балансировку, достаточно поднять три простых 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