Accueil Le blog ebiznext
Solution d'API Gateway avec Ansible et Openresty / Envoy

openresty / envoy pour le cas d’usage API Gateway. Seront abordés la mise en œuvre, la facilité d’usage grace à la puissance d’ansible ainsi qu’un REX sur l’utilisation du ProxyProtocol afin de configurer plusieurs instances derrière un Load Balancer OVH. L’intégralité du code est disponible sur le repo Ebiznext.

Une solution d’API Management est une solution d’entreprise souvent très onéreuse. En effet, en plus de l’API Gateway, elle comprend plusieurs composants qui permettent de gérer la création, la publication, la monétisation et l’inscription aux API à consommer.

Cet article se positionne dans le périmètre de l’API Gateway qui permet de répondre à ces problématiques: quand exposer une API? Qui l’utilise? Qui a le droit de l’utiliser? et à quelles fréquence?

Nous avons constaté, lors de nos différentes prestations chez nos clients où sont déployées des solutions d’API Management, que la plupart des fonctionnalités indispensables lors des démarrages de projets ou de la phase initiale d’exposition d’APIs, ne dépasse pas celle du périmètre de l’API Gateway, à savoir

  • Gérer les redirections api internes et/ou externes
  • Ajouter une règle de routage en fonction de l’url
  • Réécriture url
  • Gérer les hôtes virtuels
  • Basic auth
  • Rate limiting
  • White listing
  • Support du SSL
  • Haute disponibilité

On l’aura compris ce qu’on attend d’une API Gateway, c’est de filtrer, limiter et sécuriser le trafic vers et depuis les API du SI.

Bien sûr, beaucoup de solutions existent en open source (Kong, Tyk, Ambassador, Express Gateway, WOSS2, et d’autres). Le but de cette publication est de montrer qu’il est facile de répondre rapidement aux besoins primordiaux, en utilisant des outils simples et rapides d’implémentation, sans sacrifier performance, agilité et maintenabilité.

Dans cette publication, nous présenterons comment construire une API Gateway pour gérer facilement l’exposition de ses microservices avec des outils OpenSource – pour la plupart déjà présents dans l’infrastructure SI – tout en étant compatible avec les pratiques DevOps (Infrastructure-as-Code, GitOps, Culture de collaboration, Déploiement continu).

Nous commencerons par les apports d’outils comme Ansible pour la simplification de la configuration, puis les spécificités et limites de l’implémentation de notre solution avec deux outils que nous voyons souvent confrontés ces deux dernières années à savoir: la référence Nginx/Openresty, que nous avons déployé à maintes reprises en production et le nouveau venu Envoy.

Le tableau ci-dessous présente un tableau comparatif des possibilités offertes par les deux reverse proxy choisis.

  Nginx / OpenResty Envoy
Virtual hosts X X
Redirect and rewrite URL X X
Basic auth X ?
Rate limiting X ?
White listing X ?
SSL X X

Nous terminerons par un retour d’expérience sur la manière d’offrir une haute disponibilité à la solution dans un contexte de déploiement sur le cloud.

Ansible

L’idée est simple: se baser sur la puissance offerte par Ansible.

  • Les inventaires de plateforme: DRY la même base de code permet d’adresser plusieurs plateformes de Dev/Recette/Production

├── platforms
|  └── dev
|  └── prod
|  └── vagrant
|     ├── Vagrantfile
|     ├── docker-server.vdi
|     ├── group_vars
|     ├── host_vars
|     ├── provision
|     └── vagrant-inventory.ini

  • Les mécanismes de surcharge des variables de playbook Ansible: Ceci nous permet de définir des variables par défaut et de les surcharger par groupe de machines, plateforme, ligne de commande, etc.

  • Le mécanisme de templating avec Jinja2: Tout le boiler plate de configuration est sous forme de template avec des expressions conditionnelles en fonction de la configuration


