Istio Ambient Mesh 四层负载均衡实现剖析

本文涉及的产品
传统型负载均衡 CLB,每月750个小时 15LCU
全局流量管理 GTM,标准版 1个月
云解析 DNS,旗舰版 1个月
简介: 前言k8s通过service将相同类型的工作负载组织成为一组集群,并提供了负载均衡的能力,可以将请求随机路由到集群中的端点。然而在Istio Ambient Mesh中,为了实现四层安全,Istio Ambient Mesh通过配置iptables规则,将流量拦截到ztunnel组件,以便实现4层流量的加密处理后再向对端ztunnel发出,最终对端ztunnel再将流量转发至目标工作负载,而这样一

前言

k8s通过service将相同类型的工作负载组织成为一组集群,并提供了负载均衡的能力,可以将请求随机路由到集群中的端点。然而在Istio Ambient Mesh中,为了实现四层安全,Istio Ambient Mesh通过配置iptables规则,将流量拦截到ztunnel组件,以便实现4层流量的加密处理后再向对端ztunnel发出,最终对端ztunnel再将流量转发至目标工作负载,而这样一来就无法利用K8s的iptables规则进行负载均衡。本文将通过iptables规则分析、网络抓包、代码分析为大家拆解Istio Ambient Mesh如何对四层流量进行负载均衡。

K8s四层负载均衡

我们先来简单地梳理一下k8s的负载均衡机制,k8s为每个service分配了唯一的域名和对应的ip地址,当集群内的pod对服务发起访问时,数据包到达宿住机即会命中nat表的PRETOUTING链中如下iptables规则,进入nat表的KUBE-SERVIECS链:

*nat
-A PREROUTING -m comment --comment "kubernetes service portals" -j KUBE-SERVICES

KUBE-SERVICES链中存在多条匹配数据包目标地址的规则,我们可以看到前三条为DNS劫持路由,第四条则是匹配名为httpbin的service的地址(ClusterIP)和其服务端口8000,匹配成功后,会跳转到KUBE-SVC-FREKB6WNWYJLKTHC链。

*nat
-A KUBE-SERVICES -d 10.96.0.10/32 -p udp -m comment --comment "kube-system/kube-dns:dns cluster IP" -m udp --dport 53 -j KUBE-SVC-TCOU7JCQXEZGVUNU
-A KUBE-SERVICES -d 10.96.0.10/32 -p tcp -m comment --comment "kube-system/kube-dns:dns-tcp cluster IP" -m tcp --dport 53 -j KUBE-SVC-ERIFXISQEP7F7OF4
-A KUBE-SERVICES -d 10.96.0.10/32 -p tcp -m comment --comment "kube-system/kube-dns:metrics cluster IP" -m tcp --dport 9153 -j KUBE-SVC-JD5MR3NA4I4DYORP
-A KUBE-SERVICES -d 10.96.130.105/32 -p tcp -m comment --comment "default/httpbin:http cluster IP" -m tcp --dport 8000 -j KUBE-SVC-FREKB6WNWYJLKTHC
-A KUBE-SERVICES -d 10.96.0.1/32 -p tcp -m comment --comment "default/kubernetes:https cluster IP" -m tcp --dport 443 -j KUBE-SVC-NPX46M4PTMTKRN6Y

KUBE-SVC-FREKB6WNWYJLKTHC是一条专门为httpbin服务创建的链,我们来看一下这条链上的规则,可以看到,这个链的nat表中存在多条-m statistic --mode random --probability 0.16666666651规则,这些规则便是k8s服务路由的核心规则,请求将随机命中这些规则,然后跳转到规则指定的自定义链,根据规则的comment不难看出,这些规则每条都对应httpbin服务的一个Pod地址。

