Unifier les requêtes HTTP et appels GRPC sur un domaine unique pour une configuration plus modulable : exemple avec Woodpecker

J’ai installé le service d’intégration continue Woodpecker, afin de remplacer DroneCI, que l’entreprise l’ayant racheté a décidé de l’enterrer. Woodpecker étant un fork de la dernière version libre de Drone, son utilisation est globalement semblable.

Néanmoins, les équipes ont suivi des orientations différentes sur certains aspects, et la communication avec les agents/runners, qui se faisaient avant au moyen de websockets, est réalisée dans Woodpecker au moyen du protocole GRPC.

La solution proposée par la documentation de Woodpecker est d’utiliser 2 domaines : un sera utilisé pour l’interface web et l’API REST, le second pour GRPC. Est-ce vraiment nécessaire ?

Configuration nginx utilisant 2 domaines

Woodpecker expose l’interface web et GRPC sur deux ports différents : respectivement 8000 et 9000.

L’approche la plus simple pour exposer ces deux services sur Internet est d’utiliser deux domaines distincts. Voici à quoi pourrait ressembler la configuration de notre reverse-proxy :

server {
    listen 80;
    server_name woodpecker.example.com;

    location / {
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Host $http_host;

        proxy_pass http://127.0.0.1:8000;
        proxy_redirect off;
        proxy_http_version 1.1;
        proxy_buffering off;

        chunked_transfer_encoding off;
    }
}

server {
    listen 80 http2;
    server_name woodpeckeragent.example.com;

    location / {
        grpc_pass grpc://127.0.0.1:9000;
    }
}

Nous utilisons ici le module GRPC de nginx avec notamment la directive grpc_pass. Cette directive est similaire à la directive proxy_pass : elle transmettra au port 9000 de la machine locale tous les paquets GRPC arrivant sur le domaine woodpeckeragent.example.com.

Par contre, déclarer 2 domaines, obtenir 2 certificats, … pour 1 seul et même service, avec un domaine que l’on va avoir tendance à oublier et négliger, personnellement je préfère éviter. Voyons donc si l’on ne peut pas réussir à faire mieux.

Le protocole GRPC

Sans rentrer dans les détails, GRPC est un protocole proche de HTTP/2. À ce titre, de nombreux reverse-proxy sont capables de transmettre les requêtes GRPC. C’est le cas de Caddy dont un exemple succinct est donné, Traefik. Mais nginx aussi supporte la transmission de requêtes GRPC.

Lorsqu’une requête GRPC est reçue par le reverse-proxy, celui-ci voit une requête HTTP/2 semblable à :

POST /pkg.Service/Function HTTP/2.0
Host: grpc.example.com
User-Agent: grpc-go/1.21.0
[...]

Rien donc a priori de déroutant pour un serveur web. On doit bien pouvoir faire quelque chose de malin pour utiliser ces similitudes à notre avantage.

Regrouper requêtes HTTP et appels GRPC sur un même domaine

Le chemin des requêtes GPRC est fixe, il dépend du fichier protobuf décrivant les appels et les structures. Chaque Service est déclaré au sein d’un paquetage (pkg), des Fonctions complètent ensuite le chemin. Tous les appels sont des POST.

Il convient donc d’extraire les différentes routes HTTP utilisées par chaque service protobuf. Voyons le fichier suivant pour Woodpecker :

https://github.com/woodpecker-ci/woodpecker/blob/main/pipeline/rpc/proto/woodpecker.proto

[...]
package proto;
[...]
service Woodpecker {
  rpc Version       (Empty)                returns (VersionResponse) {}
  rpc Next          (NextRequest)          returns (NextResponse) {}
  rpc Init          (InitRequest)          returns (Empty) {}
  rpc Wait          (WaitRequest)          returns (Empty) {}
  rpc Done          (DoneRequest)          returns (Empty) {}
  rpc Extend        (ExtendRequest)        returns (Empty) {}
  rpc Update        (UpdateRequest)        returns (Empty) {}
  rpc Log           (LogRequest)           returns (Empty) {}
  rpc RegisterAgent (RegisterAgentRequest) returns (RegisterAgentResponse) {}
  rpc ReportHealth  (ReportHealthRequest)  returns (Empty) {}
}
[...]
service WoodpeckerAuth {
  rpc Auth          (AuthRequest)          returns (AuthResponse) {}
}
[...]

