Retour d'expérience sur l'écriture de programmes en python

· Read in about 17 min · (3500 words)

Pour les ingénieurs systèmes et/ou réseau, nous avons souvent recours au développement de script, que ce soit en bash, en python ou d’autres langages pour automatiser nos tâches où fournir des API pour nos clients. Il est important d’avoir de bonne pratique, que ce soit dans le design de l’application, dans le développement, mais aussi dans le déploiement. Ce qui va permettre de définir des règles, car l’application va évoluer pour répondre à nos besoins mais aussi aux clients et ils seront sûrement modifié par d’autres développeurs.

Dans cet article, je vais vous faire part d’un retour d’expérience que j’ai acquise sur mon lieu de travail lors de développement de programmes en python. Au sein de notre équipe, nous avons défini des protocoles pour la gestion de nos programmes:

  • Utilisation de Gitflow pour la gestion de nos branches
  • Déploiement du code dans le repositorie GitLab
  • Utilisation des merges requests pour faire des reviews de code
  • Qualification du code en respectant le standard PEP8
  • Rendre le programme modulaire
  • Définir un ensemble de tests pour valider le fonctionnement du script
  • Déploiement du code sur les serveurs de production.

Processus de développement

Le diagramme ci-dessous illustre le processus de développement, en passant par les phases de validation de code, du déploiement vers notre environnement de dev et sur nos serveurs de prod:

Workflow

Avant toute mise en production, nous devons faire nos tests unitaires, mais aussi fonctionnelle. Lorsque ces tests sont concluant, nous faisons une merge request (MR), c’est une demande de fusion dans la branche develop. Cette MR va permettre de faire une review du code pour toute nos équipes et ils peuvent faire une analyse du code et donner des critiques sur ces modifications. Si une optimisation peut être faite, nous reprenons la modification du code et les tests. Lorsque l’équipe valide ces modifications, nous pouvons merger dans la branche de developpement.

Pour merger dans la branche master, nous créons une release, puis, le déploiement va se faire sur cette release. Une release c’est un tag spécifique qui est associé à la branche master.

Pour permettre de déployer, nous utilisons ansible, qui est un outil d’orchestration très puissant et très riche en fonctionnalités.

Dans les sections qui suivent, nous allons voir comment nous faisons nos tests de validation, mais aussi la personnalisation de notre application pour qu’elle soit fonctionnelle dans un environnement de bench et de prod, puis la méthode que mettons en place pour un déploiement en réduisant les risques de coupure de service.

Gitflow

Gitflow fournit des extensions pour Git et permet d’appliquer un même modèle de branches sur un repositorie distant. C’est un outil pratique, surtout lorsque nous travaillons à plusieurs sur un même projet.

Dans le cas d’un projet, le procéssus de développement passe par différentes phases:

  • Création de nouvelle fonctionnalités
  • Correction de bug
  • Mise en production

Grâce à Gitflow, nous pouvons suivre ce modèle. Comme vu plus haut, Gitflow fournit différentes extensions et permet de créer différente nature de branches: les feature, les hotfix et les release.

Les branches feature concerne les nouvelles fonctionnalités que nous souhaitons apporté au projet et les merges se font dans une branche de développement spécifique: develop.

Les branches hotfix permet de corriger des bugs et de merger directement dans la branche master et develop.

Et enfin, les branches release concerne les mises en productions. Lors d’une release, cela merge la branche develop dans la branche master. Les noms de nos releases suivent une nomenclature qui est la suivantes: x.y.z. Chaque valeur est un entier et qui représente une version du projet, la valeur x représente la version majeure du projet, la valeur y indique la version mineure du projet, cela concerne essentiellement les nouvelles features et la valeur z représente les bugs que nous corrigeons. Les releases nous permet aussi de faire nos rollbacks, pour cela, nous redéployons à la version antérieure du projet qui est en production.

Je vous invite à lire ce site qui explique très bien l’utilisation de Gitflow: https://danielkummer.github.io/git-flow-cheatsheet/index.fr_FR.html

Micro-service

Pour la plupart de nos projets, nous exécutons nos applications dans des environnements containeriser. Pour permettre de personnaliser nos applications, nous devons passer des arguments à ces derniers. Cette personnalisation va permettre d’utiliser des environnements différents, comme la bench et la prod. Nous avons ici deux écoles:

  • La première consiste à passer les paramètres en arguments du programme via le module ArgParse.
  • La seconde consiste à utiliser les variables d’environnements.

