Développer et déployer un site entier en Go (Golang)

Temps de lecture ~10 minutes

À mon avis Go (Golang) est un excellent choix de langage pour le développement web :

  • il permet les requêtes non bloquantes et rend la concurrence facile
  • il facilite le test du code et le déploiement puisqu’il ne demande d’installer aucun environnement d’exécution ou de dépendances
  • il n’exige pas d’installer un serveur HTTP front-end tel qu’Apache ou Nginx puisqu’il en embarque déjà un excellent dans sa bibliothèque standard
  • il ne vous force pas à utiliser un framework web puisque tous les outils requis pour le développement web sont déjà disponibles dans la bibliothèque standard

Il y a encore quelques années, le manque de bibliothèques et tutoriels autour de Go étaient un problème, mais aujourd’hui ce n’est plus le cas. Je vais vous montrer étape par étape comment construire un site web en Go et le déployer sur votre serveur Linux de A à Z.

Les bases

Imaginons que vous souhaitiez développer une page HTML basique appelée love-mountains. Comme vous le savez peut-être déjà, la génération de love-mountains se fait dans une fonction, et il vous faut lancer le serveur web avec une route pointant vers cette fonction. C’est une bonne pratique que d’utiliser des templates HTML lorsque l’on fait du développement web alors générons notre page via un template basique ici. C’est aussi une bonne pratique que d’importer des paramètres tels que le chemin vers le dossier de template depuis des variables d’environnement pour une meilleure flexibilité.

Voici votre code Go :

package main

import (
    "html/template"
    "net/http"
)

// Get path to template directory from env variable
var templatesDir = os.Getenv("TEMPLATES_DIR")

// loveMountains renders the love-mountains page after passing some data to the HTML template
func loveMountains(w http.ResponseWriter, r *http.Request) {
    // Build path to template
    tmplPath := filepath.Join(templatesDir, "love-mountains.html")
    // Load template from disk
    tmpl := template.Must(template.ParseFiles(tmplPath))
    // Inject data into template
    data := "La Chartreuse"
    tmpl.Execute(w, data)
}

func main() {
    // Create route to love-mountains web page
    http.HandleFunc("/love-mountains", loveMountains)
    // Launch web server on port 80
    http.ListenAndServe(":80", nil)
}

La récupération de données dynamiques depuis un template se fait facilement via {{.}} ici. Voici votre template love-mountains.html:

<h1>I Love Mountains<h1>
<p>The mountain I prefer is {{.}}</p>

HTTPS

De nos jours, mettre en place l’HTTPS sur votre site est devenu quasiment obligatoire. Comment passer votre site Go HTTPS ?

Lier les certificats TLS

Avant tout, générez votre certificat et votre clé privée au format .pem. Vous pouvez parfaitement les générer vous-même avec openssl (mais vous allez vous retrouver avec un certificat auto-signé qui déclenchera une alerte de sécurité dans le navigateur), ou bien vous pouvez commander un certificat auprès d’une tierce partie de confiance comme Let’s Encrypt. Personnellement, j’utilise Let’s Encrypt et Certbot afin de générer les certificats et les renouveler automatiquement sur mes serveurs. Plus d’infos sur l’utilisation de Certbot ici.

Il vous faut alors dire à Go où se trouve votre certificat et votre clé privée. J’importe ces chemins depuis des variables d’environnement.

On utilise désormais la fonctionListenAndServeTLS au lieu de ListenAndServe :

[...]

// Load TLS cert info from env variables
var tlsCertPath = os.Getenv("TLS_CERT_PATH")
var tlsKeyPath = os.Getenv("TLS_KEY_PATH")

[...]

func main() {
    [...]
    // Serve HTTPS on port 443
    http.ListenAndServeTLS(":443", tlsCertPath, tlsKeyPath, nil)
}

Forcer les redirections HTTPS

