Aller au contenu principal
genie-logicielSockets, client-serveur et conteneurs

Sockets, client-serveur et conteneurs

Ce que ce chapitre apporte

  • Décrire l'architecture client-serveur et la répartition des rôles.
  • Définir un socket et nommer les éléments qui identifient une extrémité.
  • Comparer TCP et UDP et savoir lequel choisir.
  • Expliquer pourquoi un flux d'octets impose de délimiter les messages.
  • Décrire l'enchaînement des appels côté serveur et côté client.
  • Définir image et conteneur, et dire ce qu'ils apportent au déploiement.
  • Situer la conteneurisation dans une chaîne d'intégration continue.
Où on va
Les mécanismes du chapitre précédent supposent une seule machine. Dès que deux programmes s'exécutent sur des machines différentes, il faut un moyen de les faire communiquer, et un moyen de les déployer de façon reproductible. Ce chapitre traite des deux : l'architecture client-serveur avec les sockets, puis la conteneurisation et son rôle dans une chaîne de livraison.

L'architecture client-serveur

Définition

Dans une architecture client-serveur, un programme dit serveur attend des demandes et y répond ; un ou plusieurs programmes dits clients émettent ces demandes.

La dissymétrie porte sur l'initiative. Le serveur ne connaît pas ses clients à l'avance et ne les contacte jamais spontanément : il écoute et réagit. Le client connaît l'adresse du serveur et déclenche l'échange.

Cette répartition explique l'ordre des opérations. Le serveur doit être en écoute avant qu'un client ne se connecte, et il doit pouvoir traiter plusieurs clients, ce qui ramène aux threads du chapitre précédent.

Le socket

Définition

Un socket est le point d'accès par lequel un programme émet et reçoit des données sur un réseau. Une extrémité de communication est identifiée par une adresse IP, un numéro de port et un protocole de transport.

L'adresse désigne la machine, le port désigne le programme sur cette machine. Un serveur web occupe habituellement le port 443, un serveur de base de données un autre : c'est ce qui permet à une même machine d'héberger plusieurs services.

Une connexion établie se caractérise par quatre valeurs : adresse et port de la source, adresse et port de la destination. C'est ce qui permet à un serveur de distinguer deux clients venant de la même machine.

TCP et UDP

TCPUDP
Connexionétablie avant l'échangeaucune
Livraisongarantie, retransmission en cas de pertesans garantie
Ordrepréservénon garanti
Unitéflux d'octets continudatagrammes délimités
Coûtplus élevéminimal
Emploifichiers, requêtes, tout ce qui doit arriver entierflux audio ou vidéo, découverte, mesures
TCP transporte un flux, pas des messages
C'est le point que la plupart des programmes réseau ratent au début. TCP garantit que les octets arrivent dans l'ordre et sans perte. Il ne garantit pas qu'un envoi corresponde à une réception.
Deux envois consécutifs peuvent être reçus en une seule lecture, et un envoi de mille octets peut être reçu en trois morceaux. Le protocole applicatif doit donc porter lui-même la délimitation des messages.

Délimiter les messages

Trois techniques existent, et le choix a des conséquences.

Un délimiteur, souvent un saut de ligne, marque la fin d'un message. Simple, lisible, mais interdit au contenu de comporter ce caractère sans échappement.

Une longueur en préfixe annonce le nombre d'octets à lire. C'est la solution générale, qui accepte n'importe quel contenu binaire.

Une taille fixe convient quand tous les messages ont la même structure.

main.py
Sortie
>_ Prêt à exécuter…
Le défaut qui n'apparaît qu'en production
En local, les messages sont petits et la latence nulle : chaque envoi tient dans une lecture, et le programme paraît correct. Sur un réseau réel, avec des messages plus gros et des découpages imposés par la taille des paquets, les lectures partielles deviennent la règle.
Le symptôme classique est un message tronqué ou deux messages collés, apparaissant sous charge et jamais en test.

La séquence des appels

L'ordre est imposé par la nature de la connexion, et il se retient facilement.

   SERVEUR                                CLIENT

   creer le socket
   associer a une adresse et un port
   se mettre en ecoute
        |                                 creer le socket
   accepter  <----- demande de connexion --- se connecter
        |                                        |
        |<---------------- envoyer ------------- |
        |----------------- recevoir ------------>|
        |                                        |
   fermer                                    fermer

L'appel qui accepte une connexion est bloquant : il attend qu'un client se présente. Il rend un nouveau socket, propre à cette connexion, tandis que le socket d'écoute reste disponible pour les suivants.

