Écrire un simple équilibreur sur Go


Les équilibreurs de charge jouent un rôle clé dans l'architecture Web. Ils vous permettent de répartir la charge sur plusieurs backends, améliorant ainsi l'évolutivité. Et puisque nous avons plusieurs backends configurés, le service devient hautement disponible, car en cas de panne sur un serveur, l'équilibreur peut choisir un autre serveur de travail.

Ayant joué avec des équilibreurs professionnels comme NGINX, j'ai essayé de créer un équilibreur simple pour le plaisir. Je l'ai écrit sur Go, c'est un langage moderne qui supporte le parallélisme complet. La bibliothèque standard de Go possède de nombreuses fonctionnalités et vous permet d'écrire des applications hautes performances avec moins de code. De plus, pour faciliter la distribution, il génère un seul binaire lié statiquement.

Comment fonctionne notre équilibreur


Différents algorithmes sont utilisés pour répartir la charge entre les backends. Par exemple:

  • Round Robin - la charge est répartie uniformément, en tenant compte de la même puissance de calcul des serveurs.
  • Round Robin pondéré - Selon la puissance de traitement, les serveurs peuvent se voir attribuer des poids différents.
  • Least Connections - la charge est répartie sur les serveurs avec le moins de connexions actives.

Dans notre équilibreur, nous implémentons l'algorithme le plus simple - Round Robin.



Sélection au Round Robin


L'algorithme Round Robin est simple. Il donne à tous les interprètes la même possibilité d'accomplir des tâches.


Sélectionnez les serveurs dans Round Robin pour gérer les demandes entrantes.

Comme le montre l'illustration, l'algorithme sélectionne les serveurs dans un cercle, de manière cyclique. Mais nous ne pouvons pas les sélectionner directement , non?

Et si le serveur ment? Nous n'avons probablement pas besoin de lui envoyer de trafic. Autrement dit, le serveur ne peut pas être utilisé directement tant que nous ne l'avons pas amené à l'état souhaité. Il est nécessaire de diriger le trafic uniquement vers les serveurs qui sont opérationnels.

Définir la structure


Nous devons suivre tous les détails liés au backend. Vous devez savoir s'il est vivant et suivre l'URL. Pour ce faire, nous pouvons définir la structure suivante:

type Backend struct { URL *url.URL Alive bool mux sync.RWMutex ReverseProxy *httputil.ReverseProxy } 

Ne vous inquiétez pas, je vais vous expliquer la signification des champs dans le Backend.

Maintenant, dans l'équilibreur, vous devez en quelque sorte suivre tous les backends. Pour ce faire, vous pouvez utiliser Slice et un compteur variable. Définissez-le dans ServerPool:

 type ServerPool struct { backends []*Backend current uint64 } 

Utilisation de ReverseProxy


Comme nous l'avons déjà déterminé, l'essence de l'équilibreur consiste à distribuer le trafic vers différents serveurs et à renvoyer les résultats au client. Comme l'indique la documentation de Go:

ReverseProxy est un gestionnaire HTTP qui prend les demandes entrantes et les envoie à un autre serveur, renvoyant les réponses par procuration au client.

Exactement ce dont nous avons besoin. Pas besoin de réinventer la roue. Vous pouvez simplement diffuser nos demandes via ReverseProxy .

 u, _ := url.Parse("http://localhost:8080") rp := httputil.NewSingleHostReverseProxy(u) // initialize your server and add this as handler http.HandlerFunc(rp.ServeHTTP) 

À l'aide de httputil.NewSingleHostReverseProxy(url) vous pouvez initialiser ReverseProxy , qui diffusera les demandes à l' url transmise. Dans l'exemple ci-dessus, toutes les demandes ont été envoyées à localhost: 8080 et les résultats ont été envoyés au client.

Si vous regardez la signature de la méthode ServeHTTP, vous pouvez y trouver la signature du gestionnaire HTTP. Par conséquent, vous pouvez le transmettre à HandlerFunc dans http .

D'autres exemples sont dans la documentation .

Pour notre équilibreur, vous pouvez lancer ReverseProxy avec l' URL associée dans Backend afin que ReverseProxy achemine les demandes vers l' URL .

Processus de sélection du serveur


Lors de la prochaine sélection de serveurs, nous devons ignorer les serveurs sous-jacents. Mais vous devez organiser le décompte.

De nombreux clients se connectent à l'équilibreur et lorsque chacun d'eux demande au nœud suivant de transférer le trafic, une condition de concurrence critique peut se produire. Pour éviter cela, nous pouvons bloquer ServerPool avec mutex . Mais ce sera redondant, d'ailleurs nous ne voulons pas du tout bloquer ServerPool . Nous avons juste besoin d'augmenter le compteur d'une unité.

La meilleure solution pour répondre à ces exigences serait l'incrémentation atomique. Go le prend en charge avec le package atomic .

 func (s *ServerPool) NextIndex() int { return int(atomic.AddUint64(&s.current, uint64(1)) % uint64(len(s.backends))) } 

