Blog

Redémarrage gracieux du serveur avec Go

Chargement...

10 min de lecture

Redémarrage gracieux du serveur avec Go

Go a été conçu comme un langage backend et est principalement utilisé comme tel. Les serveurs sont le type de logiciel le plus courant produit avec. La question à laquelle je vais répondre ici est : comment mettre à niveau proprement un serveur en cours d'exécution ? Objectifs : Ne pas fermer les connexions existantes : par exemple, nous ne voulons pas interrompre aucune.

Go a été conçu comme un langage backend et est principalement utilisé comme tel. Les serveurs sont le type de logiciel le plus courant produit avec cela. La question à laquelle je vais répondre ici est : comment mettre à jour proprement un serveur en cours d'exécution ?

Objectifs :

  • Ne pas fermer aucune des connexions existantes : par exemple, nous ne voulons pas interrompre un déploiement en cours. Cependant, nous souhaitons pouvoir mettre à jour nos services quand nous le voulons sans aucune contrainte.

  • Le socket doit toujours être disponible pour les utilisateurs : si le socket est indisponible à un moment donné, certains utilisateurs peuvent recevoir un message 'connexion refusée', ce qui n'est pas acceptable.

  • La nouvelle version du processus doit être démarrée et doit remplacer l'ancienne.

Principe

Dans les systèmes d'exploitation basés sur UNIX, la manière courante d'interagir avec des processus de longue durée est les signaux.

  • SIGTERM : Demander à un processus de s'arrêter proprement

  • SIGHUP : Redémarrage/rechargement du processus (exemple : nginx, sshd, apache)

Une fois qu'un signal SIGHUP est reçu, il y a plusieurs étapes pour redémarrer le processus proprement :

  1. Le serveur cesse d'accepter de nouvelles connexions, mais le socket reste ouvert.

  2. La nouvelle version du processus est démarrée.

  3. Le socket est 'donné' au nouveau processus qui commencera à accepter de nouvelles connexions.

  4. Une fois que l'ancien processus a fini de servir son client, le processus doit s'arrêter.

Cesser d'accepter des connexions

Les serveurs ont cela en commun : ils contiennent une boucle infinie acceptant des connexions :

for {
conn, err := listener.Accept()
// Handle connection
}
for {
conn, err := listener.Accept()
// Handle connection
}
for {
conn, err := listener.Accept()
// Handle connection
}
for {
conn, err := listener.Accept()
// Handle connection
}

Pour rompre cette boucle, le moyen le plus simple est de définir un délai d'attente sur l'écouteur, quand SetTimeout(time.Now()) est appelé, listener.Accept() renverra immédiatement une erreur de délai d'attente que vous pouvez attraper et gérer.

for {
conn, err := listener.Accept()
if err != nil {
if nerr, ok := err.(net.Err); ok && nerr.Timeout() {
fmt.Println(“Stop accepting connections”)
return
}
}
}
for {
conn, err := listener.Accept()
if err != nil {
if nerr, ok := err.(net.Err); ok && nerr.Timeout() {
fmt.Println(“Stop accepting connections”)
return
}
}
}
for {
conn, err := listener.Accept()
if err != nil {
if nerr, ok := err.(net.Err); ok && nerr.Timeout() {
fmt.Println(“Stop accepting connections”)
return
}
}
}
for {
conn, err := listener.Accept()
if err != nil {
if nerr, ok := err.(net.Err); ok && nerr.Timeout() {
fmt.Println(“Stop accepting connections”)
return
}
}
}

Il est important de comprendre qu'il y a une différence entre cette opération et la fermeture de l'écouteur. Dans ce cas, le processus écoute toujours sur un port par exemple, mais les connexions sont mises en file d'attente par la pile réseau du système d'exploitation, en attendant qu'un processus les accepte.

Démarrer la nouvelle version du processus

Go fournit une primitive ForkExec pour créer un nouveau processus. (Elle ne permet pas uniquement de fork, cf Est-il sûr de fork() un processus Golang ?) Vous pouvez partager certaines informations avec ce nouveau processus, comme des descripteurs de fichiers ou votre environnement.

execSpec := &syscall.ProcAttr{
Env: os.Environ(),
Files: []uintptr{os.Stdin.Fd(), os.Stdout.Fd(), os.Stderr.Fd()},
}
fork, err := syscall.ForkExec(os.Args[0], os.Args, execSpec)
[]
execSpec := &syscall.ProcAttr{
Env: os.Environ(),
Files: []uintptr{os.Stdin.Fd(), os.Stdout.Fd(), os.Stderr.Fd()},
}
fork, err := syscall.ForkExec(os.Args[0], os.Args, execSpec)
[]
execSpec := &syscall.ProcAttr{
Env: os.Environ(),
Files: []uintptr{os.Stdin.Fd(), os.Stdout.Fd(), os.Stderr.Fd()},
}
fork, err := syscall.ForkExec(os.Args[0], os.Args, execSpec)
[]
execSpec := &syscall.ProcAttr{
Env: os.Environ(),
Files: []uintptr{os.Stdin.Fd(), os.Stdout.Fd(), os.Stderr.Fd()},
}
fork, err := syscall.ForkExec(os.Args[0], os.Args, execSpec)
[]

