Statamic Peak

Article

Release Mix et image Docker

On va voir ensemble comment créer une Release Mix et une image Docker pour une application écrite en Elixir avec Phoenix Framework grâce à Mix Release.

Vous avez développé votre application avec amour en local depuis un moment et il est temps pour vous de la mettre en ligne. Félicitations !

Vous pouvez utilisez des services comme Fly.io ou Gigalixir si vous voulez des outils clef en main pour le déploiement. Mais si vous voulez le faire sur votre propre serveur, suivez le guide.

Dans cet article et les prochains, nous allons voir :

Tester que votre application se compile en production

Dans un premier temps, vous devez avoir une application qui se compile en production sans erreur. Vous pouvez le vérifier en le faisant en local sur votre machine.

mix deps.get --only prod
MIX_ENV=prod mix compile
MIX_ENV=prod mix assets.deploy

Si tout marche bien, vous n'avez pas d'erreur, sinon vous avez des corrections à faire avant de continuer.

Générer les fichiers nécessaires aux releases

Phoenix peut générer pour vous les fichiers nécessaires à son fonctionnement sous la forme d'une release mix. Dans notre cas, on va lui préciser que l'on veut faire ça avec Docker.

mix phx.gen.release --docker

Il va même vous donner la liste des commandes que l'on pourra plus tard. Si vous avez des warnings concernant votre configuration, vous pouvez les regarder maintenant avant de continuer sur la partie déploiement.

Si vous avez un environnement Docker en local, vous pouvez tester de build votre image.

docker build . -t myapp

Modifier le Dockerfile pour inclure des packages NPM (optionnel)

Si vous avez seulement utilisé Phoenix Framework et Tailwind, vous pouvez passer à la suite ! Mais si vous avez installé des packages NPM dans votre dossier assets, vous avez une étape supplémentaire.

Tout d'abord, petit rappel pour les plus juniors d'entre nous : si vous installez un package avec NPM, vous devez l'installer avec le flag --save voir même --save-dev comme Phoenix Framework inclut un bundler (esbuild).

Ceci étant dit, en local vous n'avez rien à faire, alors pourquoi faut-il une étape supplémentaire ? Tout simplement parce que votre pipeline a besoin de NPM pour installer les dépendances, exactement comme vous l'avez fait en local.

On va simplement rajouter une nouvelle image, avant le builder qui contient le code suivant.

FROM node:lts-slim as builder-node

# prepare build dir
WORKDIR /app

COPY assets ./assets/

# set build ENV
ENV NODE_ENV=prod

# install npm dependencies
RUN npm --prefix ./assets ci --progress=false --no-audit --loglevel=error

Qu'est-ce que ça veut dire ?

On démarre une image node, qu'on place dans /app, comme les autres.
On copie le dossier assets de notre projet dans ./assets/, soit /app/assets dans l'image.

Et, on y installe les dépendances de production.

Ensuite, il suffit de rajouter un COPY dans notre builder, juste avant l'appel RUN mix assets.deploy.

COPY --from=builder-node /app/assets assets

# compile assets
RUN mix assets.deploy # <== lui il est là de base, on va mettre notre COPY juste au dessus

Ajouter un entrypoint

Le Dockerfile officielle est une bonne base mais elle laisse plusieurs questions en suspens :

  • Comment l'utiliser avec docker compose ?

  • Comment lancer nos migrations ?

Pour tout ça, on va utiliser un script qui sera la commande qu'on va exécuter au lancement du conteneur.

Concrètement, on va supprimer la dernière ligne du fichier pour la remplacer par un appel d'un script que l'on va créer juste après.

ENTRYPOINT ["/app/entrypoint.sh"]

Il faut aussi rajouter postgresql-client dans la liste des dépendances à installer via apt-get.

Ça nous fait donc cette étape au total :

FROM ${RUNNER_IMAGE}