Pour le moment nous avons un site web qui écoute sur les ports 80 et 443 à la fois. Il serait bien de rediriger automatiquement les utilisateurs du port 80 vers 443 avec une redirection 301. Il nous faut pour cela créer une nouvelle goroutine dédiée à la redirection de http:// vers https:// (même principe que ce que vous feriez dans la configuration d’un serveur front-end comme Nginx). Voici ce qu’il faut faire :

[...]

// httpsRedirect redirects HTTP requests to HTTPS
func httpsRedirect(w http.ResponseWriter, r *http.Request) {
    http.Redirect(
        w, r,
        "https://"+r.Host+r.URL.String(),
        http.StatusMovedPermanently,
    )
}

func main() {
    [...]
    // Catch potential HTTP requests and redirect them to HTTPS
    go http.ListenAndServe(":80", http.HandlerFunc(httpsRedirect))
    // Serve HTTPS on port 443
    http.ListenAndServeTLS(":443", tlsCertPath, tlsKeyPath, nil)
}

Les assets statiques

Servir les assets statiques (comme les images, les vidéos, les fichiers Javascript, les fichiers CSS,…) stockés sur le disque est relativement facile mais désactiver le directory listing relève un peu plus de la bidouille.

Servir les fichiers statiques depuis le disque

En Go, la façon la plus sécurisée de servir des fichiers depuis le disque est d’utiliser http.FileServer. Par exemple, disons que nous stockons les fichiers statiques dans un dossier sur le disque appelé static, et que nous voulons les servir à cette adresse : https://my-website/static. Voici comment il faut s’y prendre :

[...]
http.Handle("/", http.FileServer(http.Dir("static")))
[...]

Empêcher le directory listing

Par défaut,http.FileServer permet un directory listing sans limitation, ce qui signifie que https://my-website/static affichera tous vos fichiers statiques. Ce n’est pas ce que nous voulons pour des questions à la fois de sécurité et de propriété intellectuelle.

Désactiver le directory listing requiert la création d’un FileSystem custom. Créons une struct qui implémente l’interface http.FileSystem. Cette struct doit avoir une méthode Open() afin de satisfaire l’interface. Cette méthode Open() vérifie en premier si le chemin vers le fichier ou le répertoire existe, et si c’est le cas elle détermine s’il s’agit d’un fichier ou d’un répertoire. Si le chemin est un répertoire alors nous retournons une erreur file does not exist qui sera convertie en une page d’erreur HTTP 404 pour l’utilisateur final. De cette façon l’utilisateur ne peut pas savoir s’il a atteint un dossier qui existe vraiment ou non.

Une fois de plus, importons le chemin vers le dossier de fichiers statiques depuis une variable d’environnement.

[...]

// Get path to static assets directory from env variable
var staticAssetsDir = os.Getenv("STATIC_ASSETS_DIR")

// neuteredFileSystem is used to prevent directory listing of static assets
type neuteredFileSystem struct {
    fs http.FileSystem
}

func (nfs neuteredFileSystem) Open(path string) (http.File, error) {
    // Check if path exists
    f, err := nfs.fs.Open(path)
    if err != nil {
        return nil, err
    }

    // If path exists, check if is a file or a directory.
    // If is a directory, stop here with an error saying that file
    // does not exist. So user will get a 404 error code for a file or directory
    // that does not exist, and for directories that exist.
    s, err := f.Stat()
    if err != nil {
        return nil, err
    }
    if s.IsDir() {
        return nil, os.ErrNotExist
    }

    // If file exists and the path is not a directory, let's return the file
    return f, nil
}

func main() {
    [...]
    // Serve static files while preventing directory listing
    mux := http.NewServeMux()
    fs := http.FileServer(neuteredFileSystem{http.Dir(staticAssetsDir)})
    mux.Handle("/", fs)
    [...]
}

Exemple complet

Au final, voici à quoi ressemblerait votre site en entier :

package main

import (
    "html/template"
    "net/http"
    "os"
    "path/filepath"
)