Nous augmentons atomiquement la valeur actuelle de un et retournons l'index en modifiant la longueur du tableau. Cela signifie que la valeur doit toujours être comprise entre 0 et la longueur du tableau. En fin de compte, nous serons intéressés par un indice spécifique, pas le compteur entier.

Choisir un serveur en direct


Nous savons déjà que nos demandes tournent cycliquement sur tous les serveurs. Et nous n'avons qu'à sauter le ralenti.

GetNext() renvoie toujours une valeur allant de 0 à la longueur du tableau. À tout moment, nous pouvons obtenir le nœud suivant, et s'il est inactif, nous devons rechercher plus loin dans le tableau dans le cadre de la boucle.


Nous parcourons le tableau.

Comme le montre l'illustration, nous voulons passer du nœud suivant à la fin de la liste. Cela peut être fait en utilisant next + length . Mais pour sélectionner un index, vous devez le limiter à la longueur du tableau. Cela peut facilement être fait en utilisant l'opération de modification.

Après avoir trouvé un serveur qui fonctionne pendant la recherche, il doit être marqué comme courant:

 // GetNextPeer returns next active peer to take a connection func (s *ServerPool) GetNextPeer() *Backend { // loop entire backends to find out an Alive backend next := s.NextIndex() l := len(s.backends) + next // start from next and move a full cycle for i := next; i < l; i++ { idx := i % len(s.backends) // take an index by modding with length // if we have an alive backend, use it and store if its not the original one if s.backends[idx].IsAlive() { if i != next { atomic.StoreUint64(&s.current, uint64(idx)) // mark the current one } return s.backends[idx] } } return nil } 

Éviter la condition de concurrence dans la structure Backend


Ici, vous devez vous souvenir d'un problème important. La structure Backend contient une variable que plusieurs goroutines peuvent modifier ou interroger en même temps.

Nous savons que les goroutines liront la variable plus que ne lui écriront. Par conséquent, pour sérialiser l'accès à Alive nous avons choisi RWMutex .

 // SetAlive for this backend func (b *Backend) SetAlive(alive bool) { b.mux.Lock() b.Alive = alive b.mux.Unlock() } // IsAlive returns true when backend is alive func (b *Backend) IsAlive() (alive bool) { b.mux.RLock() alive = b.Alive b.mux.RUnlock() return } 

Équilibrer les demandes


Nous pouvons maintenant formuler une méthode simple pour équilibrer nos demandes. Il échouera uniquement si tous les serveurs tombent.

 // lb load balances the incoming request func lb(w http.ResponseWriter, r *http.Request) { peer := serverPool.GetNextPeer() if peer != nil { peer.ReverseProxy.ServeHTTP(w, r) return } http.Error(w, "Service not available", http.StatusServiceUnavailable) } 

Cette méthode peut être transmise au serveur HTTP simplement en tant que HandlerFunc .

 server := http.Server{ Addr: fmt.Sprintf(":%d", port), Handler: http.HandlerFunc(lb), } 

Nous acheminons le trafic uniquement vers des serveurs en cours d'exécution


Notre équilibreur a un sérieux problème. Nous ne savons pas si le serveur fonctionne. Pour le savoir, vous devez vérifier le serveur. Il existe deux façons de procéder:

  • Actif: en exécutant la requête en cours, nous constatons que le serveur sélectionné ne répond pas et le marquons comme inactif.
  • Passif: vous pouvez envoyer une requête ping aux serveurs à un certain intervalle et vérifier l'état.

Vérification active des serveurs en cours d'exécution


