>_ Software sucks

Sauvez l'environnement, arrêtez d'utiliser pipenv

Publié le 2023-11-08

Docker, pipenv et Flask sont dans une stack...

Derrière ce qui s'apparente à une mauvaise blague se cache une petite session de debug, tout aussi peu drôle.

L'idée

Partant d'une stack relativement classique - à savoir un frontend, un backend et une base de donnée - le projet louable est de mettre tout ça dans des containers et utiliser Docker Compose en guise d'orchestrateur pour faciliter le développement local du projet, réduisant ainsi le nombre d'injures dûes à l'outillage Python.

Le projet

⚠ Soyez raisonnables, n'utilisez pas ça en production hein.

Tout part d'un fichier compose.yaml, drastiquement simplifié pour ce billet :

services:
  database:
    image: mysql:8
    ports:
      - 3306:3306
    environment:
      - MYSQL_ALLOW_EMPTY_PASSWORD=true
      - MYSQL_DATABASE=my_database
    volumes:
      - ./tmp/db:/var/lib/mysql

  backend:
    build:
      context: ./my-backend
      dockerfile: Dockerfile.dev
    ports:
      - 5000:5000
    environment:
      - SQLALCHEMY_DATABASE_URI=mysql+pymysql://root@database/my_database

    depends_on:
      - database

    volumes:
      - ./my-backend:/backend

Jusque là, rien de bien sorcier mise à part la verbosité naturelle d'un fichier YAML et les spécificités de Docker auxquelles on s'habitue plus ou moins rapidement (comme tout syndrôme de Stockholm informatique).

On a une base de donnée my_database, exposée sur le port 3306 de votre machine avec un volume monté pour garantir la persistence entre deux lancement de la stack.

Le backend, quant à lui, est exposé via le port 5000, avec le répertoire de la base de code monté pour que les changements fait puisse se propager dans le container. On injecte quelques variables d'environment, notamment l'URI de la base de donnée qu'on utilisera.

Pour notre backend, une simple application Flask dont l'originalité sera quelconque : exposer une route d'API, interagir avec la base de données et envoyer une réponse au client.

$ ls -la my-backend

.
..
.env
Dockerfile.dev
Pipfile
Pipfile.lock
app.py

Pour la gestion de dépendances, comme vous l'aurez deviné, on utilisera pipenv.

$ pipenv install Flask SQLAlchemy pymysql

Et pour notre application principale :

# in app.py

import os
from http import HTTPStatus
from flask import Flask
from sqlalchemy import create_engine, text

app = Flask(__name__)

engine = create_engine(os.environ["SQLALCHEMY_DATABASE_URI"])


@app.route("/monitoring/ping")
def ping():
    return "pong"


@app.route("/monitoring/ping-db")
def ping_db():
    try:
        with engine.connect() as conn:
            conn.execute(text("SELECT 1")).all()

            return "OK", HTTPStatus.OK
    except Exception as e:
        return str(e), HTTPStatus.INTERNAL_SERVER_ERROR

On expose deux endpoints :

Le fichier .env contiendra toutes les variables d'environnement servant à la configuration de notre backend :

SQLALCHEMY_DATABASE_URI=mysql+pymysql://localhost:3306/my_database_local

En l'état, on peut déjà lancer l'application et tester notre API :

$ pipenv run flask run

Loading .env environment variables...
 * Tip: There are .env or .flaskenv files present. Do "pip install python-dotenv" to use them.
 * Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI serv
er instead.
 * Running on http://127.0.0.1:5000
Press CTRL+C to quit

Et dans un autre terminal :

$ http http://localhost:5000/monitoring/ping

HTTP/1.1 200 OK
Connection: close
Content-Length: 4
Content-Type: text/html; charset=utf-8
Date: Wed, 08 Nov 2023 09:33:41 GMT
Server: Werkzeug/3.0.1 Python/3.11.6

pong

$ http http://localhost:5000/monitoring/ping-db

HTTP/1.1 500 INTERNAL SERVER ERROR
Connection: close
Content-Length: 178
Content-Type: text/html; charset=utf-8
Date: Wed, 08 Nov 2023 09:33:57 GMT
Server: Werkzeug/3.0.1 Python/3.11.6