Le lien avec le chapitre précédent
Un serveur qui traite un client à la fois fait attendre tous les autres pendant la durée de l'échange. Pour servir plusieurs clients, il faut soit un thread par connexion, soit des entrées-sorties asynchrones.
Le thread par connexion est simple à écrire et ne passe pas l'échelle : quelques milliers de connexions coûtent quelques milliers de piles. Les serveurs à forte charge emploient l'asynchrone, où un petit nombre de threads gère un grand nombre de connexions en attente.

Conteneurs

Définitions

Une image est un modèle immuable contenant une application et toutes ses dépendances : bibliothèques, moteur d'exécution, fichiers de configuration.

Un conteneur est une instance en cours d'exécution d'une image, isolée du reste du système mais partageant son noyau.

L'apport se formule en une phrase : l'image contient tout ce dont l'application a besoin, donc elle s'exécute à l'identique partout où un moteur de conteneurs est installé.

Machine virtuelleConteneur
Contenusystème d'exploitation completapplication et dépendances
Noyaule siencelui de l'hôte
Démarragedizaines de secondesmoins d'une seconde
Tailleplusieurs gigaoctetsdizaines à centaines de mégaoctets
Isolationforteplus légère
Ce que la conteneurisation supprime
La catégorie d'incidents où le programme fonctionne sur un poste et pas sur un autre. Les causes habituelles sont une version de bibliothèque différente, une variable d'environnement absente, un chemin qui n'existe pas.
Une image fige ces éléments. Le poste du développeur, le serveur d'intégration et la production exécutent alors le même contenu, ce qui rend les écarts impossibles plutôt qu'improbables.

Une image se décrit dans un fichier de recette, et se construit par couches successives. Chaque instruction produit une couche, réutilisée telle quelle si elle n'a pas changé, ce qui accélère considérablement les reconstructions.

# Etape de compilation, avec les outils de developpement
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS compilation
WORKDIR /source
COPY *.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /application

# Image finale, sans les outils de compilation
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /application
COPY --from=compilation /application .
EXPOSE 8080
ENTRYPOINT ["dotnet", "MonService.dll"]
La construction en deux étapes
La première image contient le kit de développement, nécessaire à la compilation et inutile ensuite. La seconde ne contient que le moteur d'exécution et le résultat compilé.
Le gain est double. La taille finale est bien plus petite, ce qui accélère les transferts. Et la surface exposée est réduite : ni compilateur, ni code source, ni outils dans l'image qui tourne en production.

La chaîne d'intégration continue

Définition

L'intégration continue consiste à fusionner fréquemment le travail de chacun dans une branche commune, et à valider automatiquement chaque fusion par une compilation et une exécution des tests.

L'enchaînement habituel se déclenche à chaque envoi de commits :

  1. Récupération du code depuis le dépôt.
  2. Compilation.
  3. Exécution des tests.
  4. Analyse de qualité du code.
  5. Construction de l'image.
  6. Publication de l'image dans un registre.
  7. Déploiement, automatique ou déclenché manuellement.
Ce que l'intégration continue change vraiment
Elle raccourcit le délai entre l'introduction d'un défaut et sa découverte. Un défaut détecté dix minutes après le commit se corrige par son auteur, qui a encore le contexte en tête. Le même défaut découvert trois semaines plus tard demande de reconstituer ce qui a été fait, souvent par quelqu'un d'autre.
C'est aussi ce qui donne leur valeur aux tests du premier chapitre : sans exécution automatique, une suite de tests finit par ne plus être lancée.

Exercices type

Pourquoi ne peut-on pas supposer qu'un envoi TCP correspond à une réception ?

Parce que TCP transporte un flux d'octets et non des messages. Il garantit l'ordre et l'absence de perte, rien de plus.

Le système regroupe ou découpe les envois selon la taille des paquets, la congestion et ses propres optimisations. Deux envois peuvent arriver dans une seule lecture, un envoi peut arriver en plusieurs.

Le protocole applicatif doit donc délimiter lui-même : par un caractère de fin, par une longueur en préfixe, ou par une taille fixe.

UDP se comporte différemment : un datagramme est reçu entier ou pas du tout, ce qui règle la délimitation mais supprime les garanties de livraison et d'ordre.

Un serveur de fichiers doit-il utiliser TCP ou UDP ?

TCP, sans hésitation.

Un fichier doit arriver entier, dans l'ordre et sans altération. UDP ne garantit aucun des trois : des datagrammes peuvent se perdre, arriver dans le désordre ou être dupliqués.

Employer UDP obligerait à réimplémenter les accusés de réception, la retransmission et le réordonnancement, c'est-à-dire à réécrire TCP, moins bien.

