Intégration d’un service en Go avec systemd: activation par socket

Vincent Bernat

Dans un article précédent, j’ai souligné certaines fonctionnalités utiles de systemd pour écrire un service en Go, notamment pour indiquer la disponibilité et prouver la vivacité. Un autre point intéressant est l’activation par socket1 : systemd écoute pour le compte de l’application et, lors de la première requête, démarre le service avec une copie de la socket en écoute. Lennart Poettering détaille dans un article :

Si un service meurt, sa socket d’écoute reste en place, sans perdre un seul message. Après un redémarrage du service suite à une panne, il peut continuer là où il s’est arrêté. Si un service est mis à niveau, nous pouvons redémarrer le service tout en conservant ses sockets, assurant ainsi que le service est continuellement disponible. Aucune connexion n’est perdue pendant la mise à niveau.

Il s’agit d’une solution pour obtenir un déploiement sans impact d’une application. Un autre avantage est la possibilité d’exécuter un démon avec moins de privilèges : perdre des droits est une tâche ardue avec Go2.

La base#

Reprenons notre sympathique serveur de pages 404 :

package main

import (
    "log"
    "net"
    "net/http"
)

func main() {
    listener, err := net.Listen("tcp", ":8081")
    if err != nil {
        log.Panicf("cannot listen: %s", err)
    }
    http.Serve(listener, nil)
}

Voici la version utilisant l’activation par socket, à l’aide de go-systemd :

package main

import (
    "log"
    "net/http"

    "github.com/coreos/go-systemd/activation"
)

func main() {
    listeners, err := activation.Listeners(true) // ❶
    if err != nil {
        log.Panicf("cannot retrieve listeners: %s", err)
    }
    if len(listeners) != 1 {
        log.Panicf("unexpected number of socket activation (%d != 1)",
            len(listeners))
    }
    http.Serve(listeners[0], nil) // ❷
}

En ❶, nous récupérons les sockets en écoute fournies par systemd. En ❷, nous utilisons la première d’entre elles pour servir les requêtes HTTP. Testons le résultat avec systemd-socket-activate3 :

$ go build 404.go
$ systemd-socket-activate -l 8000 ./404
Listening on [::]:8000 as 3.

Dans un autre terminal, effectuons quelques requêtes :

$ curl '[::1]':8000
404 page not found
$ curl '[::1]':8000
404 page not found

Deux fichiers sont nécessaires pour compléter l’intégration avec systemd :

  • un fichier .socket décrivant la socket,
  • un fichier .service décrivant le service associé.

Voici le contenu du fichier 404.socket :

[Socket]
ListenStream = 8000
BindIPv6Only = both

[Install]
WantedBy = sockets.target

La page de manuel systemd.socket(5) décrit les options disponibles. BindIPv6Only = both est explicitement utilisé car sa valeur par défaut dépend de la distribution. Voici ensuite le contenu du fichier 404.service :

[Unit]
Description = 404 micro-service

[Service]
ExecStart = /usr/bin/404

systemd sait que les deux fichiers sont liés car ils partagent un même préfixe. Placez les dans /etc/systemd/system et exécutez systemctl daemon-reload et systemctl start 404.​socket. Votre service est désormais prêt à accepter des connexions !

Gestion des connexions existantes#

Notre serveur de pages 404 a un défaut majeur : les connexions existantes sont sauvagement tuées lorsque le démon est arrêté ou redémarré. Corrigeons cela !

Attendre quelques secondes les connexions existantes#

Nous pouvons inclure une courte période de tolérance pour terminer les connexions en cours. À l’issue de celles-ci, les connexions restantes sont tuées :

// À la réception du signal, ferme en douceur le serveur et
// attend 5 secondes la fin des connexions en cours.
done := make(chan struct{})
quit := make(chan os.Signal, 1)
server := &http.Server{}
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

go func() {
    <-quit
    log.Println("server is shutting down")
    ctx, cancel := context.WithTimeout(context.Background(),
        5*time.Second)
    defer cancel()
    server.SetKeepAlivesEnabled(false)
    if err := server.Shutdown(ctx); err != nil {
        log.Panicf("cannot gracefully shut down the server: %s", err)
    }
    close(done)
}()

// Accepte de nouvelles connexions.
server.Serve(listeners[0])

// Attend la fin des connexions en cours avant de sortir.
<-done

À la réception du signal de terminaison, la goroutine reprend et planifie l’arrêt du service :

Shutdown() ferme en douceur le serveur sans interrompre les connexions actives. Shutdown() fonctionne en fermant d’abord les sockets en écoute puis en fermant toutes les connexions inactives et enfin en attendant indéfiniment que les connexions retombent au repos et s’arrêtent.

