FAQ OpenResty | Chargement dynamique, NYI et mise en cache de Shared Dict

API7.ai

January 19, 2023

OpenResty (NGINX + Lua)

La série d'articles sur OpenResty a été mise à jour jusqu'à présent, et la partie sur l'optimisation des performances est tout ce que nous avons appris. Félicitations à vous pour ne pas avoir pris de retard, pour continuer à apprendre et à pratiquer activement, et pour laisser vos réflexions avec enthousiasme.

Nous avons collecté de nombreuses questions plus typiques et intéressantes, et voici un aperçu de cinq d'entre elles.

Question 1 : Comment accomplir le chargement dynamique des modules Lua ?

Description : J'ai une question sur le chargement dynamique implémenté dans OpenResty. Comment puis-je utiliser la fonction loadstring pour terminer le chargement d'un nouveau fichier après qu'il a été remplacé ? Je comprends que loadstring ne peut charger que des chaînes de caractères, donc si je veux recharger un fichier/module Lua, comment puis-je le faire dans OpenResty ?

Comme nous le savons, loadstring est utilisé pour charger une chaîne de caractères, tandis que loadfile peut charger un fichier spécifié, par exemple : loadfile("foo.lua"). Ces deux commandes atteignent le même résultat. Quant à la manière de charger des modules Lua, voici un exemple :

resty -e 'local s = [[
local ngx = ngx
local _M = {}
function _M.f()
    ngx.say("hello world")
end
return _M
]]
local lua = loadstring(s)
local ret, func = pcall(lua)
func.f()'

Le contenu de la chaîne s est un module Lua complet. Ainsi, lorsque vous détectez un changement dans le code de ce module, vous pouvez redémarrer le chargement avec loadstring ou loadfile. De cette manière, les fonctions et variables qu'il contient seront mises à jour en conséquence.

Pour aller plus loin, vous pouvez également encapsuler la récupération des changements et le rechargement dans une fonction appelée code_loader.

local func = code_loader(name)

Cela rend les mises à jour de code beaucoup plus concises. En même temps, code_loader utilise généralement un cache lru pour mettre en cache s afin d'éviter d'appeler loadstring à chaque fois.

Question 2 : Pourquoi OpenResty n'interdit-il pas les opérations bloquantes ?

Description : Au fil des années, je me suis toujours demandé, puisque ces appels bloquants sont officiellement déconseillés, pourquoi ne pas simplement les désactiver ? Ou ajouter un indicateur pour laisser l'utilisateur choisir de les désactiver ?

Voici mon opinion personnelle. Premièrement, parce que l'écosystème autour d'OpenResty n'est pas parfait, parfois nous devons appeler des bibliothèques bloquantes pour implémenter certaines fonctionnalités. Par exemple, avant la version 1.15.8, vous deviez utiliser la bibliothèque Lua os.execute au lieu de lua-resty-shell pour appeler des commandes externes. Par exemple, dans OpenResty, la lecture et l'écriture de fichiers ne sont encore possibles qu'avec la bibliothèque Lua I/O, et il n'existe pas d'alternative non bloquante.

Deuxièmement, OpenResty est très prudent concernant de telles optimisations. Par exemple, lua-resty-core a été développé pendant longtemps, mais il n'a jamais été activé par défaut, nécessitant que vous appeliez manuellement require 'resty.core'. Il a été activé jusqu'à la dernière version 1.15.8.

Enfin, les mainteneurs d'OpenResty préfèrent standardiser les appels bloquants en générant automatiquement du code Lua hautement optimisé via le compilateur et le DSL. Ainsi, il n'y a pas d'effort pour faire quelque chose comme des options d'indicateur sur la plateforme OpenResty elle-même. Bien sûr, je ne suis pas sûr que cette direction puisse résoudre le problème.

Du point de vue d'un développeur externe, le problème plus pratique est de savoir comment éviter de tels blocages. Nous pouvons étendre les outils de détection de code Lua, comme luacheck, pour trouver et alerter sur les opérations bloquantes courantes, ou nous pouvons désactiver ou réécrire de manière intrusive certaines fonctions directement en réécrivant _G, par exemple :

resty -e '_G.ngx.print = function()
ngx.say("hello")
end
ngx.print()'

# hello

Avec cet exemple de code, vous pouvez réécrire directement la fonction ngx.print.

Question 3 : L'opération de NYI de LuaJIT a-t-elle un impact significatif sur les performances ?

Description : loadstring montre never dans la liste NYI de LuaJIT. Cela aura-t-il un grand impact sur les performances ?

Concernant le NYI de LuaJIT, nous n'avons pas besoin d'être trop stricts. Pour les opérations qui peuvent être JIT, l'approche JIT est naturellement la meilleure ; mais pour les opérations qui ne peuvent pas encore être JIT, nous pouvons continuer à les utiliser.

