前端灰度发布
1. 架构图
✏️架构图说明
🔁 请求流程:
用户发起访问请求(含 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两个之间进行灰度)
⚙️ 核心组件
🔐 1. 白名单控制
仅允许特定 CIDR 范围的客户端 IP 发起请求。
const allowedCIDRs = [
"38.181.86.96/27",
"114.247.64.195/32",
...
];
🍪 2. Cookie 灰度分流
🗂️ 3. URI 路径重写规则
🧠 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
⏳ 5. Cookie 失效时间控制
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
- 打开aws的 lambda地址
- 创建function 需要区分环境(因为测试和预发是有白名单限制的,所以function最好分环境易于后面扩展)
- 代码(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;
};
- deploy
- test 可选
{
"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"
}
]
}
}
}
}
]
}
- Versions(相当于发布)
- 在cloudfront Distributions 绑定 lambda function
6.2 Viewer Response 的 lambda function
- 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;
};
- 在cloudfront Distributions 绑定 lambda function
除了代码其余的都和view request都一样
🧪 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}%")