前端灰度发布

1. 架构图

image

✏️架构图说明
🔁 请求流程:
用户发起访问请求(含 UA / IP / Cookie)
CloudFront Viewer Request 触发 Lambda 函数

  • 校验 IP 是否在白名单中
  • 判断 Cookie 或 Query 是否指定版本
  • 根据分流比例(如 60% beta)选择版本
  • 重写 URI 到 /beta/...
    CloudFront Viewer Response 设置 Cookie
  • 将分流结果写入 fenliu-cookie
  • 支持固定过期时间或 Session Cookie
    最终资源命中 oss 目录下的静态内容

🧩 核心特性:
✅ 支持 IP 白名单(CIDR 段)
✅ 支持 Cookie 灰度标记
✅ 支持 Query 强制设置版本(用于测试)
✅ 重写静态资源路径,避免前端感知版本切换
✅ 设置 Cookie 保持灰度一致性
✅ Viewer Response 阶段安全写 Cookie

2. 📘 发布手册(CloudFront + Lambda@Edge+oss)

🎯 背景

为了实现 灰度发布控制,本方案基于 CloudFront + Lambda@Edge,通过 Cookie 和 IP 白名单双重机制,实现版本 dev|test|pre|prod 与 beta 的智能分流。(最终实现的是在prod和beta两个之间进行灰度)

⚙️ 核心组件

image

🔐 1. 白名单控制

仅允许特定 CIDR 范围的客户端 IP 发起请求。

const allowedCIDRs = [
  "38.181.86.96/27",
  "114.247.64.195/32",
  ...
];

image

🗂️ 3. URI 路径重写规则

image

🧠 4. 分流控制参数

const BEAT_TRAFFIC_RATIO = 0.6;
const VERSION_DEFAULT = "dev"; 
const VERSION_BETA = "beta"; 
const COOKIE_NAME = "fenliu-cookie";
const ALLOWED_VERSIONS = [VERSION_DEFAULT, VERSION_BETA];
version = Math.random() < BEAT_TRAFFIC_RATIO ? VERSION_BETA : VERSION_DEFAULT;
// Math.random() 是 JavaScript 中生成 [0, 1) 之间的伪随机数 的方法。常用于:概率判断、随机取样、打散数组、分流灰度等
# 随机分配cookie
https://xxx.com/home

# 强制进入 beta,设置 Cookie
https://xxx.com/home?fenliu-cookie=beta
const USE_SESSION_COOKIE = false;          // true 表示 Session Cookie(浏览器关闭失效)
const COOKIE_MAX_AGE_SECONDS = 31536000;      // 1年
// 86400 秒 = 24 小时
// 604800 秒 = 7 天
// 31536000 秒 = 1 年

📦 6. 示例部署流程

6.1 Viewer Request 的 lambda function

  1. 打开aws的 lambda地址
  2. 创建function 需要区分环境(因为测试和预发是有白名单限制的,所以function最好分环境易于后面扩展)
    image
  3. 代码(Code)
    代码中区分环境
    // 如果VERSION_DEFAULT的值是dev,那么VERSION_BETA的值也是dev。
    // 如果VERSION_DEFAULT的值是test,那么VERSION_BETA的值也是test。
    // 如果VERSION_DEFAULT的值是pre,那么VERSION_BETA的值也是pre。
    // 如果VERSION_DEFAULT的值是prod,那么VERSION_BETA的值也是beta。
/**
 * -------------------------------
 * CloudFront Function 灰度 + IP 白名单控制脚本
 * -------------------------------
 * beta-deploy-dev 功能:
 * 1. IP校验(测试|预发)
 * 2. 灰度流量控制
 * 3. 从 query / cookie 中确定分流版本
 * 4. uri重写: URI 到正确灰度资源路径
 * 5. 版本识别:设置头部标记用于响应阶段写 cookie
 * 
 * 当前版本:v1.0.0
 * 最后修改日期:2025-04-02
 * 作者:@yajun.su
 */

