Déployer une application Web C++ sur Heroku avec Docker et Nix

Posté par  (site web personnel) . Édité par ZeroHeure, Davy Defaud, Nÿco et palm123. Modéré par ZeroHeure. Licence CC By‑SA.
Étiquettes :
30
15
nov.
2018
Communauté

Les services de plate‐forme (PaaS) comme Heroku permettent de déployer des applications Web écrites dans des langages comme PHP, Ruby, Java… Cependant, déployer des applications C++ est plus compliqué (portabilité de l’interface binaire ABI, gestion des dépendances, etc.). Cette article présente plusieurs solutions pour déployer des applications Web C++ sur Heroku, en utilisant des images Docker et le gestionnaire de paquet Nix.

Sommaire

Exemple d’application Web C++ avec le cadriciel Wt

Wt est un cadriciel Web basé widget. Il permet de définir les composants de l’interface et leurs interactions, de façon similaire aux API d’interfaces graphiques de bureau comme Qt ou gtkmm. Wt produit des applications Web client‐serveur, mais ceci est transparent pour le développeur. Pour illustrer cet article, prenons une application simple qui répète le texte entré par l’utilisateur :

Appli

Cette application peut être implémentée avec le code suivant (myrepeat.cpp) :

#include <Wt/WApplication.h>
#include <Wt/WBreak.h>
#include <Wt/WContainerWidget.h>
#include <Wt/WLineEdit.h>
#include <Wt/WText.h>

using namespace std;
using namespace Wt;

// définit une application Web
struct App : WApplication {
  App(const WEnvironment& env) : WApplication(env) {

    // ajoute des widgets
    auto myEdit = root()->addWidget(make_unique<WLineEdit>());
    root()->addWidget(make_unique<WBreak>());
    auto myText = root()->addWidget(make_unique<WText>());

    // connecte les widgets aux fonctions de rappel
    auto editFunc = [=]{ myText->setText(myEdit->text()); };
    myEdit->textInput().connect(editFunc);
  }
};

// lance l’application Web
int main(int argc, char **argv) {
  auto mkApp = [](const WEnvironment& env) { return make_unique<App>(env); };
  return WRun(argc, argv, mkApp);
}

Ce code peut être compilé et exécuté localement, avec les commandes suivantes :

g++ -O2 -o myrepeat myrepeat.cpp -lwthttp -lwt
./myrepeat --docroot . --http-address 0.0.0.0 --http-port 3000

Cependant, on ne peut pas déployer directement le binaire généré sur un service comme Heroku, car le système distant peut être différent du système local. Une solution classique consiste à construire une image Docker contenant un système autonome. C’est ce que font les quatre solutions présentées ci‐dessous.

Solution 1 : Dockerfile simple

Un Dockerfile permet de définir un système complet. On part d’une image de base, ici une Debian 9, on installe les dépendances et l’on construit notre application à partir de son code source. Ici, on installe Wt manuellement car Debian fournit la version 3 et l’on a besoin de la version 4.

# configure l'image de base
FROM debian:stretch-slim
RUN apt-get update
RUN apt-get install -y --no-install-recommends \
    ca-cacert \
    cmake \
    build-essential \
    libboost-all-dev \
    libssl-dev \
    wget \
    zlib1g-dev

# installe Wt4
WORKDIR /root
RUN wget https://github.com/emweb/wt/archive/4.0.4.tar.gz
RUN tar zxf 4.0.4.tar.gz
WORKDIR /root/wt-4.0.4/build
RUN cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTS=OFF -DBUILD_EXAMPLES=OFF ..
RUN make -j2 install
RUN ldconfig

# compile notre application puis configure la commande de lancement
WORKDIR /root/myrepeat
ADD . /root/myrepeat
RUN g++ -O2 -o myrepeat myrepeat.cpp -lwthttp -lwt
CMD /root/myrepeat/myrepeat --docroot . --http-address 0.0.0.0 --http-port $PORT

On note la variable d’environnement PORT dans la commande de lancement, qui sera définie par Heroku lors du déploiement. On peut ensuite construire et lancer localement l’image :