*nat
-A KUBE-SVC-FREKB6WNWYJLKTHC ! -s 10.244.0.0/16 -d 10.96.130.105/32 -p tcp -m comment --comment "default/httpbin:http cluster IP" -m tcp --dport 8000 -j KUBE-MARK-MASQ
-A KUBE-SVC-FREKB6WNWYJLKTHC -m comment --comment "default/httpbin:http -> 10.244.1.5:80" -m statistic --mode random --probability 0.16666666651 -j KUBE-SEP-ILXXWWPSKGBZA46S
-A KUBE-SVC-FREKB6WNWYJLKTHC -m comment --comment "default/httpbin:http -> 10.244.1.6:80" -m statistic --mode random --probability 0.20000000019 -j KUBE-SEP-AOCDBVEKKM5YHLBK
-A KUBE-SVC-FREKB6WNWYJLKTHC -m comment --comment "default/httpbin:http -> 10.244.1.7:80" -m statistic --mode random --probability 0.25000000000 -j KUBE-SEP-DAUDCGLE4GJUMXCA
-A KUBE-SVC-FREKB6WNWYJLKTHC -m comment --comment "default/httpbin:http -> 10.244.2.10:80" -m statistic --mode random --probability 0.33333333349 -j KUBE-SEP-QIYMDL2I6J6HKXCT
-A KUBE-SVC-FREKB6WNWYJLKTHC -m comment --comment "default/httpbin:http -> 10.244.2.7:80" -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-IOJMKVPIYFDGJ3TX
-A KUBE-SVC-FREKB6WNWYJLKTHC -m comment --comment "default/httpbin:http -> 10.244.2.9:80" -j KUBE-SEP-5RMYTATHPXR6OQCH

命中上面的一条随机规则后,将继续跳转到一个自定义链,这个链中的内容则主要是进行DNAT操作:-m tcp -j DNAT --to-destination 10.244.1.5:80

*nat
-A KUBE-SEP-ILXXWWPSKGBZA46S -s 10.244.1.5/32 -m comment --comment "default/httpbin:http" -j KUBE-MARK-MASQ
-A KUBE-SEP-ILXXWWPSKGBZA46S -p tcp -m comment --comment "default/httpbin:http" -m tcp -j DNAT --to-destination 10.244.1.5:80

至此,我们已经简单分析完毕了k8s的负载均衡规则,接下来我们来看一看Istio Ambient Mesh如何绕过k8s的负载均衡规则。

Istio Ambient四层负载均衡

由于Istio Ambient Mesh提供了四层流量安全的能力,Istio Ambient Mesh的设计要求流量从源Pod离开后,首先进入ztunnel Pod,在ztunnel内完成负载均衡、加密(如果启用)后,发往对端Pod所在的节点,再进入ztunnel Pod,并由ztunnel pod将流量解密,再透传至目标Pod。

跳过K8s service iptables规则

我们先看一下宿主机上的iptables规则,来了解Istio如何跳过了K8s的负载均衡

mangle*
-A PREROUTING -j ztunnel-PREROUTING # Istio加入的ztunnel-PREROUTING链

可以看到,Istio在PREROUTING链中插入了一条规则,当流量命中这条规则时,将跳转到ztunnel-PREROUTING链继续执行。在这个链中,将为数据包打上0x100标记:

*mangle
......
-A ztunnel-PREROUTING -p tcp -m set --match-set ztunnel-pods-ips src -j MARK --set-xmark 0x100/0x100

mangle表的PREROUTING链处理完毕后,进入nat表的PREROUTING链,Istio又在K8s的service规则前插入了一条规则,直接跳转到ztunnel-PREROUTING.

*nat
-A PREROUTING -j ztunnel-PREROUTING
-A PREROUTING -m comment --comment "kubernetes service portals" -j KUBE-SERVICES
-A PREROUTING -d 172.18.0.1/32 -j DOCKER_OUTPUT

而nat表的ztunnel-PREROUTING链则直接接受了带有0x100标记的数据包,从而使得其跳过了后续k8s service的iptables规则:

*nat
-A ztunnel-PREROUTING -m mark --mark 0x100/0x100 -j ACCEPT

0x100标记除了在iptables规则中起到了跳过K8s service规则的作用以外,还被用于策略路由,以使得流量被重定向到ztunnel,但本文主要聚焦负载均衡,故这一部分不展开讨论。

ztunnel pod内抓包验证

我们通过sleep应用内的curl访问httpbin服务,并在sleep应用pod所在的宿主机上运行的ztunnel pod内抓包,以验证上述规则生效,应用和服务的部署信息为:

类型

名称

地址:端口

Pod

sleep-bc9998558-8sf7z

10.244.2.8:Random

Service

httpbin.default.svc.cluster.local

10.96.130.105.8000