'use strict';

const BEAT_TRAFFIC_RATIO = 0.6;
const VERSION_DEFAULT = "dev"; 
const VERSION_BETA = "dev"; 
const COOKIE_NAME = "fenliu-cookie";
const ALLOWED_VERSIONS = [VERSION_DEFAULT, VERSION_BETA];

// IP 工具函数
function ipToNumber(ip) {
  const parts = ip.split(".");
  return (parseInt(parts[0], 10) << 24) +
         (parseInt(parts[1], 10) << 16) +
         (parseInt(parts[2], 10) << 8) +
         parseInt(parts[3], 10);
}

function cidrToRange(cidr) {
  const [ip, prefix] = cidr.split("/");
  const ipNum = ipToNumber(ip);
  const mask = ~((1 << (32 - parseInt(prefix, 10))) - 1);
  const startIp = ipNum & mask;
  const endIp = startIp + (1 << (32 - parseInt(prefix, 10))) - 1;
  return { start: startIp, end: endIp };
}

function isIpAllowed(ip, allowedRanges) {
  const ipNum = ipToNumber(ip);
  return allowedRanges.some(range => ipNum >= range.start && ipNum <= range.end);
}

function getCookieValue(cookieHeader, key) {
  if (!cookieHeader) return "";
  const cookies = cookieHeader.split(";");
  for (const cookie of cookies) {
    const [k, v] = cookie.trim().split("=");
    if (k === key) return v;
  }
  return "";
}

function getQueryParam(querystring, key) {
  if (!querystring) return "";
  const params = querystring.split("&");
  for (const param of params) {
    const [k, v] = param.split("=");
    if (k === key) return v;
  }
  return "";
}

export const handler = async (event) => {
  const request = event.Records[0].cf.request;
  const headers = request.headers;
  const clientIP = request.clientIp;
  const query = request.querystring || "";
  const cookieHeader = headers.cookie?.[0]?.value || "";
  let uri = request.uri;

  // ✅ IP 白名单控制
  const allowedCIDRs = [
    "114.247.64.195/32", "111.193.175.169/32", "106.39.2.192/26",
    "36.110.91.128/26", "61.135.33.112/29", "114.247.64.192/26",
    "164.52.50.0/24", "154.12.185.0/24", "103.158.82.0/24",
    "103.135.144.0/24", "129.227.75.0/24", "164.52.53.0/24",
    "116.211.6.218/32", "103.135.144.162/32", "103.158.82.219/32",
    "114.247.64.198/32", "164.52.53.230/32", "103.158.82.218/32",
    "8.217.139.196/32", "208.87.242.83/32", "221.222.145.86/32",
    "103.233.178.89/32", "221.179.162.99/32", "47.243.82.26/32",
    "1.2.3.4/32", "38.181.86.96/27", "154.29.159.195/32"
  ];
  const allowedRanges = allowedCIDRs.map(cidrToRange);
  if (!isIpAllowed(clientIP, allowedRanges)) {
    return {
      status: '403',
      statusDescription: 'Forbidden',
      headers: {
        'content-type': [{ key: 'Content-Type', value: 'text/plain' }]
      },
      body: `${clientIP} is not allowed, Please @Su.mumu`
    };
  }

  // ✅ 登录路径透传
  if (uri.startsWith("/__/auth")) {
    return request;
  }

  // ✅ 获取版本(优先级:Query > Cookie > 随机)
  const versionFromQuery = getQueryParam(query, COOKIE_NAME);
  if (versionFromQuery && !ALLOWED_VERSIONS.includes(versionFromQuery)) {
    return {
      status: '403',
      statusDescription: 'Invalid Version',
      headers: {
        'content-type': [{ key: 'Content-Type', value: 'text/plain' }]
      },
      body: `Version '${versionFromQuery}' is not allowed`
    };
  }

  let version = "";
  if (ALLOWED_VERSIONS.includes(versionFromQuery)) {
    version = versionFromQuery;
  } else {
    version = getCookieValue(cookieHeader, COOKIE_NAME);
    if (!ALLOWED_VERSIONS.includes(version)) {
      version = Math.random() < BEAT_TRAFFIC_RATIO ? VERSION_BETA : VERSION_DEFAULT;
    }
  }

  // ✅ 设置 header,Viewer Response 阶段写 Cookie
  request.headers['x-set-fenliu-cookie'] = [{
    key: 'x-set-fenliu-cookie',
    value: version
  }];

  // ✅ URI 重写逻辑
  if (uri === "/") uri = "/home/index.html";
  if (uri.startsWith('/home/') && !uri.includes('.')) uri = '/home/index.html';
  if (!uri.includes(".") && !uri.endsWith("/index")) {
    uri = uri.endsWith("/") ? uri + "index.html" : uri + "/index.html";
  }

  if (uri.startsWith("/home")) {
    request.uri = `/playlet/${version}${uri}`;
  } else if (uri.startsWith("/official")) {
    request.uri = `/website/${version}${uri}`;
  } else {
    request.uri = `/playlet/${version}${uri}`;
  }

  return request;
};
  1. deploy
    image
  2. test 可选
    image
{
  "Records": [
    {
      "cf": {
        "config": {
          "distributionId": "EXAMPLE"
        },
        "request": {
          "uri": "/home",
          "method": "GET",
          "querystring": "fenliu-cookie=beta",
          "clientIp": "1.2.3.4",
          "headers": {
            "host": [
              { "key": "Host", "value": "yourdomain.cloudfront.net" }
            ],
            "user-agent": [
              { "key": "User-Agent", "value": "Test-Agent" }
            ],
            "cookie": [
              {
                "key": "Cookie",
                "value": "someCookie=1; another=abc"
              }
            ]
          }
        }
      }
    }
  ]
}
  1. Versions(相当于发布)
    image
  2. 在cloudfront Distributions 绑定 lambda function
    image