docker build -t myrepeat:v1 .
docker run --rm -it -e PORT=3000 -p 3000:3000 myrepeat:v1

L’application est alors accessible à partir d’un navigateur Web, à l’adresse http://localhost:3000.

L’interface console d’Heroku permet de déployer des images Docker très facilement. Ceci nécessite, bien évidemment, un compte sur Heroku (voir Heroku for free). Par exemple, pour déployer une image Docker dans une application myrepeat, à partir du Dockerfile précédent :

heroku container:login
heroku create myrepeat
heroku container:push web --app myrepeat
heroku container:release web --app myrepeat

L’application déployée est alors accessible à l’adresse http://myrepeat.herokuapp.com/. Cependant, l’image Docker générée est lourde (876 Mio) car elle contient tous les paquets de développement et les produits de compilation de Wt.

Solution 2 : Dockerfile multi‐stage

Pour réduire la taille de l’image Docker, on peut compiler notre application dans un système dédié puis récupérer, dans le système final, le binaire généré et ses dépendances.

# configure une image pour construire notre application
FROM debian:stretch-slim as builder
RUN apt-get update
RUN apt-get install -y --no-install-recommends \
    ca-cacert \
    cmake \
    build-essential \
    libboost-all-dev \
    libssl-dev \
    wget \
    zlib1g-dev

# installe Wt4
WORKDIR /root
RUN wget https://github.com/emweb/wt/archive/4.0.4.tar.gz
RUN tar zxf 4.0.4.tar.gz
WORKDIR /root/wt-4.0.4/build
RUN cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTS=OFF -DBUILD_EXAMPLES=OFF -DSHARED_LIBS=OFF ..
RUN make -j2 install

# construit notre application, avec liaison statique 
WORKDIR /root/myrepeat
ADD . /root/myrepeat
RUN g++ -static -O2 -o myrepeat myrepeat.cpp -pthread -lwthttp -lwt \
        -lboost_system -lboost_thread -lboost_filesystem -lboost_program_options \
        -lz -lssl -lcrypto -ldl

# crée l'image finale, contenant notre application
FROM debian:stretch-slim
RUN apt-get update
WORKDIR /root
COPY --from=builder /root/myrepeat/myrepeat /root/
CMD /root/myrepeat --docroot . --http-address 0.0.0.0 --http-port $PORT

On peut construire, exécuter et déployer une image de la même façon que précédemment mais l’image obtenue est beaucoup plus légère (83 Mio).

Solution 3 : configuration Nix simple

Avec Nix, il est très facile de configurer un projet. Pour cela, on définit une dérivation, dans un fichier default.nix :

{ pkgs ? import <nixpkgs> {}, wt ? pkgs.wt }: 

pkgs.stdenv.mkDerivation {
  name = "myrepeat";
  src = ./.;
  buildInputs = [ wt ];
  buildPhase = "g++ -O2 -o myrepeat myrepeat.cpp -lwthttp -lwt";
  installPhase = ''
    mkdir -p $out/bin
    cp myrepeat $out/bin/
  '';
}

On peut alors construire notre application avec la commande nix-build puis exécuter le binaire obtenu :

nix-build
./result/bin/myrepeat --docroot . --http-address 0.0.0.0 --http-port 3000

Nix peut également construire des images Docker. Ceci est documenté dans le manuel Nix et dans le wiki Nix. À la place du Dockerfile, on écrit un fichier Nix (par exemple docker.nix), qui décrit l’image Docker à construire :