{% for cluster in clusters|list %}
    - name: {{ cluster.name }}_cluster
      connect_timeout: {{ cluster.timeout }}
      type: {{ cluster.type }}
      dns_lookup_family: V4_ONLY
      lb_policy: ROUND_ROBIN
      hosts: [{ socket_address: { address: {{ cluster.address }}, port_value: {{ cluster.port}} }}]
      tls_context: { {% if cluster.tls is defined and cluster.tls.sni is defined and cluster.tls.sni|length %}sni: {{ cluster.tls.sni }} {% endif %}}
  {% endfor %}
  admin:
    access_log_path: "/var/log/envoy/admin_access.log"
    address:
      socket_address: { address: {{ envoy.config.admin.address }}, port_value: {{ envoy.config.admin.port }} }

  • Les modules Ansible: Pour illustration, les taches de déploiement du service utilisent le module docker_container.
- name: Ensure the envoy service is running
  docker_container:
    name: ""
    image: ":"
    state: started
    ports:
      - ":"
      - ":"
    volumes:
      - "/envoy.yaml:/etc/envoy/envoy.yaml:ro"
      - "/:/var/log/envoy/:rw"
  become: yes

Grace à la modularité d’Ansible, vous pouvez déployer le même service via l’orchestrateur de votre choix. Nous avons mis en place la même solution en déployant via Marathon ou bien kubernetes via le k8s module.

  • Simplicité de la syntaxe YAML: En effet, outre la déclaration des taches Ansible qui est plus lisible, il nous a permis aussi de mettre en place un DSL de configuration intuitif et facile à maintenir.

Définir les hosts

La définition des hosts, ainsi que la configuration de toutes les fonctionnalités qui vont être présentées par la suite, se situent au niveau du fichier roles/{envoy,openresty}/defaults/main.yml

Il faut qu’il y ait au moins une occurrence d’hôte virtuel. En effet comme le permettent aussi bien openresty qu’envoy, il est possible de déclarer autant d’hôtes virtuels.

pour openresty:

virtual_hosts:
  - name: example.com
    domains:
      - "example.com"
      - "www.example.com"
  - name: api.example.com
    domains:
      - "api.example.com"

Pour envoy, la syntaxe est la même, à une subtilité près: Si l’upstream nécessite de spécifier un port, il faut le spécifier aussi au niveau des domaines.

virtual_hosts:
 - name: local_service
   domains:
     - "localhost"

Ici les valeurs de port d’écoute et de port d’administration pour envoy n’apparaissent que s’il faut surcharger les valeurs par défaut, définies dans {openresty,envoy}/vars/main.yml

Si on veut déployer le service sur un autre port, il faut valoriser la variable openresty_conf_port.

virtual_hosts:
  - name: example.com
    domains:
      - "example.com"
      - "www.example.com"
  - name: api.example.com
    domains:
      - "api.example.com"

openresty_conf_port: 9090

Pour envoy, c’est les variables envoy_listener_port et envoy_admin_port

 virtual_hosts:
 - name: local_service
   domains:
     - "localhost:10000"

envoy_listener_port: 10000
envoy_admin_port: 9902
 

On remarque une particularité d’envoy; bien que le port soit spécifié au niveau de la configuration, il faut l’indiquer dans les différents domains déclaré – aucune documentation lue n’en parle, d’une manière spécifique.

Redirection et réécriture d’URL

La déclaration d’un upstream ainsi que des règles de filtrage se fait avec la syntaxe suivante pour openresty

upstreams:
    - name: exchangerates_api
      servers:
        - "api.exchangeratesapi.io:443"
      limit: "6r/m"
(...)

domains:
  - "example.com"
locations:
  - { name: "/er/", rewrite: "/", proxy_pass: "https://exchangerates_api"}
# - { name: "/app/", regex: "/app/(.*) /api/app/v2/$1", proxy_pass: "https://app_api"}

Une écriture en multi ligne est aussi possible

domains:
  - "example.com"