(pymysql.err.OperationalError) (2003, "Can't connect to MySQL server on 'localhost' ([Errno 61] Connection refused)")
(Background on this error at: https://sqlalche.me/e/20/e3q8)

La deuxième erreur est normale, étant donné qu'on n'a pas encore mis en place la base de données correspondante.

Pour packager notre application dans un container, on utilisera le Dockerfile.dev suivant :

FROM python:3.11

WORKDIR /backend

ENV FLASK_APP=app

RUN pip install pipenv==2023.2.18

COPY Pipfile Pipfile
COPY Pipfile.lock Pipfile.lock

RUN pipenv sync --dev

COPY app.py app.py

RUN pipenv install --dev --skip-lock python-dotenv

EXPOSE 5000

CMD ["pipenv", "run", "flask", "run", "--host=0.0.0.0"]

En l'état, notre image docker contiendra tout ce qu'il faut pour pouvoir faire tourner notre serveur. Pour développer localement, on viendra monter notre codebase sur le répertoire /backend du container.

Pour des raisons évidentes de flexibilité, les variables d'environnement doivent être prioritaires sur les variables définies dans notre fichier .env.

Que le fun commence

Qu'est-ce qui arrive quand on lance notre projet via docker compose ?

$ docker compose up

[+] Building 0.0s (0/0)
Attaching to docker-flask-pipenv-backend-1, docker-flask-pipenv-database-1
docker-flask-pipenv-database-1  | 2023-11-08 09:52:06+00:00 [Note] [Entrypoint]: Entrypoint script for MySQ
L Server 8.1.0-1.el8 started.
docker-flask-pipenv-database-1  | 2023-11-08 09:52:06+00:00 [Note] [Entrypoint]: Switching to dedicated use
r 'mysql'
docker-flask-pipenv-database-1  | 2023-11-08 09:52:06+00:00 [Note] [Entrypoint]: Entrypoint script for MySQ
L Server 8.1.0-1.el8 started.
docker-flask-pipenv-database-1  | 2023-11-08 09:52:07+00:00 [Note] [Entrypoint]: Initializing database file
s
docker-flask-pipenv-database-1  | 2023-11-08T09:52:07.223732Z 0 [System] [MY-015017] [Server] MySQL Server
Initialization - start.
docker-flask-pipenv-database-1  | 2023-11-08T09:52:07.226360Z 0 [Warning] [MY-011068] [Server] The syntax '
--skip-host-cache' is deprecated and will be removed in a future release. Please use SET GLOBAL host_cache_
size=0 instead.
docker-flask-pipenv-database-1  | 2023-11-08T09:52:07.226496Z 0 [System] [MY-013169] [Server] /usr/sbin/mys
qld (mysqld 8.1.0) initializing of server in progress as process 81
docker-flask-pipenv-database-1  | 2023-11-08T09:52:07.236347Z 0 [Warning] [MY-010159] [Server] Setting lowe
r_case_table_names=2 because file system for /var/lib/mysql/ is case insensitive
docker-flask-pipenv-database-1  | 2023-11-08T09:52:07.251522Z 1 [System] [MY-013576] [InnoDB] InnoDB initia
lization has started.
docker-flask-pipenv-backend-1   | Loading .env environment variables...
docker-flask-pipenv-backend-1   |  * Serving Flask app 'app'
docker-flask-pipenv-backend-1   |  * Debug mode: off
docker-flask-pipenv-backend-1   | WARNING: This is a development server. Do not use it in a production depl
oyment. Use a production WSGI server instead.
docker-flask-pipenv-backend-1   |  * Running on all addresses (0.0.0.0)
docker-flask-pipenv-backend-1   |  * Running on http://127.0.0.1:5000
docker-flask-pipenv-backend-1   |  * Running on http://172.20.0.3:5000
docker-flask-pipenv-backend-1   | Press CTRL+C to quit
docker-flask-pipenv-database-1  | 2023-11-08T09:52:09.017942Z 1 [System] [MY-013577] [InnoDB] InnoDB initialization has ended.
docker-flask-pipenv-database-1  | 2023-11-08T09:52:10.809406Z 6 [Warning] [MY-010453] [Server] root@localhost is created with an empty password ! Please consider switching off the --initialize-insecure option.

Vérifions que notre application tourne correctement :

$ http http://localhost:5000/monitoring/ping

HTTP/1.1 200 OK
Connection: close
Content-Length: 4
Content-Type: text/html; charset=utf-8
Date: Wed, 08 Nov 2023 09:52:41 GMT
Server: Werkzeug/3.0.1 Python/3.11.6

pong

Et maintenant, vérifions que la connection à la base de donnée fonctionne elle aussi :

$ http http://localhost:5000/monitoring/ping-db

HTTP/1.1 500 INTERNAL SERVER ERROR
Connection: close
Content-Length: 191
Content-Type: text/html; charset=utf-8
Date: Wed, 08 Nov 2023 09:54:38 GMT
Server: Werkzeug/3.0.1 Python/3.11.6

(pymysql.err.OperationalError) (2003, "Can't connect to MySQL server on 'localhost' ([Errno 99] Cannot assign requested address)")
(Background on this error at: https://sqlalche.me/e/20/e3q8)

Maintenant essayons en ayant l'URI de la base de donnée en dur :

# In app.py

engine = create_engine("mysql+pymysql://database:3306/my_database")

Evidemment, toute la chaîne fonctionne comme prévue :

$ http http://localhost:5000/monitoring/ping-db

HTTP/1.1 200 OK
Connection: close
Content-Length: 2
Content-Type: text/html; charset=utf-8
Date: Wed, 08 Nov 2023 10:04:34 GMT
Server: Werkzeug/3.0.1 Python/3.11.6

OK

Si on affiche la valeur récupérer de l'environnement par le serveur pour notre URI, via un bête print on obtient :

Database URI:  mysql+pymysql://localhost:3306/my_database_local

Oui, la valeur qui est dans notre .env 🙃

La solution

Tout est dans la documentation. Entre autre : les fonctionnements les plus contre intuitif.

If a .env file is present in your project, $ pipenv shell and $ pipenv run will automatically load it, for you

Pipenv a donc la facheuse tendance à prendre le contenu du fichier .env et écraser les variables d'environnement, ne laissant aucun moyen d'en surcharger certaines au besoin.

Deux pistes pour résoudre notre problème :

On partira sur la deuxième solution pour des raisons de simplicité.

La solution la plus directe est donc d'utiliser la variable d'environnement PIPENV_DONT_LOAD_ENV :

backend:
  build:
    context: ./my-backend
    dockerfile: Dockerfile.dev
  ports:
    - 5000:5000
  environment:
    - PIPENV_DONT_LOAD_ENV=1
    - SQLALCHEMY_DATABASE_URI=mysql+pymysql://root@database/my_database

  depends_on:
    - database

  volumes:
    - ./my-backend:/backend

Et voilà.

Et pas merci pour ces trois heures d'arrachage de cheveux.

Jean-Pierre Bacry