{ pkgs ? import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/18.09.tar.gz") {} }:

let

  # importe la configuration de notre application
  myapp = import ./default.nix { inherit pkgs; };

  # script pour lancer notre application, dans l'image Docker
  entrypoint = pkgs.writeScript "entrypoint.sh" ''
    #!${pkgs.stdenv.shell}
    $@ --docroot . --http-address 0.0.0.0 --http-port $PORT
  '';

in

# construit l'image Docker, avec notre application
pkgs.dockerTools.buildImage {
  name = "myrepeat";
  tag = "v3";
  config = {
    Entrypoint = [ entrypoint ];
    Cmd = [ "${myapp}/bin/myrepeat" ];
  };
}

À partir de ce fichier docker.nix, on peut construire une image Docker et la charger dans le registre Docker local :

nix-build docker.nix && docker load < result

On peut alors exécuter l’image Docker localement comme avec les solutions précédentes. Pour le déploiement, on définit une étiquette vers le registre Docker d’Heroku et on y charge notre image :

heroku container:login
heroku create myrepeat
docker tag myrepeat:v3 registry.heroku.com/myrepeat/web
docker push registry.heroku.com/myrepeat/web
heroku container:release web --app myrepeat

L’image Docker obtenue est assez lourde (579 Mio) car elle est construite à partir des paquets Nix standards, qui sont génériques.

Solution 4 : configuration Nix optimisée

Pour réduire la taille de l’image Docker générée, on peut adapter les options des paquets Nix à notre application. Pour cela, on peut redéfinir les options des dérivations ou écrire nos propres dérivations. Par exemple, on peut réécrire la dérivation Wt de la façon suivante (fichier wt.nix) :

{ stdenv, fetchFromGitHub, cmake, boost, openssl, zlib }:

stdenv.mkDerivation {

  name = "wt";

  src = fetchFromGitHub {
    owner = "emweb";
    repo = "wt";
    rev = "4.0.4";
    sha256 = "17kq9fxc0xqx7q7kyryiph3mg0d3hnd3jw0rl55zvzfsdd71220w";
  };

  enableParallelBuilding = true;

  buildInputs = [ cmake boost openssl zlib ];

  cmakeFlags = [ "-DCMAKE_BUILD_TYPE=Release" "-DBUILD_TESTS=OFF" "-DBUILD_EXAMPLES=OFF" ];
}

On modifie ensuite le fichier docker.nix de façon à prendre en compte notre version de Wt :

{ pkgs ? import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/18.09.tar.gz") {} }:

let

  # importe un paquet de Wt optimisé pour notre application
  mywt = pkgs.callPackage ./wt.nix {};

  # importe la configuration de notre application, en utilisant notre version de Wt
  myapp = import ./default.nix { inherit pkgs; wt = mywt; };

  entrypoint = pkgs.writeScript "entrypoint.sh" ''
    #!${pkgs.stdenv.shell}
    $@ --docroot . --http-address 0.0.0.0 --http-port $PORT
  '';

in

  pkgs.dockerTools.buildImage {
    name = "myrepeat";
    tag = "v4";
    config = {
      Entrypoint = [ entrypoint ];
      Cmd = [ "${myapp}/bin/myrepeat" ];
    };
  }

On peut alors construire et déployer une image Docker de la même façon qu’avec la solution précédente. L’image Docker générée ici fait 105 Mio.

Conclusion

Sans être aussi riche que Node.js ou PHP, C++ possède également des cadriciels Web intéressants. Notamment Wt, qui permet de développer des applications client‐serveur avec une API très proche des cadriciels d’interface de bureau, comme Qt et gtkmm.

Si les PaaS comme Heroku permettent facilement de déployer des applications dans les « langages Web classiques », il est également souvent possible de déployer des images Docker, et donc des applications C++.

Les fichiers Dockerfile permettent de construire des images Docker relativement facilement. Cependant, construire une image optimisée demande un peu plus de travail (image multi‐stage, compilation statique…), notamment pour éviter d’inclure un inutile environnement de compilation dans l’image à déployer.

Enfin, le gestionnaire de paquets Nix permet également de construire des images Docker, avec les Docker Tools. Ces outils s’intègrent au système de gestion de paquets de Nix, ce qui permet de profiter de ses avantages (fichiers Nix, composition, reproductibilité, isolation…).

 Bémol

Cependant, n’oubliez pas que Heroku n’exécute pas d’images Docker. Les layers sont extraits et ça tourne sous LXC. Il y a quelques incidences à prendre en compte :

Expose

De base, en Docker, une image expose un ou plusieurs ports, et ça permet de savoir quoi mettre en correspondance et qui écoute. Pour Heroku, ça ne fonctionne pas, il faut passer $PORT :

CMD /root/myrepeat --docroot . --http-address 0.0.0.0 --http-port $PORT

Point d’entrée

Le point d’entrée (entrypoint) des images est surchargé par /bin/sh -c s’il n’est pas défini. Par exemple, si l’on utilise distroless pour faire une image Go, le point d’entrée est null et la commande est le binaire Go. Et ça fonctionne bien sous Docker. Mais sous Heroku, c’est /bin/sh -c <binaire> qui est executé.

En mettant le binaire dans le point d’entrée et la commande à "", ça fonctionne.

HealthChecks

Les bilans de santé (healthchecks) ne sont pas pris en charge, le Dyno manager fait automatiquement ses propres checks.

Pour avoir une idée des autres limites, voir Unsupported Dockerfile commands.


N. D. M. : Ce bémol n’est pas de l’auteur, mais de CrEv.

Aller plus loin

  • # Wt <3

    Posté par  (site web personnel) . Évalué à 5.

    J'ai beaucoup aimé cet article, merci ça apprend pleins de choses, je dois bien avouer que Docker et Nix m’intéresse (sans pour autant me faire franchir le pas de l'utilisation pour l'instant) mais Wt … !

    Je l'ai utilisé il y a quelques années et j'avais été très surpris de son fonctionnement, à l'époque je n'avais jamais fais que de l'HTML/JS/CSS. Il a un fonctionnement très proche de vu.js pour la gestion de son interface, avec de la régénération à la volée. Et côté serveur on retrouve des logiques proches de celles de Laravel (par exemple, il y en a d'autre mais moi et le web…) le tout mâtiné d'une sacrée ressemblance avec Qt.

    Le plus que je trouve par rapport à Laravel par exemple c'est vraiment sa simplicité, le fait que je connaisse maintenant assez bien le langage utilisé, sa faible empreinte mémoire, son coût en ressource.
    J'aimerai bien le voir plus utilisé, y a des acharnés de Wt ici ? Je suis preneur de conseils, vous l'utilisez comment vous ?

    • [^] # Re: Wt <3

      Posté par  (site web personnel) . Évalué à 3.

      Wt est effectivement intéressant. En plus du système de widgets, il y a du templating, de la gestion de styles, des bases de données, des widgets évolués (charts, webgl… https://www.webtoolkit.eu/widgets), etc. Le tout est plutôt simple à utiliser et en C++ plutôt moderne. Cela dit, Wt n'est pas forcément adapté à toutes les applications web et sa licence est en GPL+commercial (à la Qt).
      Quand j'aurai le temps, j'essaierai de faire des posts sur une appli Wt plus complète et sur une comparaison avec une appli webassembly + serveur léger.

      • [^] # Re: Wt <3

        Posté par  (site web personnel) . Évalué à 1. Dernière modification le 19 novembre 2018 à 02:24.

        C'est cool j'ai hâte de voir à quoi peut ressembler une vrai appli utilisant Wt, à part la mienne et des exemples j'ai pas eu l'occasion d'en voir beaucoup ! Et +1 pour pour le comparatif. J'adore les comparatifs.

    • [^] # Re: Wt <3

      Posté par  (site web personnel) . Évalué à 6. Dernière modification le 19 novembre 2018 à 09:39.

      J'aime bien Wt, que nous utilisons au boulot pour fournir tout un tas d'applications clientes au dessus de notre grosse base de code C++. Nous avons par exemple écrit un petit logiciel de gestion de droits utilisateurs, un logiciel d'analyse de trafic réseau (façon Wireshark, mais pour des messages métier), et plusieurs applications plutôt orientées formulaire, mais avec des interfaces riches et réactives.

      L'avantage principal que je trouve à Wt est de pouvoir s'interfacer directement avec notre base de code, et donc de pouvoir réutiliser au maximum nos algorithmes et nos structures.

      C'est également un cadriciel moderne, avec une bonne gestion de la mémoire (utilisation judicieuse des unique_ptr), et une simplicité biblique pour écrire des applications réactives très performantes, avec des dizaines de mise à jour à la seconde sur une page sans difficulté, laissant au cadriciel le soin de choisir Ajax, WebSockets ou autre.

      Un inconvénient, cependant, est la difficulté à obtenir la mise en page souhaitée. Je me suis arraché les cheveux pour, par exemple, obtenir qu'une table affiche correctement sa barre de défilement quand elle est dans un onglet. Toute seule sur la page, ça marche impeccable, mais dans l'onglet, il faut sacrifier à Cthulhu. Et puisqu'on en parle, cette semaine, je dois corriger un autre souci de mise en page qui m'a écrasé tous mes panneaux au lieu de les garder à taille normale et de me donner une barre de défilement. Il faut s'armer de courage, jouer avec les outils développeur du navigateur, et trouver si un changement CSS ou un forçage de hauteur d'élément va déclencher un bout de Javascript magique qui va tout corriger.

  • # From scratch ?

    Posté par  . Évalué à 2.

    Est-ce qu'il ne serait pas aussi possible de compiler en statique et de démarrer l'image from scratch ?

    J'ai souvenir d'avoir utilisé cette technique pour un projet en Rust, ça faisait des images de la taille du binaire et ressources, ce qui était assez plaisant. Je ne sais pas si c'est Heroku-proof par contre.

    • [^] # Re: From scratch ?

      Posté par  . Évalué à 1.

      Pour répondre partiellement, l'image buildée par Nix n'a pas d'image parente. Il est cependant possible d'en ajouter une si besoin.

    • [^] # Re: From scratch ?

      Posté par  (site web personnel) . Évalué à 6.

      Je ne pense pas qu'il y ait de problème à faire tourner des images from scratch sur heroku, du moment qu'on respecte leurs contraintes.
      Je ne l'ai pas fait, mais j'ai fait tourner du distroless qui est assez proche.

      C'est proche de scratch dans le sens où ce n'est pas une distribution, il n'y a pas de gestionnaire de paquet, pas de runtime, pas de shell.

      C'est mieux que scratch dans le sens où ça inclus un certain nombre de composants de base et quasi obligatoires :

      • glibc
      • libssl
      • openssl
      • ca-certificates
      • A /etc/passwd entry for a root user
      • A /tmp directory

      (source)

      Et je trouve que c'est mieux que Alpine dans le sens où ce n'est pas basé sur musl mais bien sur glibc (on se retrouve souvent avec des alpine + couche de compatibilité glibc, j'avoue ne pas bien comprendre le but).

      C'est aussi mieux dans le sens où l'image n'a pas de shell, on est plus dans l'idée d'isolation de processus que de virtualisation légère. Il y a tout de même une version :debug avec un shell busybox (à utiliser avec --entrypoint=sh par exemple).

      Par contre, c'est plus gros qu'une alpine de base, 16Mo contre 4Mo. Faudrait comparer avec une alpine et les mêmes composants.

      Bref, je recommande du distroless, surtout pour du Go mais je pense que pour rust c'est pareil.

    • [^] # Re: From scratch ?

      Posté par  (site web personnel) . Évalué à 4.

      Effectivement, les solutions présentées ici peuvent encore être optimisées. Par exemple dans la solution 2 (Dockerfile multi-stage sur base debian), l'appli est compilée en statique donc on doit pouvoir la déployer sur une image distroless ou sur un stratch + glibc.
      Avec Nix, je pense qu'il faudrait "juste" configurer le projet en statique et dockerTools devrait à peu près se débrouiller pour déployer uniquement le nécessaire à partir de stratch.

      Maintenant, pour des applis dans des langages comme go ou rust, si c'est pour déployer sur un PaaS, il ne faut pas s'embêter avec Docker mais déployer directement à partir du code source. C'est très simple et plutôt optimisé (dans cet exemple en Haskell, le slug déployé fait 11 Mo : https://nokomprendo.frama.io/tuto_fonctionnel/posts/tuto_fonctionnel_23/2018-07-01-README.html).

Suivre le flux des commentaires

Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.