Pour l'optimisation des performances, nous devons adopter une approche scientifique basée sur des statistiques, c'est ce que fait l'échantillonnage des graphes de flammes. L'optimisation prématurée est la racine de tous les maux. Nous n'avons besoin d'optimiser que le code chaud qui effectue de nombreux appels et consomme beaucoup de CPU.

Revenons à loadstring, nous ne l'appellerons pour recharger que lorsque le code change, pas à chaque requête, donc ce n'est pas une opération fréquente. À ce stade, nous n'avons pas à nous inquiéter de son impact sur les performances globales du système.

En lien avec le deuxième problème de blocage, dans OpenResty, nous invoquons parfois également des opérations d'E/S de fichiers bloquantes pendant les phases init et init worker. Cette opération est plus compromise en termes de performances que NYI, mais comme elle n'est effectuée qu'une seule fois au démarrage du service, elle est acceptable.

Comme toujours, l'optimisation des performances doit être vue d'un point de vue macro, un point auquel vous devez prêter une attention particulière. Sinon, en vous focalisant sur un détail particulier, vous risquez d'optimiser pendant longtemps sans obtenir un bon effet.

Question 4 : Puis-je implémenter moi-même un upstream dynamique ?

Description : Pour l'upstream dynamique, mon approche consiste à configurer 2 upstreams pour un service, à sélectionner différents upstreams selon les conditions de routage, et à modifier directement l'IP dans l'upstream lorsque l'IP de la machine change. Y a-t-il un inconvénient ou un piège dans cette approche par rapport à l'utilisation directe de balancer_by_lua ?

L'avantage de balancer_by_lua est qu'il permet à l'utilisateur de choisir l'algorithme d'équilibrage de charge, par exemple, s'il faut utiliser roundrobin ou chash, ou tout autre algorithme implémenté par l'utilisateur, ce qui est flexible et performant.

Si vous le faites de la manière des règles de routage, c'est la même chose en termes de résultat. Mais la vérification de santé de l'upstream doit être implémentée par vous, ce qui ajoute beaucoup de travail supplémentaire.

Nous pouvons également étendre cette question en demandant comment nous devrions implémenter ce scénario pour abtest, qui nécessite un upstream différent.

Vous pouvez décider quel upstream utiliser dans la phase balancer_by_lua en fonction de uri, host, parameters, etc. Vous pouvez également utiliser des passerelles API pour transformer ces jugements en règles de routage, en décidant quelle route utiliser dans la phase initiale access, puis en trouvant l'upstream spécifié via la relation de liaison entre la route et l'upstream. C'est une approche courante des passerelles API, et nous en parlerons plus en détail plus tard dans la section pratique.

Question 5 : La mise en cache de shared dict est-elle obligatoire ?

Description :

Dans les applications de production réelles, je pense que la couche de cache shared dict est indispensable. Il semble que tout le monde ne se souvienne que des avantages du cache lru, aucune restriction sur le format des données, pas besoin de désérialiser, pas besoin de calculer l'espace mémoire en fonction du volume k/v, pas de contention entre les workers, pas de verrous de lecture/écriture et des performances élevées.

Cependant, ne négligez pas que l'un de ses points faibles les plus fatals est que le cycle de vie du cache lru suit le Worker. Chaque fois que NGINX recharge, cette partie du cache sera complètement perdue, et à ce moment-là, s'il n'y a pas de shared dict, la source de données L3 sera saturée en quelques minutes.

Bien sûr, c'est le cas d'une concurrence plus élevée, mais puisque le cache est utilisé, le volume d'affaires n'est certainement pas faible, ce qui signifie que l'analyse mentionnée ci-dessus s'applique toujours. Si j'ai raison dans cette perspective ?

Dans certains cas, il est vrai que, comme vous l'avez dit, le shared dict n'est pas perdu lors du rechargement, donc il est nécessaire. Mais il y a un cas particulier où seul le cache lru est acceptable si toutes les données sont activement disponibles depuis L3, la source de données, dans la phase init ou init_worker.

Par exemple, si la passerelle API open source APISIX a sa source de données dans etcd, elle ne récupère les données que depuis etcd. Elle les met en cache dans le cache lru pendant la phase init_worker, et les mises à jour ultérieures du cache sont activement récupérées via le mécanisme watch de etcd. De cette manière, même si NGINX recharge, il n'y aura pas de ruée vers le cache.

Ainsi, nous pouvons avoir des préférences dans le choix de la technologie, mais ne généralisons pas de manière absolue car aucune solution unique ne peut s'adapter à tous les scénarios de mise en cache. C'est une excellente manière de construire une solution minimale viable selon les besoins du scénario réel, puis de l'augmenter progressivement.