Durant le redémarrage, les nouvelles connexions ne sont pas acceptées : elles restent dans la file d’attente associée à la socket. La taille de celle-ci est limitée et peut être configurée avec la directive Backlog. Sa valeur par défaut est 128. Vous pouvez conserver cette valeur même si votre service s’attend à recevoir de nombreuses connexions par seconde. Lorsque cette valeur est dépassée, les connexions entrantes sont ignorées. Le client réessaye automatiquement de se connecter. Sous Linux, par défaut, un client tente 5 fois (tcp_syn_retries) en 3 minutes environ. C’est un bon moyen d’éviter l’effet de troupeau qui se manifesterait au redémarrage si la taille de la file d’attente était augmentée à une valeur plus élevée.

Attendre plus longtemps les connexions existantes#

Si vous voulez attendre la fin des connexions existantes pendant une longue période, vous avez besoin d’une approche alternative pour éviter d’ignorer les nouvelles connexions pendant plusieurs minutes. Il y a une astuce très simple : demander à systemd de ne tuer aucun processus. Avec KillMode = none, seule la commande d’arrêt est exécutée et tous les processus existants ne sont pas perturbés :

[Unit]
Description = slow 404 micro-service

[Service]
ExecStart = /usr/bin/404
ExecStop  = /bin/kill $MAINPID
KillMode  = none

Si vous redémarrez le service, le processus en cours prend le temps nécessaire pour s’arrêter et systemd lance immédiatement une nouvelle instance, prête à répondre aux requêtes avec sa propre copie de la socket d’écoute. Toutefois, nous perdons la capacité d’attendre que le service s’arrête complètement, soit par lui-même, soit de force après un temps limite avec SIGKILL.

Mise à jour (01.2021)

L’utilisation de KillMode=none est désormais découragée. En plus de l’alternative proposée ci-dessous, il est possible de transmettre les connexions actives à systemd à l’aide de la fonction sd_pid_notify_with_fds(). Ensuite, le nouveau processus doit être capable de les gérer, ce qui n’est pas facile car cela nécessite de fournir également l’état associé à chaque connexion. De plus, cette fonction n’est pas encore présente dans go-systemd.

Attendre plus longtemps les connexions existantes (alternative)#

Une alternative à la solution précédente consiste à faire croire à systemd que le service est mort️ pendant le redémarrage.

done := make(chan struct{})
quit := make(chan os.Signal, 1)
server := &http.Server{}
signal.Notify(quit,
    // redémarrage:
    syscall.SIGHUP,
    // arrêt:
    syscall.SIGINT, syscall.SIGTERM)
go func() {
    sig := <-quit
    switch sig {
    case syscall.SIGINT, syscall.SIGTERM:
        // Arrêt avec limite de temps.
        log.Println("server is shutting down")
        ctx, cancel := context.WithTimeout(context.Background(),
            15*time.Second)
        defer cancel()
        server.SetKeepAlivesEnabled(false)
        if err := server.Shutdown(ctx); err != nil {
            log.Panicf("cannot gracefully shut down the server: %s", err)
        }
    case syscall.SIGHUP: // ❶
        // Exécute un processus éphémère et demande à systemd de
        // le suivre au lieu de nous.
        log.Println("server is reloading")
        pid := detachedSleep()
        daemon.SdNotify(false, fmt.Sprintf("MAINPID=%d", pid))
        time.Sleep(time.Second)

        // Attend sans limite de temps la fin des connexions en cours.
        server.SetKeepAlivesEnabled(false)
        if err := server.Shutdown(context.Background()); err != nil {
            log.Panicf("cannot gracefully shut down the server: %s", err)
        }
    }
    close(done)
}()

// Sert lentement les requêtes.
server.Handler = http.HandlerFunc(
    func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(10 * time.Second)
        http.Error(w, "404 not found", http.StatusNotFound)
    })
server.Serve(listeners[0])

// Attend que toutes les connexions se terminent.
<-done
log.Println("server terminated")

La principale différence est le traitement du signal SIGHUP en ❶ : un processus leurre de courte durée est exécuté et systemd est invité à le suivre. Quand il meurt, systemd lancera une nouvelle instance du service. Cette méthode nécessite quelques bricolages : systemd a besoin que le leurre soit son fils mais Go ne peut pas facilement se mettre en arrière plan seul. Par conséquent, nous utilisons un court script Python inclu dans la fonction detachedSleep()4 :