Chacune de ces méthodes fonctionnent, l’utilisation de l’une où l’autres va dépendre de vos habitudes, mais nous allons voir chacunes d’elles dans les sections ci-dessous.

Ligne de commandes

Prenons un exemple d’une application qui nécessite l’utilisation d’un message brocker comme RabbitMQ. Pour permettre à notre application de fonctionner, nous allons fournir comme paramètre l’URL de connexion de notre RabbitMQ:

# coding: utf-8

from argparse import ArgumentParser
import asyncio
import aio_pika

class ArgumentError(Exception):
	def __init__(self, message):
		self.message = message


class Broker():
	def __init__(self, rabbitmq):
		self.rabbitmq = rabbitmq
		self.connection = None

	async def connectToRabbitMq(self):
		try:
			self.connection = await aio_pika.connect(self.rabbitmq)
		except ConnectionError as e:
			raise e

	async def sendingMessage(self):	
		await self.connectToRabbitMq()
		channel = await self.connection.channel()
		await channel.default_exchange.publish(
			aio_pika.Message(
				body='Hello world'.encode()
			),
			routing_key='my_queue'
		)
		await self.connection.close()

def checkArguments():
	parser = ArgumentParser(description="Foo")
	parser.add_argument('-r', '--rabbitmq', help='Connect to RabbitMQ')
	return parser.parse_args()
	
def main():
	args = checkArguments()

	if not args.rabbitmq:
		raise ArgumentError("You must specify the argument rabbitmq")

	# Connect to RabbitMQ
	broker = Broker(args.rabbitmq)	
	loop = asyncio.get_event_loop()
	loop.run_until_complete(broker.sendingMessage())
	loop.close()	

if __name__ == "__main__":
	main()

Dans l’exemple ci-dessus, j’utilise le module aio_pika pour envoyer des messages à RabbitMQ de façon asynchrone, c’est-à-dire de gérer les requêtes concurrentes et les fonctions ne sont pas bloquante. Dans la fonction main, je créer une boucle d’évènement, c’est un paradigme qui va permettre de traiter les I/O de façon asynchrone, grâce au module asyncio, puis je créer mon Broker et va créer une connexion à RabbitMQ et envoyer un simple message dans un channel et bien sûr, je clôture la connexion.

Puis nous déployons notre stack RabbitMQ et de notre applicationen en spécifiant l’URL:

services:
  broker:
    image: rabbitmq
    ports:
      - 5672:5672
   frontend:
     image: myAppli
     command: python3 -m test_module --rabbitmq amqp://guest:guest@brocker
     depends_on:
       - brocker

Lorsque RabbitMQ aura démarré, notre container va démarrer et se connecter à RabbitMQ. Grâce à cette méthode, nous personnalisons notre RabbitMQ. Il est même possible de fournir l’adresse de RabbitMQ externe, c’est-à-dire qu’il n’est pas déployé dans notre stack.

Variables d’environnements

Repprenons ensuite le même programme mais avec cette fois-ci les arguments sont fournit en variable d’environnement:

# coding: utf-8

from os import getenv
import asyncio
import aio_pika


class Broker():
	def __init__(self, rabbitmq):
		self.rabbitmq = rabbitmq
		self.connection = None

	async def connectToRabbitMq(self):
		try:
			self.connection = await aio_pika.connect(self.rabbitmq)
		except ConnectionError as e:
			raise e

	async def sendingMessage(self):	
		await self.connectToRabbitMq()
		channel = await self.connection.channel()
		await channel.default_exchange.publish(
			aio_pika.Message(
				body='Hello world'.encode()
			),
			routing_key='my_queue'
		)
		await self.connection.close()

def main():
	# Connect to RabbitMQ
	broker = Broker(getenv("RABBITMQ", 'amqp://localhost'))
	loop = asyncio.get_event_loop()
	loop.run_until_complete(broker.sendingMessage())
	loop.close()	

if __name__ == "__main__":
	main()

Puis, nous déployons notre stack:

services:
  broker:
    image: rabbitmq
    ports:
      - 5672:5672
   frontend:
     image: myAppli
     environment:
       - RABBITMQ=amqp://guest:guest@brocker
     command: python3 -m test_broker
     depends_on:
       - brocker

