结合Spring Cloud Zuul 与 Kubernetes的灰度发布测试方法

虽然Kubernetes自身支持通过Label改变服务(Service)与应用实例(Endpoint)的对应关系从而做到统一服务的版本区分,但是对于从SpringCloud微服务迁移过来的项目而言,我们的很多配置暂时控制的比较死,况且将灰度版本与生产版本已Namespace的形式分开也有助于更好的资源隔离。
因此在已通过Namespace隔离生产与灰度的环境前提下,我们的灰度测试方法说明如下。

1. 前提

  • 我们的应用服务集群基于Spring Cloud 微服务全家桶演化而来。详见: SpringCloud微服务迁移至Kubernetes实践
  • 我们已有统一的用户、组、角色权限控制体系,可以方便地对用户进行标定。
  • 我们有统一的网关Zuul
  • 我们通过Kubernetes内部DNS用于服务发现与服务负载均衡

2. 实施方法

2.1 环境分割&跨环境调用

假设我们的应用明明空间包含: default(生产), default-pre(开发), default-staging(预发布)。
Kubernetes的内部DNS解析规则如下,假设需要调用的服务名称问 AAA-Service, 则同属于同一个命名空间的服务调用请求即: http://AAA-Service 优先,若需跨命名空间则注明命名空间名称即可 http://AAA-Service.defaulthttp://AAA-Service.default-pre

其它关于kubernetes的DNS机制详情请参考: DNS for Services and Pods

2.2 用户标定

用户通过登录认证,获取网关生成的JWT Token,该Token包含用户基本信息(用户组、角色),并将解密Token后的用户信息附加在请求头中即可。
通过网关Filter约定用户访问业务系统必须包含登录状态的前提下,我们可以认为所有可以顺利访问后端业务的请求均为用户认证后的有效请求。因此就可以直接从请求头重提取用户身份信息了。
假设我们生成一个用户角色为: TEST_USER, 则该具有该角色的用户登录后,请求头就能读取到该信息,从而确定这个请求需要被转发到灰度环境。

在Zuul中,前置过滤器优先于Load Balancer, 因此可以保证在经过自定义负载均衡时已经获取到用户的有效信息了, 详情请见: zuul学习四:zuul 过滤器详解

2.3 Zuul请求转发的具体实现

在本文场景中,Zuul已经脱离了Eureka服务注册,因此为每一个服务转发请求的策略一般降级为url映射。即:

zuul.routes.AAA-Service.path=/aaa/**
zuul.routes.AAA-Service.url=http://AAA-Service

但是一旦有环境切换要求,则仍然需要Zuul进行一次url选择,即自定义负载均衡,因此需要指定一个Rule的实现,此时配置变为:

AAA-Service.ribbon.listOfServers=http://AAA-Service.default,http://AAA-Service.default-pre,http://AAA-Service.default-staging
AAA-Service.ribbon.NFLoadBalancerRuleClassName=org.wsy.blog.rule.MyCustomRule
zuul.routes.AAA-Service.path=/aaa/**
zuul.routes.AAA-Service.serviceId=AAA-Service

以上配置为 serviceId=AAA-Service 的服务分配了后端服务列表(即3个环境对应的Service url), 并指定了配置的负载均衡规则 MyCustomRule
其中MyCustomRule 大致代码如下:

package org.wsy.blog.common.rule;

import java.io.UnsupportedEncodingException;
import java.util.List;
import java.util.stream.Collectors;

import javax.servlet.http.HttpServletRequest;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;

import com.netflix.loadbalancer.RoundRobinRule;
import com.netflix.loadbalancer.Server;
import com.netflix.zuul.context.RequestContext;
import org.wsy.blog.common.user.JwtUserDetail;
import org.wsy.blog.service.JwtService;

public class GrayRuleForRailsService extends RoundRobinRule {

	private static final Logger logger = LoggerFactory.getLogger(GrayRuleForRailsService.class);

	private static final String envSwitchHeader = "switch";
	private static final String debuggerRole = "DEV_DEBUGGER";
	private static final String stagingRole = "DEV_STAGING";
	private static final String accessTokenParamName = "token";

	@Autowired
	JwtService jwtService;

   // 真正的环境区分代码, 实际上我还支持了envStr请求头切换
	@Override
	public Server choose(Object key) {
		RequestContext context = RequestContext.getCurrentContext();
		HttpServletRequest request = context.getRequest();
		final Object envSwitch = request.getHeader(envSwitchHeader);
		final Object accessToken = request.getHeader(accessTokenParamName);
		boolean chooseDev = false;
		boolean chooseStaging = false;
		if (envSwitch != null) {
			try {
				String envStr = envSwitch.toString();
				if ("dev".equalsIgnoreCase(envStr)) {
					chooseDev = true;
				} else if ("staging".equalsIgnoreCase(envStr)) {
					chooseStaging = true;
				}
			} catch (Exception e) {
				logger.warn("环境请求头处理异常,使用生产环境..." + e.getMessage());
			}

		} else {
			// check userAuth
			if (accessToken != null) {
				// if not null test anyway
				try {
					JwtUserDetail user = getUser(accessToken.toString());
					if (user.getAuthorities().stream().filter(e -> e.getAuthority().equals(debuggerRole)).findAny()
							.isPresent()) {
						chooseDev = true;
					} else if (user.getAuthorities().stream().filter(e -> e.getAuthority().equals(stagingRole))
							.findAny().isPresent()) {
						chooseStaging = true;
					}
				} catch (Exception e) {
					logger.warn("ribbon check token fail. " + e.getMessage());
				}
			}
		}
		// 这里就是获取各对应环境的url, 实际上我的环境区分还包括开发环境
		Server chosenServer = null;
		if (chooseDev) {
			chosenServer = chooseDev();
		} else if (chooseStaging) {
			chosenServer = chooseStaging();
		} else {
			chosenServer = chooseProd();
		}
		return chosenServer;

	}

	private Server chooseDev() {
		List<Server> devServers = this.getLoadBalancer().getAllServers().stream()
				.filter(s -> s.getHost().contains("default-pre")).collect(Collectors.toList());
		if (devServers != null && devServers.size() > 0) {
			return devServers.get(0);
		} else {
			return null;
		}
	}

	private Server chooseStaging() {
		List<Server> devServers = this.getLoadBalancer().getAllServers().stream()
				.filter(s -> s.getHost().contains("default-staging")).collect(Collectors.toList());
		if (devServers != null && devServers.size() > 0) {
			return devServers.get(0);
		} else {
			return null;
		}
	}

	private JwtUserDetail getUser(String token)
			throws IllegalArgumentException, UnsupportedEncodingException, Exception {
		return jwtService.verify(token).getUserDetail();
	}

	private Server chooseProd() {
		List<Server> prodServers = this.getLoadBalancer().getAllServers().stream()
				.filter(s -> (!s.getHost().contains("default-pre") && !s.getHost().contains("default-staging")))
				.collect(Collectors.toList());
		if (prodServers != null && prodServers.size() > 0) {
			return prodServers.get(0);
		} else {
			return null;
		}
	}
}

你可能感兴趣的:(后端)