PYTHON

Configuration d'une webapp avec Django, Selenium et Docker

Dans cet article, je vous présente une webapp que j'ai créé permettant d'afficher l'aperçu de la fenêtre Selenium dans un Django Admin. Cet article présente également des concepts à réutiliser dans vos applications web comme l'ASGI, Celery, Django Channels et les WebSockets sécurisées.
Alexis Le BaronAlexis Le Baron

Alexis Le Baron

Développeur web chez Snoweb
Publié le 20 juillet 2021 (Mise à jour le 23 juillet 2021)

Aperçu de la webapp

django selenium docker

Ici, je remplis 4 liens depuis un model Task dans Django Admin qui sont :

  • https://www.facebook.com/
  • https://www.linkedin.com/
  • https://www.instagram.com/
  • https://twitter.com/

Lorsque je clique sur "Enregistrer" dans Django Admin, voici ce qui se passe concrètement :

  1. Une tâche est lancé avec Celery
  2. Dans cette tâche, Selenium lance Chrome
  3. Une boucle est effectuée sur les liens du model Task
  4. Pour chaque lien, Selenium le récupère et l'envois à Chrome
  5. Chrome navigue vers le lien et prend un screenshot
  6. Ces screenshots sont ensuite envoyés via une WebSocket

Cet article détaille ces concepts basés sur Python :

  1. Django Admin avec ASGI
  2. Selenium Python
  3. Celery avec RabbitMQ
  4. Django channels avec Redis

Je vous explique également comment les utiliser avec Docker.

Si vous êtes plus pratique que théorique, vous pouvez aller directement tester la webapp et explorer le code sur le lien du répertoire Github django-selenium-docker.

1. Django Admin, la webapp

Django Admin est une webapp qui permet de créer rapidement des sites d'administration. Cet outil utilise les modèles Django et les modèles Admin Django.

Utiliser Django avec Docker

Pour faire tourner Django avec Docker, j'utilise :

  • ASGI : interface entre les serveurs web, les frameworks et les applications Python compatibles avec l'asynchronisme
  • daphne : serveur de protocoles HTTP, HTTP2 et WebSocket pour ASGI et ASGI-HTTP

Cette combinaison est nécessaire pour la suite car j'utilise Django-channels.

Voici le code de mon Dockerfile :

FROM python:3.9.2-slim-buster

RUN useradd app

EXPOSE 8000

ENV PYTHONUNBUFFERED=1 \
    PORT=8000

WORKDIR /app
COPY --chown=app:app . .

RUN pip install "daphne==3.0.2"
RUN pip install -r requirements.txt

USER app

CMD set -xe; daphne -b 0.0.0.0 -p 8000 app.app.asgi:application

Django ASGI

Voici le point d'entré de notre app Django avec ASGI :

import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings.production')
django_asgi_app = get_asgi_application()

application = ProtocolTypeRouter({
    "http": django_asgi_app,
})

Postgresql, la base de données

Je connecte ensuite une base de données Postgresql dans les réglages de Django comme ceci :

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql_psycopg2',
        'NAME': 'app',
        'USER': 'app_user',
        'PASSWORD': 'changeme',
        'HOST': 'localhost',
        'PORT': '5432',
    }
}

Docker compose avec Django

Je peux maintenant lancer mes services avec docker compose comme ceci :

version: "3.3"

services:
  app_db:
    container_name: app_db
    image: postgres:13.1
    environment:
      - POSTGRES_USER=app_user
      - POSTGRES_PASSWORD=changeme
      - POSTGRES_DB=app_db
    volumes:
      - app_db:/var/lib/postgresql/13.1/main
    ports:
      - "5432:5432"
    networks:
      - app_network
    restart: on-failure
  app:
    container_name: app
    build: ./
    depends_on:
      - app_db
    ports:
      - "8001:8000"
    image: app
    networks:
      - app_network
    restart: on-failure

networks:
  app_network:

volumes:
  app_db:

Gestion des fichiers statiques

Pour le gestion des fichiers statiques de Django Admin, j'utilise un CloufFront + S3 ou j'ai déjà collecté les fichiers.

Ceci me permet d'éviter de compliquer la configuration dans mon docker compose pour la suite grâce à cette simple ligne de code dans les réglages de Django :

STATIC_URL = 'https://static.snoweb.fr/'

Gestion des fichiers medias

Je n'utilise pas de fichiers media sur ce projet.

Si vous souhaitez en utilisez, une bonne solution consiste à utiliser le paquet django-storages avec comme réglage :

DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'

Cette technique permet également de simplifier le docker compose.

2. Selenium, le navigateur web automatique

Selenium, c'est des outils et des bibliothèques permettant l'automatisation des navigateurs Web.

Utiliser Selenium avec Docker

Il existe de nombreuses façons pour faire tourner Selenium avec Docker. Pour utiliser les images officielles, rendez-vous sur docker-selenium.

Ici, je réutilise mon Dockerfile précédent en y ajoutant les dépendances et l'installation de Chrome :

FROM python:3.9.2-slim-buster

RUN useradd app

EXPOSE 8000

ENV PYTHONUNBUFFERED=1 \
    PORT=8000

WORKDIR /app
COPY --chown=app:app . .

RUN apt-get update --yes --quiet