Il est important de penser d’utiliser dès la conception de l’application comment fournir des paramètres à nos programmes et éviter que des URLs soient fournir en dur dans le code, puisque le déploiement sera statique, c’est-à-dire qu’il ne fonctionnera que pour cette environnement.

Validation du code

Durant nos développement, nous mettons en place un ensemble de règles pour qualifier notre code et qu’il respect certains standard python, comme PEP8. Durant tout commit sur une de nos branche, nous avons une pipeline gitlab qui va analyser notre code.

Nous utilisons les outils suivants pour qualifier notre code:

  • pycodestyle: outil pour vérifier le code python
  • black: formatte le code python
  • bandit: vérifie la sécurité dans le code python

L’extrait ci-dessous est notre fichier .gitlab-ci.yml illustre notre pipeline de qualification de notre code. Lorsque la vérification est un succès, nous buildons puis déployons via poetry le package python. A l’issue de ce déploiement sur notre repositorie, nous pouvons déployer notre application dans un environnement de développement.

stages:
  - code quality
  - deploy poetry

variables:
  PYTHON_IMAGE: python-image
  PACKAGE_NAME: test-python
  HUB_PYPI_DEV: https://<url-repo-pypi>/<repositorie-dev>
  HUB_PYPI_PROD: https://<url-repo-pypi>/<repositorie-prod>

PEP 8:
  image: ${PYTHON_IMAGE}:code-quality
  stage: code quality
  script:
    - pycodestyle --version
    - pycodestyle -v -r --max-line-length=88 --count --statistics ${PACKAGE_NAME}

black coding style:
  image: ${PYTHON_IMAGE}:code-quality
  stage: code quality
  script:
    - black --version
    - black -v --check ${PACKAGE_NAME}

bandit:
  image: ${PYTHON_IMAGE}:code-quality
  stage: code quality
  script:
    - bandit --version
    - bandit -v -r ${PACKAGE_NAME}

docstrings:
  image: ${PYTHON_IMAGE}:code-quality
  stage: code quality
  script:
    - pycodestyle -v ${PACKAGE_NAME}

Deploy peotry:
  image: ${PYTHON_IMAGE}:3.7-testing
  stage: deploy poetry
  script:
    - poetry config repositories.af ${AF_PYPI_DEV}
    - poetry config http-basic.af ${LDAP_USERNAME} ${LDAP_PASSWORD}
    - poetry build -v
    - poetry publish -r af -v
  retry: 2

La modularité

Lors de la conception d’une application, il est important de bien la designer. Toutes nos applications fonctionnenent en micro-service, il est donc important de bien séparer chaque service pour ne pas avoir une application monolithique, c’est-à-dire un ensemble de service dans une seule application. Par ailleurs, nous essayons d’être très modulaire. Prenons un exemple simple d’une application qui doit configurer des équipements réseaux de type switch. Cette application va essentiellement configurer les ports. Dans une infrastructure, il n’est pas rare de trouvé un ensemble d’équipements hétérogène, il peut avoir des équipements de type Cisco, Juniper, Avaya, etc. et notre application doit prendre en compte cette environnement et il doit configurer tousles équipements de nature différentes et prendre en compte les équipementiers futures.

Pour permettre la modularité, nous devons utiliser les classes abstraite. Il existe pour cela le module ABC, mais dans les exemples suivants, nous ne l’utiliserons pas. Pour permettre de créer une classe abstraite, nous devons créer la classe de base qui va contenir toutes les fonctions de configuration:

$ cat test_modulaire/worker/base.py
# coding: utf-8

class WorkerBase:
	def __init__(self):
		pass

	def create_vlan(self, vlan):
		""" This function create a vlan in switch """
		raise NotimplementedError

	def add_vlan_port(self, vlan, port):
		""" This function tag a vlan in port """
		raise NotimplementedError

	def rm_vlan_port(self, vlan, port):
		""" This function rm a vlan  in port """
		raise NotimplementedError

La classe WorkerBase va permettre de créer un “contrat” en définissant toutes les méthodes que les classes qui vont en hériter doivent implémenter. Aucun dévleoppement dans les fonctions ne doit être implémenté, mais seulement dans les classes qui en hérite. Puis, pour chaque équipements, nous créer une classe qui hérite de cette classe WorkerBase:

$ cat test_modulaire/worker/cisco.py
# coding: utf-8

from modulaire.worker.base import WorkerBase

