基于内网高可用 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 DcClientSelectorDcClientSelectedListenerCloseable {

    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数据、云原生、物联网等相关领域的技术知识分享。


浏览 22
1点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
1点赞
评论
收藏
分享

手机扫一扫分享

分享
举报