RUN apt-get update -y && apt-get install -y postgresql-client libstdc++6 openssl libncurses5 locales \
  && apt-get clean && rm -f /var/lib/apt/lists/*_*

# Set the locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen

ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8

WORKDIR "/app"
RUN chown nobody /app

# set runner ENV
ENV MIX_ENV="prod"

# Only copy the final release from the build stage
COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/petal_pro ./
COPY entrypoint.sh ./

USER nobody

ENTRYPOINT [ "/app/entrypoint.sh"]

Mais qu'y-a-t-il dans le script ?

De manière plutôt transparente pour un script bash, on va attendre que Postgres réponde, puis on va créer la base de données, si elle n'existe pas. Ensuite, on va migrer la base de données et on démarre le serveur.

#!/bin/bash
# Docker entrypoint script.

# Wait until Postgres is ready.
while ! pg_isready -q -h $PGHOST -p $PGPORT -U $PGUSER
do
  echo "$(date) - waiting for database to start"
  sleep 2
done

# Create, migrate, and seed database if it doesn't exist.
if [[ -z `psql -Atqc "\\list $PGDATABASE"` ]]; then
  echo "Database $PGDATABASE does not exist. Creating..."
  createdb -E UTF8 $PGDATABASE -l en_US.UTF-8 -T template0
  echo "Database $PGDATABASE created."
fi

/app/bin/migrate
echo "Database $PGDATABASE migrated."

/app/bin/server

Si vous voulez ajouter des données par défaut à votre projet, il n'y a pas de manière clef en main de le faire pour une Release. Il faudra créer votre propre commande spécifique. Si jamais vous le faites, n'hésitez pas à me remonter les difficultés rencontrées, je pourrais les ajouter à l'article !

J'ai choisi de lancer les migrations systématiquement mais on pourrait imaginer ne pas le vouloir et se connecter manuellement au conteneur Docker pour le faire.

Et c'est tout pour aujourd'hui, on se retrouve très vite pour notre prochain tuto sur la publication de notre image docker sur un registry.

Un dernier refactoring

Après avoir échangé avec un collègue spécialisé sur ces technologies, il m'a conseillé d'utiliser des chemins absolus et non des relatifs dans le Dockerfile pour éviter les erreurs.

On va donc ajouter un ARG au départ pour choisir notre dossier WORKSPACE et utiliser cet ARG à chaque fois qu'on avait avant /app ou un chemin relatif.

Merci Thomas !

Je vous mets le Dockerfile complet en dessous pour référence.

# Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian instead of
# Alpine to avoid DNS resolution issues in production.
#
# https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu
# https://hub.docker.com/_/ubuntu?tab=tags
#
#
# This file is based on these images:
#
#   - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
#   - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20210902-slim - for the release image
#   - https://pkgs.org/ - resource for finding needed packages
#   - Ex: hexpm/elixir:1.14.0-erlang-25.0-debian-bullseye-20210902-slim
#
ARG ELIXIR_VERSION=1.14.0
ARG OTP_VERSION=25.0
ARG DEBIAN_VERSION=bullseye-20210902-slim

ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
ARG WORKSPACE="/app"

FROM node:lts-slim as builder-node

# prepare build dir
ARG WORKSPACE
WORKDIR ${WORKSPACE}

COPY assets ${WORKSPACE}/assets/

# set build ENV
ENV NODE_ENV=prod

# install npm dependencies
RUN npm --prefix ./assets ci --progress=false --no-audit --loglevel=error


# start a new build stage for elixir
FROM ${BUILDER_IMAGE} as builder

# install build dependencies
RUN apt-get update -y && apt-get install -y build-essential git \
    && apt-get clean && rm -f /var/lib/apt/lists/*_*

# prepare build dir
ARG WORKSPACE
WORKDIR ${WORKSPACE}

# install hex + rebar
RUN mix local.hex --force && \
    mix local.rebar --force

# set build ENV
ENV MIX_ENV="prod"

# install mix dependencies
COPY mix.exs mix.lock ./
RUN mix deps.get --only $MIX_ENV
RUN mkdir config

# copy compile-time config files before we compile dependencies
# to ensure any relevant config change will trigger the dependencies
# to be re-compiled.
COPY config/config.exs config/${MIX_ENV}.exs ${WORKSPACE}/config/
RUN mix deps.compile

COPY priv ${WORKSPACE}/priv

COPY lib ${WORKSPACE}/lib

COPY --from=builder-node ${WORKSPACE}/assets ${WORKSPACE}/assets

# compile assets
RUN mix assets.deploy

# Compile the release
RUN mix compile

# Changes to config/runtime.exs don't require recompiling the code
COPY config/runtime.exs ${WORKSPACE}/config/

COPY rel ${WORKSPACE}/rel
RUN mix release


# start a new build stage so that the final image will only contain
# the compiled release and other runtime necessities
FROM ${RUNNER_IMAGE}

RUN apt-get update -y && apt-get install -y postgresql-client libstdc++6 openssl libncurses5 locales \
  && apt-get clean && rm -f /var/lib/apt/lists/*_*

# Set the locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen

ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8

ARG WORKSPACE
WORKDIR ${WORKSPACE}
RUN chown nobody ${WORKSPACE}

# set runner ENV
ENV MIX_ENV="prod"

# Only copy the final release from the build stage
COPY --from=builder --chown=nobody:root ${WORKSPACE}/_build/${MIX_ENV}/rel/petal_pro ./
COPY entrypoint.sh ./

USER nobody

ENTRYPOINT [ "/app/entrypoint.sh"]