class Worker(WorkerBase):
	def __init__(self):
		pass

	def create_vlan(self, vlan):
		""" This function create a vlan in switch """
		pass

	def add_vlan_port(self, vlan, port):
		""" This function tag a vlan in port """
		pass

	def rm_vlan_port(self, vlan, port):
		""" This function rm a vlan  in port """
		pass

$ cat test_modulaire/worker/juniper.py
# coding: utf-8

from modulaire.worker.base import WorkerBase

class Worker(WorkerBase):
	def __init__(self):
		pass

	def create_vlan(self, vlan):
		""" This function create a vlan in switch """
		pass

	def add_vlan_port(self, vlan, port):
		""" This function tag a vlan in port """
		pass

	def rm_vlan_port(self, vlan, port):
		""" This function rm a vlan  in port """
		pass

Puis, le script qui importe notre classe Worker:

$ cat _test_modulaire/_main__.py
# coding: utf-8

from importlib import import_module
from argparse import ArgumentParser

def checkArguments():
	parser = ArgumentParser(description='config switch')
	parser.add_argument('-t', '--type', help='Type switch', choices=('cisco', 'juniper'))
	return parser.parse_args()

def main():
	args = checkArguments()
	if args.type is None:
		raise ValueError("please, choose cisco or juniper")

	module = import_module(f"modulaire.worker.{args.type}")

	worker = module.Worker()
	worker.create_vlan("foo")
	
if __name__ == "__main__":
	main()

Le programme ci-dessus prend en paramètre le type d’équipement, ici c’est Cisco ou Juniper et ensuite, ont importe le module. Le programme nécessite de connaître le type d’équipement, mais il peut être facile d’imaginer qu’il prend le hostname du switch et grâce à ce hostname identifie le type, grâce à un inventaire par exemple.

Comme le montre les exemples ci-dessus, c’est très simple à mettre en place des classes abstraites en python et cela permet de rapidement d’intégrer de nouveau équipementier. Il suffit simplement de rédiger le code Worker pour effectuer les opérations sur les nouveaux switchs.

Les tests

Les tests ne sont pas facile à effectuer, surtout si vous ne posséder pas réellement d’un environnement de test, mais ils sont primordiales avant la mise en production et cela permet d’éviter des rollbacks. Il existe différents types de tests:

  • Les tests unitaires
  • Les tests d’intégrations
  • Les tests fonctionnelles

Les tests unitaires test simplement les fonctions. Chaque fonctions que vous implémenter dans votre programme doit subir des tests. Ces tests vont permettre de vérifier si la logique de votre fonction est correcte et que vous traitez bien les erreurs.

Les tests d’intégration consiste à vérifier les intéractions entre deux programmes distincts, que la communication fonctionne bien mais aussi que vous gérer bien vos retours d’erreurs et éviter tout traceback dans votre programme, surtout s’il est en production.

Les tests fonctionnelles consiste à dpéloyer toute votre stack et de faire des tests, ce qui permet de simuler une production. Ces tests nécéssite un vrais environnement de développement.

Pour permettre d’effectuer nos tests, nous utilisons l’outil pytest.

Les tests unitaires

Les tests unitaires consistent à tester toutes les fonctions. Prenons un exemple de la suite de Fibonacci (j’étais pas très inspiré). J’ai fais un module Fibonacci pour calculer la suite de Fibonacci:

$ cat fibonacci/__main__.py
# coding: utf-8

from fibonacci.fibonacci import Fibonacci
from argparse import ArgumentParser

if __name__ == "__main__":
	parser = ArgumentParser(description="Fibonacci suite")
	parser.add_argument('-r', '--range', help='Range fibonacci')
	args = parser.parse_args()	

	if not args.range:
		r = 20
	else:
		r = int(args.range)

	fibonacci = Fibonacci(r)
	fibonacci.fibonacci()
	print(fibonacci.fibo)

$ cat fibonacci/fibonacci.py
# coding: utf-8

class Fibonacci:
	def __init__(self, r):
		self.r = r
		self.fibo = [0, 1]

	def fibonacci(self):
		for i in range(1, self.r):
			if i <= 1:
				self.fibo.append(self.fibo[0] + self.fibo[1])
			else:
				self.fibo.append(self.fibo[i - 1] + self.fibo[i])

Exécuter le script de cette manière et voici le résultat:

$ python3 -m fibonacci -r 16
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765]