locations:
  - name: "/er/" 
    rewrite: "/"
    proxy_pass: "https://exchangerates_api"

Avec openresty, on bénéficie du support natif du filtrage par expressions régulières.

Pour envoy, la syntaxe est un peu plus verbeuse. En effet, cela est dû à la configuration d’envoy qui nécessite la spécification du port et du hostname pour l’upstream mais aussi l’identification du nom de serveur pour le mode TLS (SNI, permet d’utiliser les virtual host avec SSL). Cette configuration n’a pas lieu d’être pour openresty, c’est complètement transparent pour l’utilisateur.

clusters:
- name: "exchangerates_api"
  type: "LOGICAL_DNS"
  address: "api.exchangeratesapi.io"
  port: "443"
  tls:
    sni: "api.exchangeratesapi.io"
    
virtual_hosts:
 - name: local_service
   domains:
     - "localhost"
   rules:
     - { prefix: "/er/", prefix_rewrite: "/", host_rewrite: "api.exchangeratesapi.io", cluster: "exchangerates_api"}

Le choix a été fait de garder les noms des concepts utilisés, pour chaque outil. Ici on parle bien de clusters et non d’upstreams. Envoy a été crée pour les besoins du side car pattern utilisé dans l’architecture Service en maille ou Service Mesh. Pour les exemples choisis, étant donné que les API sont déployées sur le cloud, et potentiellement exposées même via api gateway ou derrière un load balancer, il faut spécifier à envoy la manière d’effectuer la résolution de nom pour atteindre l’endpoint qui nous concerne. D’où l’usage de LOGICAL_DNS pour des API publiques comme echangeratesapi.io alors que pour le site ebiznext.com la résolution est à STATIC.

Authentification basique

L’authentification basique n’est pas une fonctionnalité fournie par envoy. Seul les filtres Http, d’authentification JWT et de service externe sont fournies.

Pour protéger un service exposé, par Basic Auth, il suffit de rajouter dans le fichier openresty/defaults/main.yml l’attribut auth: yes

- { name: "/er/", rewrite: "/", proxy_pass: "https://exchangerates_api", auth: yes}

Un fois activé, le service ne sera accessible que par les utilisateurs déclarés.

users:
  - name: "janedoe"
    password: !vault |
      $ANSIBLE_VAULT;1.1;AES256
      32393264653938626439646333643234333330666432336531353732303465643764636436313239
      3234363535393863383433313832633935393463663931660a383136373832383863366234363934
      36653039636261623236396662623463346663623562633532353032633462346632346432363535
      6539396339313566340a663635393139663633636134336265626264663733366538626536376438
      3865

  - name: "johndoe"
    password: !vault |
      $ANSIBLE_VAULT;1.1;AES256
      32393264653938626439646333643234333330666432336531353732303465643764636436313239
      3234363535393863383433313832633935393463663931660a383136373832383863366234363934
      36653039636261623236396662623463346663623562633532353032633462346632346432363535
      6539396339313566340a663635393139663633636134336265626264663733366538626536376438
      3865

Les mots de pass sont chiffrés avec Ansible Vault afin d’être poussés dans la base de code, en toute sécurité.

$ echo -n 'letmein' | ansible-vault encrypt_string --stdin-name 'password'  --vault-password-file vault.txt

Reading plaintext input from stdin. (ctrl-d to end input)
password: !vault |
          $ANSIBLE_VAULT;1.1;AES256
          65303566616336623866343634316432333139636335656439656562303736333438323531313534
          3030353832663738616436366466316436316236353434640a623134626535383437356462373566
          63336664666463323466396432383864353165333033326334626462366138636631653664623761
          3534333533356339390a613664626131303734616561613339323931316364653461316663633466
          3232
Encryption successful

Limitation de débit (Rate Limiting)

De la même manière que le Basic Auth, envoy fournit un filtre http, qui permet d’appeler le service de Rate limiting lorsque la route ou l’hôte virtuel correspondent à la requête, et d’avoir en retour la configurations de limite de débit associée.