# Installe les dépendances utilisés par Chrome et Selenium
RUN apt-get install --yes --quiet --no-install-recommends \
    gettext \
    fonts-liberation \
    libasound2 \
    libatk-bridge2.0-0 \
    libatk1.0-0 \
    libatspi2.0-0 \
    libcairo2 \
    libcups2 \
    libdbus-1-3 \
    libdrm2 \
    libgbm1 \
    libgdk-pixbuf2.0-0 \
    libglib2.0-0 \
    libgtk-3-0 \
    libnspr4 \
    libnss3 \
    libpango-1.0-0 \
    libx11-6 \
    libxcb1 \
    libxcomposite1 \
    libxdamage1 \
    libxext6 \
    libxfixes3 \
    libxkbcommon0 \
    libxrandr2 \
    libxshmfence1 \
    wget \
    xdg-utils \
    netcat \
    xvfb \
 && rm -rf /var/lib/apt/lists/*

# Installe Chrome
RUN dpkg -i ./bin/google-chrome.deb

RUN pip install "daphne==3.0.2"
RUN pip install -r requirements.txt

USER app

CMD set -xe; daphne -b 0.0.0.0 -p 8000 app.app.asgi:application

Pour que le Dockerfile fonctionne, vous avez besoin de télécharger manuellement le paquet google-chrome.deb et de le placer correctement dans votre projet.

Utiliser Selenium avec Python

Pour lancer Chrome avec Python, j'utilise une classe qui hérite du driver Chrome, voici un aperçu :

import os
from django.conf import settings
from selenium import webdriver

USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36"
WINDOW_SIZE = "1200x1000"

class Browser(webdriver.Chrome):
    timeout = 15

    def __init__(self):
        
        # Ajoute le dossier des exécutable dans PATH pour que chromedriver soit disponible
        path_bin = str(settings.BASE_DIR / 'bin')
        if path_bin not in os.environ["PATH"]:
            os.environ["PATH"] += os.pathsep + path_bin
        
        # Personnalise les options de Chrome
        chrome_options = webdriver.ChromeOptions()
        
        # Permet de lancer Chrome sans fenêtre
        chrome_options.add_argument('--headless')
        
        # Google recommande cette option
        chrome_options.add_argument("--no-sandbox")
        
        # Fix des problèmes avec Docker sur la mémoire
        chrome_options.add_argument("--disable-dev-shm-usage")
        
        # Permet de personnaliser le USER_AGENT
        chrome_options.add_argument(f"user-agent={USER_AGENT}")
        
        # Permet de modifier la taille de la fenêtre
        chrome_options.add_argument(f"window-size={WINDOW_SIZE}")
        
        super().__init__(chrome_options=chrome_options)

Voici comment utiliser cette classe pour dire par exemple à Chrome d'aller sur le site de Linkedin :

browser = Browser()
browser.get("https://www.linkedin.com/")

Retrouvez toutes les commandes possibles sur la documentation de Selenium Python.

3. Celery, le travailleur qui exécute les tâches

Celery est une librairie Python qui permet de lancer des longues tâches grâce à un "travailleur". Voici des exemples d'utilisations :

  • Un utilisateur met à jour un lien dans le <header> de son site via un CMS sur un site statique. Il faut donc reconstruire les 100 pages de son site avec le <header> à jour. Celery lance une longue tâche pour reconstruire son site et le notifie quand il a terminé.
  • Un utilisateur met à jour une compétence sur son profil de développeur. Celery met à jour les données de l'algorithme puis reconstruit sa page de profil statique et notifie ensuite les recruteurs en fonction des annonces qui match avec lui.

Le travailleur a pour rôle de prendre la charge de travail de la webapp et permet d'éviter de faire attendre l'utilisateur.

Configurer une app Celery avec Django

Celery a besoin d'un broker pour transporter les messages entre les différents services.

Ici, nous utilisons RabbitMQ pour le broker.

Il est également possible d'utiliser Redis. Cependant, vous allez être limité avec certaines fonctionnalités de Celery.

Voici la configuration que j'utilise dans les réglages de Django :

# RabbitMQ
APP_BROKER_URL = 'pyamqp://'

# Permet de choisir ou stoker le résultat d'une tâche. Ici, j'utilise notre base de donnée Postgresql
APP_RESULT_BACKEND = 'django-db'

# Permet de tracker le début d'une tâche
APP_TASK_TRACK_STARTED = True

# Permet d'ajouter une durée limite d'éxecution
APP_TASK_TIME_LIMIT = 12 * 60 * 60

# Nombre de tâche en simultané
APP_WORKER_CONCURRENCY = 1

Utiliser Celery avec Python

Pour lancer Celery, je définis une app Celery dans une app Django comme ceci :

import os
from django.conf import settings
from celery import Celery

# Permet d'ajouter une variable d'environnement dans le travailleur, ici ce sont les settings de Django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.production")

app = Celery('app.core')

# Ajoute préfix APP qu'on a définit précedemment dans les settings de Django
app.config_from_object(settings, namespace='APP')

# Vide la cache de Celery
app.control.purge()

# Recherche les tâches des apps
app.autodiscover_tasks()

J'utilise ensuite cette app pour pouvoir définir une tâche.

Voici un exemple de tâche simplifiée avec un résultat "OK" qui se relance 3 fois si une Exception est levé avec un intervalle qui augmente entre les Exceptions :

from app.core.tasks_app import app

@app.task(bind=True, max_retries=3)
def task_example(self):
    try:
        # Celery termine sa tâche ici avec un résultat "OK"
        return "OK"
    except Exception as exc:
        # Une exception est levé, Celery relance la tâche avec un intervalle définit
        self.retry(exc=exc, countdown=5 * self.request.retries)

Voici le code pour lancer cette tâche depuis n'importe ou :

from app.core.tasks import task_example
task_example.delay()

Tester Celery avec Django

Celery fournis une fonction "start_worker" pour pouvoir tester facilement une tâche.

Voici comment lancer la tâche précédemment définis dans un test avec Django :

import json
from celery.contrib.testing.worker import start_worker
from django.test import SimpleTestCase
from app.core.models import Task
from app.core.tasks import app, task_example

class WorkerTest(SimpleTestCase):
    celery_worker = None
    databases = '__all__'

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.celery_worker = start_worker(app, perform_ping_check=False)
        cls.celery_worker.__enter__()

    @classmethod
    def tearDownClass(cls):
        super().tearDownClass()
        cls.celery_worker.__exit__(None, None, None)

    @classmethod
    def test_run_script(cls):
        task_example.delay()

Docker compose avec Celery

Ici, je réutilise le docker compose précédent et j'ajoute :

version: "3.3"

services:
  app_rabbitmq:
    container_name: app_rabbitmq
    hostname: rabbitmq
    image: rabbitmq:latest
    ports:
      - "5672:5672"
    networks:
      - app_network
    restart: on-failure
    environment:
      - RABBITMQ_DEFAULT_USER=app_user
      - RABBITMQ_DEFAULT_PASS=changeme
  app_db:
    container_name: app_db
    image: postgres:13.1
    environment:
      - POSTGRES_USER=app_user
      - POSTGRES_PASSWORD=changeme
      - POSTGRES_DB=app_db
    volumes:
      - app_db:/var/lib/postgresql/13.1/main
    ports:
      - "5432:5432"
    networks:
      - app_network
    restart: on-failure
  app_worker_core:
    command: sh -c "celery -A app.core worker -l info"
    container_name: app_worker_core
    depends_on:
      - app
      - app_db
      - app_rabbitmq
    hostname: app_worker_core
    image: app
    networks:
      - app_network
    restart: on-failure
  app:
    container_name: app
    build: ./
    depends_on:
      - app_db
      - app_rabbitmq
    ports:
      - "8001:8000"
    image: app
    networks:
      - app_network
    restart: on-failure

networks:
  app_network:

volumes:
  app_db:

4. Django Channels, le messager

Django Channels permet d'utiliser simplement les WebSockets avec Django. Il utilise l'ASGI que j'ai configuré précédemment.

Voici des exemples d'utilisations :

  • une messagerie instantanée
  • un jeu multijoueur en ligne

Configurer Django Channels

Django Channels a besoin d'un channel layers pour faire communiquer les services.

Ici, j'utilise Redis. Voici comment je le définis dans les réglages de Django avec le paquet channels_redis :

CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels_redis.core.RedisChannelLayer',
        'CONFIG': {
            "hosts": [('127.0.0.1', 6379)],
        },
    },
}

Utiliser Django Channels avec Python

Django Channels utilise des classes consumers. Il s'agit de l'interface de la WebSocket. Par exemple, les actions :

  • d'une nouvelle connexion
  • d'une écriture sur la WebSocket
  • d'une déconnexion

Voici un exemple de consumer qui :

  1. ajoute l'utilisateur sur un channel en fonction d'une clé "key_composer" récupéré depuis l'url
  2. définit une fonction pour envoyer un screenshot au client
  3. enlève l'utilisateur d'un channel lors d'une déconnexion
from channels.generic.websocket import WebsocketConsumer
import json
from asgiref.sync import async_to_sync


class TaskConsumer(WebsocketConsumer):
    task_key_composer = None

    def connect(self):
        self.task_key_composer = self.scope['url_route']['kwargs']['key_composer']
        async_to_sync(self.channel_layer.group_add)(
            self.task_key_composer,
            self.channel_name
        )
        self.accept()

    def disconnect(self, close_code):
        async_to_sync(self.channel_layer.group_discard)(
            self.task_key_composer,
            self.channel_name
        )

    def sync_function(self, event):
        self.send(text_data=json.dumps({
            'screenshot_b64': event['screenshot_b64']
        }))

Je définis ensuite ce consumer sur une route pour qu'il soit accessible par le client comme ceci :

from django.urls import path
from app.core.consumers import TaskConsumer

websocket_urlpatterns = [
    path('ws/task/<key_composer>/', TaskConsumer.as_asgi()),
]

J'ajoute ensuite cette route dans l'app ASGI précédemment définis :

import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings.production')
django_asgi_app = get_asgi_application()

from channels.auth import AuthMiddlewareStack
from app.core.urls import websocket_urlpatterns

application = ProtocolTypeRouter({
    "http": django_asgi_app,
    "websocket": AuthMiddlewareStack(
        URLRouter(
            websocket_urlpatterns
        )
    ),
})

Pour écrire sur la WebSocket depuis Python, voici le code à utiliser :

from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync

channel_layer = get_channel_layer()
async_to_sync(channel_layer.group_send)(
    'my_key_composer',
    {
        'type': 'sync_function',
        'screenshot_b64': browser.get_screenshot_as_base64()
    }
)

Voici l'exemple de la webapp django-selenium-docker côté client (front-end) :

  1. le client se connecte à la WebSocket
  2. récupère les screenshots
  3. met à jour l'interface
<script>
    const screenshot = document.querySelector('#screenshoot-my_key_composer');
    const wsProtocol = location.protocol !== 'https:' ? 'ws' : 'wss'
    const socket = new WebSocket(
        wsProtocol + '://'
        + window.location.host
        + '/ws/task/my_key_composer/'
    );
    socket.onmessage = function (e) {
        const data = JSON.parse(e.data);
        screenshot.src = 'data:image/png;base64, ' + data.screenshot_b64;
    };
    socket.onclose = function (e) {
        console.error('Chat socket closed unexpectedly');
    };
</script>

Docker compose avec Django Channels

Je met ensuite mon docker compose à jour avec le channel layer Redis comme ceci :

version: "3.3"

services:
  app_rabbitmq:
    container_name: app_rabbitmq
    hostname: rabbitmq
    image: rabbitmq:latest
    ports:
      - "5672:5672"
    networks:
      - app_network
    restart: on-failure
    environment:
      - RABBITMQ_DEFAULT_USER=app_user
      - RABBITMQ_DEFAULT_PASS=changeme
  app_redis:
    container_name: app_redis
    networks:
      - app_network
    image: redis:latest
    command: redis-server --requirepass changeme
    ports:
      - "6379:6379"
    restart: on-failure
  app_db:
    container_name: app_db
    image: postgres:13.1
    environment:
      - POSTGRES_USER=app_user
      - POSTGRES_PASSWORD=changeme
      - POSTGRES_DB=app_db
    volumes:
      - app_db:/var/lib/postgresql/13.1/main
    ports:
      - "5432:5432"
    networks:
      - app_network
    restart: on-failure
  app_worker_core:
    command: sh -c "celery -A app.core worker -l info"
    container_name: app_worker_core
    depends_on:
      - app
      - app_db
      - app_rabbitmq
      - app_redis
    hostname: app_worker_core
    image: app
    networks:
      - app_network
    restart: on-failure
  app:
    container_name: app
    build: ./
    depends_on:
      - app_db
      - app_rabbitmq
      - app_redis
    ports:
      - "8001:8000"
    image: app
    networks:
      - app_network
    restart: on-failure

networks:
  app_network:

volumes:
  app_db:

Les WebSockets sécurisées

Si vous utilisez les WebSockets avec l'app en HTTPS, vous devez forcément utiliser le protocle WSS (web socket secure).

Voici un exemple de configuration avec :

upstream app {
    server 127.0.0.1:8001;
}

server {
    server_name example.com;
    listen 443 ssl http2;
    client_max_body_size 50m;
    location / {
         include proxy_params;
         proxy_pass http://app;
    }
    location /ws/ {
    	 proxy_pass http://app;
         proxy_http_version 1.1;
         proxy_set_header Upgrade $http_upgrade;
         proxy_set_header Connection "upgrade";
    }
    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}

server {
    if ($host = example.com) {
        return 301 https://$host$request_uri;
    }
    server_name example.com;
    listen 80;
    return 404;
}

Conclusion

Nous avons vu dans cet article de nombreux concepts à utiliser dans une application web avec Python :

Retrouvez le code de cette webapp exemple sur le lien django-selenium-docker.

Envie d'en savoir plussur moi?