var staticAssetsDir = os.Getenv("STATIC_ASSETS_DIR")
var templatesDir = os.Getenv("TEMPLATES_DIR")
var tlsCertPath = os.Getenv("TLS_CERT_PATH")
var tlsKeyPath = os.Getenv("TLS_KEY_PATH")

// neuteredFileSystem is used to prevent directory listing of static assets
type neuteredFileSystem struct {
    fs http.FileSystem
}

func (nfs neuteredFileSystem) Open(path string) (http.File, error) {
    // Check if path exists
    f, err := nfs.fs.Open(path)
    if err != nil {
        return nil, err
    }

    // If path exists, check if is a file or a directory.
    // If is a directory, stop here with an error saying that file
    // does not exist. So user will get a 404 error code for a file/directory
    // that does not exist, and for directories that exist.
    s, err := f.Stat()
    if err != nil {
        return nil, err
    }
    if s.IsDir() {
        return nil, os.ErrNotExist
    }

    // If file exists and the path is not a directory, let's return the file
    return f, nil
}

// loveMountains renders love-mountains page after passing some data to the HTML template
func loveMountains(w http.ResponseWriter, r *http.Request) {
    // Load template from disk
    tmpl := template.Must(template.ParseFiles("love-mountains.html"))
    // Inject data into template
    data := "Any dynamic data"
    tmpl.Execute(w, data)
}

// httpsRedirect redirects http requests to https
func httpsRedirect(w http.ResponseWriter, r *http.Request) {
    http.Redirect(
        w, r,
        "https://"+r.Host+r.URL.String(),
        http.StatusMovedPermanently,
    )
}

func main() {
    // http to https redirection
    go http.ListenAndServe(":80", http.HandlerFunc(httpsRedirect))

    // Serve static files while preventing directory listing
    mux := http.NewServeMux()
    fs := http.FileServer(neuteredFileSystem{http.Dir(staticAssetsDir)})
    mux.Handle("/", fs)

    // Serve one page site dynamic pages
    mux.HandleFunc("/love-mountains", loveMountains)

    // Launch TLS server
    log.Fatal(http.ListenAndServeTLS(":443", tlsCertPath, tlsKeyPath, mux))
}

Plus votre template love-mountains.html :

<h1>I Love Mountains<h1>
<p>The mountain I prefer is {{.}}</p>

Tester, déployer, et créer un démon avec systemd

Bâtir un process de test et de déploiement robuste et facile à la fois est très important pour l’efficacité d’un projet, et Go est vraiment d’une grande aide à cet égard. Go compile tout au sein d’un seul et unique exécutable, incluant toutes les dépendances (excepté les templates mais ces derniers ne sont pas réellement des dépendances et il est de toute façon plus sage de les garder à part pour une meilleure flexibilité). Go embarque aussi son propre serveur HTTP front-end, il n’est donc pas besoin d’installer un Nginx ou un Apache. Il est ainsi relativement facile de tester votre application en local et de vous assurer que cette dernière est équivalente à votre site web en production sur le serveur (ici nous n’abordons pas la question de la persistance des données bien entendu…). Ainsi nul besoin de mettre en place un système de conteneur comme Docker dans votre workflow pour compiler et déployer !

Tester

Pour tester votre application localement, compilez votre binaire Go et lancez-le avec les bonnes variables d’environnement de cette façon :

TEMPLATES_DIR=/local/path/to/templates/dir \
STATIC_ASSETS_DIR=/local/path/to/static/dir \
TLS_CERT_PATH=/local/path/to/cert.pem \
TLS_KEY_PATH=/local/path/to/privkey.pem \
./my_go_website

Et voilà ! Votre site tourne désormais dans votre navigateur à l’adresse https://127.0.0.1.

Déployer

Le déploiement consiste juste à copier votre binaire Go sur le serveur (ainsi que vos templates, assets statiques et certificats, le cas échéant). Un simple outil tel que scp est parfait pour cela. Vous pouvez aussi utiliser rsync en cas de besoin plus poussé.

