Le dev dans les nuages

blog-le-deploiement-avec-docker-img

Blog : le déploiement avec docker


Introduction

deployment components Troisième article sur ce blog et seconde étape abordée un peu plus en détail : le déploiement avec Docker 😁.

Docker est la techno de conteneurisation la plus populaire sur le marché. Si je devais résumer docker en une phrase je dirai :

Docker permet de découpler l'application du système.

docker containerization La conteneurisation permet de builder une application et ses dépendances (runtime, system tools, system libraries et settings) en les packagant dans ce qu'on appelle une image, puis de déployer cette dernière facilement dans n'importe quel docker engine (host docker, cluster Kubernetes, AWS ECS, AWS EKS...). L'un des principal avantage de la conteneurisation est donc la portabilité. D'autre part, contrairement à la virtualisation, le système d'exploitation est partagé entre les containers, ce qui apporte deux avantages : la légèreté (lightweight) et la rapidité de déploiement.

Avec Docker, on part d'une image de base avec l'instruction FROM (par exemple alpine ou nginx) puis on ajoute des étapes jusqu'à avoir une application fonctionnelle. Une image Docker est donc composée d'une succession de couches (layers).

docker architecture Les quelques notions docker à connaitre :

  • docker host : le docker host contient l'environnement d'exécution (runtime) des containers, le daemon et le store local pour les images
  • client : le client permet d'interagir avec le daemon docker via l'API
  • container : le container est exécuté (run) dans un docker host à partir d'une image
  • dockerfile : le dockerfile est le fichier de description du build de l'image
  • image : l'image correspond à l'application buildée à partir du dockerfile et packagée
  • registry : le registry permet de stocker les images. Le registry peut être publique (Docker Hub) ou privé (GitLab container registry, ECR)

Le serveur web Nginx, le certificat SSL et le volume persistant

Qui dit site web, dit https, dit certificat. Étant parti sur AWS comme hébergeur cloud je me suis intéressé à AWS Certificate Manager. Le problème est que le certificat généré par ACM ne peut pas être exporté (donc importé dans l'intance EC2), il ne peut être utilisé que sur les load balancers AWS ou CloudFront.

J'ai donc cherché à passer par une authorité de certification tierce, et c'est là que je suis tombé sur Let's Encrypt qui fournit des certificats SSL/TLS gratuits (Let's Encrypt).

L'outil Certbot permet de générer le certificat qu'il faudra ensuite stocker à l'extérieur du container dans un volume persistant (comme détaillé dans l'article sur le hosting AWS). Le plugin python3-certbot-nginx gère la modification du fichier de configuration nginx et le redémarrage du service nginx. Le certificat étant valable 90 jours, il va falloir mettre en place un cron pour renouveller le certificat régulièrement. Le protocole ACME est utilisé par certbot pour démontrer qu'on a le contrôle sur le domaine. Ce protocole met en place le challenge http-01 (Challenge http-01) qui nécessite de créer une route (/.well-known/acme-challenge/). Ce protocol ne fonctionne que sur le port 80.

On a donc besoin d'un fichier de configuration nginx pour la génération initiale du certificat et d'un fichier de configuration nginx ensuite pour le https (on ne peut pas utiliser le https tant qu'on n'a pas généré une première fois le certificat, sinon erreur nginx).

Configuration pour l'initialisation

  1. Créer un volume "certs", démarrer un container avec l'image nginx et le volume monté dessus, puis ouvrir un bash en interactif :
docker volume create certs
docker run --name certinit --rm -d -p 80:80 -p 443:443 -v certs:/etc/letsencrypt nginx:latest
docker exec -it certinit bash
  1. Installer certbot, python3-certbot-nginx et cron :
apt-get install certbot python3-certbot-nginx cron -y
  1. Définir le fichier de configuration nginx pour créer un serveur http sur le port 80, démarrer nginx en mode daemon et générer le certificat avec certbot :
cat > /etc/nginx/conf.d/default.conf << EOF
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    root /usr/share/nginx/html/;
    server_name ledevdanslesnuages.com;
}
EOF
nginx -g 'daemon on;'
certbot --nginx --email mymail.address@domain.com -d ledevdanslesnuages.com --agree-tos --no-eff-email

Sortir du bash avec CTRL+D, le container sera alors supprimé. Le certificat est désormais présent dans le volume certs.

Configuration pour le renouvellement

  • Cron pour le renouvellement automatique du certificat :
0 */12 * * * root /usr/bin/certbot renew --post-hook "nginx -s reload"

Le job va s'éxécuter toutes les 12 heures comme l'indique la recommandation (Upstream recommends attempting renewal twice a day), mais le renouvellement aura lieu uniquement si l'expiration est dans moins de 30 jours (Renewal will only occur if expiration is within 30 days). On a donc un renouvellement effectif tous les deux mois. A noter qu'on injecte un post hook qui va redémarrer nginx àprès le renouvellement.

  • Fichier de configuration nginx :
