前言
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实现细节,敬请期待。