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

Posté par (page perso) . Licence CC by-sa.
17
15
nov.
2018

Sommaire

Les services de plateforme (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…). Ce post présente plusieurs solutions pour déployer des applications web C++ sur Heroku, en utilisant des images Docker et le gestionnaire de paquet Nix.

Voir aussi : code source - vidéo youtube - vidéo peertube

Exemple d'application web C++ avec le framework Wt

Wt est un framework web basé widget. Il permet de définir les composants de l'interface et leurs interactions, de façon similaire aux APIs d'interface graphique de bureau comme Qt ou Gtkmm. Wt produit des applications web client-serveur mais ceci est transparent pour le développeur. Pour illustrer ce post, prenons une application simple qui repè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 on construit notre application à partir de son code source. Ici, on installe Wt manuellement car Debian fournit la version 3 et 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 Mo) 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 Mo).

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 un tag 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 Mo) 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 Mo.

Conclusion

Sans être aussi riche que Node.js ou PHP, C++ possède également des frameworks web intéressants. Wt, notamment, permet de développer des applications client-serveur avec une API très proche des frameworks 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 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 paquet Nix permet également de construire des images Docker, avec les dockerTools. Ces outils s'intègrent au système de gestion de paquet de Nix, ce qui permet de profiter de ses avantages (fichiers Nix, composition, reproductibilité, isolation…).

  • # Heroku et Docker

    Posté par (page perso) . Évalué à 7.

    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++.

    Avec tout de même un bémol dont il faut être conscient: Heroku n'execute pas d'images docker. Les layers sont extraits et ça tourne sous LXC.

    Et ça a quelques incidences à prendre en compte.

    Expose

    De base en docker une image expose un ou plusieurs ports et ça permet de savoir quoi mapper 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
    

    Entrypoint

    L'entrypoint des images est surchargé par /bin/sh -c s'il n'est pas définit.
    Par exemple si on utilise distroless pour faire une image Go, l'entrypoint 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 l'entrypoint et la commande à "" ça fonctionne.

    HealthChecks

    Les healthchecks ne sont pas supportés, le Dyno manager fait automatiquement ses propres checks.

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

    Sinon j'ai jamais essayé avec Nix, mais les images multi-stage say bon mangez-en !

  • # Beast

    Posté par (page perso) . Évalué à 1.

    Moi j'ai utilisé Boost.Beast pour faire un petit simili pastebin.

    C'est très bas niveau, à peu près comme flask en python. On gère directement les requête HTTP et les réponses. La bibliothèque est assez récente et un peu jeune mais ça fonctionne plutôt bien.

    Seul problèmes :

    • pas encore de parseur d'URI pour le moment (j'ai utilisé liburiparser)
    • aucune fonction de découpage / désérialization des données en POST

    Pour le premier, c'est dans la TODO de l'auteur. À voir quand il l'implémentera.

    Pour avoir testé Wt, je n'aime pas trop le HTML généré, il est difficile de le personnaliser et nécessite pas mal de javascript. J'aime bien avoir accès total et complet au rendu généré.

    vanilla, ma distribution radicalement différente : http://projects.malikania.fr/vanilla

  • # Proposé en dépêche

    Posté par (page perso) . Évalué à 4. Dernière modification le 15/11/18 à 19:27.

    Les modérateurs éblouis par ce déploiement, ont préparé une dépêche basée sur le journal et le commentaire de CrEv. Elle est dans la file de modération.

    "La liberté est à l'homme ce que les ailes sont à l'oiseau" Jean-Pierre Rosnay

Suivre le flux des commentaires

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