通过tcpdump -i any 'src host 10.244.2.8'指令进行抓包:

03:26:11.610298 pistioout In  IP 10.244.2.8.48920 > 10.96.130.105.8000: Flags [S], seq 294867080, win 64240, options [mss 1460,sackOK,TS val 1553210180 ecr 0,nop,wscale 7], length 0
03:26:11.610336 eth0  In  IP 10.244.2.8.48920 > 10.96.130.105.8000: Flags [.], ack 286067347, win 502, options [nop,nop,TS val 1553210181 ecr 61738018], length 0
03:26:11.610377 eth0  In  IP 10.244.2.8.48920 > 10.96.130.105.8000: Flags [P.], seq 0:75, ack 1, win 502, options [nop,nop,TS val 1553210181 ecr 61738018], length 75

通过在ztunnel pod内抓包,可以验证进入ztunnel的数据包仍然保持了服务地址(ClusterIP)10.96.130.105.8000。

您可能注意到,为什么第一个数据包来自pistioout而后续的数据包来自eth0?我将在后续的文章中对这部分进行分享。

ztunnel四层负载均衡

数据包进入ztunnel Pod后将命中ztunnel pod内的iptables规则,然后被tproxy到ztunnel的15001端口:

-A PREROUTING -i pistioout -p tcp -j TPROXY --on-port 15001 --on-ip 127.0.0.1 --tproxy-mark 0x400/0xfff

接下来我们参照ztunnel的代码,分析其负载均衡逻辑的实现。

ztunnel代码分析

ztunnel关于outbound的代理实现主要在src/proxy/outbound.rs中,在run方法中,接受一个outbound连接,并创建一个OutboundConnection,然后启动一个异步任务,调用OutboundConnection的proxy方法,传入连接,持续对连接上收到的数据进行转发:

pub(super) async fn run(self) {
    let accept = async move {
        loop {
            // Asynchronously wait for an inbound socket.
            let socket = self.listener.accept().await;
            let start_outbound_instant = Instant::now();
            match socket {
                Ok((stream, _remote)) => {
                    let mut oc = OutboundConnection {
                        pi: self.pi.clone(),
                        id: TraceParent::new(),
                    };
                    let span = info_span!("outbound", id=%oc.id);
                    tokio::spawn(
                        (async move {
                            let res = oc.proxy(stream).await;
                            ...
                        })
                        .instrument(span),
                    );
                }
                ...
            }
        }
    }.in_current_span();
    ...
}

我们接下来分析一下OutboundConnection的proxy方法,proxy方法是proxy_to方法的包装,我们看一下proxy_to方法的大致逻辑结构:

pub async fn proxy_to(
        &mut self,
        mut stream: TcpStream,
        remote_addr: IpAddr,
        orig_dst_addr: SocketAddr,
        block_passthrough: bool,
    ) -> Result<(), Error> {
        if self.pi.cfg.proxy_mode == ProxyMode::Shared
            && Some(orig_dst_addr.ip()) == self.pi.cfg.local_ip
        {
            return Err(Error::SelfCall);
        }
        let req = self.build_request(remote_addr, orig_dst_addr).await?;
        ...
        if req.request_type == RequestType::DirectLocal && can_fastpath {
            // 处理本地转发逻辑
        }
        match req.protocol {
            Protocol::HBONE => {
                info!(
                    "proxy to {} using HBONE via {} type {:#?}",
                    req.destination, req.gateway, req.request_type
                );
            	// 处理HBONE转发
                let connect = async {
                    ...
                    let tcp_stream = super::freebind_connect(local, req.gateway).await?;
                    ...
                }
            }
            Protocol::TCP => {
                // 处理TCP透传
                ...
                let mut outbound = super::freebind_connect(local, req.gateway).await?;
            }
        }
    }

通过代码可以看出,proxy_to函数是ztunnel outbound转发的核心逻辑,其大致流程是:

  • 调用self.build_request构造请求
  • 根据请求的协议,进行HBONE转发或者TCP转发

而无论是HBONE转发还是TCP透传,其连接的真正目标都是req.gateway,我们来分析build_request的代码,看一看它是如何为req.gateway赋值的:

async fn build_request(
        &self,
        downstream: IpAddr,
        target: SocketAddr,
    ) -> Result<Request, Error> {
    // 这里的命名有点迷惑,其实downstream是目标地址,在当前场景下就是ClusterIP
    let downstream_network_addr = NetworkAddress {
            network: self.pi.cfg.network.clone(),
            address: downstream,
        };
        let source_workload = match self
            .pi
            .workloads
            .fetch_workload(&downstream_network_addr)
            .await
        {
            Some(wl) => wl,
            None => return Err(Error::UnknownSource(downstream)),
        };
    // 查找上游
    let us = self
        .pi
        .workloads
        .find_upstream(&source_workload.network, target, self.pi.hbone_port)
        .await;
    // 如果无法找到上游
    if us.is_none() {
        ...
    }
    // 如果上游服务有对应的waypoint
    match self.pi.workloads.find_waypoint(us.workload.clone()).await {
        ...
    }
    // 转发到ztunnel
	if !us.workload.node.is_empty()
        && self.pi.cfg.local_node.as_ref() == Some(&us.workload.node) // looks weird but in Rust borrows can be compared and will behave the same as owned (https://doc.rust-lang.org/std/primitive.reference.html)
        && us.workload.protocol == Protocol::HBONE
    {
        return Ok(Request {
            ...
            gateway: SocketAddr::from((
                us.workload
                    .gateway_address
                    .expect("gateway address confirmed")
                    .ip(),
                15008,
            )),
            ...
        });
    }
}

由于本文不准备对waypoint展开讨论,所以这里我们聚焦在目标为ztunnel的场景,可以看到ztunnel的分支下gateway被设置为us.workload.gateway_address.expect("gateway address confirmed").ip(),那这个ip是怎么来的呢,我们需要进一步看一下前面的find_upstream的实现:

pub async fn find_upstream(
        &self,
        network: &str,
        addr: SocketAddr,
        hbone_port: u16,
    ) -> Option<Upstream> {
        self.fetch_address(&network_addr(network, addr.ip())).await;
        let wi = self.info.lock().unwrap();
        wi.find_upstream(network, addr, hbone_port)
    }

find_upstream方法调用了fetch_address获取对应的workload info,然后调用wi.find_upstream,并传入获取的workloadinfo,最后返回了wi.find_upstream的返回值,接下来我们看一下find_upstream的实现:

fn find_upstream(&self, network: &str, addr: SocketAddr, hbone_port: u16) -> Option<Upstream> {
    if let Some(svc) = self.services_by_ip.get(&network_addr(network, addr.ip())) {
        let svc = svc.read().unwrap().clone();
        let Some(target_port) = svc.ports.get(&addr.port()) else {
            debug!("found VIP {}, but port {} was unknown", addr.ip(), addr.port());
            return None
        };
        // Randomly pick an upstream
        // TODO: do this more efficiently, and not just randomly
        let Some((_, ep)) = svc.endpoints.iter().choose(&mut rand::thread_rng()) else {
            debug!("VIP {} has no healthy endpoints", addr);
            return None
        };
        let Some(wl) = self.workloads.get(&network_addr(&ep.address.network, ep.address.address)) else {
            debug!("failed to fetch workload for {}", ep.address);
            return None
        };
        // If endpoint overrides the target port, use that instead
        let target_port = ep.port.get(&addr.port()).unwrap_or(target_port);
        let mut us = Upstream {
            workload: wl.to_owned(),
            port: *target_port,
        };
        Self::set_gateway_address(&mut us, hbone_port);
        debug!("found upstream {} from VIP {}", us, addr.ip());
        return Some(us);
    }
    if let Some(wl) = self.workloads.get(&network_addr(network, addr.ip())) {
        let mut us = Upstream {
            workload: wl.to_owned(),
            port: addr.port(),
        };
        Self::set_gateway_address(&mut us, hbone_port);
        debug!("found upstream: {}", us);
        return Some(us);
    }
    None
}

可以看到,这里的逻辑是,如果可以得到workload对应的services_by_ip,那么就会调用svc.endpoints.iter().choose随机选择一个endpoint作为目标地址,并使用这个地址获取到对应的workload信息,最后放入一个Upstream结构体作为返回,这里便是ztunnel中四层负载均衡的核心逻辑实现了。

总结

