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 :
GET /monitoring/ping
pour un healthcheck basiqueGET /monitoring/ping-db
pour un healthcheck naïf de la connection entre le serveur et la base de donnée
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 :
- ignorer le fichier
.env
lorsqu'on monte le répertoire du serveur dans notre image Docker (chose plus facile à dire qu'à faire) - faire en sorte que pipenv ne charge pas automatiquement ce fichier au démarrage
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.