Свой балансировщик

Post Thumbnail

Перевод статьи "This 150-Line Go Script Is Actually a Full-On Load Balancer"

Если вы делаете сервисы, которые должны справляться с большим объёмом трафика, то необходим механизм распределения этот трафик между серверами где запущены ваши сервисы. Для этого вам понадобиться балансировщик нагрузки. Конечно, сейчас множество балансировщиков нагрузки промышленного уровня (nginx как балансировщик, HAProxy и т. д.), но всегда полезно знать, как они устроены.

Для практики напишем простой HTTP-балансировщик нагрузки на Go с использованием стандартной библиотеки. В этой реализации для равномерного распределения входящих запросов по серверам мы будем использовать round robin(алгоритм циклического перебора).

Базовая структура

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

package main

import (
	"flag"
	"fmt"
	"log"
	"net"
	"net/http"
	"net/http/httputil"
	"net/url"
	"sync"
	"sync/atomic"
	"time"
)

// Backend описание бекового сервера
type Backend struct {
	URL          *url.URL
	Alive        bool
	mux          sync.RWMutex
	ReverseProxy *httputil.ReverseProxy
}

// SetAlive обновляет статус активности бэкенда
func (b *Backend) SetAlive(alive bool) {
	b.mux.Lock()
	b.Alive = alive
	b.mux.Unlock()
}

// IsAlive возвращает true, когда бэкенд активен
func (b *Backend) IsAlive() (alive bool) {
	b.mux.RLock()
	alive = b.Alive
	b.mux.RUnlock()
	return
}

// LoadBalancer описание балансировщика нагрузки
type LoadBalancer struct {
	backends []*Backend
	current  uint64
}

// NextBackend возвращает следующий доступный бэкенд для обработки запроса
func (lb *LoadBalancer) NextBackend() *Backend {
	// Простая реализация round-robin
	next := atomic.AddUint64(&lb.current, uint64(1)) % uint64(len(lb.backends))
	
	// Ищем следующий доступный сервер
	for i := 0; i < len(lb.backends); i++ {
		idx := (int(next) + i) % len(lb.backends)
		if lb.backends[idx].IsAlive() {
			return lb.backends[idx]
		}
	}
	return nil
}

Несколько ключевых моментов этой реализации:

  • Структура Backend представляет отдельный сервер с его URL и статусом работоспособности (alive).
  • Мьютекс используется для безопасного обновления и проверки статуса сервера в условиях конкурентного доступа.
  • Балансировщик (LoadBalancer) хранит список серверов (backends) и счётчик для алгоритма round-robin.
  • Атомарные операции обеспечивают безопасное увеличение счётчика при параллельных запросах.
  • Метод NextBackend реализует алгоритм round-robin, пропуская неработоспособные серверы.

Проверка работоспособности

Определение недоступности бэкенда — одна из ключевых функций любого балансировщика нагрузки. Реализуем простой механизм проверки:

// isBackendAlive проверяет, доступен ли бэкенд, устанавливая TCP-соединение
func isBackendAlive(u *url.URL) bool {
	timeout := 2 * time.Second
	conn, err := net.DialTimeout("tcp", u.Host, timeout)
	if err != nil {
		log.Printf("Сервер недоступен: %s", err)
		return false
	}
	defer conn.Close()
	return true
}

// HealthCheck проверяет статус бэкендов и обновляет их состояние
func (lb *LoadBalancer) HealthCheck() {
	for _, b := range lb.backends {
		status := isBackendAlive(b.URL)
		b.SetAlive(status)
		if status {
			log.Printf("Бэкенд %s доступен", b.URL)
		} else {
			log.Printf("Бэкенд %s недоступен", b.URL)
		}
	}
}

// HealthCheckPeriodically запускает периодическую проверку с заданным интервалом
func (lb *LoadBalancer) HealthCheckPeriodically(interval time.Duration) {
	t := time.NewTicker(interval)
	for {
		select {
		case <-t.C:
			lb.HealthCheck()
		}
	}
}

Как это работает:

  • Мы пытаемся установить TCP-соединение с бэкендом.
  • Если соединение успешно - бэкенд считается работоспособным, иначе — недоступным.
  • Проверка выполняется периодически с интервалом, заданным в HealthCheckPeriodically.

В продакшн-среде лучше использовать более продвинутую проверку, например, отправку HTTP-запроса на специальный эндпоинт. Но для начала этого достаточно.

HTTP-обработчик

Реализуем обработчик HTTP-запросов, который будет перенаправлять их на наши бэкенды:

// ServeHTTP реализует интерфейс http.Handler для LoadBalancer
func (lb *LoadBalancer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	backend := lb.NextBackend()
	if backend == nil {
		http.Error(w, "Сервис недоступен", http.StatusServiceUnavailable)
		return
	}
	
	// Перенаправляем запрос на выбранный бэкенд
	backend.ReverseProxy.ServeHTTP(w, r)
}

Как это работает:

  • Алгоритм round-robin выбирает следующий доступный бэкенд.
  • Если нет доступных бэкендов, возвращаем ошибку 503 Service Unavailable.
  • В противном случае запрос проксируется на выбранный бэкенд с помощью встроенного в Go ReverseProxy.

