虽然Kubernetes自身支持通过Label改变服务(Service)与应用实例(Endpoint)的对应关系从而做到统一服务的版本区分,但是对于从SpringCloud微服务迁移过来的项目而言,我们的很多配置暂时控制的比较死,况且将灰度版本与生产版本已Namespace的形式分开也有助于更好的资源隔离。 因此在已通过Namespace隔离生产与灰度的环境前提下,我们的灰度测试方法说明如下。
假设我们的应用明明空间包含: default(生产), default-pre(开发), default-staging(预发布)。 Kubernetes的内部DNS解析规则如下,假设需要调用的服务名称问 AAA-Service, 则同属于同一个命名空间的服务调用请求即: http://AAA-Service 优先,若需跨命名空间则注明命名空间名称即可 http://AAA-Service.default 或 http://AAA-Service.default-pre 。
其它关于kubernetes的DNS机制详情请参考: DNS for Services and Pods
用户通过登录认证,获取网关生成的JWT Token,该Token包含用户基本信息(用户组、角色),并将解密Token后的用户信息附加在请求头中即可。 通过网关Filter约定用户访问业务系统必须包含登录状态的前提下,我们可以认为所有可以顺利访问后端业务的请求均为用户认证后的有效请求。因此就可以直接从请求头重提取用户身份信息了。 假设我们生成一个用户角色为: TEST_USER, 则该具有该角色的用户登录后,请求头就能读取到该信息,从而确定这个请求需要被转发到灰度环境。
在Zuul中,前置过滤器优先于Load Balancer, 因此可以保证在经过自定义负载均衡时已经获取到用户的有效信息了, 详情请见: zuul学习四: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; } } }