Au premier abord, le programme fonctionne et le résultat semble correcte, mais nous devons nous assurer. Pour cela, nous allons créer un script de test qui va vérifie si certaines valeurs qur nous lui passons sont des valeurs dans la suite de Fibonacci:

$ cat tests/test_fibonacci.py
# coding: utf-8

from fibonacci.fibonacci import Fibonacci


def test_fibonacci():
	r = 16
	fibonacci = Fibonacci(r=r)
	fibonacci.fibonacci()
	fibo = fibonacci.fibo
	res = []
	for i in range(r):
		if i in fibo:
			res.append(True)
		else: 
			res.append(False)
	if False in res:
		assert 0

On éxécute notre test:

$ pytest tests/test_fibonacci.py -rs
============================= test session starts ==============================
platform linux -- Python 3.7.4, pytest-5.3.2, py-1.8.0, pluggy-0.13.1
rootdir: /home/geoffrey/Website/Bucchino
collected 1 item

python/tests/test_fibonacci.py F                                         [100%]

=================================== FAILURES ===================================
________________________________ test_fibonacci ________________________________

    def test_fibonacci():
    	r = 16
    	fibonacci = Fibonacci(r=r)
    	fibonacci.fibonacci()
    	fibo = fibonacci.fibo
    	res = []
    	for i in range(r):
    		if i in fibo:
    			res.append(True)
    		else:
    			res.append(False)
    	if False in res:
>   		assert 0
E     assert 0

python/tests/test_fibonacci.py:21: AssertionError
============================== 1 failed in 0.01s ===============================

Le test est relativement simple. Je parcours toutes les entrées de la suite de Fibonacci et vérifie si une valeur est présente, si elle l’est, j’ajoute un booléen True dans mon tableau sinon c’est False. A la fin de ce test, je vérifie si j’ai un booléen False dans mon tableau et dans ce cas je lève une Exception.

Ce test n’est peut-être pas le meilleur exemple pour une suite de Fibonacci, mais il permet de comprendre le fonctionnement des tests python.

Je vous invite à lire la documentation de pytest qui est très complète.

Les tests d’intégration

Les tests d’intégration permettent d’assurer le bon fonctionnement entre différents programmes. Reprenons l’exemple précedent, celui qui se connectais à RabbitMQ.

Nous devons d’abord déployer notre stack RabbitMQ:

---
version: '2'

services:
  broker:
    image: rabbitmq
    ports:
      - 5672:5672

Puis, nous allons faire des tests d’envoie de message:

# coding: utf-8

import asynctest
import aio_pika
import json
import random
import string
from random import randint

class TestRabbitMq(asynctest.TestCase):
	async def setUp(self):
		rabbitmq = 'amqp://guest:guest@localhost'
		self.connection = await aio_pika.connect(rabbitmq)
		self.channel = await self.connection.channel()

		self.body = {
			'key1': 'value1',
			'key2': {
				'foo': 'foo'
			}
		}
	
	async def tearDown(self):
		await self.connection.close()

	async def test_sending_message(self):
		for i in range(10):
			self.body['key1'] = random_string(randint(10,50))
			await self.channel.default_exchange.publish(
				aio_pika.Message(
					body=json.dumps(self.body).encode()
				),
				routing_key='queue'
			)

def random_string(length):
	return "".join(random.choice(string.ascii_uppercase) for _ in range(length)).lower()

Comme le montre l’exemple du script de test ci-dessus, j’utilise le module asynctest. Dans ce test, je créer les fonctions setUp et tearDown. La première est exécuté lors du test et va permettre de me connecter à RabbitMQ et la seconde fonction est exécuté à la fin du test. Le script possède une seule fonction de test: test_sending_message et qui va envoyer un certains nombre de message dans le message brocker RabbitMQ. Cela va fonctionner si bien sur, la connexion à réussi lors de l’exécution de la fonction setUp.

On lance ensuite notre test:

$ python3 -m unittest tests/test_rabbitmq.py
.
----------------------------------------------------------------------
Ran 1 test in 0.013s

OK

Le test est relativement simple, pour améliorer le test, il peut être important d’avoir un service de consummer pour récupérer les messages dans la queue de RabbitMQ et les traiter. Mais cet exemple va permettre de mettre en évidence l’utilité des tests et vérifier si vous traitez bien les intéractions avec les autres services.

