保活与负载均衡

将薅羊毛进行到极致

在使用无服务器平台时最常见的两个问题就是,第一额度不够,这些无服务器平台往往有免费额度限制,此时我们可以通过注册多个账号,搭建同一个服务然后在前端配置负载均衡来解决;第二个问题就是当服务一段时间不活跃时就会自动下线,下次请求时需要较长的时间来唤醒服务,此时就需要保活手段了。这里我们都通过CloudFlare Workers来实现,其他方式原理一样大家可以自行举一反三

保活

这个项目最初是因为我用Render搭建了一个API代理服务,效果还是可以的,毕竟Render是目前仅有的原生支持运行Docker的无服务器平台,但是Render有一个致命的缺点就是当你的服务10分钟没有人访问时就会自动休眠,而再次唤醒服务要几十秒的时间,这对于一个需要快速及时响应的API服务来说肯定是不能接受的,于是我就想到了利用workers以及cron触发器实现保活,代码示例:

addEventListener('scheduled', event => {
  event.waitUntil(handleScheduledEvent(event))
})

async function handleScheduledEvent(event) {
  const urls = ['https://aaa.zeabur.app/v1/chat/completions', 'https://bbb.onrender.com/v1/chat/completions']; // Replace with your actual URLs
  const body = JSON.stringify({
    "messages": [
      {
        "role": "user",
        "content": "在吗"
      }
    ],
    "stream": true,
    "model": "gpt-4-1106-preview"
  });

  const headers = {
    'Authorization': '12345',
    'Content-Type': 'application/json'
  };

  try {
    // Map each URL to a fetch POST request and execute all at once
    await Promise.all(urls.map(url => 
      fetch(url, { method: 'POST', headers: headers, body: body })
    ));
    console.log('Requests sent successfully');
  } catch (error) {
    console.error('Error sending requests:', error);
  }
}

在这个示例中我实现了向分别搭建在Zeabur和Render上的两个API服务发送POST请求的目标,每当这个worker执行时就会发送这个POST请求(效果相当于我们用客户端向API询问“在吗”),然后再到触发器页面配置Cron触发器即可,理论上只要间隔在10分钟以内即可实现保活,比如我设置了3分钟执行一次

不过需要注意的是无服务器平台往往会对实例运行时间计费,但是Render的免费额度是每个月运行750小时,算下来正好是31天出头,也就是说如果我们一个账号只运行一个服务的话是可以做到始终不间断运行的,当然如果你要运行多个服务只要注册多个账号即可

在这个示例中我们根据我们所搭建的具体服务用的是OpenAI的API请求格式发送POST请求,对于不同的服务需求不同,你也可以选择发送GET请求,适用面更广,具体的代码实现可以询问GPT4(事实上这个代码也是完全由GPT-4完成的)我们目的是提供思路

负载均衡

在上面的Workers章节中我们已经讲到了几个负载均衡的实现,这里我们想要对几个典型的应用场景再具体讲几个示例

DoH Load Balancer

这是DNS转发服务的魔改版,实现了多个DoH上游随机请求来达到负载均衡的效果,并且删除了无关代码专注DNS请求本身(注意这样会有一个问题就是通不过一些软件的测试验证,比如浏览器的加密DNS会说不合法,但实际上是可用的)

// 请求路径。请修改此路径,避免该 worker 所有人都能使用。
const endpointPath = '/dns-query';

// 多个上游 DoH 地址数组
const upstreams = [
  'https://d.adguard-dns.com/dns-query/xxx',  //foxmail.com
  'https://d.adguard-dns.com/dns-query/xxx',  //gmail.com
  'https://dns.nextdns.io/xxx',  //foxmail.com
  'https://dns.nextdns.io/xxx',  //@st0722.top
];

async function handleRequest(request, clientUrl) {
  const dnsValue = clientUrl.searchParams.get('dns')

  if (dnsValue == null) {
    return new Response('missing parameters', { status: 400 });
  }

  if (request.headers.get('accept') != 'application/dns-message') {
    return new Response('bad request header', { status: 400 });
  }

  // 随机选择一个上游地址
  const randomIndex = Math.floor(Math.random() * upstreams.length);
  let currentUpstream = new URL(upstreams[randomIndex]);
  currentUpstream.searchParams.set('dns', dnsValue);

  // 创建一个新请求,将原始请求的头信息和方法复制到新请求中
  const upstreamRequest = new Request(currentUpstream.toString(), {
    method: request.method,
    headers: new Headers(request.headers),
    body: request.method === 'POST' ? request.body : null,
  });

  // 将新请求发送给上游
  return await fetch(upstreamRequest);
}

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request, new URL(event.request.url)));
});

