Comment nous avons remplacé OpenResty par NGINX ?

Nous maintenons pour le compte d’un de nos clients une API Gateway que nous avons construite avec nos petites mains (pas de Kong ou Tyk ici, c’est une API Gateway assez simple). Cette API Gateway permet simplement de protéger un ensemble d’API (/api1, /api2) avec un token.

Pour pouvoir interroger une API (/api1, par exemple), l’utilisateur doit transmettre dans l’url un token (?token=xxx) ou un header (Authorization: Bearer xxxx).

Sous le capot, l’API Gateway va faire une sous-requête à une API d’autorisation afin de valider le token.

  • Si le token est valide, l’API Gateway acceptera de transmettre la requête aux backends de l’API (/api1) et ajoutera des informations complémentaires dans les headers de la requête vers l’API finale (exemple des rôles, …).
  • Si le token n’est pas valide, l’API Gateway refusera l’accès à l’API.

La première version de cette API Gateway était basée sur la distribution NGINX OpenResty. Nous avons déjà souvent parlé de cette distribution (ici par Thibault ou slides – par votre serviteur).

OpenResty est basée sur NGINX mais ajoute la possibilité de scripter en Lua des comportements à chaque étape de la requête. Il est par exemple possible d’écrire du code Lua qui va être exécuté avant l’accès à une ressource.

Je cite l’article de Thibault :

OpenResty embarque le module HttpLuaModule permettant l’exécution de script Lua. Plusieurs directives permettent de lancer un script à différents moments de la requête, elles sont toutes de la forme « *_by_lua ». Les fonctions disponibles dans ces directives sont limitées :

  • init_by_lua: le code sera exécuté au démarrage quand le serveur NGINX lit la configuration. Il est utile pour déclarer des variables globales ou précharger des modules ;
  • set_by_lua: permet d’effectuer un traitement et de récupérer le résultat dans un variable. Le code exécuté bloque la boucle d’événement de NGINX et doit donc être rapide ;
  • rewrite_by_lua: le code est exécuté pour chaque requête et après l’exécution du module HttpRewrite ;
  • access_by_lua: le code est exécuté pour chaque requête et après l’exécution du module HttpAccess ;
  • header_filter_by_lua: utilisé uniquement pour filtrer les headers de la requête ;
  • content_by_lua: utilisé lorsqu’un script renvoie du contenu via par exemple ngx.say ;
  • body_filter_by_lua: le code est exécuté après réception de données de réponse et permet de modifier le contenu renvoyé au client. Il peut être lancé plusieurs fois par requête selon le volume de données ;
  • log_by_lua: le code est exécuté après l’écriture dans le access log.

Cette API Gateway est en place depuis 2014. Nous l’avons mis à jour régulièrement et elle répond parfaitement à nos besoins. Elle traite en rythme de croisière plusieurs centaines de requêtes par seconde sans sourciller. Merci NGINX ❤️.

Pourquoi avoir décidé de la remplacer ?

Jusqu’alors nous pensions que le seul moyen de gérer une API Gateway sous NGINX était de coder cette logique en Lua. Cela nous obligeait soit à compiler manuellement NGINX avec le support du module Lua, soit à utiliser la distribution OpenResty qui supporte nativement ce language.

C’était avant de découvrir l’article de NGINX présentant le module auth_request.

