Les timeout - Une mesure de protection essentiel

Dans une application web, les timeouts surviennent lorsqu'un composant du service coupe la connexion réseau. Cela peut se produire côté client, côté serveur ou au niveau d'un service intermédiaire tel qu'un proxy ou un load balancer. Les timeouts sont configurés pour protéger l'application et le système contre une utilisation excessive de ressources critiques telles que les connexions, la mémoire, le CPU, le disque, les threads ou la base de données. Une surutilisation de ces ressources due à un appel incontrôlé peut provoquer une cascade de problèmes dans l'ensemble de l'application et du système. Les timeouts sont donc essentiels pour garantir la stabilité et les performances de l'application web.

Nous allons examiner de plus près les différents types de timeouts, leur impact sur les performances de l'application web et les meilleures pratiques pour les gérer efficacement.

Il existe différents types de timeouts à plusieurs niveaux dans toutes les applications web. Pour simplifier la compréhension et limiter la portée des informations, nous allons nous concentrer sur les trois timeouts les plus importants de nginx en exemple. Il y a également d'autres timeouts dans votre navigateur, dans le lookup DNS et dans l'attente d'un verrou dans une base de données, mais comprendre les timeouts de base dans nginx vous donnera une bonne base pour comprendre leur utilisation et leur nécessité. Les trois timeouts importants de nginx sont : connect_timeout, send_timeout et read_timeout.

Le premier timeout est le connect_timeout, qui intervient au moment d'établir une connexion avec le protocole de communication utilisé. Dans le cas de nginx, qui est un serveur web, le protocole de communication est HTTP et inclut aussi l'échange TLS. Un autre exemple de protocole de communication est celui avec une base de données, que ce soit pour établir une connexion MySQL, PostgreSQL ou SQL Server. Le timeout de connexion arrive très rarement dans le contexte normal d'une application. Ce timeout survient souvent s'il y a déjà un problème présent au niveau de l'utilisation des ressources d'un système. Pour un serveur web, le timeout est souvent configuré pour accepter une certaine latence provenant du client, comme l'utilisation d'un réseau WiFi ou 3G. Pour un serveur de base de données, le timeout est souvent beaucoup plus court, car la connexion se fait entre deux serveurs se trouvant habituellement sur le même réseau.

Le deuxième timeout est le send_timeout, qui intervient lorsque le serveur web, par exemple votre application .NET, reçoit la requête provenant du client. Le timeout est configuré pour prendre en compte la vitesse ou la stabilité d'envoi du client et la capacité du processus final à accepter le trafic. Comme avec le connect_timeout, il est rare dans le contexte normal d'une application. Le timeout surviendra si un client n'arrive pas à envoyer le prochain paquet TCP/UDP dans un délai approprié, cela est rare lorsque la connexion reste ouverte puisque souvent la connexion se fermera avant que le délai soit assez long pour déclancher le timeout. Le timeout surviendra surtout si une surutilisation des ressources ou un blocage arrive dans le serveur de l'application. Par exemple, si le serveur web accepte la connexion, mais n'arrive pas à ouvrir la base de données ou établir le verrou qu'il pourrait avoir besoin. Cela peut également se produire dans le cas de l'écriture sur le disque si celui-ci arrête de répondre ou se bloque, ce qui peut arriver en cas de corruption ou de problème matériel.

Le dernier timeout, et celui que l'on rencontre le plus souvent, est le read_timeout. Celui-ci représente l'envoi d'une réponse (en-tête + corps) du serveur vers le client. Le read_timeout est déclenché par le temps entre deux paquets dans une réponse. Cela signifie qu'il ne sera pas déclenché si la réponse est envoyée progressivement, comme un téléchargement de fichier ou le streaming d'informations. Celui-ci arrive dans un contexte normal lorsqu'il y a un traitement trop long du côté serveur sans envoyer de réponse au client. La cause du timeout est souvent quelque chose que nous avons programmé et non due au serveur ou au réseau. Dans un contexte anormal, comme lors d'une surutilisation des ressources ou d'un problème de serveur, le timeout se produit en raison du manque de ressources disponibles pour compléter la requête dans un délai raisonnable.