6.2 Viewer Response 的 lambda function

  1. Code
    不用区分环境,所有的环境都用下面的代码
'use strict';

// ✅ Cookie 名称
const COOKIE_NAME = 'fenliu-cookie';

// ✅ Cookie 固定有效期(单位:秒)
// 86400 秒 = 24 小时
// 604800 秒 = 7 天
// 31536000 秒 = 1 年
const COOKIE_MAX_AGE_SECONDS = 31536000;

// ✅ 浏览器关闭后自动失效开关:如果想让浏览器关闭就失效可设置为true,标识不设置COOKIE_MAX_AGE_SECONDS。如果设置为false,cookie的失效时间以 COOKIE_MAX_AGE_SECONDS设置的为准
const USE_SESSION_COOKIE = false;

export const handler = async (event) => {
  // ✅ 取出原始请求和响应对象
  const request = event.Records[0].cf.request;
  const response = event.Records[0].cf.response;
  const headers = request.headers;

  // ✅ 获取 CloudFront Function 注入的灰度标记头
  const cookieHeader = headers['x-set-fenliu-cookie'];

  // ✅ 如果存在灰度版本标记,则设置对应 Cookie
  if (cookieHeader && cookieHeader.length > 0) {
    const version = cookieHeader[0].value;

    // ✅ 默认构造 Session Cookie(不含 Max-Age)
    let cookieValue = `${COOKIE_NAME}=${version}; Path=/; HttpOnly; Secure; SameSite=Lax`;

    // ✅ 如果关闭会话 Cookie 模式,则添加 Max-Age 指定过期时间
    if (!USE_SESSION_COOKIE && COOKIE_MAX_AGE_SECONDS > 0) {
      cookieValue = `${COOKIE_NAME}=${version}; Path=/; Max-Age=${COOKIE_MAX_AGE_SECONDS}; HttpOnly; Secure; SameSite=Lax`;
    }

    // ✅ 设置响应头中的 Set-Cookie
    response.headers['set-cookie'] = [{
      key: 'Set-Cookie',
      value: cookieValue
    }];
  }

  // ✅ 返回最终响应对象(带上 Set-Cookie)
  return response;
};
  1. 在cloudfront Distributions 绑定 lambda function
    除了代码其余的都和view request都一样
    image