À partir de ce fichier descriptif, nous venons de déterminer l’intégralité des routes qui pourront être empruntées par tous les clients utilisant cette version du service.

Le package proto contient 2 services : Woodpecker et WoodpeckerAuth. Cela donne donc 2 routes racines :

  • /proto.Woodpecker/
  • /proto.WoodpeckerAuth/

Derrière chacune, on retrouvera les fonctions décrites par chaque ligne rpc. Par exemple :

  • /proto.Woodpecker/Version
  • /proto.Woodpecker/Done
  • /proto.Woodpecker/Log
  • /proto.WoodpeckerAuth/Auth

Configuration nginx

Voici donc à quoi pourrait ressembler notre configuration nginx, en utilisant un seul domaine :

server {
    listen 80;
    http2 on;
    server_name woodpecker.example.com;

    location / {
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Host $http_host;

        proxy_pass http://127.0.0.1:8000;
        proxy_redirect off;
        proxy_http_version 1.1;
        proxy_buffering off;

        chunked_transfer_encoding off;
    }

    location /proto.Woodpecker/ {
        grpc_pass grpc://127.0.0.1:9000;
    }

    location /proto.WoodpeckerAuth/ {
        grpc_pass grpc://127.0.0.1:9000;
    }
}

Notez que cela ne fonctionne bien évidemment qu’en activant HTTP/2 au moyen de la directive http2.

Il n’est pas nécessaire d’indiquer individuellement les fonctions, nginx réalisera le routage en se contentant du package et du nom du service.

Timeout sur la fonction Next

Woodpecker déclare une fonction Next qui attend les informations d’un prochain travail à effectuer. Ce temps d’attente est généralement très variable, plutôt très long si votre CI n’est pas sollicitée en permanence.

nginx va prendre l’initiative, par défaut, d’arrêter toute requête inactive depuis plus de 60 secondes. Cela occasionne un grand nombre de reconnexions pour l’agent Woodpecker, visibles dans les journaux de l’agent :

{"level":"error","error":"rpc error: code = Unavailable desc = unexpected HTTP status code received from server: 504 (Gateway Timeout); transport: received unexpected content-type \"text/html\"","time":"2023-10-27T11:28:51Z","message":"grpc error: wait(): code: Unavailable: rpc error: code = Unavailable desc = unexpected HTTP status code received from server: 504 (Gateway Timeout); transport: received unexpected content-type \"text/html\""}

L’agent se reconnectera de lui-même, mais on peut tout de même réduire l’occurence de cette reconnexion (dont l’intérêt principal est de ne rien faire tant que le serveur ne nous réveille pas), en ajoutant cette ligne à notre configuration nginx :

    location /proto.Woodpecker/Next {
        grpc_pass grpc://127.0.0.1:9008;
        grpc_read_timeout 3600s;
    }

Ces quelques lignes demandent à nginx de ne pas couper les connexions inactives avant 1 heure d’inactivité, uniquement pour la fonction Next du protocole.

Filtrer les IP pouvant accéder au service GRPC

Outre la directive grpc_pass, il est bien évidemment possible d’utiliser toutes les directives que l’on a l’habitude d’utiliser.

Nous pourrions par exemple vouloir filtrer les IP ayant accès à GRPC. On pourrait faire cela avec les directives allow et deny :

    location /proto.Woodpecker/ {
        allow 192.168.0.0/24;
        deny all;

        grpc_pass grpc://127.0.0.1:9000;
    }

Seuls les agents utilisent le protocole GRPC, il serait tout à fait légitime de mettre une telle règle en place.


L’unification des requêtes HTTP et des appels GRPC sous un seul et même domaine présente de nombreux avantages. Non seulement cette approche simplifie la configuration du reverse-proxy, mais elle permet également une gestion plus centralisée et épurée du service.

D’ailleurs, GRPC et protobuf sont une excellente solution pour transmettre des données structurées sur le réseau, n’hésitez pas à y jeter un œil.