这个方案可以添加无穷多个上游地址(批量注册账号实现)并且随机选择其中一个上游地址发送,来突破AdGuard DNS和NextDNS的一个月30万次请求的限制,并且绕过墙的DoH监测(目前墙已经开始灰度屏蔽常见的DoH域名,尤其是这些国外的服务,那我们用自己的域名基本就可以规避这个问题)

API Load Balancer

当我们用无服务器平台搭建API代理时常常遇到的问题就是每隔一段时间后初次请求API由于冷却问题会非常慢,可能要过好久才会有响应,这个问题我们上面已经通过保活解决,不过大家可能也注意到了我上面有两个网址,搭建在两个不同的平台,俗话说鸡蛋不能放在一个篮子里,无论是什么原因导致其中一个平台掉线时,另一个平台都能快速顶上,这时就需要用负载均衡实现,下面是一个示例:

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
})
addEventListener('scheduled', event => {
  event.waitUntil(monitorUpstreams())
})
const upstreams = [
  { url: '<https://a.domain.com>', lastResponseTime: Infinity },
  { url: '<https://b.domain.com>', lastResponseTime: Infinity }
]
async function handleRequest(request) {
  // 定时监控上游响应速度
  monitorUpstreams()
  let upstreamUrl = getFastestUpstream().url
  let newUrl = new URL(upstreamUrl)
  newUrl.pathname = new URL(request.url).pathname
  newUrl.search = new URL(request.url).search
  newUrl.hash = new URL(request.url).hash
  return fetch(newUrl.toString(), request)
}
function getFastestUpstream() {
  return upstreams.sort((a, b) => a.lastResponseTime - b.lastResponseTime)[0]
}
// 检测上游响应速度
async function monitorUpstreams() {
  for (let upstream of upstreams) {
    const startTime = Date.now()
    await fetch(`${upstream.url}/swagger/index.html`).then(() => {
      const endTime = Date.now()
      upstream.lastResponseTime = endTime - startTime
    }).catch(() => {
      upstream.lastResponseTime = Infinity
    })
  }
}

在这个示例中,因为我们这个项目在上游地址的/swagger/index.html目录下有一个网页,于是我们让worker定期访问这个网页来看加载速度,谁加载快下一次就用谁,这不仅实现了负载均衡还实现了每次都请求最快的上游地址(当然严谨上来说网页加载速度快并不完全等于处理我们的API速度就快)

API Redirect and Distribute Traffic

该项目是在我冥思苦想好久之后突然想出来的一个新奇思路,我的本意是想要实现API的分流,比如当用户使用gpt-3.5系列模型时使用渠道一,当用户使用gpt-4系列模型时使用渠道二(为什么要这么做?因为目前gpt-3.5系列模型的第三方代理服务基本已经是完全免费了,比如我们在AI初始章节中讲的ChatAnywhere服务,就提供免费的密钥,但是gpt-4服务还是要用付费的密钥;但是在客户端中我们只能配置一个密钥和一个上游地址,所以就需要分流实现gpt-3.5系列免费而gpt-4用付费密钥)

在这之前市面上貌似只有oneapi项目(我们在AI章节中也有介绍)可以实现,但是该项目搭建使用比较麻烦而且最烦人的还是首屏加载时间很长导致体验不舒适,我们用了没多久就抛弃了,之后一直在找其他能实现的项目却没有找到,今天灵光一闪就想到了可以通过匹配POST请求中的对应字段实现自动分流,代码示例如下:

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
})