Vous pouvez voir que le processus démarre une nouvelle version de lui-même avec exactement le même argument os.Args

Envoyer le socket au processus fils et le récupérer

Comme vous l'avez vu juste avant, vous pouvez passer des descripteurs de fichiers à votre nouveau processus, et avec un peu de magie UNIX (tout est un fichier), nous pouvons envoyer le socket au nouveau processus et il pourra l'utiliser et accepter les connexions en attente et futures.

Mais le processus fork-exécuté doit savoir qu'il doit récupérer son socket d'un fichier et non en construire un nouveau (ce qui serait déjà utilisé de toute façon, car nous n'avons pas fermé l'écouteur existant). Vous pouvez le faire comme vous voulez, le plus courant est via l'environnement ou avec un indicateur de ligne de commande.

listenerFile, err := listener.File()
if err != nil {
log.Fatalln("Fail to get socket file descriptor:", err)
}
listenerFd := listenerFile.Fd()

// Set a flag for the new process start process
os.Setenv("_GRACEFUL_RESTART", "true")

execSpec := &syscall.ProcAttr{
Env: os.Environ(),
Files: []uintptr{os.Stdin.Fd(), os.Stdout.Fd(), os.Stderr.Fd(), listenerFd},
}
// Fork exec the new version of your server
fork, err := syscall.ForkExec(os.Args[0], os.Args, execSpec

listenerFile, err := listener.File()
if err != nil {
log.Fatalln("Fail to get socket file descriptor:", err)
}
listenerFd := listenerFile.Fd()

// Set a flag for the new process start process
os.Setenv("_GRACEFUL_RESTART", "true")

execSpec := &syscall.ProcAttr{
Env: os.Environ(),
Files: []uintptr{os.Stdin.Fd(), os.Stdout.Fd(), os.Stderr.Fd(), listenerFd},
}
// Fork exec the new version of your server
fork, err := syscall.ForkExec(os.Args[0], os.Args, execSpec

listenerFile, err := listener.File()
if err != nil {
log.Fatalln("Fail to get socket file descriptor:", err)
}
listenerFd := listenerFile.Fd()

// Set a flag for the new process start process
os.Setenv("_GRACEFUL_RESTART", "true")

execSpec := &syscall.ProcAttr{
Env: os.Environ(),
Files: []uintptr{os.Stdin.Fd(), os.Stdout.Fd(), os.Stderr.Fd(), listenerFd},
}
// Fork exec the new version of your server
fork, err := syscall.ForkExec(os.Args[0], os.Args, execSpec

listenerFile, err := listener.File()
if err != nil {
log.Fatalln("Fail to get socket file descriptor:", err)
}
listenerFd := listenerFile.Fd()

// Set a flag for the new process start process
os.Setenv("_GRACEFUL_RESTART", "true")

execSpec := &syscall.ProcAttr{
Env: os.Environ(),
Files: []uintptr{os.Stdin.Fd(), os.Stdout.Fd(), os.Stderr.Fd(), listenerFd},
}
// Fork exec the new version of your server
fork, err := syscall.ForkExec(os.Args[0], os.Args, execSpec

Puis au début du programme :

var listener *net.TCPListener
if os.Getenv("_GRACEFUL_RESTART") == "true" { // The second argument should be the filename of the file descriptor // however, a socker is not a named file but we should fit the interface // of the os.NewFile function.
file := os.NewFile(3, "")
listener, err := net.FileListener(file)
if err != nil {
// handle
}
var bool ok
listener, ok = listener.(*net.TCPListener)
if !ok {
// handle
}
} else {
listener, err = newListenerWithPort(12345

var listener *net.TCPListener
if os.Getenv("_GRACEFUL_RESTART") == "true" { // The second argument should be the filename of the file descriptor // however, a socker is not a named file but we should fit the interface // of the os.NewFile function.
file := os.NewFile(3, "")
listener, err := net.FileListener(file)
if err != nil {
// handle
}
var bool ok
listener, ok = listener.(*net.TCPListener)
if !ok {
// handle
}
} else {
listener, err = newListenerWithPort(12345

var listener *net.TCPListener
if os.Getenv("_GRACEFUL_RESTART") == "true" { // The second argument should be the filename of the file descriptor // however, a socker is not a named file but we should fit the interface // of the os.NewFile function.
file := os.NewFile(3, "")
listener, err := net.FileListener(file)
if err != nil {
// handle
}
var bool ok
listener, ok = listener.(*net.TCPListener)
if !ok {
// handle
}
} else {
listener, err = newListenerWithPort(12345

var listener *net.TCPListener
if os.Getenv("_GRACEFUL_RESTART") == "true" { // The second argument should be the filename of the file descriptor // however, a socker is not a named file but we should fit the interface // of the os.NewFile function.
file := os.NewFile(3, "")
listener, err := net.FileListener(file)
if err != nil {
// handle
}
var bool ok
listener, ok = listener.(*net.TCPListener)
if !ok {
// handle
}
} else {
listener, err = newListenerWithPort(12345

Le descripteur de fichier n'a pas été choisi au hasard, le descripteur de fichier 3, c'est parce que dans le tableau de uintptr qui a été envoyé au fork, l'écouteur a obtenu l'index 3. Faites attention aux erreurs de déclaration d'ombre.

Dernière étape, attendre que les connexions de l'ancien serveur s'arrêtent

À ce stade, c'est tout, nous avons passé la responsabilité à un autre processus qui fonctionne maintenant correctement, la dernière opération pour l'ancien serveur est d'attendre que les connexions se ferment. Il existe une manière simple de l'implémenter avec Go, grâce à la structure sync.WaitGroup fournie dans la bibliothèque standard.

Chaque fois qu'une connexion est acceptée, 1 est ajouté au WaitGroup, puis, nous diminuons le compteur lorsqu'il est fait :

for {
conn, err := listener.Accept() wg.Add(1)
go func() {
handle(conn)
wg.Done()
}()
}
for {
conn, err := listener.Accept() wg.Add(1)
go func() {
handle(conn)
wg.Done()
}()
}
for {
conn, err := listener.Accept() wg.Add(1)
go func() {
handle(conn)
wg.Done()
}()
}
for {
conn, err := listener.Accept() wg.Add(1)
go func() {
handle(conn)
wg.Done()
}()
}

En conséquence, pour attendre la fin des connexions, vous devez simplement appeler wg.Wait(), comme il n'y a pas de nouvelle connexion, nous attendons que wg.Done() ait été appelé pour tous les gestionnaires en cours.

Bonus : ne pas attendre indéfiniment mais un temps donné

Avec un time.Timer, il est vraiment simple d'implémenter cela :

timeout := time.NewTimer(time.Minute)
wait := make(chan struct{})
go func() {
wg.Wait()
wait <- struct{}{}
}()

select {
case <-timeout.C:
return WaitTimeoutError
case <-wait:
return nil

timeout := time.NewTimer(time.Minute)
wait := make(chan struct{})
go func() {
wg.Wait()
wait <- struct{}{}
}()

select {
case <-timeout.C:
return WaitTimeoutError
case <-wait:
return nil

timeout := time.NewTimer(time.Minute)
wait := make(chan struct{})
go func() {
wg.Wait()
wait <- struct{}{}
}()

select {
case <-timeout.C:
return WaitTimeoutError
case <-wait:
return nil

timeout := time.NewTimer(time.Minute)
wait := make(chan struct{})
go func() {
wg.Wait()
wait <- struct{}{}
}()

select {
case <-timeout.C:
return WaitTimeoutError
case <-wait:
return nil

Conclusion

Utiliser ForkExec avec le passage de socket est un moyen vraiment efficace de mettre à jour un processus sans perturber les connexions, au maximum, les nouveaux clients attendront quelques millisecondes, le temps que le nouveau serveur démarre et récupère le socket, mais cette durée est vraiment courte.

Cet article faisait partie de notre série #FridayTechnical, il n'y aura pas d'article la semaine prochaine, joyeux Noël à tous.

Liens :

— Léo Unbekandt CTO @ Scalingo

Léo Unbekandt, Scalingo

Léo Unbekandt

Léo est le fondateur et CTO de Scalingo. Il a étudié en France en tant qu'ingénieur cloud (ENSIIE) et en Angleterre (Cranfield University). Il est responsable du développement technique de Scalingo et il gère notre équipe technique.

Restez informé

Recevez des articles et des mises à jour de la plateforme dans votre boîte de réception.

Prêt à déployer en toute confiance ?

Découvrez des déploiements sans temps d'arrêt, une mise à l'échelle automatique intelligente et une infrastructure entièrement gérée. Commencez à déployer vos applications sur Scalingo dès aujourd'hui.

Aucune carte de crédit requise • Déployez en quelques minutes • Annulez à tout moment

Déployez une application ou base de données

Commencez à déployer

Rejoignez les équipes qui misent sur une plateforme conçue pour livrer rapidement, opérer sereinement, avec des valeurs européennes et un support humain.

Déployez une application ou base de données

Commencez à déployer

Rejoignez les équipes qui misent sur une plateforme conçue pour livrer rapidement, opérer sereinement, avec des valeurs européennes et un support humain.

Déployez une application ou base de données

Commencez à déployer

Rejoignez les équipes qui misent sur une plateforme conçue pour livrer rapidement, opérer sereinement, avec des valeurs européennes et un support humain.