// detachedSleep exécute un processus dormant une seconde
// en arrière plan et retourne son PID.
func detachedSleep() uint64 {
    py := `
import os
import time

pid = os.fork()
if pid == 0:
    for fd in {0, 1, 2}:
        os.close(fd)
    time.sleep(1)
else:
    print(pid)
`
    cmd := exec.Command("/usr/bin/python3", "-c", py)
    out, err := cmd.Output()
    if err != nil {
        log.Panicf("cannot execute sleep command: %s", err)
    }
    pid, err := strconv.ParseUint(strings.TrimSpace(string(out)), 10, 64)
    if err != nil {
        log.Panicf("cannot parse PID of sleep command: %s", err)
    }
    return pid
}

Pendant le rechargement, il peut y avoir une courte période pendant laquelle le nouveau et l’ancien processus acceptent les requêtes entrantes. Si vous ne le souhaitez pas, vous pouvez déplacer la création du processus leurre en dehors de la goroutine, après server.Serve() ou implémenter un mécanisme de synchronisation. Il y a aussi un éventuel problème de concurrence lorsque nous disons à systemd de suivre un autre PID (voir PR #7816).

Le fichier 404.service doit être mis à jour :

[Unit]
Description = slow 404 micro-service

[Service]
ExecStart    = /usr/bin/404
ExecReload   = /bin/kill -HUP $MAINPID
Restart      = always
NotifyAccess = main
KillMode     = process

Chacune des directives supplémentaires a son importance :

  • ExecReload indique comment recharger le processus (avec SIGHUP).
  • Restart indique de redémarrer le processus s’il s’arrête de manière « inattendue », notamment lors du rechargement5.
  • NotifyAccess précise quels sont les processus autorisés à envoyer des notifications comme le changement de PID.
  • KillMode indique de ne tuer que le processus identifié comme principal. Les autres sont laissés tranquilles.

Déploiement sans impact ?#

Le déploiement sans impact est une entreprise difficile sur Linux. Par exemple, HAProxy a eu une longue liste de tentatives jusqu’à ce qu’une solution appropriée, mais complexe, soit implémentée dans HAproxy 1.8. Comment se débrouille-t-on avec notre simple mise en œuvre ?

Du point de vue du noyau, il y a une seule socket avec une file d’attente unique. Cette socket est associée à plusieurs descripteurs de fichiers : un dans systemd et un dans le processus en cours. La chaussette reste en vie tant qu’il y a au moins un descripteur de fichier. Une connexion entrante est placée par le noyau dans la file d’attente et peut être traitée à partir de n’importe quel descripteur avec l’appel système accept(). Par conséquent, cette approche permet de réaliser un déploiement sans impact : aucune connexion entrante n’est rejetée.

En revanche, HAProxy utilisait plusieurs sockets différentes pour écouter sur les mêmes adresses, grâce à l’option SO_REUSEPORT6. Chaque socket a sa propre file d’attente et le noyau répartit les connexions entrantes entre chacune d’elles. Lorsqu’une socket se ferme, le contenu de sa file d’attente est perdu. Si une connexion entrante se trouvait ici, elle reçoit une réinitialisation. Une modification élégante pour Linux afin de signaler qu’une socket ne devrait plus recevoir de nouvelles connexions a été rejetée. HAProxy 1.8 recycle désormais les sockets existantes vers les nouveaux processus par le biais d’une socket Unix.

J’espère que ce billet et le précédent montrent combien systemd est un compagnon appréciable pour un service en Go : disponibilité, vivacité et activation par socket sont quelques unes des fonctionnalités utiles pour construire une application plus fiable.

Annexe: leurre écrit en Go#

Mise à jour (03.2018)

Sur /r/golang, on m’a fait remarquer que, dans la version où systemd suit un leurre, le script Python peut être remplacé par une invocation de l’exécutable principal qui se base sur un changement d’environnement pour prendre le rôle du leurre. Voici le code remplaçant la fonction detachedSleep() function :

func init() {
    // Au plus tôt, vérifie si on doit jouer le rôle
    // du leurre.
    state := os.Getenv("__SLEEPY")
    os.Unsetenv("__SLEEPY")
    switch state {
    case "1":
        // Première étape, se réexécuter
        execPath := self()
        child, err := os.StartProcess(
            execPath,
            []string{execPath},
            &os.ProcAttr{
                Env: append(os.Environ(), "__SLEEPY=2"),
            })
        if err != nil {
            log.Panicf("cannot execute sleep command: %s", err)
        }

        // Publie le PID du fils et sort.
        fmt.Printf("%d", child.Pid)
        os.Exit(0)
    case "2":
        // Dort et sort.
        time.Sleep(time.Second)
        os.Exit(0)
    }
}

// self retourne le chemin absolu vers nous-même. Cela repose sur
// /proc/self/exe qui peut être un lien symbolique vers un fichier
// supprimé (durant une mise à jour par exemple).
func self() string {
    execPath, err := os.Readlink("/proc/self/exe")
    if err != nil {
        log.Panicf("cannot get self path: %s", err)
    }
    execPath = strings.TrimSuffix(execPath, " (deleted)")
    return execpath
}

// detachedSleep détache un processus qui dort une seconde et retourne
// son PID.
func detachedSleep() uint64 {
    cmd := exec.Command(self())
    cmd.Env = append(os.Environ(), "__SLEEPY=1")
    out, err := cmd.Output()
    if err != nil {
        log.Panicf("cannot execute sleep command: %s", err)
    }
    pid, err := strconv.ParseUint(strings.TrimSpace(string(out)), 10, 64)
    if err != nil {
        log.Panicf("cannot parse PID of sleep command: %s", err)
    }
    return pid
}

Annexe : nommage des sockets#

Pour un service donné, systemd peut fournir plusieurs sockets. Pour les différencier, il est possible de les nommer. Par exemple, supposons que nous voulions aussi retourner des codes d’erreur 403 depuis le même service mais sur un port différent. Nous ajoutons une définition de socket supplémentaire, 403.socket, liée à la tâche 404.service :

[Socket]
ListenStream = 8001
BindIPv6Only = both
Service      = 404.service

[Install]
WantedBy=sockets.target

À moins de le spécifier explicitement avec la directive FileDescriptorName, le nom de la socket est le nom de l’unité : 403.socket. go-systemd fournit la fonction ListenersWithName() pour récupérer une correspondance entre les noms et les sockets :

package main

import (
    "log"
    "net/http"
    "sync"

    "github.com/coreos/go-systemd/activation"
)

func main() {
    var wg sync.WaitGroup

    // Associe un nom de socket à une fonction de gestion.
    handlers := map[string]http.HandlerFunc{
        "404.socket": http.NotFound,
        "403.socket": func(w http.ResponseWriter, r *http.Request) {
            http.Error(w, "403 forbidden",
                http.StatusForbidden)
        },
    }

    // Récupère les sockets en écoute.
    listeners, err := activation.ListenersWithNames(true)
    if err != nil {
        log.Panicf("cannot retrieve listeners: %s", err)
    }

    // Pour chaque socket, invoque une goroutine en utilisant
    // la fonction de gestion adéquate.
    for name := range listeners {
        for idx := range listeners[name] {
            wg.Add(1)
            go func(name string, idx int) {
                defer wg.Done()
                http.Serve(
                    listeners[name][idx],
                    handlers[name])
            }(name, idx)
        }
    }

    // Attend que toutes les goroutines terminent.
    wg.Wait()
}

Compilons le service et lançons le via systemd-socket-activate:

$ go build 404.go
$ systemd-socket-activate -l 8000 -l 8001 \
>                         --fdname=404.socket:403.socket \
>                         ./404
Listening on [::]:8000 as 3.
Listening on [::]:8001 as 4.

Dans une autre console, nous pouvons tester une requête sur chacune des deux adresses :

$ curl '[::1]':8000
404 page not found
$ curl '[::1]':8001
403 forbidden

  1. La traduction de socket en français n’est pas évidente. Quand le contexte est suffisamment clair, je m’amuse parfois à dire « chaussette » 🧦 car “socket” est souvent écrit sock dans les programmes. On pourrait le traduire par « prise réseau », idéal pour rendre perplexe la plupart des lecteurs. ↩︎

  2. De nombreuses caractéristiques d’un processus sous Linux sont attachées aux fils d’exécution. L’environnement d’exécution de Go les gère de manière transparente pour l’utilisateur. Jusqu’à récemment, cela rendait certaines fonctionnalités, comme setuid() ou setns(), inutilisables. ↩︎

  3. Avec d’anciennes versions de systemd (avant 230), la commande peut s’appeler /lib/systemd/systemd-activate↩︎

  4. Python est un bon candidat : il est sans doute disponible sur le système, il est d’assez bas niveau pour implémenter facilement la fonctionnalité et, en tant que langage interprété, il ne nécessite pas d’étape de compilation.

    Il n’y a pas besoin d’appeler fork() deux fois car il faut uniquement détacher le leurre du processus courant. Cela simplifie sensiblement le code Python. ↩︎

  5. Cette directive n’est pas essentielle car le processus serait aussi redémarré via l’activation de la socket. ↩︎

  6. Cette approche est plus pratique lors d’un rechargement car il n’y a pas à déterminer quelles sockets réutiliser et lesquelles créer à partir de zéro. De plus, lorsque plusieurs processus ont besoin d’accepter des connexions, l’utilisation de plusieurs sockets est plus performante car les différents processus ne se disputeront pas sur un verrou partagé pour accepter des connexions. ↩︎