Avec la solution basée sur openresty, il suffit de rajouter à la déclaration d’un upstream, la limite correspondante.

upstreams:
  - name: site_ebiz
    servers:
      - "5.39.25.113:443"
  - name: exchangerates_api
    servers:
      - "api.exchangeratesapi.io:443"
    limit: "6r/m"
  - name: service_1
    servers:
      - "service1"
    limit: "10r/s"

La syntaxe est intuitive est parlante. Ici pour exchangerates_api les appels sont limités à une requête toute les 10 secondes.

Listes Blanches (White listing)

Encore une fonctionnalité non présente sur envoy et où on comprend au fur et à mesure de notre exploration de l’outil qu’il n’a pas été conçu pour les mêmes besoins et usage que d’autres reverse proxy plus matures. En effet, Nginx prend en charge les listes Blanches et noires IP en utilisant les directives allow et deny fournis par le module http_acces. Il est donc nettement plus facile d’enrichir notre solution d’API gateway, en se basant sur openresty qui inclus de facto ce module.

Les Listes blanches peuvent être définies d’une manière globale par hôte virtuel ou par service.

(...)
domains:
  - "localhost"
locations:
  - { name: "/ebiznext/", rewrite: "/", proxy_pass: "https://site_ebiz", whitelist: ['193.248.38.15', '80.12.42.127'] } # whitelist par service
  - { name: "/er/", rewrite: "/", proxy_pass: "https://exchangerates_api", auth: yes}
whitelist:
  - 193.248.38.15
  - 80.12.42.127
(...)

SSL

Pour activer Le SSL il suffit de rajouter dans le fichier defaults/main.yml la variable d’activation ssl ainsi que le prefix des fichiers chaine de certificats et clés privé.

(Pour les besoin de la demo, un certificat auto signé est généré au moment du provisioning de la VM)

Pour envoy

ssl: yes
envoy_config_certs_prefix: "localhost"

Pour openresty, il est possible d’activer le SSL par hôte virtuel

virtual_hosts:
  - name: localhost
    ssl: yes
    domains:
      - "localhost"

Haute disponibilité

Comme évoqué au début du texte, une des fonctionnalités des plus indispensables est la Haute disponibilité de notre API Gateway. Pour l’assurer, il nous faut au minimum deux instances dans un sytème distribué d’évolution horizontale. C’est là où intervient le répartiteur de charge Load Balancer qui permet d’assurer un switch rapide vers l’instance fonctionnelle de notre API Gateway en cas de lenteur ou même de dysfonctionnement.

Load balancer in front of `openresty` API Gateway

Cependant rajouter un Load Balancer nécessite quelques adaptations et configurations en plus. Dans ce qui va suivre, nous allons voire comment mettre en place une telle architecture avec l’offre LoadBalncer d’OVH.

Prérequis

Pour mettre en oeuvre le déploiement de cette solution vous devez disposer des éléments suivants :

  • un service OVH Load Balancer ;
  • un serveur accessible sur le quel seront déployées nos instances openresty

Configuration OVH

La ferme de serveur est un élément d’infrastructure OVH qui permet d’exposer au frontend Load Balancer un service de type HTTP/S, TCP ou UDP. Il a pour rôle aussi de repartir la charge en transmettant aux serveurs le trafic reçu par le frontend. Dans notre cas, nous avons déclaré une ferme de serveurs pour notre service HTTP qui permettra de transmettre les requêtes HTTPS vers le noeud DC/OS sur le quel tourne nos instances d’API Gateway.

Nous choisirons donc le protocole TCP, afin de pouvoir utiliser le ProxyProtocol au niveau du Frontend Load Balancer.