🧪 7. 测试验证

  • ✅ 使用 Playwright 模拟多 IP / UA 客户端
  • ✅ 检查 Cookie 是否正确设置
  • ✅ 校验页面是否加载目标版本(通过 HTML / version 埋点)
  • ✅ 批量统计命中率

可以通过下面的代码进行测试验证

  • 以下的代码实际是从index.html读取看有么有关键字dev|beta,所以需要再index.html加入以下类似代码
  • 执行测试代码前先按照两个包
pip3.11 install playwright
playwright install 
  • 测试验证代码
from playwright.sync_api import sync_playwright
import ipaddress
import random

# === 配置 ===
COOKIE_NAME = "fenliu-cookie"
TARGET_DOMAIN = "https://xxx.com"
TARGET_PATH = "/home"
TARGET_URL = f"{TARGET_DOMAIN}{TARGET_PATH}"
CIDR_BLOCK = "38.181.86.96/27"
NUM_IPS_TO_TEST = 10
VERSIONS = ["dev", "beta"]  # 支持混测版本

# === 命中统计 ===
stats = {
    "total": 0,
    "dev": {"expected": 0, "matched": 0},
    "beta": {"expected": 0, "matched": 0}
}

# === IP 生成 ===
def generate_ips(cidr):
    net = ipaddress.IPv4Network(cidr)
    return [str(ip) for ip in net.hosts()]

# === 单个客户端测试 ===
def run_test(version, ip):
    with sync_playwright() as p:
        print(f"\n🚀 版本: {version} | 模拟 IP: {ip}")
        browser = p.chromium.launch(headless=True)

        context = browser.new_context(
            extra_http_headers={"X-Forwarded-For": ip}
        )
        page = context.new_page()

        # 第一次请求,带 query 设置 cookie
        first_url = f"{TARGET_URL}?{COOKIE_NAME}={version}"
        print(f"➡️ 请求 1:{first_url}")
        page.goto(first_url, wait_until="networkidle")

        cookies = context.cookies()
        match = next((c for c in cookies if c["name"] == COOKIE_NAME), None)
        if match:
            print(f"✅ Cookie 设置:{match['name']}={match['value']}")
        else:
            print("❌ Cookie 设置失败")

        # 第二次访问,仅带 Cookie
        print(f"➡️ 请求 2(带 Cookie):{TARGET_URL}")
        page.goto(TARGET_URL, wait_until="networkidle")
        html = page.content()

        stats["total"] += 1
        stats[version]["expected"] += 1
        if version in html:
            stats[version]["matched"] += 1
            print("✅ 页面命中版本 ✅")
        else:
            print("⚠️ 页面未命中版本")

        browser.close()

# === 主入口 ===
if __name__ == "__main__":
    all_ips = generate_ips(CIDR_BLOCK)
    selected_ips = random.sample(all_ips, min(NUM_IPS_TO_TEST, len(all_ips)))

    for ip in selected_ips:
        test_version = random.choice(VERSIONS)
        run_test(test_version, ip)

    # === 最终统计结果 ===
    print("\n📊 分流测试结果统计:")
    print(f"总请求数: {stats['total']}")
    for v in VERSIONS:
        print(f"\n版本: {v}")
        print(f"  ➤ 期望命中数: {stats[v]['expected']}")
        print(f"  ✅ 实际命中数: {stats[v]['matched']}")
        hit_rate = (stats[v]["matched"] / stats[v]["expected"]) * 100 if stats[v]["expected"] else 0
        print(f"  🎯 命中率: {hit_rate:.1f}%")
posted @ 2025-04-03 11:16  Hello_worlds  阅读(150)  评论(0)    收藏  举报