server {
    server_name ledevdanslesnuages.com;
    root /usr/share/nginx/html/;

    location / {
        try_files $uri $uri.html $uri/ =404;
    }
    
    # custom 404 page
    error_page 404 /404.html;
    location = /404.html {
        internal;
    }

    listen [::]:443 ssl ipv6only=on; # managed by Certbot
    listen 443 ssl; # managed by Certbot
    ssl_certificate /etc/letsencrypt/live/ledevdanslesnuages.com/fullchain.pem; # managed by Certbot
    ssl_certificate_key /etc/letsencrypt/live/ledevdanslesnuages.com/privkey.pem; # managed by Certbot
    include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot

}
server {
    if ($host = ledevdanslesnuages.com) {
        return 301 https://$host$request_uri;
    } # managed by Certbot


    listen 80 default_server;
    listen [::]:80 default_server;
    server_name ledevdanslesnuages.com;
    return 404; # managed by Certbot
}

On voit :

  • que le http est redirigé vers le https
  • que le chemin pour servir les fichiers statiques est /usr/share/nginx/html/
  • que les routes https correspondent à l'arborescence locale /usr/share/nginx/html/*, et que si une route n'est pas trouvée on affiche la page custom 404
  • que la configuration SSL est appliquée par certbot pour faire pointer vers le certificat actif

Le Dockerfile

# app build stage
FROM node:alpine AS app-builder
WORKDIR /app
COPY ./package.json ./
COPY ./package-lock.json ./
RUN npm ci
COPY . ./
RUN npm run build

# image build stage
FROM nginx:stable
COPY --from=app-builder /app/out /usr/share/nginx/html
RUN apt-get update \
    && apt-get install certbot python3-certbot-nginx cron -y \
    && mkdir /scripts
WORKDIR /
COPY ./data/cron-cert-renew /scripts
COPY ./data/init-letsencrypt.sh /scripts
COPY ./data/default.conf /etc/nginx/conf.d/default.conf
RUN chmod +x  /scripts/init-letsencrypt.sh \
    && crontab /scripts/cron-cert-renew
HEALTHCHECK --interval=5m --timeout=5s CMD curl -f https://ledevdanslesnuages.com/ || exit 1
CMD [ "sh", "-c", "cron && nginx -g 'daemon off;'" ]

On constate deux instructions FROM, c'est ce qu'on appelle un multi-stage build (chaque stage utilise une base différente). Cela permet de copier des fichiers de l'un à l'autre en ayant une image finale qui ne contient que ce dont on a besoin. On a donc un stage pour le build de l'application, et un autre pour le build de l'image :

  • stage pour le build de l'application :
    • on utilise la base "alpine" qui une distribution Linux ultra-légère
    • on nomme le stage "app-builder" avec l'instruction "AS", ce qui permettra d'y faire référence dans l'autre stage
    • on installe des modules avec "npm ci" qui permet de s'assurer que les dépendances dans "package-lock.json" seront bien prises
    • une fois les modules nodejs installés, on vient copier le code et la data avec "COPY . ./"
    • enfin on build l'application avec "npm run build". Les fichiers statiques vont être déposés dans "/app/out"
  • stage pour le build de l'image :
    • on utilise la base "nginx"
    • on copie le contenu de "/app/out" dans "/usr/share/nginx/html" pour servir les fichiers avec nginx
    • on installe certbot, python3-certbot-nginx et cron
    • puis on copie le fichier de cron pour le renouvellement et le fichier de configuration nginx
    • ensuite on prend on compte le fichier cron pour le renouvellement
    • le healthcheck fait un curl sur "https://ledevdanslesnuages.com/" toutes les 5 minutes avec un timeout de 5s
    • enfin on démarre cron et on lance nginx en mode "daemon off", c'est à dire en premier plan (recommandé pour usage avec Docker)

Les commandes Docker

  • docker login
docker login -u $REGISTRY_USER -p $REGISTRY_PASSWORD $REGISTRY

Cette commande permet de se connecter à un registry en indiquant nom, utilisateur et mot de passe. Lors de la CI on va utiliser 2 registries : le GitLab Container Registry et AWS ECR

  • docker run
docker run $IMAGE_TEST npm run lint

Cette commande permet d'exécuter une commande dans le container, en faisant un pull de l'image si nécessaire et en démarrant le container. Ici on exécute la commande "npm run lint"

  • docker build
docker build -t $IMAGE_TEST .

Cette commande permet de construire une image docker à partir du fichier Dockerfile. On peut cible un stage du Dockerfile en ajoutant le paramètre "--target", par exemple "--target app-builder"

  • docker tag
docker tag IMAGE_DEPLOY $AWS_ECR_REGISTRY:latest

Cette commande permet d'appliquer un tag sur une image. Une image a le format suivant : [HOST[:PORT_NUMBER]/]PATH. Ici on vient appliquer le tag "latest"

  • docker push
docker push $IMAGE_TEST

Cette commande permet de pousser une image vers le registry

Le mot de la fin

Je pense que quiconque essaie Docker est rapidement convaincu par ses avantages. Docker est très pertinent dans le devops car transverse : utile aussi bien en environnement de développement, qu'en CI/CD, qu'en déploiement. D'autre part, sa portabilité le rend relativement agnostique quant au cloud provider et réduit ainsi le vendor lock-in.