Nous allons nous concentrer sur le read_timeout, car c'est celui sur lequel nous avons le plus de contrôle et qui pose le plus grand risque pour l'application dans notre contexte. Lors de la configuration du timeout, le choix du temps dépend de l'utilisation et de l'objectif de l'application. Le timeout doit être suffisamment long pour ne pas affecter la majorité des requêtes normales dans une application web. Dans une application web, un délai de 3 secondes est souvent considéré comme le maximum avant de perdre un utilisateur. Dans le contexte d'une application affichant beaucoup de données de manière asynchrone, comme un tableau de bord en JavaScript, le temps doit être plus élevé pour permettre le chargement d'une plus grande quantité de données. Le timeout doit également être suffisamment court pour limiter l'impact qu'une seule requête problématique peut avoir sur un serveur.

Un exemple de situation avec une requête problématique est une boucle infinie accidentelle. Cette boucle effectue du traitement, des calculs ou des appels SQL, et elle va monopoliser une grande quantité de CPU jusqu'à ce qu'elle se termine, ce qui n'arrivera pas naturellement. Cela a pour conséquence de ralentir les prochains appels à l'API, pouvant entraîner une accumulation de lenteurs progressives jusqu'à ce qu'une limite soit atteinte, comme la limite de connexion au serveur ou de mémoire. Dans un tel cas, cela peut entraîner un temps d'arrêt (downtime) de l'application et potentiellement de tout le serveur ou du stack entier. Pour se protéger de ce genre de cas, le timeout est configuré suffisamment bas pour que le serveur web voie rapidement qu'une requête roule sans rien retourner comme résultat. Il coupe alors la requête et réduit l'impact sur les ressources. Ce genre de requête problématique peut aussi arriver dans un contexte normal avec un appel qui devrait être fonctionnel. Il est possible que le serveur appelle un autre service en arrière-plan et que celui-ci bloque. Par exemple, si votre application communique avec un stockage d'objets comme AWS S3 dans des requêtes appelées régulièrement et que celui-ci ne fonctionne pas correctement (ne retourne pas d'erreur, mais timeout), lorsque cela arrive, vous pouvez atteindre une limite de connexion entrante de votre application, car toutes les connexions en cours sont bloquées à attendre le retour de S3.

Malgré toutes ces solutions, les timeouts ne sont pas une solution parfaite. Même un timeout court de 15 secondes (qui est le temps par défaut dans la plupart des configurations) peut encore causer des problèmes. C'est pourquoi le modèle du circuit breaker existe. Le circuit breaker permet d'éviter l'accumulation de ressources dues à une attente réseau (timeout). Pour en savoir plus sur le circuit breaker, consultez les liens suivants : https://martinfowler.com/bliki/CircuitBreaker.html et https://microservices.io/patterns/reliability/circuit-breaker.html.

La règle générale est de garder le timeout aussi bas que possible et de trouver une solution alternative si celui-ci est atteint. La première chose à faire est de se demander si le temps requis est normal, et s'il y a une optimisation possible pour améliorer les performances. Ensuite, il faut examiner s'il est possible de faire l'action de manière asynchrone en utilisant une queue ou un autre mécanisme similaire côté serveur. Le but des deux premières solutions est de ne pas retenir l'utilisateur pendant que la requête se fait. Une requête qui déclenche le read_timeout ne retourne rien pendant la requête, mais seulement à la fin. Pour un utilisateur, c'est un chargement sans aucune indication que cela fonctionne ou que cela arrive à terme, sans aucun retour d'information.

Si après avoir effectué ces vérifications, il est toujours nécessaire de garder l'utilisateur pendant que la requête se fait, une bonne solution consiste à envoyer une réponse partielle (feedback ou streaming) au fur et à mesure que le traitement est effectué, afin de garder la connexion ouverte. Cela permet d'indiquer à tous les intervenants, aussi bien le client que les serveurs qui transmettent la requête, que c'est normal et qu'il y a quelque chose qui se passe. Cela évite le déclenchement du timeout.

Enfin, il est possible qu'après avoir effectué toutes ces vérifications, les solutions ne puissent pas être implémentées pour une raison ou une autre. Dans ce cas, il est possible d'augmenter le timeout, mais il est important de le faire de manière contrôlée. Il faut l'appliquer uniquement à ce qui nécessite une augmentation et il faut utiliser une valeur logique plutôt que de simplement désactiver ou mettre une valeur artificiellement trop élevée.

En conclusion, les timeouts, tout comme d'autres limites, sont là pour protéger l'application et non pour lui nuire. Il est important de tenir compte des limites lors du développement d'une fonctionnalité, et il est particulièrement important de ne jamais simplement chercher la solution qui contourne ces limites sans penser aux conséquences.


Auteur : Christian Dion

References

  1. https://nginx.org/en/docs/http/ngx_http_proxy_module.html