基于内网高可用 VIP 实现生产、容灾环境切换
共 18458字,需浏览 37分钟
·
2024-07-02 07:30
开篇之前,老周觉得有必要先铺垫一下,我们先来说一说 VIP ( Virtual IP ) 相关的技术。
一、虚拟IP概述
1.1 VIP 是什么
虚拟IP(Virtual IP Address,简称VIP)是一个未分配给真实弹性云服务器网卡的IP地址。弹性云服务器除了拥有私有IP地址外,还可以拥有虚拟IP地址,用户可以通过其中任意一个IP(私有IP/虚拟IP)访问此弹性云服务器。
同时,虚拟IP地址拥有私有IP地址同样的网络接入能力,包括VPC内二三层通信、VPC之间对等连接访问,以及弹性公网IP、VPN、云专线等网络接入。
您可以为多个主备部署的弹性云服务器绑定同一个虚拟IP地址,然后为虚拟IP绑定一个弹性公网IP,搭配Keepalived,实现主服务器故障后,自动切换至备服务器,打造高可用容灾组网。
1.2 典型组网
虚拟IP主要用在弹性云服务器的主备切换,搭配Keepalived,达到高可用性HA(High Availability)的目的。当主服务器发生故障无法对外提供服务时,动态将虚拟IP切换到备服务器,继续对外提供服务。本节介绍两种典型的组网模式。
1.2.1 典型组网1:HA高可用性模式
场景举例:如果您想要提高服务的高可用性,避免单点故障,可以用“一主一备”或“一主多备”的方法组合使用弹性云服务器,这些弹性云服务器对外表现为一个虚拟IP。当主服务器故障时,备服务器可以转为主服务器,继续对外提供服务。
将2台同子网的弹性云服务器绑定同一个虚拟IP。
将这2台弹性云服务器配置Keepalived,实现一台为主服务器,一台为备份服务器。Keepalived可参考业内通用的配置方法,此处不做详细介绍。
1.2.2 典型组网2:高可用负载均衡集群
场景举例:如果您想搭建高可用负载均衡集群服务,您可以采用Keepalived + LVS(DR)来实现。
将2台弹性云服务器绑定同一个虚拟IP。
将绑定了虚拟IP的这2台弹性云服务器配置Keepalived+LVS(DR模式),组成LVS主备服务器。这2台服务器作为分发器将请求均衡地转发到不同的后端服务器上执行。
配置另外2台弹性云服务器作为后端RealServer服务器。
1.3 VIP ( Virtual IP ) 调度技术
在计算机领域中,VIP(Virtual IP)调度是一种负载均衡技术,用于在计算机网络中和将客户端请求分发到多个服务器节点。通过一个虚拟IP地址分配多个物理或虚拟服务器,VIP调度可以实现流量分发,高可用性和性能优化。以下是一些常见的VIP调度技术:
1.3.1 基于硬件负载均衡器
硬件负载均衡器是专门的硬件设备,通过硬件芯片和专用软件实现负载均衡。硬件负载均衡器可以配置VIP,并根据特定的负载均衡算法(如轮询,加权轮询,最小连接数)将流量分发到后端服务器。
1.3.2 基于软件负载均衡器
软件负载均衡器是在普通服务器上运行的负载均衡软件,如Nginx,HAProxy,Java 当中的Gateway网关。这些都可以配置VIP,并使用配置的负载均衡算法将流量分发到不同的服务器节点。
1.3.3 DNS 负载均衡
在DNS负载均衡中,VIP是通过DNS解析后返回不同的服务器IP地址来实现的。客户端的DNS请求会返回多个服务器IP地址,然后客户端根据特定算法选择其中的一个IP地址进行连接。这种方式在配置和管理上比较简单,但可能会受到DNS缓存等因素的影响。
1.3.4 容器编排平台
在容器编排平台(如 Kubernetes、Docker Swarm) 中,VIP通常用于服务发现和负载均衡。编排平台可以为服务分配VIP,然后使用内置的负载均衡机制将流量分发到不同的容器实例。
1.3.5 IPVS(IP Virtual Server)
IPVS是Linux内核的一个功能,可以VIP调度。他可以配置多种负载均衡算法,包括轮询、加权轮询、最小连接数等,将流量分发到后端服务器。
小结:VIP调度技术可以根据不同的需求和场景进行切换。它可以在分布式系统、容器集群、web应用等多种场景中发挥作用,提高系统的性能和可用性。
1.4 应用场景
场景一:通过弹性公网IP访问虚拟IP。
你的应用需要具备高可用性并通过Internet对外提供服务,推荐使用弹性公网IP绑定虚拟IP功能。
场景二:通过VPN/云专线/对等连接访问虚拟IP。
你的应用需要具备高可用性并且需要通过Internet访问,同时需要具备安全性(VPN),保证稳定的网络性能(云专线),或者需要通过其他VPC访问(对等连接)。
二、方案设计
上面一节主要介绍VIP相关的技术,这一节我们就来说说具体的方案设计细节。
2.1 背景
我们边缘云的服务通过内网访问总部的服务,拉取相应的数据。我们总部的服务、数据库都做了蓝、绿,也就相当于容灾、生产两套环境。应用很稳定,服务、数据库有问题直接把流量切到容灾(蓝)环境。那么问题来了,DNS域名解析就有问题咋整,都还没到你的服务层,即便你服务层做了蓝绿也无济于事。你可能会说,老周啊,你上面的第一节中典型组网2:高可用负载均衡集群不是可以解决这个问题么?没错,但当光缆被挖断、机房出现异常,或因不可抗拒原因(如地质灾害)等造成业务不可用,这一片云服务器都得挂,这就得考虑异地多活的方案了。
2.2 方案选择
跨城容灾方案说明:
正常使用主域名调用,备域名需有流量,保证业务能实时切换。当域名出现请求超时、读写超时,自动换备域名重试。
交易主链路和交易备链路做好动态流量分配,保证遇到异常能够自动切换。例如可以统计主备域名的连接耗时、丢包率、业务失败率,出现异常情况(例如5秒钟内统计业务失败率超过50%)可自动切换到最优链路。
注:
主域名:api.company.com
备用域名:api-backup.company.com
2.2.1 方案一:统计主域名实际请求成功率,实现主备域名实时切换策略
方案流程图
业务请求流程
准备好全局存储空间(比如配置文件、内存空间等)存放“域名信息”、“日志信息”并进行初始化;
发起业务请求之前,从域名信息库里面获取域名;
使用当前域名发起请求,成功,则上报成功结果并且流程结束;
使用当前域名发起请求,失败(连接超时、读写超时),则上报失败结果并且获取另一个域名进行重试,流程结束;
因业务问题导致失败,商户侧根据自身逻辑处理;
第3步和第4步中上报的请求结果存储规则:保留主域名10分钟内最近100次请求,商户也可根据实际情况自行调整。
成功率统计流程
定义主域名最小可用率,比如90%(具体数值商户可根据实际业务情况进行设定);
启动定时探测器,取【业务请求流程】中的请求结果数据,对其进行汇总统计,计算成功率,每分钟一次(计算频率商户可根据实际业务情况进行设定);
当主域名请求成功率大于等于最小可用率时更新当前域名为主域名;
当主域名请求成功率小于最小可用率时更新当前域名为备用域名。
2.2.2 方案二:定时探测主域名连通性,实现主备域名实时切换策略
方案流程图
业务请求流程准备好全局存储空间(比如配置文件、内存空间等)存放“域名信息”并进行初始化;
发起交易前,从“域名信息”中获取当前域名;
使用当前域名发起请求,成功,则流程结束;
使用当前域名发起请求,失败(连接超时、读写超时),获取另一个域名进行重试,流程结束;
因业务问题导致失败,商户侧根据自身逻辑处理;
定时探测流程
启动定时探测器,每分钟一次进行主域名探测(探测频率商户可根据业务实际情况自行设定);
连续探测主域名5次,失败(连接超时)次数小于3次,更新域名信息为主域名,失败(连接超时)次数大于等于3次,更新域名信息为备用域名;
探测方式可用curl、telnet等方式发起。
大家根据自己的业务适当的选择方案,这里老周选择方案二来实现。
三、代码实现
3.1 总部客户端选择器
/**
* 总部客户端选择器
*
* @author 微信公众号【老周聊架构】
*/
public interface DcClientSelector {
/**
* 获取总部客户端
*/
WebClient getDcClient();
/**
* 刷新配置
*/
void refresh(InoutProperties properties);
/**
* 选择任务开始
*/
void start();
/**
* 选择任务停止
*/
void stop();
}
3.2 总部客户端选择监听器
/**
* 总部客户端选择监听器
*
* @author 微信公众号【老周聊架构】
*/
public interface DcClientSelectedListener {
/**
* 监听变更的客户端(看是生产客户端还是容灾客户端)
*/
void selected(WebClient client);
}
3.3 高可用总部客户端选择器
/**
* 高可用总部客户端选择器
*
* @author 微信公众号【老周聊架构】
*/
@Slf4j
public class HaDcClientSelector implements DcClientSelector, DcClientSelectedListener, Closeable {
private WebClient dcClient;
private final ExecutorService selectExecutor;
private InoutProperties properties;
private SelectTask selectTask;
public HaDcClientSelector(InoutProperties properties) {
Assert.isTrue(StringUtils.isNoneBlank(properties.getRemote().getDcIpHosts(), properties.getRemote().getDcDomainHost()), "riemann网关的组VIP或域名缺失");
this.properties = properties;
this.selectExecutor = Executors.newCachedThreadPool();
}
@Override
public void refresh(InoutProperties properties) {
Assert.isTrue(StringUtils.isNoneBlank(properties.getRemote().getDcIpHosts(), properties.getRemote().getDcDomainHost()), "riemann网关的组VIP或域名缺失");
this.properties = properties;
}
@Override
public void start() {
DcClient[] dcClients = Arrays.stream(properties.getRemote().getDcIpHosts().split(",")).map(s -> new DcClient(s, properties)).toArray(DcClient[]::new);
this.stop();
this.selectTask = new SelectTask(dcClients, properties.getRemote().getBaseZno(), this);
selectExecutor.submit(this.selectTask);
}
@Override
public void stop() {
if (this.selectTask != null) {
this.selectTask.stop();
}
}
@Override
public void close() {
this.selectExecutor.shutdown();
}
@Override
public WebClient getDcClient() {
return dcClient;
}
@Override
public void selected(WebClient client) {
this.dcClient = client;
}
...
}
3.4 总部Client类
private static class DcClient {
private final String host;
private final WebClient webClient;
private final String healthCheckUri;
private final Consumer<HttpHeaders> headersConsumer;
private boolean enable = true;
public DcClient(String host, InoutProperties inoutProperties) {
this.host = host;
this.webClient = WebClients.newWebClient(host);
this.healthCheckUri = inoutProperties.getRemote().getDispGwHealthUri();
headersConsumer = headers -> {
headers.add(HOST, inoutProperties.getRemote().getDcDomainHost());
};
}
public Mono<Boolean> healthCheck() {
return this.webClient
.get()
.uri(this.healthCheckUri)
.headers(headersConsumer)
.retrieve()
.bodyToMono(Object.class)
.timeout(Duration.ofSeconds(5))
.retryWhen(Retry.backoff(3, Duration.ofSeconds(1)))
.flatMap(result -> {
log.debug("disp client: {} check success, system time: {}.", this.host, result);
this.enable = true;
return Mono.just(true);
})
.onErrorResume(e -> {
log.warn("disp client: {} check failed, change client to disable.", this.host);
this.enable = false;
return Mono.just(false);
});
}
public WebClient getWebClient() {
return webClient;
}
public boolean isEnable() {
return enable;
}
}
3.5 任务选择类
private static class SelectTask implements Runnable {
private volatile boolean stop = false;
private final DcClient selfClient;
private final DcClient[] dcClients;
private DcClient currentClient;
private final DcClientSelectedListener listener;
public SelectTask(DcClient[] dcClients, String baseZno, DcClientSelectedListener listener) {
this.dcClients = dcClients;
this.selfClient = dcClients[Math.abs(baseZno.hashCode()) % this.dcClients.length];
this.currentClient = selfClient;
this.listener = listener;
// 初始化
this.listener.selected(this.selfClient.getWebClient());
}
public void stop() {
this.stop = true;
}
@Override
public void run() {
while (!stop) {
// 回切
if (this.currentClient != selfClient) {
this.selfClient.healthCheck().block();
if (this.selfClient.isEnable()) {
log.warn("服务端恢复,自动回切: {}.", this.selfClient.host);
this.currentClient = this.selfClient;
this.listener.selected(this.currentClient.getWebClient());
}
} else {
if (this.dcClients.length > 1) {
// 健康检查是否可用,不可用时自动漂移
this.currentClient.healthCheck().block();
if (!this.currentClient.isEnable()) {
DcClient change = Arrays.stream(this.dcClients)
.filter(c -> c != this.currentClient)
.peek(c -> c.healthCheck().block())
.filter(DcClient::isEnable)
.findFirst()
.orElse(null);
if (change != null) {
log.warn("当前服务端: {}不可用,自动漂移至: {}.", this.currentClient.host, change.host);
this.currentClient = change;
this.listener.selected(this.currentClient.getWebClient());
}
}
}
}
try {
Thread.sleep(60000L);
} catch (InterruptedException e) {
this.stop = true;
}
}
}
}
3.6 UML图
四、小结
相信大多数小伙伴都看懂了吧?没看懂的话也没关系,留言或私聊老周一起交流交流。
还是小结下吧,首先地区用户通过封装的DcClient客户端访问总部的资源,配置里的 dcIpHosts 就是总部网关组VIP组成的地址。这里我已经实现了上述方案二里的定时探测主域名连通性,实现主备域名实时切换策略。
欢迎大家关注我的公众号【老周聊架构】,AI、大数据、云原生、物联网等相关领域的技术知识分享。