Blog

Redémarrage gracieux de serveur avec Go

Chargement...

10 min de lecture

Redémarrage gracieux de serveur avec Go

Go a été conçu comme un langage backend et est principalement utilisé dans ce contexte. Les serveurs sont le type de logiciel le plus courant développé avec lui. La question à laquelle je vais répondre ici est la suivante : comment mettre à jour proprement un serveur en cours d’exécution ?

Redémarrage de serveur avec Go

Go a été conçu comme un langage backend et est principalement utilisé dans ce contexte. Les serveurs sont le type de logiciel le plus courant développé avec lui. La question à laquelle je vais répondre ici est la suivante : comment mettre à jour proprement un serveur en cours d’exécution ?

Objectifs :

  • Ne fermer aucune connexion existante : par exemple, nous ne voulons pas interrompre un déploiement en cours. En revanche, nous voulons pouvoir mettre à jour nos services à tout moment, sans contrainte.

  • Le socket doit toujours rester disponible pour les utilisateurs : si le socket devient indisponible, certains utilisateurs pourraient recevoir une erreur « connection refused », ce qui n’est pas acceptable.

  • La nouvelle version du processus doit être lancée et remplacer l’ancienne.

Principe

Dans les systèmes d’exploitation de type UNIX, la manière habituelle d’interagir avec des processus longue durée consiste à utiliser des signaux.

  • SIGTERM : demande à un processus de s’arrêter proprement

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

Lorsqu’un signal SIGHUP est reçu, plusieurs étapes permettent de redémarrer le processus proprement :

  • Le serveur cesse d’accepter de nouvelles connexions, mais le socket reste ouvert.

  • La nouvelle version du processus est lancée.

  • Le socket est « transmis » au nouveau processus, qui commencera à accepter les nouvelles connexions.

  • Une fois que l’ancien processus a terminé de servir ses clients, il doit s’arrêter.

Arrêter d’accepter de nouvelles connexions

Les serveurs ont un point commun : ils contiennent une boucle infinie qui accepte les 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 interrompre cette boucle, le plus simple est de définir un timeout sur le listener. Lorsque listener.SetTimeout(time.Now()) est appelé, listener.Accept() retourne immédiatement une erreur de timeout que vous pouvez intercepter 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 la différence entre cette opération et la fermeture du listener. 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.

Lancer la nouvelle version du processus

Go fournit une primitive ForkExec pour démarrer un nouveau processus. (Elle ne permet pas de faire uniquement un fork, voir « Is it safe to fork() a Golang process? ».) 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 les mêmes arguments os.Args.

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

Comme vous venez de le voir, il est possible de passer des descripteurs de fichiers au nouveau processus. Avec un peu de magie UNIX (« tout est fichier »), nous pouvons transmettre le socket au nouveau processus, qui pourra alors l’utiliser pour accepter les connexions en attente et les futures connexions.

Mais le processus lancé via fork-exec doit savoir qu’il doit récupérer son socket depuis un fichier, et non en créer un nouveau — qui serait déjà utilisé, puisque nous n’avons pas fermé le listener existant. Vous pouvez le faire comme vous le souhaitez ; le plus courant est d’utiliser une variable d’environnement ou un flag en 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 3 n’a pas été choisi au hasard : c’est parce que, dans le slice de uintptr envoyé au fork, le listener se trouve à l’index 3. Attention aux erreurs de shadow declaration.

Dernière étape : attendre que les connexions de l’ancien serveur se terminent

À ce stade, tout est prêt : nous avons passé le relais à un autre processus, qui fonctionne maintenant correctement. La dernière opération pour l’ancien serveur consiste à attendre que les connexions soient fermées. Il existe une manière simple de l’implémenter en Go, grâce à la structure sync.WaitGroup fournie par la bibliothèque standard.

À chaque fois qu’une connexion est acceptée, on ajoute 1 au WaitGroup, puis on décrémente le compteur lorsque le traitement est terminé :

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()
}()
}

Ainsi, pour attendre la fin des connexions, il suffit d’appeler wg.Wait(). Comme aucune nouvelle connexion n’est acceptée, on attend que wg.Done() ait été appelé pour tous les handlers en cours.

Bonus : ne pas attendre indéfiniment, mais pendant une durée donnée

Avec un time.Timer, c’est très simple à mettre en place :

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 transmission de socket est une méthode très efficace pour 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 ce délai reste très court.

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égradé arrière-plan section

Déployez une application ou base de données

Commencez à déployer

Rejoignez les équipes qui misent sur une plateforme cloud européenne et souveraine, conçue pour livrer rapidement et opérer sereinement.

Dégradé arrière-plan section

Déployez une application ou base de données

Commencez à déployer

Rejoignez les équipes qui misent sur une plateforme cloud européenne et souveraine, conçue pour livrer rapidement et opérer sereinement.

Dégradé arrière-plan section

Déployez une application ou base de données

Commencez à déployer

Rejoignez les équipes qui misent sur une plateforme cloud européenne et souveraine, conçue pour livrer rapidement et opérer sereinement.