Istio Ambient Mesh通过iptables跳过了k8s的负载均衡,随后在ztunnel中实现了4层负载均衡逻辑,在未来的文章中我将继续拆解Istio Ambient Mesh实现细节,敬请期待。

相关实践学习
SLB负载均衡实践
本场景通过使用阿里云负载均衡 SLB 以及对负载均衡 SLB 后端服务器 ECS 的权重进行修改,快速解决服务器响应速度慢的问题
负载均衡入门与产品使用指南
负载均衡(Server Load Balancer)是对多台云服务器进行流量分发的负载均衡服务,可以通过流量分发扩展应用系统对外的服务能力,通过消除单点故障提升应用系统的可用性。 本课程主要介绍负载均衡的相关技术以及阿里云负载均衡产品的使用方法。
目录
相关文章
|
JSON Rust 安全
Istio Ambient Mesh Ztunnel实现剖析(1)配置解析
前言在Istio Ambient Mesh中,社区引入了名为ztunnel的新组件,ztunnel的名字来源于Zero-Trust Tunnel,即零信任管道,Ztunnel 旨在专注于Ambient Mesh中工作负载4层安全能力,例如 mTLS、身份验证、L4 授权,而无需进行七层流量解析。ztunnel 确保流量高效、安全地传输到负责七层处理的Waypoint Proxy或在对端无waypo
361 0
|
Kubernetes 负载均衡 网络协议
全网最细,深度解析 Istio Ambient Mesh 流量路径
本文旨在对 Istio Ambient Mesh 的流量路径进行详细解读,力求尽可能清晰地呈现细节,以帮助读者完全理解 Istio Ambient Mesh 中最为关键的部分。
883 15
|
存储 Kubernetes 负载均衡
【Kubernetes的Service Mesh发展历程及Istio架构、存储供应使用NFS flexvolume CSI接口】
【Kubernetes的Service Mesh发展历程及Istio架构、存储供应使用NFS flexvolume CSI接口】
217 0
|
Prometheus 负载均衡 Kubernetes
Service Mesh: Istio vs Linkerd
根据CNCF的最新年度调查,很明显,很多人对在他们的项目中使用服务网格表现出了浓厚的兴趣,并且许多人已经在他们的生产中使用它们。近69%的人正在评估Istio,64%的人正在研究Linkerd。Linkerd是市场上第一个服务网格,但是Istio使服务网格更受欢迎。这两个项目都是最前沿的,而且竞争非常激烈,因此选择一个项目是一个艰难的选择。在此博客文章中,我们将了解有关Istio和Linkerd体系结构,其运动部件的更多信息,并比较其产品以帮助您做出明智的决定。
153 0
Service Mesh 的实现,Google 的 Istio
Service Mesh 的实现,Google 的 Istio
92 0
|
XML Kubernetes 负载均衡
Dubbo3实践: proxy mesh using Envoy & Istio
> 本示例演示了如何使用 Istio+Envoy 的 Service Mesh 部署模式开发 Dubbo3 服务。Dubbo3 服务使用 Triple 作为通信协议,通信过程经过 Envoy 数据面拦截,同时使用标准 Istio 的流量治理能力治理 Dubbo。 遵循以下步骤,可以轻松掌握如何开发符合 Service Mesh 架构的 Dubbo 服务,并将其部署到 Kubernetes 并接入
469 0
|
负载均衡 Kubernetes 网络协议
Service Mesh对比:Istio与Linkerd
Service Mesh对比:Istio与Linkerd
1083 0
Service Mesh对比:Istio与Linkerd
|
运维 Kubernetes 网络协议
Rainbond 5.5 发布,支持Istio和扩展第三方Service Mesh框架
Rainbond 5.5 版本主要优化扩展性。服务治理模式可以扩展第三方 ServiceMesh 架构,兼容kubernetes 管理命令和第三方管理平台。
Rainbond 5.5 发布,支持Istio和扩展第三方Service Mesh框架
|
5月前
|
缓存 负载均衡 算法
解读 Nginx:构建高效反向代理和负载均衡的秘密
解读 Nginx:构建高效反向代理和负载均衡的秘密
122 2
|
4月前
|
负载均衡 算法 应用服务中间件
nginx自定义负载均衡及根据cpu运行自定义负载均衡
nginx自定义负载均衡及根据cpu运行自定义负载均衡
82 1