


Créer un serveur proxy inverse évolutif comme Nginx avec Node.js et TypeScript
Jan 05, 2025 am 08:48 AML'inspiration
Dans l'architecture de microservices actuelle, les proxys inverses jouent un rôle crucial dans la gestion et le routage des demandes entrantes vers divers services backend.
Un proxy inverse se place devant les serveurs web d'une application et intercepte les requêtes provenant des machines clientes. Cela présente de nombreux avantages tels que l'équilibrage de charge, les adresses IP des serveurs d'origine cachées conduisant à une meilleure sécurité, la mise en cache, la limitation de débit, etc.
Dans une architecture distribuée et microservice, un point d'entrée unique est nécessaire. Les serveurs proxy inverse comme Nginx sont utiles dans de tels scénarios. Si plusieurs instances de notre serveur sont en cours d'exécution, gérer et garantir un routage efficace des requêtes devient délicat. Un proxy inverse comme Nginx est une solution parfaite dans ce cas. Nous pouvons pointer notre domaine vers l'adresse IP du serveur Nginx et le Nginx acheminera la requête entrante selon la configuration vers l'une des instances tout en s'occupant de la charge gérée par chacune.
Comment Nginx fait-il si bien ?
Je recommanderai de lire cet article de Nginx qui explique en détail comment Nginx est capable de prendre en charge une grande échelle de requêtes avec une fiabilité et une rapidité exceptionnelles : Architecture Nginx
En bref, Nginx a un processus maître et un tas de processus de travail. Il dispose également de processus d'assistance tels que Cache Loader et Cache Manager. Le maître et le processus de travail font tout le gros travail.
- Processus principal : gère la configuration et génère des processus enfants.
- Cache Loader/Manager : gérez le chargement et l'élagage du cache avec un minimum de ressources.
- Processus de travail : gérez les connexions, les E/S de disque et la communication en amont, en s'exécutant de manière non bloquante et indépendante.
Les processus de travail gèrent plusieurs connexions de manière non bloquante, réduisant ainsi les changements de contexte. Ils sont monothread, s'exécutent indépendamment et utilisent la mémoire partagée pour les ressources partagées telles que le cache et les données de session. Cette architecture aide Nginx à réduire le nombre de changements de contexte et à augmenter la vitesse plus rapidement qu'une architecture multi-processus bloquante.
En nous inspirant de cela, nous utiliserons le même concept de processus maître et de travail et implémenterons notre propre serveur proxy inverse basé sur les événements qui sera capable de gérer des milliers de connexions par processus de travail.
Architecture du projet
Notre mise en œuvre du proxy inverse suit ces principes de conception clés :
- Basé sur la configuration : tout le comportement du proxy est défini dans un fichier de configuration YAML, ce qui facilite la modification des règles de routage.
- Sécurité des types : les schémas TypeScript et Zod garantissent la validité de la configuration et la sécurité des types d'exécution.
- Évolutivité : le module de cluster Node.js permet d'utiliser plusieurs cœurs de processeur pour de meilleures performances.
- Modularité : séparation claire des préoccupations avec des modules distincts pour la configuration, la logique du serveur et la validation du schéma.
Structure du projet
├── config.yaml # Server configuration ├── src/ │ ├── config-schema.ts # Configuration validation schemas │ ├── config.ts # Configuration parsing logic │ ├── index.ts # Application entry point │ ├── server-schema.ts # Server message schemas │ └── server.ts # Core server implementation └── tsconfig.json # TypeScript configuration
Composants clés
- config.yaml : définit la configuration du serveur, y compris le port, les processus de travail, les serveurs en amont, les en-têtes et les règles de routage.
- config-schema.ts : définit les schémas de validation à l'aide de la bibliothèque Zod pour garantir que la structure de configuration est correcte.
- server-schema.ts : Spécifie les formats de message échangés entre les processus maître et de travail.
- config.ts : fournit des fonctions d'analyse et de validation du fichier de configuration YAML.
- server.ts : implémente la logique du serveur proxy inverse, y compris la configuration du cluster, la gestion HTTP et le transfert des demandes.
- index.ts : sert de point d'entrée, analyse les options de ligne de commande et lance le serveur.
Gestion des configurations
Le système de configuration utilise YAML. Voici comment cela fonctionne :
server: listen: 8080 # Port the server listens on. workers: 2 # Number of worker processes to handle requests. upstreams: # Define upstream servers (backend targets). - id: jsonplaceholder url: jsonplaceholder.typicode.com - id: dummy url: dummyjson.com headers: # Custom headers added to proxied requests. - key: x-forward-for value: $ip # Adds the client IP to the forwarded request. - key: Authorization value: Bearer xyz # Adds an authorization token to requests. rules: # Define routing rules for incoming requests. - path: /test upstreams: - dummy # Routes requests to "/test" to the "dummy" upstream. - path: / upstreams: - jsonplaceholder # Routes all other requests to "jsonplaceholder".
Les demandes entrantes sont évaluées par rapport aux règles. En fonction du chemin, le proxy inverse détermine à quel serveur en amont transmettre la demande.
Validation de la configuration (config-schema.ts)
Nous utilisons Zod pour définir des schémas stricts pour la validation de la configuration :
import { z } from "zod"; const upstreamSchema = z.object({ id: z.string(), url: z.string(), }); const headerSchema = z.object({ key: z.string(), value: z.string(), }); const ruleSchema = z.object({ path: z.string(), upstreams: z.array(z.string()), }); const serverSchema = z.object({ listen: z.number(), workers: z.number().optional(), upstreams: z.array(upstreamSchema), headers: z.array(headerSchema).optional(), rules: z.array(ruleSchema), }); export const rootConfigSchema = z.object({ server: serverSchema, }); export type ConfigSchemaType = z.infer<typeof rootConfigSchema>;
Analyse et validation des configurations (config.ts)
Le module config.ts fournit des fonctions utilitaires pour analyser et valider le fichier de configuration.
import fs from "node:fs/promises"; import { parse } from "yaml"; import { rootConfigSchema } from "./config-schema"; export async function parseYAMLConfig(filepath: string) { const configFileContent = await fs.readFile(filepath, "utf8"); const configParsed = parse(configFileContent); return JSON.stringify(configParsed); } export async function validateConfig(config: string) { const validatedConfig = await rootConfigSchema.parseAsync( JSON.parse(config) ); return validatedConfig; }
Logique du serveur proxy inverse (server.ts)
Le serveur utilise le module de cluster Node.js pour l'évolutivité et le module http pour gérer les requêtes. Le processus maître distribue les requêtes aux processus de travail, qui les transmettent aux serveurs en amont. Explorons en détail le fichier server.ts, qui contient la logique de base de notre serveur proxy inverse. Nous décomposerons chaque composant et comprendrons comment ils fonctionnent ensemble pour créer un serveur proxy évolutif.
L'implémentation du serveur suit une architecture maître-travailleur utilisant le module cluster de Node.js. Cette conception nous permet de :
- Utiliser plusieurs cœurs de processeur
- Gérer les demandes simultanément
- Maintenir une haute disponibilité
- Isoler le traitement des demandes
-
Processus maître :
- Crée des processus de travail
- Distribue les demandes entrantes entre les travailleurs
- Gère le pool de travailleurs
- Gère les plantages et les redémarrages des travailleurs
-
Processus de travail :
- Gérer les requêtes HTTP individuelles
- Faire correspondre les requêtes aux règles de routage
- Transférer les requêtes vers les serveurs en amont
- Traiter les réponses et les renvoyer aux clients
Configuration du processus principal
├── config.yaml # Server configuration ├── src/ │ ├── config-schema.ts # Configuration validation schemas │ ├── config.ts # Configuration parsing logic │ ├── index.ts # Application entry point │ ├── server-schema.ts # Server message schemas │ └── server.ts # Core server implementation └── tsconfig.json # TypeScript configuration
Le processus maître crée un pool de travailleurs et transmet la configuration à chaque travailleur via des variables d'environnement. Cela garantit que tous les travailleurs ont accès à la même configuration.
Demander la distribution
server: listen: 8080 # Port the server listens on. workers: 2 # Number of worker processes to handle requests. upstreams: # Define upstream servers (backend targets). - id: jsonplaceholder url: jsonplaceholder.typicode.com - id: dummy url: dummyjson.com headers: # Custom headers added to proxied requests. - key: x-forward-for value: $ip # Adds the client IP to the forwarded request. - key: Authorization value: Bearer xyz # Adds an authorization token to requests. rules: # Define routing rules for incoming requests. - path: /test upstreams: - dummy # Routes requests to "/test" to the "dummy" upstream. - path: / upstreams: - jsonplaceholder # Routes all other requests to "jsonplaceholder".
Le processus maître utilise une stratégie de distribution aléatoire simple pour attribuer les demandes aux travailleurs. Bien qu'elle ne soit pas aussi sophistiquée que les algorithmes à tour de rôle ou les algorithmes de moindre connexion, cette approche fournit une répartition de charge décente pour la plupart des cas d'utilisation. La logique de distribution des requêtes :
- Sélectionne au hasard un travailleur dans le pool
- Crée une charge de travail équilibrée entre les travailleurs
- Gère les cas extrêmes où les travailleurs peuvent être indisponibles
Logique de demande de processus de travail
Chaque travailleur écoute les messages, compare les demandes aux règles de routage et les transmet au serveur en amont approprié.
import { z } from "zod"; const upstreamSchema = z.object({ id: z.string(), url: z.string(), }); const headerSchema = z.object({ key: z.string(), value: z.string(), }); const ruleSchema = z.object({ path: z.string(), upstreams: z.array(z.string()), }); const serverSchema = z.object({ listen: z.number(), workers: z.number().optional(), upstreams: z.array(upstreamSchema), headers: z.array(headerSchema).optional(), rules: z.array(ruleSchema), }); export const rootConfigSchema = z.object({ server: serverSchema, }); export type ConfigSchemaType = z.infer<typeof rootConfigSchema>;
Le processus maître communique avec les travailleurs en construisant une charge utile de message standardisée, comprenant toutes les informations de demande nécessaires, à l'aide de Node.js IPC (Inter-Process Communication) et en validant la structure du message à l'aide des schémas Zod.
Les travailleurs gèrent le traitement réel des demandes et le proxy. Chaque travailleur :
- Charge sa configuration à partir des variables d'environnement
- Valide la configuration à l'aide des schémas Zod
- Maintient sa propre copie de la configuration
Les travailleurs sélectionnent les serveurs en amont par :
- Trouver l'ID en amont approprié à partir de la règle
- Localisation de la configuration du serveur en amont
- La validation du serveur amont existe
Le mécanisme de transfert des requêtes :
- Crée une nouvelle requête HTTP vers le serveur en amont
- Diffuse les données de réponse
- Agrége le corps de la réponse
- Renvoie la réponse au processus maître
Exécution du serveur
Pour exécuter le serveur, suivez ces étapes :
- Construire le projet :
import fs from "node:fs/promises"; import { parse } from "yaml"; import { rootConfigSchema } from "./config-schema"; export async function parseYAMLConfig(filepath: string) { const configFileContent = await fs.readFile(filepath, "utf8"); const configParsed = parse(configFileContent); return JSON.stringify(configParsed); } export async function validateConfig(config: string) { const validatedConfig = await rootConfigSchema.parseAsync( JSON.parse(config) ); return validatedConfig; }
- Démarrez le serveur :
if (cluster.isPrimary) { console.log("Master Process is up ?"); for (let i = 0; i < workerCount; i++) { const w = cluster.fork({ config: JSON.stringify(config) }); WORKER_POOL.push(w); console.log(Master Process: Worker Node spinned: ${i}); } const server = http.createServer((req, res) => { const index = Math.floor(Math.random() * WORKER_POOL.length); const worker = WORKER_POOL.at(index); if (!worker) throw new Error("Worker not found."); const payload: WorkerMessageSchemaType = { requestType: "HTTP", headers: req.headers, body: null, url: ${req.url}, }; worker.send(JSON.stringify(payload)); worker.once("message", async (workerReply: string) => { const reply = await workerMessageReplySchema.parseAsync( JSON.parse(workerReply) ); if (reply.errorCode) { res.writeHead(parseInt(reply.errorCode)); res.end(reply.error); } else { res.writeHead(200); res.end(reply.data); } }); }); server.listen(port, () => { console.log(Reverse Proxy listening on port: ${port}); }); }
- Mode développement :
const server = http.createServer(function (req, res) { const index = Math.floor(Math.random() * WORKER_POOL.length); const worker = WORKER_POOL.at(index); const payload: WorkerMessageSchemaType = { requestType: "HTTP", headers: req.headers, body: null, url: ${req.url}, }; worker.send(JSON.stringify(payload)); });
Dans la capture d'écran ci-dessus, nous pouvons voir qu'il y a 1 nœud maître et 2 processus de travail en cours d'exécution. Notre serveur proxy inverse écoute sur le port 8080.
Dans le fichier config.yaml, nous décrivons deux serveurs en amont à savoir : jsonplaceholder et dummy. Si nous voulons que toutes les requêtes arrivant sur notre serveur soient acheminées vers jsonplaceholder, nous mettons la règle comme suit :
├── config.yaml # Server configuration ├── src/ │ ├── config-schema.ts # Configuration validation schemas │ ├── config.ts # Configuration parsing logic │ ├── index.ts # Application entry point │ ├── server-schema.ts # Server message schemas │ └── server.ts # Core server implementation └── tsconfig.json # TypeScript configuration
De même, si nous voulons que notre requête au point de terminaison /test soit acheminée vers notre serveur factice en amont, nous mettons la règle comme suit :
server: listen: 8080 # Port the server listens on. workers: 2 # Number of worker processes to handle requests. upstreams: # Define upstream servers (backend targets). - id: jsonplaceholder url: jsonplaceholder.typicode.com - id: dummy url: dummyjson.com headers: # Custom headers added to proxied requests. - key: x-forward-for value: $ip # Adds the client IP to the forwarded request. - key: Authorization value: Bearer xyz # Adds an authorization token to requests. rules: # Define routing rules for incoming requests. - path: /test upstreams: - dummy # Routes requests to "/test" to the "dummy" upstream. - path: / upstreams: - jsonplaceholder # Routes all other requests to "jsonplaceholder".
Testons ça !
Wow, c'est cool ! Nous naviguons vers localhost:8080 mais en réponse, nous pouvons voir que nous avons reçu la page d'accueil de jsonplaceholder.typicode.com. L'utilisateur final ne sait même pas que nous voyons une réponse provenant d'un serveur distinct. C'est pourquoi les serveurs Reverse Proxy sont importants. Si nous avons plusieurs serveurs exécutant le même code et que nous ne souhaitons pas exposer tous leurs ports aux utilisateurs finaux, utilisez un proxy inverse comme couche d'abstraction. Les utilisateurs accéderont au serveur proxy inverse, un serveur très robuste et rapide, et il déterminera vers quel serveur acheminer la demande.
Appuyons maintenant sur localhost:8080/todos et voyons ce qui se passe.
Notre demande a de nouveau été transmise par proxy inverse au serveur jsonplaceholder et a reçu une réponse JSON à partir de l'URL résolue : jsonplaceholder.typicode.com/todos.
Flux de communication
Visualisons le flux complet des demandes :
Le client envoie la demande → Processus principal
Processus principal → Travailleur sélectionné
Travailleur → Serveur en amont
Serveur en amont → Travailleur
Travailleur → Processus principal
Processus principal → Client
Considérations relatives aux performances
L'architecture multi-processus offre plusieurs avantages en termes de performances :
- Utilisation du processeur : les processus de travail peuvent s'exécuter sur différents cœurs de processeur, en utilisant les ressources matérielles disponibles.
- Isolement des processus : un accident chez un travailleur n'affecte pas les autres, ce qui améliore la fiabilité.
- Répartition de la charge : la répartition aléatoire des demandes permet d'éviter qu'un seul travailleur ne soit submergé.
Améliorations futures
Bien que fonctionnelle, la mise en œuvre actuelle pourrait être améliorée avec :
- Meilleur équilibrage de charge : implémentez des algorithmes plus sophistiqués comme le round-robin ou les moindres connexions.
- Vérifications de santé : ajoutez des vérifications de santé périodiques pour les serveurs en amont.
- Mise en cache : implémentez la mise en cache des réponses pour réduire la charge du serveur en amont.
- Métriques : ajoutez des métriques de style Prometheus pour la surveillance.
- Support WebSocket : étendez le proxy pour gérer les connexions WebSocket.
- Prise en charge HTTPS : ajoutez des fonctionnalités de terminaison SSL/TLS.
Conclusion
Construire un serveur proxy inverse à partir de zéro peut sembler intimidant au début, mais comme nous l'avons exploré, c'est une expérience enrichissante. En combinant les clusters Node.js, TypeScript et la gestion de configuration basée sur YAML, nous avons créé un système évolutif et efficace inspiré de Nginx.
Il est encore possible d'améliorer cette implémentation : un meilleur équilibrage de charge, une meilleure mise en cache ou une meilleure prise en charge de WebSocket ne sont que quelques idées à explorer. Mais la conception actuelle constitue une base solide pour expérimenter et évoluer davantage. Si vous avez suivi, vous êtes désormais équipé pour approfondir vos connaissances sur les proxys inverses ou même commencer à créer des solutions personnalisées adaptées à vos besoins.
Si vous souhaitez vous connecter ou voir plus de mon travail, consultez mon GitHub, LinkedIn.
Le référentiel de ce projet peut être trouvé ici.
J'aimerais entendre vos réflexions, commentaires ou idées d'amélioration. Merci d'avoir lu et bon codage ! ?
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!

Article chaud

Outils chauds Tags

Article chaud

Tags d'article chaud

Bloc-notes++7.3.1
Éditeur de code facile à utiliser et gratuit

SublimeText3 version chinoise
Version chinoise, très simple à utiliser

Envoyer Studio 13.0.1
Puissant environnement de développement intégré PHP

Dreamweaver CS6
Outils de développement Web visuel

SublimeText3 version Mac
Logiciel d'édition de code au niveau de Dieu (SublimeText3)

Sujets chauds

Remplacer les caractères de chaîne en javascript

jQuery Vérifiez si la date est valide

jQuery obtient un rembourrage / marge d'élément

10 vaut la peine de vérifier les plugins jQuery

Http débogage avec le nœud et le http-console

jQuery Ajouter une barre de défilement à div

Tutoriel de configuration de l'API de recherche Google personnalisé