Transformer votre app en démon avec systemd

Vous pourriez lancer votre site web sur le serveur en vous contentant de la commande ci-dessus, mais il est bien mieux de lancer votre site sous forme de service (démon) afin que votre système Linux le lance automatiquement au démarrage (en cas de redémarrage serveur) et essaie aussi de le redémarrer en cas de crash de l’application. Sur les distributions Linux modernes, la meilleure façon de s’y prendre est d’utiliser systemd, qui est l’outil par défaut dédié à la gestion des services système. Rien à installer donc !

Supposons que vous mettiez votre binaire Go dans /var/www sur votre serveur. Créez un nouveau fichier qui décrira votre service dans le répertoire systemd : /etc/systemd/system/my_go_website.service. Mettez maintenant le contenu suivant à l’intérieur :

[Unit]
Description=my_go_website
After=network.target auditd.service

[Service]
EnvironmentFile=/var/www/env
ExecStart=/var/www/my_go_website
ExecReload=/var/www/my_go_website
KillMode=process
Restart=always
RestartPreventExitStatus=255
Type=simple

[Install]
WantedBy=multi-user.target

La directive EnvironmentFile pointe vers un fichier env dans lequel vous pouvez mettre toutes vos variables d’environnement. systemd s’occuper de charger ces variables et de les passer à votre programme. J’ai placé le fichier dans /var/www mais n’hésitez pas à le placer autre part. Voici à quoi votre fichier env devrait ressembler :

TEMPLATES_DIR=/remote/path/to/templates/dir
STATIC_ASSETS_DIR=/remote/path/to/static/dir
TLS_CERT_PATH=/remote/path/to/cert.pem
TLS_KEY_PATH=/remote/path/to/privkey.pem

N’hésitez pas à vous documenter sur systemd pour plus de détails sur la configuration ci-dessus.

À présent :

  • lancez ce qui suit pour lier votre app à systemd : systemctl enable my_go_website
  • lancez ce qui suit pour démarrer votre site : systemctl start my_go_website
  • redémarrez avec : systemctl restart my_go_website
  • arrêtez avec : systemctl stop my_go_website

Remplacer Javascript par WebAssembly (Wasm)

Voici une petite section bonus pour ceux qui se sentent l’âme d’un aventurier !

Depuis la version 1.11 de Go vous pouvez désormais compiler Go vers WebAssembly (Wasm). Plus de détails ici. C’est vraiment cool puisque Wasm peut servir de substitut à JavaScript. En d’autres mots vous pouvez théoriquement remplacer JavaScript par Go à travers Wasm.

Wasm est supporté par les navigateurs modernes mais reste malgré tout relativement expérimental. Personnellement, je ne l’utiliserais que pour une preuve de concept pour le moment, mais à moyen terme cela pourrait devenir une excellente façon de développer en Go sur l’intégralité de votre stack. Patience donc !

Conclusion

Vous savez désormais comment développer un site web entier en Go, et le déployer sur un serveur Linux. Pas de serveur front-end à installer, pas de cauchemar à cause des dépendances, et des performances excellentes… C’était plutôt facile pas vrai ?

Si vous voulez apprendre à développer une Single Page App (SPA) avec Go et Vue.js, jetez un coup d’œil à mon autre post sur le sujet ici.

Also available in English

Rate Limiting d'API avec Traefik, Docker, Go, et la mise en cache

Limiter l'utilisation de l'API en fonction d'une règle avancée de limitation du débit n'est pas si facile. Pour y parvenir derrière l'API NLP Cloud, nous utilisons une combinaison de Docker, Traefik (comme proxy inverse) et la mise en cache locale dans un script Go. Si cela est fait correctement, vous pouvez améliorer considérablement les performances de votre limitation de débit et étrangler correctement les demandes d'API sans sacrifier la vitesse des demandes. Continuer de lire