Le ProxyProtocol a été développé par l’équipe du Load Balancer HAPRoxy comme l’homologue en TCP des En-Têtes HTTP standards telles que X-Forwarded-For. Il permet de router une requête au niveau L4 (Contrairement au routage Http/s Au niveau L7), sans perte d’informations, même si elle est chiffrée, et sans modification d’infrastructure. Cependant, il est important que tous les composants de la chaine soient compatibles avec ProxyProtocol.

Une fois notre ferme déclarée nous l’avons donc associée au Frontend Load balancer, tout en choisissant comme protocole de relais, le protocole TCP.

Il nous reste maintenant à ajouter des serveurs à notre Ferme. Les serveurs correspondent aux noeuds public de notre cluster DC/OS sur les quels le service openresty a été exposé. Il est important de choisir une version de ProxyProtocole, la V1 est la la version la plus largement gérée.

Configuration du ProxyProtocol coté openresty

Openresty supporte la version 1 du ProxyProtocol. Il est capable d’en extraire les principales informations, à savoir l’adresse IP et le port source du client tels que vu par le service OVH Load Balancer. Ces informations sont exposées à travers la variable proxy_protocol_addr. De même que pour son homologue HTTP X-Forwarded-For, openresty peut se servir de cette variable pour prendre en compte la bonne adresse source dans les logs avec le module ngx_http_realip.

La liste des addresses ip à configurer afin qu’il puissent joindre le serveur openresty, est disponible au niveau de la page d’accueil du tableau de board OVH.

Voici les directives à ajouter dans le fichier de configuration openresty de chaque instance.


listen 443 proxy_protocol;

...

set_real_ip_from 10.108.0.0/19;
set_real_ip_from 10.108.32.0/19;
set_real_ip_from 10.108.64.0/19;
set_real_ip_from 10.108.96.0/19;
set_real_ip_from 10.108.128.0/19;
set_real_ip_from 10.108.192.0/19;
set_real_ip_from 10.109.0.0/19;
set_real_ip_from 10.109.32.0/19;
set_real_ip_from 10.109.96.0/19;
set_real_ip_from 10.109.128.0/19;
set_real_ip_from 10.109.160.0/19;
set_real_ip_from 10.109.192.0/19;
set_real_ip_from 10.109.224.0/19;
set_real_ip_from 10.110.0.0/19;
set_real_ip_from 10.110.32.0/19;
set_real_ip_from 10.110.64.0/19;
set_real_ip_from 10.110.96.0/19;
set_real_ip_from 10.110.128.0/19;
set_real_ip_from 10.110.160.0/19;
set_real_ip_from 10.110.192.0/19;
set_real_ip_from 10.110.224.0/19;
set_real_ip_from 10.111.0.0/19;
set_real_ip_from 10.111.32.0/19;
set_real_ip_from 10.111.64.0/19;
set_real_ip_from 10.111.96.0/19;
set_real_ip_from 10.111.128.0/19;
set_real_ip_from 10.111.160.0/19;
set_real_ip_from 10.111.192.0/19;


# see https://nginx.org/en/docs/http/ngx_http_realip_module.html
real_ip_header proxy_protocol;

...

proxy_set_header X-Real-IP        $proxy_protocol_addr;
proxy_set_header X-Forwarded-For  $proxy_protocol_addr;
...

Ces adaptations sont disponibles dans le repo github sous la branche proxy-rotocol.

La solution qui a inspiré cette publication a été déployé dans le cloud OVH au sein d’un Cluster Mesos DC/OS, mais le principe général reste le même que la solution soit déployée sur DC/OS ou Kubernetes, sur AWS, GCP ou Azure.

Conclusion

Dans cet article, nous avons vu qu’il était possible de monter facilement une solution d’api gateway à base d’outils open source. Ce cas d’usage nous a permis de le réaliser avec deux outils à vocation similaire sur le périmètre fonctionnel “proxy”: openresty et envoy. Nous avons également vu comment il est relativement simple d’adapter la configuration de notre solution pour le support du ProxyProtocol afin d’être hautement diponible dans un contexte de production.