微服務(wù)如何灰度發(fā)布?你會嗎?
微服務(wù)中的灰度發(fā)布(又稱為金絲雀發(fā)布)是一種持續(xù)部署策略,它允許在正式環(huán)境的小部分用戶群體上先部署新版本的應(yīng)用程序或服務(wù),而不是一次性對所有用戶同時發(fā)布全新的版本。
這種方式有助于在生產(chǎn)環(huán)境中逐步驗證新版本的穩(wěn)定性和兼容性,同時最小化潛在風(fēng)險,不影響大部分用戶的正常使用。
1.灰度發(fā)布關(guān)鍵步驟
在 Spring Cloud 微服務(wù)架構(gòu)中,實現(xiàn)灰度發(fā)布通常涉及到以下幾個方面:
- 流量分割:
根據(jù)一定的策略(如用戶 ID、請求頭信息、IP 地址等)將流入的請求分配給不同版本的服務(wù)實例。
使用 Spring Cloud Gateway、Zuul 等 API 網(wǎng)關(guān)組件實現(xiàn)路由規(guī)則,將部分請求定向至新版本的服務(wù)節(jié)點。
- 版本標識:
新版本服務(wù)啟動時會注冊帶有特定版本標簽的服務(wù)實例到服務(wù)注冊中心(如 Eureka 或 Nacos)。
請求在路由時可以根據(jù)版本標簽選擇相應(yīng)版本的服務(wù)實例。
- 監(jiān)控與評估:
在灰度發(fā)布的階段,運維團隊會對新版本服務(wù)的性能、穩(wěn)定性以及用戶體驗等方面進行實時監(jiān)控和評估。
如果新版本表現(xiàn)良好,則可以逐漸擴大灰度范圍直至全面替換舊版本。
- 故障恢復(fù)與回滾:若新版本出現(xiàn)問題,可通過快速撤銷灰度發(fā)布策略,使所有流量恢復(fù)到舊版本服務(wù),實現(xiàn)快速回滾,確保服務(wù)整體可用性。
通過 Spring Cloud 的擴展組件和自定義路由策略,開發(fā)人員可以輕松實現(xiàn)灰度發(fā)布功能,確保在微服務(wù)架構(gòu)中安全、平滑地進行版本迭代升級。
2.實現(xiàn)思路
灰色發(fā)布的常見實現(xiàn)思路有以下幾種:
- 根據(jù)用戶劃分:根據(jù)用戶標識或用戶組進行劃分,在整個用戶群體中只選擇一小部分用戶獲得新功能。
- 根據(jù)地域劃分:在不同地區(qū)或不同節(jié)點上進行劃分,在其中的一小部分地區(qū)或節(jié)點進行新功能的發(fā)布。
- 根據(jù)流量劃分:根據(jù)流量的百分比或請求次數(shù)進行劃分,只將一部分請求流量引導(dǎo)到新功能上。
而在生產(chǎn)環(huán)境中,比較常用的是根據(jù)用戶標識來實現(xiàn)灰色發(fā)布,也就是說先讓一小部分用戶體驗新功能,以發(fā)現(xiàn)新服務(wù)中可能存在的某種缺陷或不足。
3.底層實現(xiàn)
Spring Cloud 全鏈路灰色發(fā)布的關(guān)鍵實現(xiàn)思路如下圖所示:
圖片
灰度發(fā)布的具體實現(xiàn)步驟如下:
- 前端程序在灰度測試的用戶 Header 頭中打上標簽,例如在 Header 中添加“gray-tag: true”,其表示要進行灰常測試(訪問灰度服務(wù)),而其他則為訪問正式服務(wù)。
- 在負載均衡器 Spring Cloud LoadBalancer 中,拿到 Header 中的“gray-tag”進行判斷,如果此標簽不為空,并等于“true”的話,表示要訪問灰度發(fā)布的服務(wù),否則只訪問正式的服務(wù)。
- 在網(wǎng)關(guān) Spring Cloud Gateway 中,將 Header 標簽“gray-tag: true”繼續(xù)往下一個調(diào)用服務(wù)中傳遞。
- 在后續(xù)的調(diào)用服務(wù)中,需要實現(xiàn)以下兩個關(guān)鍵功能:
在負載均衡器 Spring Cloud LoadBalancer 中,判斷灰度發(fā)布標簽,將請求分發(fā)到對應(yīng)服務(wù)。
將灰度發(fā)布標簽(如果存在),繼續(xù)傳遞給下一個調(diào)用的服務(wù)。
經(jīng)過第四步的反復(fù)傳遞之后,整個 Spring Cloud 全鏈路的灰度發(fā)布就完成了。
4.具體實現(xiàn)
4.1 版本標識
在灰度發(fā)布的執(zhí)行流程中,有一個核心的問題,如果在 Spring Cloud LoadBalancer 進行服務(wù)調(diào)用時,區(qū)分正式服務(wù)和灰度服務(wù)呢?
這個問題的解決方案是:在灰度服務(wù)既注冊中心的 MetaData(元數(shù)據(jù))中標識自己為灰度服務(wù)即可,而元數(shù)據(jù)中沒有標識(灰度服務(wù))的則為正式服務(wù),以 Nacos 為例,它的設(shè)置如下:
spring:
application:
name: gray-user-service
cloud:
nacos:
discovery:
username: nacos
password: nacos
server-addr: localhost:8848
namespace: public
register-enabled: true
metadata: { "gray-tag":"true" } # 標識自己為灰度服務(wù)4.2 負載均衡調(diào)用灰度服務(wù)
Spring Cloud LoadBalancer 判斷并調(diào)用灰度服務(wù)的關(guān)鍵實現(xiàn)代碼如下:
private Response<ServiceInstance> getInstanceResponse(List<ServiceInstance> instances,
Request request) {
// 實例為空
if (instances.isEmpty()) {
if (log.isWarnEnabled()) {
log.warn("No servers available for service: " + this.serviceId);
}
return new EmptyResponse();
} else { // 服務(wù)不為空
RequestDataContext dataContext = (RequestDataContext) request.getContext();
HttpHeaders headers = dataContext.getClientRequest().getHeaders();
// 判斷是否為灰度發(fā)布(請求)
if (headers.get(GlobalVariables.GRAY_KEY) != null &&
headers.get(GlobalVariables.GRAY_KEY).get(0).equals("true")) {
// 灰度發(fā)布請求,得到新服務(wù)實例列表
List<ServiceInstance> findInstances = instances.stream().
filter(s -> s.getMetadata().get(GlobalVariables.GRAY_KEY) != null &&
s.getMetadata().get(GlobalVariables.GRAY_KEY).equals("true"))
.toList();
if (findInstances.size() > 0) { // 存在灰度發(fā)布節(jié)點
instances = findInstances;
}
} else { // 查詢非灰度發(fā)布節(jié)點
// 灰度發(fā)布測試請求,得到新服務(wù)實例列表
instances = instances.stream().
filter(s -> s.getMetadata().get(GlobalVariables.GRAY_KEY) == null ||
!s.getMetadata().get(GlobalVariables.GRAY_KEY).equals("true"))
.toList();
}
// 隨機正數(shù)值 ++i( & 去負數(shù))
int pos = this.position.incrementAndGet() & Integer.MAX_VALUE;
// ++i 數(shù)值 % 實例數(shù) 取模 -> 輪詢算法
int index = pos % instances.size();
// 得到服務(wù)實例方法
ServiceInstance instance = (ServiceInstance) instances.get(index);
return new DefaultResponse(instance);
}
}以上代碼為自定義負載均衡器,并使用了輪詢算法。如果 Header 中有灰度標簽,則只查詢灰度服務(wù)的節(jié)點實例,否則則查詢出所有的正式節(jié)點實例(以供服務(wù)調(diào)用或服務(wù)轉(zhuǎn)發(fā))。
4.3 網(wǎng)關(guān)傳遞灰度標識
要在網(wǎng)關(guān) Spring Cloud Gateway 中傳遞灰度標識,只需要在 Gateway 的全局自定義過濾器中設(shè)置 Response 的 Header 即可,具體實現(xiàn)代碼如下:
package com.example.gateway.config;
import com.loadbalancer.canary.common.GlobalVariables;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@Component
public class LoadBalancerFilter implements GlobalFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 得到 request、response 對象
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
if (request.getQueryParams().getFirst(GlobalVariables.GRAY_KEY) != null) {
// 設(shè)置金絲雀標識
response.getHeaders().set(GlobalVariables.GRAY_KEY,
"true");
}
// 此步驟正常,執(zhí)行下一步
return chain.filter(exchange);
}
}4.4 微服務(wù)中傳遞灰度標簽
HTTP 調(diào)用工具 Openfeign,我們需要在微服務(wù)間繼續(xù)傳遞灰度標簽,它的實現(xiàn)代碼如下:
import feign.RequestInterceptor;
import feign.RequestTemplate;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.Enumeration;
import java.util.LinkedHashMap;
import java.util.Map;
@Component
public class FeignRequestInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
// 從 RequestContextHolder 中獲取 HttpServletRequest
ServletRequestAttributes attributes = (ServletRequestAttributes)
RequestContextHolder.getRequestAttributes();
// 獲取 RequestContextHolder 中的信息
Map<String, String> headers = getHeaders(attributes.getRequest());
// 放入 openfeign 的 RequestTemplate 中
for (Map.Entry<String, String> entry : headers.entrySet()) {
template.header(entry.getKey(), entry.getValue());
}
}
/**
* 獲取原請求頭
*/
private Map<String, String> getHeaders(HttpServletRequest request) {
Map<String, String> map = new LinkedHashMap<>();
Enumeration<String> enumeration = request.getHeaderNames();
if (enumeration != null) {
while (enumeration.hasMoreElements()) {
String key = enumeration.nextElement();
String value = request.getHeader(key);
map.put(key, value);
}
}
return map;
}
}




