Si une erreur ReverseProxy lance la fonction de rappel ErrorHandler . Cela peut être utilisé pour détecter les pannes:

 proxy.ErrorHandler = func(writer http.ResponseWriter, request *http.Request, e error) { log.Printf("[%s] %s\n", serverUrl.Host, e.Error()) retries := GetRetryFromContext(request) if retries < 3 { select { case <-time.After(10 * time.Millisecond): ctx := context.WithValue(request.Context(), Retry, retries+1) proxy.ServeHTTP(writer, request.WithContext(ctx)) } return } // after 3 retries, mark this backend as down serverPool.MarkBackendStatus(serverUrl, false) // if the same request routing for few attempts with different backends, increase the count attempts := GetAttemptsFromContext(request) log.Printf("%s(%s) Attempting retry %d\n", request.RemoteAddr, request.URL.Path, attempts) ctx := context.WithValue(request.Context(), Attempts, attempts+1) lb(writer, request.WithContext(ctx)) } 

Pour développer ce gestionnaire d'erreurs, nous avons utilisé les capacités des fermetures. Cela nous permet de capturer des variables externes telles que les URL de serveur dans notre méthode. Le gestionnaire vérifie le compteur de nouvelles tentatives et s'il est inférieur à 3, nous envoyons à nouveau la même demande au même serveur. En effet, en raison d'erreurs temporaires, le serveur peut abandonner nos demandes, mais il devient bientôt disponible (le serveur peut ne pas avoir de sockets libres pour les nouveaux clients). Vous devez donc régler la temporisation pour une nouvelle tentative après environ 10 ms. A chaque demande nous augmentons le compteur de tentatives.

Après l'échec de chaque tentative, nous marquons le serveur comme inactif.

Vous devez maintenant attribuer un nouveau serveur pour la même demande. Nous le ferons en utilisant le compteur de tentatives en utilisant le package de context . Après avoir augmenté le compteur de tentatives, nous le passons à lb pour sélectionner un nouveau serveur pour traiter la demande.

Nous ne pouvons pas le faire indéfiniment, nous vérifierons donc si le nombre maximal de tentatives a été atteint avant de poursuivre le traitement de la demande.

Vous pouvez simplement obtenir le compteur de tentatives à partir de la demande, s'il atteint le maximum, nous interrompons la demande.

 // lb load balances the incoming request func lb(w http.ResponseWriter, r *http.Request) { attempts := GetAttemptsFromContext(r) if attempts > 3 { log.Printf("%s(%s) Max attempts reached, terminating\n", r.RemoteAddr, r.URL.Path) http.Error(w, "Service not available", http.StatusServiceUnavailable) return } peer := serverPool.GetNextPeer() if peer != nil { peer.ReverseProxy.ServeHTTP(w, r) return } http.Error(w, "Service not available", http.StatusServiceUnavailable) } 

Il s'agit d'une implémentation récursive.

Utilisation du package de contexte


Le package de context vous permet de stocker des données utiles dans des requêtes HTTP. Nous l'utiliserons activement pour suivre les données liées aux demandes - compteurs de Attempt et de nouvelle Attempt .

Tout d'abord, vous devez définir les clés du contexte. Il est recommandé d'utiliser non pas une chaîne, mais des valeurs numériques uniques. Go a un mot-clé iota pour l'implémentation incrémentielle des constantes, chacune contenant une valeur unique. Il s'agit d'une excellente solution pour définir des touches numériques.

 const ( Attempts int = iota Retry ) 

Vous pouvez ensuite extraire la valeur, comme nous le faisons habituellement avec le HashMap . La valeur par défaut peut dépendre de la situation actuelle.

 // GetAttemptsFromContext returns the attempts for request func GetRetryFromContext(r *http.Request) int { if retry, ok := r.Context().Value(Retry).(int); ok { return retry } return 0 } 

Validation passive du serveur


Les contrôles passifs identifient et récupèrent les serveurs tombés en panne. Nous les cinglons à un certain intervalle pour déterminer leur statut.

Pour envoyer une requête ping, essayez d'établir une connexion TCP. Si le serveur répond, nous le marquons comme fonctionnant. Cette méthode peut être adaptée pour appeler des points de terminaison spécifiques comme /status . Assurez-vous de fermer la connexion après sa création pour réduire la charge supplémentaire sur le serveur. Sinon, il tentera de maintenir cette connexion et finira par épuiser ses ressources.

 // isAlive checks whether a backend is Alive by establishing a TCP connection func isBackendAlive(u *url.URL) bool { timeout := 2 * time.Second conn, err := net.DialTimeout("tcp", u.Host, timeout) if err != nil { log.Println("Site unreachable, error: ", err) return false } _ = conn.Close() // close it, we dont need to maintain this connection return true } 

Vous pouvez maintenant parcourir les serveurs et marquer leurs statuts:

 // HealthCheck pings the backends and update the status func (s *ServerPool) HealthCheck() { for _, b := range s.backends { status := "up" alive := isBackendAlive(b.URL) b.SetAlive(alive) if !alive { status = "down" } log.Printf("%s [%s]\n", b.URL, status) } } 

Pour exécuter ce code périodiquement, vous pouvez exécuter le minuteur dans Go. Il vous permettra d'écouter les événements de la chaîne.

 // healthCheck runs a routine for check status of the backends every 2 mins func healthCheck() { t := time.NewTicker(time.Second * 20) for { select { case <-tC: log.Println("Starting health check...") serverPool.HealthCheck() log.Println("Health check completed") } } } 

Dans ce code, le canal <-tC renverra une valeur toutes les 20 secondes. select vous permet de définir cet événement. En l'absence de situation default , il attend qu'au moins un cas puisse être exécuté.

Maintenant, exécutez le code dans un goroutine distinct:

 go healthCheck() 

Conclusion


Dans cet article, nous avons examiné de nombreuses questions:

  • Algorithme Round Robin
  • ReverseProxy de la bibliothèque standard
  • Mutex
  • Opérations atomiques
  • Court-circuits
  • Rappels
  • Opération de sélection

Il existe de nombreuses autres façons d'améliorer notre équilibreur. Par exemple:

  • Utilisez le tas pour trier les serveurs en direct afin de réduire la portée de la recherche.
  • Recueillir des statistiques.
  • Implémentez l'algorithme round-robin pondéré avec le moins de connexions.
  • Ajoutez la prise en charge des fichiers de configuration.

Et ainsi de suite.

Le code source est ici .

Source: https://habr.com/ru/post/fr476276/


All Articles