Les tests fonctionnelles

Les tests fonctionnelles nécessite un environnement de développement dédié, c’est-à-dire un serveur de développement et des équipements pour faire nos tests. Par exemple, pour un de mes projets récents, j’ai du faire du développement pour gérer des VIP (Virtual IP) au niveau d’un loadbalancer. Pour éviter d’effectuer nos tests sur nos loadbalancers de production, nous avons mis en place des loadbalancers virtuelles qui nous ont servis pour faire nos tests. Dans ces tests, vous déployés toute votre stack de services et effectués toutes les opérations de votre application.

En conclusion de cette section consacré aux tests, il est très recommandé d’effectué des tests avant toute mise en production. Par expérience, il m’est arrivé d’effectuer un rollback sur une mise en production car les tests n’ont pas été fait correctement.

Déploiement BlueGreen

Pour permettre de réduire les coupures de services, nous mettons en place un déploiement en BlueGreen pour certains de nos projets.

il arrive souvent qu’une application se toruve derrière un loadbalancer et ce dernier possède un certains nombres de nodes qui hébergent l’application, pour permettre un déploiement en BlueGreen, nous supprimons un node du pool et nous déployons l’application sur le node, puis nous l’intégrons de nouveau dans le pool. La figure ci-dessous illustre l’application hébergé sur nos nodes derrière un loadbalancer avant et après un déploiement:

Loadbalancing BlueGreen

Comme le montre la figure ci-dessous, la novelle version de l’application est déployé sur un node, si la configuration du loadbalancer est en round-robin, 1 requête sur 3 sera envoyés sur notre node, il suffit simplement de vérifier le bon fonctionnement de l’application. Si l’application fonctionne, nous pouvons déployer la nouvelle version sur les autres nodes en appliquant la même méthodologie. Si nous remarquons des échecs, il suffit tout simplement de redéployer la version antérieure.

Une autre méthode de déploiement est d’utiliser un port d’écoute différents. Prenons un exemple d’une application située derrière un loadbalancer de type HaProxy configurée en round-robin et contient 3 nodes dans VIP (Virtual IP). Chaque node héberge l’application et expose le port 9080. L’exemple ci-dessous est un extrait de la configuration de la VIP HaProxy:

listen api_foo_443
  description vip frontend
  mode http
  balance round-robin
  option forwardfor
  http-request set-header X-SSL %[ssl_fc]
  bind-process 1
  bind <ip frontend>:443 ssl crt /etc/haproxy/ssl/certificate.pem ciphers AES256-SHA:AES128-SHA:RC4-SHA:RC4-MD5:!LOW:!aNULL no-sslv3

  server <ip node1>:9080--<hostname-node1> <ip node1>:9080 check
  server <ip node2>:9080--<hostname-node2> <ip node2>:9080 check
  server <ip node3>:9080--<hostname-node3> <ip node3>:9080 check

Lors d’un déploiement en BlueGreen, nous déployons la stack sur l’un des nodes, mais sur un port d’écoute différent, par exemple 9081, nous avons donc sur un même node la stack à la version n qui expose le port 9080 et la stack à la version n + 1. Au niveau de notre loadbalancer, nous modifions la configuration pour qu’il redirige les requêtes sur le nouveau port:

  server <ip node1>:9080--<hostname-node1> <ip node1>:9080 check
  server <ip node2>:9080--<hostname-node2> <ip node2>:9080 check
  server <ip node3>:9081--<hostname-node3> <ip node3>:9081 check

En cas de rollback, il suffit simplement de changer le port du node 3.

Nous avons vu deux méthodes de déploiement en mode BlueGreen, la première en remplaçant la stack actuelle par la nouvelle version et la seconde de déployer en parallèle la nouvelle version mais de modifier simplement le port d’écoute. L’utilisation de l’une de ces métodes va déprendre de votre stack. Par exemple, pour un de nos projets, nous avons un stack complète qui contient différents services et certains de ces services utilise des VIPs et ces services sont dépendant l’une de l’autres, donc l’utilisation de la seconde méthode semble compliqué, car il faudrais modifier toutes les VIPS de cette stack pour pointer sur la nouvelle version.

Conclusion

Voilà, cet article avait pour but de faire un retour d’expérience sur la façon dont nous créons, testons et déployons nos application python au sein de notre équipes et j’espère que cela peut vous aidez dans la réalisation de vos applications.