Cet article nous a amené à réfléchir à notre solution, à trouver des désavantages à OpenResty et des arguments à son remplacement :

  1. Le rythme de développement de OpenResty n’est pas aussi soutenu que NGINX. La communauté est bien plus petite. A l’heure où nous écrivons ces lignes, la dernière version stable de OpenResty est la version 1.13.6 (basée sur NGINX 1.13.6) qui date de mai 2018. La dernière version de NGINX est la version 1.15.8 de décembre 2018.
  2. OpenResty est une solution un peu exotique (convaincre notre hébergeur d’utiliser OpenResty n’avait pas été chose aisée) et nous souhaitions revenir à quelque chose de plus standard.
  3. Pour faire la sous-requête à l’API d’autorisation, nous utilisons en Lua la directive ngx.location.capture (exemple : ngx.location.capture('/api/oauth/checkToken?token=xxx)). Cette directive n’est pas compatible HTTP/2.
  4. Nous souhaitions depuis plusieurs mois mutualiser un autre service NGINX avec le service d’API Gateway afin de rationnaliser notre infrastructure.

La réalisation d’un rapide prototype en utilisant le module auth_request a fini de nous convaincre qu’il était temps de dire « Au revoir » à OpenResty.

Utilisation du module auth_request

Le module auth_request permet de définir une location qui va être utilisée comme sous-requête d’autorisation par NGINX. Si la sous-requête d’autorisation renvoie autre chose que 200, l’accès à la ressource sera interdit.

Vous trouverez ci-dessous la configuration commentée et un peu simplifiée que nous utilisons en production.


location = /auth {
    internal;
    set $query '';
    # Si la requête originale contient un token
    if ($request_uri ~* "token=([a-zA-Z0-9]*)") {
      set $query "token=$1";
    }

    # Si la requête contient l'entête Authorization Bearer xxx
    if ($http_authorization ~* "Bearer ([a-zA-Z0-9]*)") {
      set $query "token=$1";
    }

    # Si pas de token ou de bearer, on renvoie une 200 car on souhaite
    # quand même renvoyer la requête au backend. Dans votre cas, cela 
    # ne sera pas forcément nécessaire.
    # Certains endpoints sont accessibles sans rôles. 
    # Seul le backend est capable de le déterminer.
    if ($query = '') {
      return 200;
    }

    # On passe le token à l'API d'autorisation
    proxy_pass              http://api-oauth.domain.com/api-auth/checkToken?$query;
    proxy_pass_request_body off;
    proxy_set_header        Content-Length "";
    proxy_set_header        Authorization "";

    # Ajout d'un cache nginx (sur le disque) qui permet 
    # de mettre en cache les appels à l'API d'autorisation
    proxy_cache auth_cache;
    # La clé de cache contient juste le token
    proxy_cache_key oauth-key-$query;
    proxy_cache_valid 200 302 404 10m;
    proxy_cache_valid 401 403 500 1m;
    proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
    proxy_cache_lock on;
    proxy_cache_background_update on;
    # On ignore les entêtes de cache de l'api d'autorisation
    proxy_ignore_headers Cache-Control Expires;
    add_header X-Cache-Status $upstream_cache_status always;
}

location /api1 {
    # Code permettant de valider l'accès à l'API
    auth_request /auth;
    # La sous-requête retourne un header X-Auth-Roles 
    # que nous stockons dans la variable $roles
    auth_request_set $roles $upstream_http_x_auth_roles;
    auth_request_set $appName $upstream_http_x_auth_clientname;

    # suppression du paramètre token dans les query params
    # nos APIs n'acceptent pas des paramètres non autorisés
    # nous sommes obligés de le faire en plusieurs étapes 
    # afin de supprimer le token dans tous les cas
    # Il y a sûrement moyen de le faire en une seule regex 
    # mais la regex sera moins lisible
    # cas 1- ?p1=v1&token=toto&p2=v2
    if ($args ~ (.*)token=[a-zA-Z0-9]*[&?](.*)) {
      set $args $1$2;
    }
    # cas 2- ?p1=v1&token=toto
    if ($args ~ (.*)&token=[a-zA-Z0-9]*) {
      set $args $1;
    }

    # cas 3- ?token=toto
    if ($args ~ token=[a-zA-Z0-9]*) {
      set $args '';
    }

    # Suppression du header Authorization 
    # pour ne pas l'envoyer au backend
    proxy_set_header        Authorization "";
    proxy_pass http://api1.domain.com$uri$is_args$args;
    # On envoie au backend la valeur $roles retournée 
    # par la sous-requêtes
    proxy_set_header X-Roles $roles;
}

Pour vous aider à comprendre la configuration, nous avons une API d’autorisation accessible sur api-oauth.domain.com. Pour valider un token, il suffit d’envoyer une requête sur /api-auth/checkToken?token=xxxx Si le token est invalide, l’API renvoie une 401. Si le token est valide, l’API renvoie une 200 avec une réponse de ce type (notez bien les headers de la réponse) :


HTTP/1.1 200 OK
Server: nginx
Content-Type: application/json
Connection: keep-alive
Cache-Control: private, must-revalidate
Date: Thu, 21 Feb 2019 12:31:11 GMT
X-auth-Roles: GROUP1,GROUP2
X-auth-ClientName: application-name-1
Expires: Sun, 04 Dec 292277026596 15:30:07 GMT
/

Les headers transmis par l’API d’autorisation peuvent être récupérés et transmis à l’API via les variables NGINX $upstream_http_x* et la directive auth_request_set.

Dans l’exemple ci-dessus, nous mettons en cache la sous-requête afin que l’API d’autorisation ne soit pas interrogée pour toutes les requêtes (10 minutes de cache pour un token valide, 1 minute de cache pour un token invalide).

Il est également à noter que :

  • Nos API sont strictes sur les paramètres reçus. Nous n’acceptons pas de paramètres non gérés. L’API Gateway doit donc supprimer le paramètre « token » avant de transmettre la requête aux backends.
  • Certaines de nos APIs possèdent des « endpoints » public et privés. Les API finales disposent donc aussi d’un mécanisme de sécurité. C’est la raison pour laquelle si un client nous interroge sans token, la requête est néanmoins transmise au backend. Selon votre contexte, ce mécanisme pourra être désactivé.

Les astuces de M. Truc © pour le module auth_request

Dans notre exemple, nous utilisons abondamment les if. Si vous êtes un utilisateur averti de NGINX, vous savez que le « if c’est le mal ». Nous n’avons pas trouvé d’autres solutions plus élégantes. Vos commentaires sont les bienvenus.

La version initiale basée sur OpenResty disposait d’un mécanisme de Throttling par token. Un token était autorisé à effectuer un nombre de requêtes limité par heure. Les scripts Lua nous permettaient de conserver en cache un compteur. Nous avons pour le moment perdu cette fonctionnalité mais nous envisageons de la réactiver en utilisant le module ngx_http_limit_req_module et en l’activant en fonction de propriétés retournées par l’API d’autorisation.

Nous disposons néanmoins toujours d’un mécanisme de gestion de Throttling plus bas niveau que nous ne détaillerons pas ici.

Conclusion

Nous avons fait la migration chez notre client en début de semaine. L’infrastructure reposait sur plusieurs serveurs derrière un load balancer. Nous avons réalisé la migration sur un premier serveur et modifié la règle de load balancing afin que ce nouveau serveur ne reçoive qu’un pour cent du trafic. Tout s’est parfaitement bien passé (à l’exception de problèmes annexes que nous tairons ici 🙃). Nous avons ensuite augmenté petit à petit le trafic sur le premier serveur en constatant toujours que tout se passait parfaitement bien. Puis le lendemain, nous avons finalement migré les autres serveurs. La migration a été totalement transparente pour l’utilisateur. Nous utilisons désormais la version officielle de NGINX et nous en sommes ravis.

A noter que les performances sont similaires (vous pouvez comparer par rapport à la semaine dernière) : Average request time

Nous avons parfaitement conscience qu’il existe sur le marché des solutions éprouvées (API Management) que nous aurions pu utiliser. Cela n’était pas l’objet de la mission qui nous avait été confiée et cela aurait nécessité un travail bien plus important (impact possible sur l’API d’autorisation ou sur le mécanisme de gestion des tokens…).

blog comments powered by Disqus