Équilibreur de charge Cloudflare Worker avec vérifications d'état (gratuit)
Un équilibreur de charge pratique et gratuit utilisant Cloudflare Workers + Cloudflare Tunnel, avec des vérifications d'état périodiques et un basculement automatique.
Cloudflare Load Balancer est excellent, mais c’est un produit payant. Si votre cas d’utilisation est « J’ai deux répliques derrière Cloudflare Tunnel et je veux simplement un basculement si l’une tombe en panne », vous pouvez implémenter un équilibreur de charge simple en périphérie à l’aide d’un Cloudflare Worker sur la version gratuite.
L’idée principale est la suivante :
- Vous exposez deux noms d’hôte différents, un par tunnel (primaire et réplique).
- Vous exposez un troisième nom d’hôte (celui que vous partagez avec les utilisateurs) qui pointe vers le Worker.
- Le Worker effectue des vérifications de santé et redirige chaque requête vers la meilleure cible.
Architecture (recommandée)
Vous aurez généralement besoin de 3 noms d’hôte :
service.votredomaine.com:- Pointe vers le Worker (c’est l’URL publique)
service-primaire.votredomaine.com:- Tunnel Cloudflare pointant vers votre serveur primaire
service-replique.votredomaine.com:- Tunnel Cloudflare pointant vers votre serveur réplique
Cela résout la limitation courante de Cloudflare Tunnel : vous ne pouvez pas associer le même nom d’hôte à plusieurs tunnels, mais vous pouvez associer des noms d’hôte différents et placer un Worker devant.
Fonctionnement du Worker
- Il maintient une carte
serverHealthen mémoire avec :healthy: dernier état de santé connulastCheck: horodatage de la dernière sonde de santé
- Pour chaque requête entrante :
- Il actualise l’état de santé si le cache est plus ancien que
HEALTH_CHECK_INTERVAL. - Il choisit le premier serveur en bonne santé.
- Si aucun n’est en bonne santé, il revient au premier serveur.
- Il actualise l’état de santé si le cache est plus ancien que
- Il ajoute un en-tête
X-Served-Bypour le débogage. - Si l’appel
fetchdu proxy échoue, il réessaie une fois avec l’autre serveur.
Détail important : le cache est stocké dans la mémoire isolée du Worker. Cela signifie qu’il ne s’agit pas d’un cache global garanti (il peut être réinitialisé lors des démarrages à froid). Pour un basculement simple, cela est généralement suffisant.
Le code
Remplacez les URLs des tunnels ci-dessous par les vôtres.
// Cloudflare Worker Load Balancer with Health Check
// Configure your tunnel URLs here
const SERVERS = [
{
url: 'https://your-tunnel-1.yourdomain.com',
name: 'Server 1',
healthCheckPath: '/health' // or '/' if you don't have a specific endpoint
},
{
url: 'https://your-tunnel-2.yourdomain.com',
name: 'Server 2',
healthCheckPath: '/health'
}
]
// Settings
const HEALTH_CHECK_TIMEOUT = 5000 // 5 seconds
const HEALTH_CHECK_INTERVAL = 30000 // Check every 30 seconds
// Server status cache (kept by the Worker)
let serverHealth = {}
// Health check
async function checkHealth(server) {
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT)
const response = await fetch(server.url + server.healthCheckPath, {
method: 'GET',
signal: controller.signal,
headers: {
'User-Agent': 'Cloudflare-Worker-HealthCheck'
}
})
clearTimeout(timeoutId)
// Healthy if 2xx or 3xx
return response.status >= 200 && response.status < 400
} catch (error) {
console.log(`Health check failed for ${server.name}:`, error.message)
return false
}
}
// Pick an available server
async function getAvailableServer() {
// Refresh health check if needed
for (const server of SERVERS) {
const lastCheck = serverHealth[server.url]?.lastCheck || 0
const now = Date.now()
if (now - lastCheck > HEALTH_CHECK_INTERVAL) {
const isHealthy = await checkHealth(server)
serverHealth[server.url] = {
healthy: isHealthy,
lastCheck: now
}
}
}
// Pick the first healthy server
for (const server of SERVERS) {
if (serverHealth[server.url]?.healthy) {
return server
}
}
// If none are healthy, use the first as fallback
console.log('No healthy server found, using fallback')
return SERVERS[0]
}
// Main handler
export default {
async fetch(request, env, ctx) {
// Pick server
const server = await getAvailableServer()
// Build target URL keeping original path
const url = new URL(request.url)
const targetUrl = new URL(url.pathname + url.search, server.url)
// Clone request for the chosen server
const modifiedRequest = new Request(targetUrl, {
method: request.method,
headers: request.headers,
body: request.body,
redirect: 'follow'
})
// Debug header (optional)
modifiedRequest.headers.set('X-Served-By', server.name)
try {
// Proxy request
const response = await fetch(modifiedRequest)
// Clone response to add headers
const newResponse = new Response(response.body, response)
newResponse.headers.set('X-Served-By', server.name)
return newResponse
} catch (error) {
// If it fails, try the other server
console.log(`Error while accessing ${server.name}, trying fallback`)
const fallbackServer = SERVERS.find((s) => s.url !== server.url)
if (fallbackServer) {
const fallbackUrl = new URL(url.pathname + url.search, fallbackServer.url)
const fallbackRequest = new Request(fallbackUrl, {
method: request.method,
headers: request.headers,
body: request.body,
redirect: 'follow'
})
return fetch(fallbackRequest)
}
return new Response('All servers are unavailable', { status: 503 })
}
}
}
Configuration (étape par étape)
-
Créez deux noms d’hôte de tunnel
- Nom d’hôte du tunnel principal :
service-primaire.votredomaine.com - Nom d’hôte du tunnel réplique :
service-replique.votredomaine.com
- Nom d’hôte du tunnel principal :
-
Créez un Worker
- Tableau de bord Cloudflare
- Workers & Pages
- Créer un Worker
- Collez le code et mettez à jour la liste
SERVERS
-
Dirigez votre nom d’hôte public vers le Worker
Ajoutez une route (ou un domaine personnalisé) pour que
service.votredomaine.com/*soit géré par ce Worker. -
Testez quel serveur traite les requêtes
curl -I https://service.yourdomain.comRecherchez
X-Served-Bydans les en-têtes de réponse. -
Testez le basculement
Arrêtez temporairement le tunnel principal et réessayez la même requête. L’en-tête doit passer à votre réplique.
Remarques et limitations
- Applications avec état : si votre service stocke des états sur disque (sessions, téléchargements, historique de chat, etc.), vous aurez probablement besoin d’un stockage partagé ou d’une synchronisation entre le serveur principal et la réplique.
- Point de terminaison de vérification de santé : préférez un point de terminaison léger (ou
/) et évitez les logiques lourdes. - WebSockets / connexions longues : selon votre plan Cloudflare et le comportement de votre application, les connexions longues peuvent nécessiter une attention particulière.
- Portée du cache :
serverHealthest en mémoire isolée, pas dans un magasin de données global. Si vous avez besoin d’un état de santé partagé plus fiable, envisagez KV, Durable Objects ou une surveillance externe.
Une autre façon de procéder
Vous pourriez utiliser le même Tunnel sur deux serveurs/services, de sorte à avoir un Tunnel nommé « Services » et le connecter à « Serveur A » et « Serveur B ». Certaines personnes pensent que cette solution est meilleure, et en termes de simplicité, elle l’est. Mais si vous souhaitez séparer les Tunnels, Cloudflare Worker Load Balancer est meilleur.
Mais si vous voulez toujours un Tunnel sur tous vos serveurs, voici ce que j’ai remarqué concernant l’utilisation d’un Tunnel partagé sur toutes les machines :
Si vous utilisez un Tunnel partagé sur toutes les machines :
┌─────────────────┐
│ Tunnel "main" │
└─────────────────┘
│
┌───────────┼───────────┬───────────┐
▼ ▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│Server A │ │Server B │ │Server C │ │Server D │
│ │ │ │ │ │ │ │
│Jellyfin │ │Navidrome│ │qBittor. │ │OpenWebUI│
└─────────┘ └─────────┘ └─────────┘ └─────────┘
Problème : Cloudflare redirigera jellyfin.domaine.com vers N’IMPORTE QUEL serveur (A, B, C ou D de manière aléatoire), mais Jellyfin n’est que sur le Serveur A ! 💥 Résultat :- 75 % des requêtes aboutiront à une erreur 502 (elles sont tombées sur le mauvais serveur)
- Vous perdez le contrôle de l’endroit où chaque service s’exécute
- Un vrai désordre
Cela fonctionne, mais ce n’est pas recommandé.
Dans ce cas, LA BONNE FAÇON d’utiliser des répliques avec un seul Tunnel serait de partager un Tunnel UNIQUEMENT lorsque le MÊME service est répliqué sur PLUSIEURS machines :
Scénario 1 : Vous souhaitez une bascule (failover) depuis Open WebUI
┌─────────────────────┐
│ Tunnel "openwebui" │
└─────────────────────┘
│
┌────┴────┐
▼ ▼
┌─────────┐ ┌─────────┐
│Server A │ │Server B │
│ │ │ │ ← Same Open WebUI (PostgreSQL+Redis shared)
│OpenWebUI│ │OpenWebUI│
└─────────┘ └─────────┘
Cela fonctionne, car Open WebUI est présent sur les deux serveurs.
Scénario 2 : Jellyfin n’est que sur 1 serveur
┌──────────────────┐
│ Tunnel "media" │
└──────────────────┘
│
▼
┌─────────┐
│Server A │
│ │
│Jellyfin │ ← Only here
└─────────┘
✅ Correct ! Tunnel dédié, car Jellyfin n’est présent qu’à un seul endroit.
Résumé :
- 1 tunnel par machine : Services différents sur des machines différentes
- 1 tunnel sur plusieurs machines : Même service répliqué (bascule/failover)
- 1 tunnel sur toutes les machines : Jamais (sauf si tous les services sont sur toutes les machines)
À retenir
N’oubliez pas que les données partagées doivent être accessibles par tous les serveurs, sinon vous aurez des incohérences dans les données. Et ce, jusqu’à ce que les configurations divergent ou que vous deviez configurer chacun différemment.