Tutoriel pour faire une API REST avec Golang, MongoDB NoSQL et Nginx Proxy dans du Docker

Présentation du projet d’API REST avec un backend écrit en Golang, une base NoSQL Mongo DB avec un proxy Nginx dans du Docker Swarm

Je sais le titre est long, mais vous ne serez pas déçu ! Pour mettre en oeuvre cet exemple je vais mettre en oeuvre une stack Docker dans mon cluster Docker Swarm. Je vais contruire toutes les images des containers Docker de cet exemple d’application REST développée en Go.

comment développer une API avec Docker

Le fonctionnement de l’architecture suit ce schéma.

Création de l’image Docker MongoDB

Pour créer ce container Docker, je m’appuie sur la distribution Linux Alpine, qui permet d’obtenir des images de petite taille.

FROM alpine

COPY run.sh /root
RUN set -x && \
    chmod +x /root/run.sh && \
    apk update && \
    apk upgrade && \
    apk add --no-cache mongodb && \
    rm /usr/bin/mongoperf && \
    rm -rf /var/cache/apk/*

    VOLUME /data/db
    EXPOSE 27017 28017

    ENTRYPOINT [ "/root/run.sh" ]
    CMD ["mongod"]

Le script sh permet principalement de démarrer le serveur NoSQL Mongo DB en mode non-root.

#!/bin/sh
# Docker entrypoint (pid 1), run as root
[ "$1" = "mongod" ] || exec "$@" || exit $?

# Make sure that database is owned by user mongodb
[ "$(stat -c %U /data/db)" = mongodb ] || chown -R mongodb /data/db

# Drop root privilege (no way back), exec provided command as user mongodb
cmd=exec; for i; do cmd="$cmd '$i'"; done
exec su -s /bin/sh -c "$cmd" mongodb

Développement de l’API REST avec Golang et Gorilla Mux

Gorilla Mux est une librairie qui permet de créer des routes pour l’API Restful. Je vais créer 6 routes (voir à la fin du code source ci-dessous), une pour connaitre le status de mon application back-app et 5 pour mettre en oeuvre le CRUD (Create, ReadOne, ReadMany, Update, Delete). Chaque route est associée à une méthode HTTP et pointe vers une fonction, qui va agir sur la base de données MongoDB.

Mon application Golang, démarre et écoute sur le port 3000.

package main

import (
        "os"
        "encoding/json"
        "log"
        "net/http"

        "gopkg.in/mgo.v2/bson"
        "github.com/gorilla/mux"

        . "github.com/user/app/config"
        . "github.com/user/app/dao"
        . "github.com/user/app/models"
)

var config = Config{}
var dao = ContactsDAO{}

// GET list of contacts
func AllContacts(w http.ResponseWriter, r *http.Request) {
        contacts, err := dao.FindAll()
        if err != nil {
                respondWithError(w, http.StatusInternalServerError, err.Error())
                return
        }
        retResponse(w, http.StatusOK, contacts)
}

// GET a contact by its ID
func FindContactEndpoint(w http.ResponseWriter, r *http.Request) {
        params := mux.Vars(r)
        contact, err := dao.FindById(params["id"])
        if err != nil {
                respondWithError(w, http.StatusBadRequest, "Invalid Contact ID")
                return
        }
        retResponse(w, http.StatusOK, contact)
}

// POST a new contact
func CreateContact(w http.ResponseWriter, r *http.Request) {
        defer r.Body.Close()
        var contact Contact
        if err := json.NewDecoder(r.Body).Decode(&contact); err != nil {
                respondWithError(w, http.StatusBadRequest, "Invalid request payload")
                return
        }
        contact.ID = bson.NewObjectId()
        if err := dao.Insert(contact); err != nil {
                respondWithError(w, http.StatusInternalServerError, err.Error())
                return
        }
        retResponse(w, http.StatusCreated, contact)
}

// PUT update an existing contact
func UpdateContact(w http.ResponseWriter, r *http.Request) {
        defer r.Body.Close()
        var contact Contact
        if err := json.NewDecoder(r.Body).Decode(&contact); err != nil {
                respondWithError(w, http.StatusBadRequest, "Invalid request payload")
                return
        }
        if err := dao.Update(contact); err != nil {
                respondWithError(w, http.StatusInternalServerError, err.Error())
                return
        }
        retResponse(w, http.StatusOK, map[string]string{"result": "success"})
}

// DELETE an existing contact
func DeleteContact(w http.ResponseWriter, r *http.Request) {
        defer r.Body.Close()
        var contact Contact
        if err := json.NewDecoder(r.Body).Decode(&contact); err != nil {
                respondWithError(w, http.StatusBadRequest, "Invalid request payload")
                return
        }
        if err := dao.Delete(contact); err != nil {
                respondWithError(w, http.StatusInternalServerError, err.Error())
                return
        }
        retResponse(w, http.StatusOK, map[string]string{"result": "success"})
}

func StatusContact(w http.ResponseWriter, r *http.Request) {
        name, err := os.Hostname()
        if err != nil {
                panic(err)
        }
        retResponse(w, http.StatusOK, map[string]string{"server":name,"result": "success"})
}

func respondWithError(w http.ResponseWriter, code int, msg string) {
        retResponse(w, code, map[string]string{"error": msg})
}

func retResponse(w http.ResponseWriter, code int, payload interface{}) {
        response, _ := json.Marshal(payload)
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(code)
        w.Write(response)
}

// Parse the configuration file 'config.toml', and establish a connection to DB
func init() {
        config.Read()

        dao.Server = config.Server
        dao.Database = config.Database
        dao.Connect()
}

// Define HTTP request routes
func main() {
        r := mux.NewRouter()
        r.HandleFunc("/app-back-status", StatusContact)
        r.HandleFunc("/contacts", AllContacts).Methods("GET")
        r.HandleFunc("/contacts", CreateContact).Methods("POST")
        r.HandleFunc("/contacts", UpdateContact).Methods("PUT")
        r.HandleFunc("/contacts", DeleteContact).Methods("DELETE")
        r.HandleFunc("/contacts/{id}", FindContactEndpoint).Methods("GET")
        if err := http.ListenAndServe(":3000", r); err != nil {
                log.Fatal(err)
        }
}

Construction de l’image Docker de mon application Golang

Pour les détails sur la compréhension de ce fichier Dockerfile, veuillez consulter mon article précédent Comment utiliser Docker multi-stage build avec Golang.

FROM golang as builder
WORKDIR /go/src/github.com/user/app
COPY . .
RUN set -x && \
    go get -d -v . && \
    CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .

FROM scratch
WORKDIR /root/
COPY --from=builder /go/src/github.com/user/app .
CMD ["./app"]

Configuration du Proxy Nginx avec génération d’un certificat auto-signé

Attention à ce stade, l’ensemble des fichers certificats SSL est incorporé dans l’image Docker !

FROM alpine:edge
RUN     set -x \
        && apk update \
        && apk upgrade \
        && apk add --no-cache nginx inotify-tools openssl
RUN     openssl req     -x509 -nodes \
                        -days 365 \
                        -newkey rsa:2048 \
                        -keyout server.key \
                        -out server.crt  \
                        -subj "/C=FR/ST=Aquitaine/L=Bordeaux/O=MeMyselfAndI/OU=IT Department/CN=webapp.local" \
        && openssl dhparam -out dhparam.pem 2048 \
        && mkdir /etc/certs \
        && mv server.* /etc/certs \
        && mv dhparam.pem /etc/certs \
        && apk del openssl \
        && rm -rf /var/cache/apk/*
COPY nginx.conf /etc/nginx/nginx.conf
COPY reload.sh /
RUN chmod +x reload.sh
RUN mkdir -p /run/nginx
EXPOSE 80 443
CMD ["/reload.sh"]

Au cours de la génération de l’image Docker Nginx, la commande openssl est exécutée pour créer le certificat SSL.

Dans le ficher Dockerfile, inotify surveille si le certificat SSL est mis à jour.

#!/bin/sh

nginx -g "daemon off;" &

while true
do
  inotifywait -e create -e modify /etc/certs /etc/nginx/conf.d/
  nginx -t
    if [ $? -eq 0 ]
      then
        echo "Reloading Nginx Configuration"
        nginx -s reload
      fi
done

3 choses sont à noter dans le fichier de configuration Nginx :

  • toutes les requêtes http sont redirigées vers https
  • la ligne nginx-status renvoie une réponse au format json pour indiquer si le proxy Nginx est disponible
  • la ligne qui renvoie les requêtes vers l’application app-back sur le port 3000

J’ai activé le protocole http2, mais pour une raison que je ne connais pas, l’équipe Alpine Linux n’a pas activé cette option dans le package Nginx.

server {
    listen 80;
    listen [::]:80;
    location / {
        if ($scheme = http) {
            return 301 https://$host$request_uri;
        }
    }
}
server {
    listen 443 http2 ssl default_server;
    listen [::]:443 http2 ssl default_server;
    server_name nginx;
    ssl_protocols TLSv1.2;
    ssl_ciphers EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
    ssl_prefer_server_ciphers On;
    ssl_certificate             /etc/certs/server.crt;
    ssl_certificate_key         /etc/certs/server.key;
    ssl_dhparam /etc/certs/dhparam.pem;
    ssl_session_cache shared:SSL:128m;
    add_header Strict-Transport-Security "max-age=31557601; includeSubDomains";
    ssl_stapling on;
    ssl_stapling_verify on;
    # Your favorite resolver may be used instead of the Google one below
    resolver 8.8.4.4 8.8.8.8 valid=300s;
    resolver_timeout 10s;
    root /var/www;
    index index.html;
    location /nginx-status {
        default_type application/json;
        return 200 '{"status":"200", "message": "Healthcheck OK"}';
    }
    location ~^/(contacts|app-back-status) {
        proxy_pass       http://app-back:3000;
        proxy_set_header Host      $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

Déploiement des services Docker

Création de la stack Docker

Voici le fichier de composition docker-stack.yml qui va me permettre de déployer nos services Docker, il est composé de 3 services :

  • app-back l’application Golang qui propose l’interface REST vers la base MongoDB
  • nginx le proxy
  • mongodb la base NoSQL

Je crée 2 networks :

  • mongo-go : pour les échanges entre mongodb et mon application REST Golang
  • nginx-go : pour les échanges entre nginx et mon application RESTful Go
version: "3"
services:
  app-back:
    image: itwars/mygo
    networks:
      - mongo-go
      - nginx-go
    ports:
      - 3000:3000
    depends_on:
      - mongodb
    deploy:
      replicas: 1
      update_config:
        parallelism: 2
        delay: 10s
      restart_policy:
        condition: on-failure
  nginx:
    image: itwars/nginx-http2
    volumes:
            - /home/vrh/docker/myrepo/stack-mongo-golang-vuejs-nginx/nginx-http2/conf/:/etc/nginx/conf.d/
    ports:
            - 80:80
            - 443:443
    networks:
            - nginx-go
    depends_on:
      - app-back
    deploy:
      replicas: 1
      update_config:
        parallelism: 2
        delay: 10s
      restart_policy:
        condition: on-failure
  mongodb:
    image: itwars/mongodb
    volumes:
            - mongodb-data:/data/db
    networks:
      - mongo-go
    ports:
            - 27017:27017
            - 28017:28017
    deploy:
      replicas: 1
      update_config:
        parallelism: 2
        delay: 10s
      restart_policy:
        condition: on-failure
networks:
  mongo-go:
  nginx-go:
volumes:
  mongodb-data:

Deploiement de la stack

Pour démarrer la stack de services Docker dans un cluster Docker Swarm, j’utilise la commande suivante :

docker stack deploy -c docker-stack.yml myapp

Attention: si vous démarrer cette stack Docker dans un cluster Docker Swarm, il faut au préalable que les images Docker aient été pushées sur Docker Hub.

curl -k -H "Content-Type: application/json" https://192.168.1.24/app-back-status 2>/dev/null | jq

Voici la réponse de l’application Golang :

{
    "result": "success",
    "server": "50f0ead2e935"
}

Pour ajouter un nouvel enregistrement dans la base de données MongoDB :

curl -k -d '{"nom":"RABAH", "prenom":"Vincent", "telephone":"0000000"}' -H "Content-Type: application/json" -X POST https://192.168.1.24/contacts

La réponse du serveur :

{
    "id":"59e65c15e34ad00001c19cf7",
    "prenom":"Vincent",
    "nom":"RABAH",
    "telephone":"0000000"
}

Scalabilité du cluster Docker Swarm

Maintenant, je vais scaler le service app-back et vérifier que mes requêtes sont bien distribuées dans le cluster Docker Swarm

docker service scale test_app-back=3

test_app-back scaled to 3

Maintenant si j’exécute la commande suivante 4 fois, on constate que ma requête est bien distribuée sur les 3 instances de mon application Golang :

curl -k -H "Content-Type: application/json" https://192.168.1.24/app-back-status 2>/dev/null | jq

Le résultat de 4 requêtes :

{
  "result": "success",
  "server": "0c07a393b077"
}
{
  "result": "success",
  "server": "c01f9d78eb68"
}
{
  "result": "success",
  "server": "50f0ead2e935"
}
{
  "result": "success",
  "server": "0c07a393b077"
}

Conclusion provisoire

J’espère que ce tuto, vous permettra d’avancer comme moi. Personnellement, il contribue à me faire progresser un peu en Golang et à tout “Dockeriser”.

N’hésitez pas à me laisser un commentaire et à ajouter une étoile sur mon repo GitHub. Merci.