Обратите внимание, что пакет net/http/httputil предоставляет тип ReverseProxy, который берёт на себя всю сложность проксирования HTTP-запросов.

Собираем всё вместе

Давайте реализуем функцию main для настройки и запуска нашего балансировщика нагрузки:

func main() {
	// Парсим флаги командной строки
	port := flag.Int("port", 8080, "Порт для работы балансировщика")
	flag.Parse()

	// Список бэкенд-серверов
	serverList := []string{
		"http://localhost:8081",
		"http://localhost:8082", 
		"http://localhost:8083",
	}

	// Создаём балансировщик нагрузки
	lb := LoadBalancer{}

	// Инициализируем бэкенды
	for _, serverURL := range serverList {
		url, err := url.Parse(serverURL)
		if err != nil {
			log.Fatal(err)
		}

		// Создаём обратный прокси для бэкенда
		proxy := httputil.NewSingleHostReverseProxy(url)
		proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
			log.Printf("Ошибка: %v", err)
			http.Error(w, "Сервис недоступен", http.StatusServiceUnavailable)
		}

		// Добавляем бэкенд в балансировщик
		lb.backends = append(lb.backends, &Backend{
			URL:          url,
			Alive:        true,
			ReverseProxy: proxy,
		})
		log.Printf("Добавлен бэкенд: %s", url)
	}

	// Первоначальная проверка здоровья
	lb.HealthCheck()

	// Запускаем периодические проверки
	go lb.HealthCheckPeriodically(time.Minute)

	// Запускаем сервер
	server := http.Server{
		Addr:    fmt.Sprintf(":%d", *port), 
		Handler: &lb,
	}

	log.Printf("Балансировщик нагрузки запущен на порту :%d\n", *port)
	if err := server.ListenAndServe(); err != nil {
		log.Fatal(err)
	}
}

Что происходит в функции main:

  1. Настройка порта через флаги командной строки
  2. Определение списка бэкенд-серверов
  3. Для каждого бэкенда:
    • Парсим URL
    • Создаём обратный прокси
    • Добавляем обработчик ошибок
    • Регистрируем бэкенд в балансировщике
  4. Первоначальная проверка работоспособности серверов
  5. Запуск фоновой проверки здоровья с интервалом в минуту
  6. Старт HTTP-сервера с балансировщиком в качестве обработчика

Весь процесс инициализации логируется для удобства отладки. В случае ошибок при запуске сервера программа завершается с соответствующим сообщением.

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

Для тестирования нашего балансировщика нам понадобятся несколько бэкенд-серверов. Простейшая реализация бэкенд-сервера может выглядеть так:

// Сохраните этот код в backend.go
package main

import (
	"flag"
	"fmt"
	"log"
	"net/http"
	"os"
)

func main() {
	port := flag.Int("port", 8081, "Порт для работы сервера")
	flag.Parse()

	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		hostname, _ := os.Hostname()
		fmt.Fprintf(w, "Бэкенд-сервер на порту %d, хост: %s, путь запроса: %s\n", *port, hostname, r.URL.Path)
	})

	log.Printf("Бэкенд запущен на порту :%d\n", *port)
	if err := http.ListenAndServe(fmt.Sprintf(":%d", *port), nil); err != nil {
		log.Fatal(err)
	}
}

Запустим несколько экземпляров этого сервера на разных портах:

go build -o backend backend.go
./backend -port 8081 &
./backend -port 8082 &
./backend -port 8083 &

Соберем и запустим сам балансировщик:

go build -o load-balancer main.go
./load-balancer

Теперь можно отправлять запросы для проверки:

curl http://localhost:8080/test

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

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

Возможные улучшения

Это минимальная реализация, но есть множество способов её усовершенствовать:

  1. Разные алгоритмы балансировки: взвешенный round-robin, наименьшее количество соединений, выбор на основе хэша IP-адреса

  2. Улучшенная проверка работоспособности: полноценные HTTP-запросы к health-эндпоинту вместо простой проверки TCP-соединения

  3. Сбор метрик: подсчёт количества запросов, измерение времени ответа, отслеживание частоты ошибок для каждого бэкенда

  4. Sticky-сессии: гарантия, что запросы от одного клиента всегда попадают на один и тот же бэкенд

  5. Поддержка TLS: добавление HTTPS для безопасных соединений

  6. Динамическая конфигурация: возможность обновлять список бэкендов без перезапуска

Мы создали простой, но эффективный HTTP-балансировщик нагрузки, используя только стандартную библиотеку Go. Этот пример демонстрирует важные концепции сетевого программирования и мощь встроенных сетевых возможностей Go.

Хотя это решение не готово для продакшена, оно даёт прочную основу для понимания принципов работы балансировщиков нагрузки. Вся реализация занимает около 150 строк кода - что свидетельствует о выразительности Go и богатстве его стандартной библиотеки.

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

Исходный код доступен по ссылке: https://github.com/rezmoss/simple-load-balancer