UDP se justifie quand la perte est préférable au retard : un flux vidéo continue en sautant une image, alors qu'attendre la retransmission produirait un gel.

Que se passe-t-il si un serveur traite les clients un par un ?

Chaque client attend que tous les précédents soient servis. Le débit s'effondre dès que les échanges durent, et un client lent bloque tous les autres.

La solution simple est un thread par connexion. Elle fonctionne jusqu'à quelques centaines de clients, puis le coût des piles et des commutations devient dominant.

Au-delà, on emploie des entrées-sorties asynchrones : un petit nombre de threads gère un grand nombre de connexions, chacune ne consommant un thread que pendant le traitement effectif, pas pendant l'attente.

C'est le même arbitrage que celui du chapitre sur les processus, entre simplicité d'écriture et passage à l'échelle.

Quelle différence entre une image et un conteneur ?

Une image est un modèle immuable, comparable à une classe. Elle contient l'application, ses dépendances et sa configuration de démarrage.

Un conteneur est une instance en cours d'exécution de cette image, comparable à un objet. Plusieurs conteneurs peuvent être lancés depuis la même image, chacun avec son propre état.

La conséquence pratique tient à l'immuabilité. Modifier un conteneur en cours d'exécution ne modifie pas l'image, et la modification disparaît au redémarrage. Pour changer durablement quelque chose, il faut reconstruire l'image.

C'est une contrainte, et c'est elle qui garantit la reproductibilité.

Pourquoi construire une image en deux étapes ?

Pour que le kit de développement, nécessaire à la compilation, n'entre pas dans l'image finale.

La première étape compile. La seconde repart d'une image contenant seulement le moteur d'exécution, et y copie le résultat compilé.

Deux gains. La taille : une image sans compilateur ni code source pèse une fraction de l'autre, ce qui accélère les transferts et les démarrages.

La surface exposée : un attaquant qui obtient un accès dans le conteneur n'y trouve ni compilateur, ni sources, ni outils de développement.

Que perd-on à intégrer une fois par mois plutôt qu'en continu ?

La capacité à situer les défauts.

Un mois de travail fusionné en une fois produit des conflits nombreux et enchevêtrés, et si les tests échouent, la cause peut se trouver dans n'importe lequel des commits accumulés.

En intégration continue, chaque commit est validé séparément. Un échec désigne un changement précis, et son auteur en a encore le contexte en tête.

On perd aussi le retour rapide sur la conception. Une interface mal choisie se découvre à l'intégration, et plus elle est découverte tard, plus le code écrit par-dessus est important.

La méthode

  1. Identifie qui prend l'initiative pour répartir les rôles client et serveur.
  2. Choisis TCP dès que le contenu doit arriver entier, UDP quand le retard coûte plus cher que la perte.
  3. Délimite tes messages dans le protocole applicatif, sans supposer qu'une lecture corresponde à un envoi.
  4. Prévois la concurrence côté serveur avant d'écrire la boucle d'acceptation.
  5. Construis tes images en deux étapes, et n'y laisse rien d'inutile.
  6. Traite l'image comme immuable : pour modifier, reconstruire.
  7. Fais exécuter les tests par la chaîne d'intégration, pas seulement sur les postes.

En résumé

  • Dans une architecture client-serveur, le serveur écoute et le client prend l'initiative.
  • Une extrémité se caractérise par adresse, port et protocole ; une connexion par les quatre valeurs des deux extrémités.
  • TCP : connexion, livraison garantie, ordre préservé, flux d'octets. UDP : sans connexion ni garantie, datagrammes délimités.
  • Un flux impose de délimiter les messages applicativement, par délimiteur, longueur en préfixe ou taille fixe.
  • L'appel qui accepte une connexion est bloquant et rend un socket propre à cette connexion.
  • Servir plusieurs clients suppose un thread par connexion ou des entrées-sorties asynchrones.
  • Une image est immuable, un conteneur en est une instance en exécution.
  • La construction en deux étapes réduit la taille et la surface exposée.
  • L'intégration continue raccourcit le délai entre l'introduction d'un défaut et sa découverte.

Et ensuite ? Le bloc est parcouru. Il aura mené des principes qui rendent un logiciel modifiable jusqu'aux mécanismes que le système met à sa disposition, en passant par la façon de le structurer et de le décrire. Ces deux niveaux se répondent plus qu'il n'y paraît : la plupart des décisions de conception se justifient par ce que le système fait réellement en dessous.

Sockets, client-serveur et conteneurs | Plateforme ETS