async function handleRequest(request) {
  // 仅处理POST请求。
  if (request.method !== 'POST') {
    return new Response('不允许的方法', { status: 405 })
  }

  // 检查Authorization头部。
  const authHeader = request.headers.get('Authorization');
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return new Response('请检查是否输入了API密钥', { status: 401 })
  }

  // 提取令牌并验证。
  const token = authHeader.split(' ')[1];
  if (token !== '123456') {
    return new Response('API密钥错误', { status: 401 })
  }

  // 解析请求体。
  const requestBody = await request.json();

  // 检查并修改`model`值。
  let upstreamUrl;
  if (['gpt-3.5-turbo-0125', 'gpt-3.5-turbo-1106', 'gpt-3.5-turbo'].includes(requestBody.model)) {
    requestBody.model = 'gpt-3.5-turbo-0125';
    request = new Request(request, {
      body: JSON.stringify(requestBody)
    });
    request.headers.set('Authorization', 'Bearer 456789');
    upstreamUrl = 'https://api.chatanywhere.tech';
  } else if (['gpt-4', 'gpt-4-1106-preview', 'gpt-4-0125-preview'].includes(requestBody.model)) {
    requestBody.model = 'gpt-4-0125-preview';
    request = new Request(request, {
      body: JSON.stringify(requestBody)
    });
    request.headers.set('Authorization', 'Bearer 987654');
    upstreamUrl = 'https://ngedlktfticp.cloud.sealos.io';
  } else {
    // 如果`model`值不有效,则返回错误响应。
    return new Response(JSON.stringify({
      "error": {
        "message": "仅限使用 gpt-3.5-turbo, gpt-4 和 gpt-4-turbo。",
        "type": "model_error",
        "param": null,
        "code": "403 FORBIDDEN"
      }
    }), { status: 403, headers: { 'Content-Type': 'application/json' } });
  }

  // 从原始请求中提取URL路径。
  const url = new URL(request.url);
  upstreamUrl += url.pathname;

  // 将修改后的请求转发到上游服务。
  const response = await fetch(upstreamUrl, request);

  // 将上游服务的响应返回给用户。
  return new Response(response.body, response);
}

在这个示例中我们实现了以下功能:

  1. 验证Authorization令牌,也就是用户输入的API密钥,这里我们使用123456示范,并且修改发送给上游地址时的值,比如我们在实例中将使用gpt-3.5模型时的令牌修改成了456789,使用gpt-4模型时的令牌修改成了987654。

  2. 根据model字段值修改请求体,当用户选择'gpt-3.5-turbo-0125', 'gpt-3.5-turbo-1106', 'gpt-3.5-turbo'这三个模型时统一修改成性能最佳也最便宜的gpt-3.5-turbo-0125,当用户选择'gpt-4', 'gpt-4-1106-preview', 'gpt-4-0125-preview'这三个模型时统一修改成性能最佳也最便宜的gpt-4-0125-preview。

  3. 将修改后的请求转发到原始请求的相同路径,并且根据模型选择上游地址,并且用的也是上游地址对应的令牌。

  4. 将上游服务的响应返回给用户,如果model值无效,则返回错误信息。

这时我们的一个示范,功能非常精简,我们也在考虑后续增加一些新功能,复杂的版本应该会放到GitHub,大家也可以根据自己的需求修改代码或添加对应的功能。另外不改变任何请求内容,纯粹根据请求网址进行转发的小白版本,可以做到根据不同的请求网址转发到不同的上游:

const TELEGRAPH_URL_OPENAI = 'https://api.openai.com';
const TELEGRAPH_URL_FREE = 'https://api.chatanywhere.tech';
const TELEGRAPH_URL_VIP = 'https://ngedlktfticp.cloud.sealos.io';

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
})

async function handleRequest(request) {
  const url = new URL(request.url);
  let targetUrl;

  if (url.hostname === 'api3.st0722.top') {
    targetUrl = TELEGRAPH_URL_FREE;
  } else if (url.hostname === 'api4.st0722.top') {
    targetUrl = TELEGRAPH_URL_VIP;
  } else {
    // 默认的目标服务器URL
    targetUrl = TELEGRAPH_URL_OPENAI;
  }

  url.host = targetUrl.replace(/^https?:\/\//, '');

  const modifiedRequest = new Request(url.toString(), {
    headers: request.headers,
    method: request.method,
    body: request.body,
    redirect: 'follow'
  });

  const response = await fetch(modifiedRequest);
  const modifiedResponse = new Response(response.body, response);

  // 添加允许跨域访问的响应头
  modifiedResponse.headers.set('Access-Control-Allow-Origin', '*');

  return modifiedResponse;
}

最后更新于