前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
社区首页 >专栏 >彻底弄懂Service Mesh透明代理 TPROXY

彻底弄懂Service Mesh透明代理 TPROXY

作者头像
挖坑的张师傅
发布于 2024-07-05 06:58:29
发布于 2024-07-05 06:58:29
1.2K00
代码可运行
举报
文章被收录于专栏:张师傅的博客张师傅的博客
运行总次数:0
代码可运行

在现代微服务架构中, Service Mesh 作为基础设施层为服务间通信提供了强大支持,其中透明代理是一项关键技术,这篇文章做了一个比较细致的分析,彻底弄懂 TPROXY 透明代理/REDIRECT 的技术细节,涉及到下面这些内容:

  • service-mesh 中 TPROXY 和 REDIRECT 模式
  • 内核提供的透明代理 TPROXY 功能
  • 自定义 iptables 规则链
  • conntrack 状态跟踪
  • IP_TRANSPARENT、SO_MARK 选项
  • 自定义路由表、策略路由
  • istio 等透明代理的实现原理
  • SO_ORIGINAL_DST、NAT 与 conntrack
  • systemtap 内核观测

实验环境介绍

为了更好地理解透明代理的工作机制, 我们搭建了以下实验环境:

  • 两台主机, 每台运行一个容器
  • 容器间通过 Flannel vxlan 进行通信
  • 容器 A (IP: 172.100.1.2) 运行一个监听 8080 端口的 HTTP 服务
  • 容器 B (IP: 172.100.36.2) 作为 HTTP 请求的发起端

此时的基于 vxlan 的 flannel 容器通信如下形式。

tproxy01

在没有配置任何 iptables 规则时, 容器 B 可以正常访问容器 A 的 8080 端口服务。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
» sudo ip netns exec aaa curl http://172.100.1.2:8080/hello

method: GET
url: /hello
peer addr: 172.100.36.0:60434

这个 8080 端口的 http-server 服务会返回客户端的 IP 地址和端口。

透明代理的需求

在 Service Mesh 方案中,我们需要引入一个 proxy 来做流量的代理,它的角色有点类似于一个 nginx,用于做正向和反向代理。

tproxy-00

普通的代理方式存在一些限制,我们有两个朴素且原始的需求:

  • proxy 可以接管所有端口的入流量
  • 让后端对前面有一层代理无感,后端服务可以获取到客户端的真实源 IP

很明显,如果通过普通的代理技术,首先不能很好的监听所有的流量,其次经过代理以后,后端服务的请求源 ip 会变为本机 ip。

为了解决这些问题, 我们需要利用 Linux 内核提供的 TPROXY 功能来实现真正的透明代理。

TPROXY(Transparent Proxy)

TPROXY 需要 Linux 内核 2.2 及以上版本的支持。它允许在用户空间程序中透明地代理流量,使得应用程序无需知道是否存在代理服务器, 流量可以被透明地重定向到代理服务。

此时我们来在 A 容器中新增一条 iptables 规则,将非本地目的地址的 TCP 数据包通过 TPROXY 转发到本机监听的 15006 端口,同时对数据包打上 0x539 的标记(这个标记值你可以自己随意指定,这里保持跟 istio 一致)

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
iptables -t mangle -A PREROUTING ! -d 127.0.0.1/32 -p tcp -j TPROXY --on-port 15006 --on-ip 0.0.0.0 --tproxy-mark 0x539

此时 B 再次 curl A 容器的服务,现象变为了 B 发送给 A 的 SYN 包没有回复 SYN+ACK,B 一直重传 SYN。

是不是因为 A 容器内并没有一个服务监听 15006 端口,导致没有回复 SYN+ACK 呢,启动一个服务监听 15006 端口试试。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
use std::net::SocketAddr;

use nix::sys::socket;
use nix::sys::socket::sockopt;
use tokio::net::{TcpSocket, TcpStream};

const PORT: u16 = 15006;
const LISTENER_BACKLOG: u32 = 65535;


#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let listen_addr = format!("0.0.0.0:{}", PORT).parse().unwrap();
    println!("Listening on: {}", listen_addr);
    let socket = TcpSocket::new_v4()?;

    // #[cfg(any(target_os = "linux"))]
    // socket::setsockopt(&socket, sockopt::IpTransparent, &true)?;

    socket.bind(listen_addr)?;
    let listener = socket.listen(LISTENER_BACKLOG)?;

    while let Ok((mut downstream_conn, _)) = listener.accept().await {
        println!("accept new connection, peer[{:?}]->local[{:?}]", downstream_conn.peer_addr()?, downstream_conn.local_addr()?);

        tokio::spawn(async move {
            // 处理连接,这里调用 sleep
            let result = handle_connection(downstream_conn).await;
            match result {
                Ok(_) => {
                    println!("connection closed");
                }
                Err(err) => {
                    println!("connection closed with error: {:?}", err);
                }
            }
        });
    }

    Ok(())
}

async fn handle_connection(mut downstream_conn: TcpStream) -> anyhow::Result<()> {
    tokio::time::sleep(tokio::time::Duration::from_secs(u64::MAX)).await;
    Ok::<(), anyhow::Error>(())
}

B 再次 curl A 容器的服务,现象依旧是一直重传 SYN。

为了解决这个问题,需要介绍另外一个重要的知识点,IP_TRANSPARENT

IP_TRANSPARENT 介绍

IP_TRANSPARENT 是一个 Linux 中的 socket 选项, 主要用于实现透明代理功能,它具有以下两个关键作用:

  • 接收 TPROXY 重定向的连接:允许应用程序接收通过 iptables TPROXY 规则重定向的连接流量。这使得代理服务器可以无缝拦截和处理原本不是发往它的网络流量
  • 绑定到非本地 IP:通常情况下,socket 只能绑定到主机自身的 IP 地址。但启用 IP_TRANSPARENT 选项后,应用程序可以绑定到任意 IP 地址,即使该地址不属于本机网络接口

以下是修改后的 Rust 代码,监听 15006 端口并设置 IP_TRANSPARENT 选项:

在 B 容器再次 curl 以后,此时可以看到三次握手可以成功了。通过日志我们可以看到,目前 tproxy 拿到的客户端 ip 也是正确的,是 B 容器所在节点的 flannel.1 的 ip 172.100.36.0.

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
$ ./target/debug/tproxy-rs
Listening on: 0.0.0.0:15006
accept new connection, peer[172.100.36.0:45966]->local[172.100.1.2:8080]

大家可能会注意到,尽管我们的 tproxy-rs 程序实际上监听的是 15006 端口,但连接的本地地址却显示为 8080 端口。这种 "欺骗" 效果正是 IP_TRANSPARENT 选项和 iptables 规则共同作用的结果。

不过因为我们没有真正把流量代理到目标服务,所以 curl 请求的 http 响应是不会返回的。

IP_TRANSPARENT 的内核代码介绍

为什么仅仅给套接字添加 IP_TRANSPARENT 选项就能使握手成功呢?这需要从 linux 内核源码角度去理解,文件位于 net/netfilter/xt_TPROXY.ctproxy 是一个 netfilter 框架下一个内核模块,target 处理函数是 tproxy_tg4_v1

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
static struct xt_target tproxy_tg_reg[] __read_mostly = {
 {
  .name  = "TPROXY",
  .family  = NFPROTO_IPV4,
  .table  = "mangle",
  .target  = tproxy_tg4_v1,
  .revision = 1,
  .targetsize = sizeof(struct xt_tproxy_target_info_v1),
  .checkentry = tproxy_tg4_check,
  .hooks  = 1 << NF_INET_PRE_ROUTING,
  .me  = THIS_MODULE,
 },
}
module_init(tproxy_tg_init);
module_exit(tproxy_tg_exit);
MODULE_DESCRIPTION("Netfilter transparent proxy (TPROXY) target module.");

tproxy_tg4_v1 真正调用 tproxy_tg4 函数:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
static unsigned int
tproxy_tg4(struct net *net, struct sk_buff *skb, __be32 laddr, __be16 lport,
       u_int32_t mark_mask, u_int32_t mark_value)
{
    const struct iphdr *iph = ip_hdr(skb);
    struct udphdr _hdr, *hp;
    struct sock *sk;

    hp = skb_header_pointer(skb, ip_hdrlen(skb), sizeof(_hdr), &_hdr);
    if (hp == NULL)
        return NF_DROP;

    // 查找是否存在已经建连的 socket   
    sk = nf_tproxy_get_sock_v4(net, skb, iph->protocol,
                   iph->saddr, iph->daddr,
                   hp->source, hp->dest,
                   skb->dev, NF_TPROXY_LOOKUP_ESTABLISHED);

    laddr = nf_tproxy_laddr4(skb, laddr, iph->daddr);
    if (!lport)
        lport = hp->dest;

    if (sk && sk->sk_state == TCP_TIME_WAIT)
        sk = nf_tproxy_handle_time_wait4(net, skb, laddr, lport, sk);
    else if (!sk)
        // 没有找到已经建连的 socket,查找 tproxy 重定向地址/端口的 listener
        /* no, there's no established connection, check if
         * there's a listener on the redirected addr/port */
        sk = nf_tproxy_get_sock_v4(net, skb, iph->protocol,
                       iph->saddr, laddr,
                       hp->source, lport,
                       skb->dev, NF_TPROXY_LOOKUP_LISTENER);

    // 如果 tproxy 目标 socket 存在,且设置了 IP_TRANSPARENT,则返回 NF_ACCEPT
    if (sk && nf_tproxy_sk_is_transparent(sk)) {
        // 设置 skb 的 mark
        skb->mark = (skb->mark & ~mark_mask) ^ mark_value;

        pr_debug("redirecting: proto %hhu %pI4:%hu -> %pI4:%hu, mark: %x\n",
             iph->protocol, &iph->daddr, ntohs(hp->dest),
             &laddr, ntohs(lport), skb->mark);

        nf_tproxy_assign_sock(skb, sk);
        return NF_ACCEPT;
    }
    // 如果 tproxy 目标 socket 不存在,或者 socket 存在但没有设置 IP_TRANSPARENT,返回 NF_DROP
    pr_debug("no socket, dropping: proto %hhu %pI4:%hu -> %pI4:%hu, mark: %x\n",
         iph->protocol, &iph->saddr, ntohs(hp->source),
         &iph->daddr, ntohs(hp->dest), skb->mark);
    return NF_DROP;
}

可以看到这段代码的逻辑是:

  • 先找是否有已经建连好的连接
  • 没有找到已经建连的 socket,查找 tproxy 重定向地址/端口的 listener
  • 如果 tproxy 目标 socket 存在,且设置了 IP_TRANSPARENT,则返回 NF_ACCEPT
  • 如果 tproxy 目标 socket 不存在,或者 socket 存在但没有设置 IP_TRANSPARENT,返回 NF_DROP

为了验证我们之前的结论,我们可以使用 SystemTap 来深入分析 tproxy_tg4 函数的行为。通过对比设置和未设置 IP_TRANSPARENT 选项时 tproxy_tg4 函数的返回值,脚本如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
probe begin {
    printf("probe begin!\n")
}
probe module("xt_TPROXY").function("tproxy_tg4") {
    printf("Entering tproxy_tg4, args: %s\n", $$parms)
    iphdr = __get_skb_iphdr($skb);
    saddr = format_ipaddr(__ip_skb_saddr(iphdr), %{ AF_INET %})
    daddr = format_ipaddr(__ip_skb_daddr(iphdr), %{ AF_INET %})
    tcphdr = __get_skb_tcphdr($skb);
    dport = __tcp_skb_dport(tcphdr);
    sport = __tcp_skb_sport(tcphdr);
    printf("[skb]: [src]%s:%d -> [dst]%s:%d\n", saddr, sport, daddr, dport);

}
probe module("xt_TPROXY").function("tproxy_tg4").return {
    printf("Exiting tproxy_tg4, return : %d\n", $return);
}

未设置 IP_TRANSPARENT 时:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
probe begin!
Entering tproxy_tg4, args: net=0xffff9f7a9b9a3600 skb=0xffff9f7da21c9e00 laddr=0x0 lport=0x9e3a mark_mask=0xffffffff mark_value=0x539
[skb]: [src]172.100.36.0:40756 -> [dst]172.100.1.2:8080
Exiting tproxy_tg4, return : 0

我们可以观察到:

  • mark 值确实被设置为 0x539,与我们的预期一致。
  • 返回值为 0,对应内核中的 NF_DROP,表示这个数据包被丢弃。
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
#define NF_DROP 0
#define NF_ACCEPT 1

设置 IP_TRANSPARENT 后:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
$ sudo stap -g tproxy_tg4_test.stp

probe begin!
Entering tproxy_tg4, args: net=0xffff9f7a9b9a3600 skb=0xffff9f7b8c83e200 laddr=0x0 lport=0x9e3a mark_mask=0xffffffff mark_value=0x539
[skb]: [src]172.100.36.0:38938 -> [dst]172.100.1.2:8080
Exiting tproxy_tg4, return : 1

设置 IP_TRANSPARENT 后,tproxy_tg4 返回值为 1,对应内核中的 NF_ACCEPT,表示这个数据包被接受。

完整的代码见:https://github.com/arthur-zhang/mesh-proxy-demo/tree/main/tproxy_step_bind_ip_transparent

下一步是让我们的 tproxy-rs 程序与真正的后端服务建立连接,实现完整的透明代理功能。理想情况下,tproxy-rs 将作为中间人,将流量从客户端无缝转发到 http-server。这个过程可以描述如下:

tproxy-01

为了实现完整的透明代理功能,我们需要让 tproxy-rs 程序执行以下步骤:

  • 接收来自客户端的连接
  • 与后端 http-server 建立新的连接
  • 在两个连接之间转发数据

connect 如何指定源 ip(伪装 IP 地址)

在网络编程中,通常 connect() 操作不需要显式指定源 IP 地址,操作系统会根据路由规则自动选择合适的源 IP。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
# 在容器 A 内请求本机服务
curl http://172.100.1.2:8080/hello

对应的 tcpdump 抓包如下

可以看到此时选择的网络接口是 lo,源 ip 地址是本机 ip(172.100.1.2),这很合理。

为了让后端服务器感知到真实的源 IP (172.100.36.0),我们需要在建立连接时强制指定源 IP。虽然通常 connect 操作不需要指定源 IP,但在这种特殊情况下,我们可以使用 bind 来实现。

运行代码后出现绑定失败的错误:

因为 172.100.36.0 这个 IP 地址并不属于 A 容器的网络命名空间。内核对应的源码如下:在 net/ipv4/af_inet.cinet_bind 函数

我们重点看红框中的部分代码,如果当前 bind 的地址无法被分配,则开始判断:

  • 如果系统不允许非本地绑定(sysctl_ip_nonlocal_bind 为 0)
  • 且套接字没有设置 freebind 或 transparent 标志
  • 且提供的 IP 地址不是 INADDR_ANY
  • 且地址类型不是本地、多播或广播
  • 则返回 EADDRNOTAVAIL 错误

如果想让 bind 成功,我们可以对 socket 设置 IP_TRANSPARENT 选项。

设置此选项后,socket 将被允许绑定到非本地 IP 地址。

但 curl 并没有正常返回。我们在 A 容器中抓包,发现与 172.100.1.2:8080 的三次握手有问题,从 lo 收到了 SYN 包,回复的 SYN + ACK 是从 eth0 网卡,随后收到了 RST 包。

这个问题的过程如下:

  • 我们伪造了源 IP 地址发送请求没有问题。
  • 当需要回复 SYN+ACK 包时,内核会查询系统的主路由表来决定使用哪个网卡发送数据包。在这种情况下,由于 172.100.36.0 不是本机的 IP 地址,内核选择了默认路由,即通过 eth0 网卡发送。

为了解决这个问题,我们需要实现一种特殊的处理方式,使内核将 172.100.36.0 视为本机 IP 地址。这就需要用到策略路由(Policy Routing)

策略路由(Policy-based Routing)

根据路由决策的方式不同,路由可以分为

  • 策略路由:根据 IP 源地址、端口、报文长度等灵活来进行路由选择
  • 普通路由:仅根据报文的目的地址来选择出接口和下一跳的地址

策略路由更加灵活,功能更加强大,比如你可以通过策略路由实现将 SSH 流量通过一个网关发送,而 HTTP 流量通过另一个网关发送,从而实现负载均衡

策略路由的使用分为两部分:自定义路由表和匹配策略。

Linux 系统默认有三个路由表:

  • 本地路由表(Local table):路由表编号 255,由内核自动维护,负责本地接口地址、广播地址的路由
  • 主路由表(Main table):路由表编号 254,负责单播目的地的路由,我们 route -n 默认会查这个表
  • 默认路由表(Default table):路由表编号 253,一般都是空的

除了上述默认表, 管理员还可以添加自定义路由表, 表 ID 取值范围是 1~252。自定义路由表的创建和使用与内置表没有什么区别,可以使用 ip route 命令将路由添加和查看自定义路由表。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
# 新增规则到编号为 128 的自定义路由表表
$ ip route add 192.168.10.0/24 via 172.100.1.1 dev eth0 table 128

# 查看编号为 128 的自定义路由表
$ ip route list table 128
192.168.10.0/24 via 172.100.1.1 dev eth0

除了自定义路由表,策略路由另外一个重要的组成部分是匹配策略。策略路由提供了很多种类型的匹配规则,比如 fromtotosfwmarkiifoif

比如 from 根据数据包的源地址来匹配规则,fwmark 根据数据包的防火墙标记(firewall mark)来匹配规则。

有了上面的基础,我们来看一下策略路由如何在透明代理应用。

以 istio 为例,它创建一个编号为 133 的自定义路由表,

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
# 创建一条路由规则到编号为 133 的路由表
$ sudo ip route add local 0.0.0.0/0 dev lo table 133

这条路由表项表示所有目的地为 0.0.0.0/0(即所有地址)的数据包都通过 lo(本地回环接口)处理。

同时增加一条路由策略规则:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
# 增加策略路由
$ sudo ip rule add fwmark 0x539 lookup 133

fwmark 0x539 表示匹配防火墙标记为 0x539 的数据包,如果匹配成功, 就查找路由表 133 来确定如何路由这个数据包。

这个时候策略路由是有了,这还不够,我们还需要对包打上标记 0x539,这样才可以命中策略路由规则。

SO_MARK 选项

SO_MARK 是一个强大的套接字选项,它允许我们给通过特定套接字发送的所有数据包打上标记。以下是 SO_MARK 的典型使用方式:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
uint32_t mark = 0x539;  // 设置标记值为 0x539
setsockopt(sockfd, SOL_SOCKET, SO_MARK, &mark, sizeof(mark));

通过这样的设置,从该套接字发出的所有数据包都会带有 0x539 这个标记。这个标记可以被后续的网络处理过程(如 iptables 规则和路由决策)识别和利用。

我们来测试一下,修改 tproxy-rs 的代码新增这个值。

我们测试一下,实际上没有什么变化。

这是因为我们只是通过 SO_MARK 我们只是对发送的 SYN 包打了标记,回复的 SYN+ACK 并没有这个标记,这样这个回复的 SYN+ACK 就不会命中策略路由,出口路由依旧选择了 eth0。

为了验证一下这个结论,我们先开启 iptables 的 trace 日志。这些规则将记录所有 TCP 数据包在 iptables 规则链中的流转过程。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
# iptables -t raw -A PREROUTING -p tcp -j TRACE
# iptables -t raw -A OUTPUT -p tcp -j TRACE

通过分析 trace 日志,我们可以看到:

  • 发起的 SYN 包:通过 SO_MARK 选项成功地带上了 0x539 标记。
  • 回复的 SYN+ACK 包:没有携带 0x539 标记。

作为响应包,SYN+ACK 是由内核自动生成的,没有经过我们的应用程序处理,它没有被设置 SO_MARK。没有标记的 SYN+ACK 包无法匹配我们的策略路由规则,内核使用默认路由表进行路由决策,选择了 eth0 作为出口。

要解决这个问题,我们需要确保 SYN+ACK 包也能带上正确的标记。这可以通过使用 conntrack 模块来实现:

  • conntrack 可以跟踪整个连接的状态。
  • 我们可以配置 iptables 规则,使用 conntrack 模块保存和恢复连接的标记

首先我们需要弄清楚「连接标记(Connection Mark)」与「数据包标记(Packet Mark)」的区别:

  • 数据包标记 (Packet Mark)只应用于单个数据包
  • 连接标记 (Connection Mark)存储在连接跟踪表中,跨越整个连接的生命周期,连接跟踪标记通常在数据包进入时被设置。

比如:

  • -m connmark --mark 0x539 的作用是匹配那些属于入站时被打上 0x539 标记的连接的所有数据包。
  • -m mark --mark 0x539 的作用是匹配被打上 0x539 标记的单个数据包

conntrack 模块提供了几个关键的操作来管理这些标记:

  • --set-mark / --set-xmark:设置单个数据包的标记 示例:iptables -t mangle -A PREROUTING -j MARK --set-mark 0x539
  • --save-mark:将数据包的标记保存到连接跟踪表中 示例:iptables -t mangle -A PREROUTING -j CONNMARK --save-mark
  • --restore-mark:从连接跟踪表中恢复标记到数据包 示例:iptables -t mangle -A OUTPUT -j CONNMARK --restore-mark

接下来就是要用 conntrack 模块匹配数据包的连接状态。使得本来不带 MARK 的 SYN+ACK 包也能打上 MARK,使得包可以走到 133 策略路由规则。

因为 istio 的 iptables 规则为了支持更多的特性比较复杂,为了更清楚的知道透明代理相关的功能,我简化了最需要的几条规则,完整的 iptables 规则如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
创建自定义链
iptables -t mangle -N MY_INBOUND

# 将所有入站 TCP 流量导向自定义链
iptables -t mangle -A PREROUTING -p tcp -j MY_INBOUND

# 对已标记的包直接返回, 避免重复处理
iptables -t mangle -A MY_INBOUND -p tcp -m mark --mark 0x539 -j RETURN

# 对已建立的连接设置标记
iptables -t mangle -A MY_INBOUND -p tcp -m conntrack --ctstate RELATED,ESTABLISHED -j MARK --set-xmark 0x539/0xffffffff

# 使用 TPROXY 重定向非本地流量到代理端口
iptables -t mangle -A MY_INBOUND ! -d 127.0.0.1/32 -p tcp -j TPROXY --on-port 15006 --on-ip 0.0.0.0 --tproxy-mark 0x539/0xffffffff

# 保存数据包标记到连接
iptables -t mangle -A PREROUTING -p tcp -m mark --mark 0x539 -j CONNMARK --save-mark --nfmask 0xffffffff --ctmask 0xffffffff

# 恢复连接标记到数据包
iptables -t mangle -A OUTPUT -p tcp -m connmark --mark 0x539 -j CONNMARK --restore-mark --nfmask 0xffffffff --ctmask 0xffffffff
ro

通过上面的规则,我们可以做到:

  • 入流量被正确标记和重定向
  • 连接状态被跟踪
  • 出流量能够恢复正确的标记

通过这种配置, 我们可以确保所有相关的数据包, 包括 SYN+ACK, 都能被正确标记并通过策略路由规则进行处理。

包在 iptables 规则链中的流转全过程

我们来梳理一下整个包的过程,先来看前半部分,也就是红框中的流量部分。

内核收到 B 容器发过来的 SYN 包(SRC = 172.100.36.0:12345 DST = 172.100.1.2:8080):

初始时,SYN 包不携带任何 MARK 标记。当该包经过 MY_INBOUND 链时,它匹配到该链的第三条规则。这条规则执行以下操作:

  • 将 TCP 包劫持并重定向至 15006 端口
  • 为包添加 0x539 MARK 标记
  • 终止在 PREROUTING 链中的后续匹配过程

完成 PREROUTING 链的处理后,系统会进行路由判断,以确定该包的目标地址是否为本机。在本例中,包的目标 IP 地址为 172.100.1.2。经过路由表匹配,系统判定这是一个发往本机的数据包。

tproxy-syn-0

内核回复 SYN+ACK 给对端

内核向对端发送 SYN+ACK 响应包。此时,SYN+ACK 包不携带任何 MARK 标记,conntrack 连接也没有 MARK 标记。因此,该包不会匹配 OUTPUT 链中的任何规则。经过出路由规则判断后,SYN+ACK 包将直接通过 eth0 接口发送出去。

tproxy-syn_ack-0

内核收到对端的 ACK 包

  • 当收到 ACK 包时,包会首先经过 MY_INBOUND 链的第二条规则,并被设置为 MARK 0x539。
  • 随后,包会匹配 MY_INBOUND 链的第三条规则,通过 TPROXY 劫持到 15006 端口。

接着,系统进行路由决策,判断该包的目标地址是否为本机,发往本机继续处理。

tproxy-ack-0

内核收到 HTTP 包内容

内核收到 PSH 包的处理流程与收到 ACK 包的一致。

tproxy-push-0

接下来,我们来看 tproxy-rs 与后端服务器通信的部分,即下图红框所示的部分。

connect 发送 SYN

当我们使用 connect 发送 SYN 包时,会设置 SO_MARK,将包标记为 0x539 MARK。然而,此时 conntrack 的 MARK 仍为空,因此该包不会匹配 OUTPUT 链中的任何规则。

经过路由决策后,SYN 包将通过 lo 接口发出。

tproxy-conncect-syn

内核收到 SYN

上一步发出去的 SYN 包由于是在本机,依旧是本机内核处理,将会经历以下步骤:

  • 命中 MY_INBOUND 的第一条规则,因为包含 0x539 MARK,跳出 MY_INBOUND 链
  • 经过 PREROUTING 链中继续处理,并匹配到 --mark 0x539 规则,执行 save-mark 操作,将包的 MARK 保存到连接的 MARK 中。

完成 PREROUTING 链的处理后,包进入路由规则匹配阶段。系统发现这是一个发往本机的包,随后将其交给本机处理。

tproxy-connect-syn-2

内核回复 SYN+ACK

回复的 SYN+ACK 自然是不带 0x539 这个 MARK 的,但由于 conntrack 关联的连接具有该 MARK,SYN+ACK 包会匹配 OUTPUT 链中的条件,触发 --restore-mark 操作,将连接的 MARK 应用到 SYN+ACK 包上。

这样 SYN+ACK 数据包就有了 0x539 这个 MARK,它将在后续的路由匹配中命中我们自定义的 133 路由表。

尽管目的地址(172.100.36.0)本来不是本机地址,SYN+ACK 包本应通过 eth0 接口发出,但由于策略路由的使用,使得原本非本机的目的地址(172.100.36.0)被当作本地地址来处理。

tproxy-connect-syn-ack-0

内核收到 SYN+ACK

当内核收到 SYN+ACK 包后,将会经过以下 iptables 链的处理:

  • 命中 MY_INBOUND 的第一条规则,跳出 MY_INBOUND 链,继续 PREROUTING 链
  • 在 PREROUTING 链中,包的 MARK 被保存到 conntrack 的 MARK 中

经过策略路由匹配后,包被判定为发往本机,并交由本机处理。

tproxy-conncect-syn_ack2

内核回复 ACK

这个比较简单,过程如下图所示。

tproxy-conncect-ack0

剩下的流程与之前基本上差不多,就不再赘述。至此,我们就把 TPROXY 模式所涉及的方方面面介绍清楚了。

完整代码见:https://github.com/arthur-zhang/mesh-proxy-demo/tree/main/tproxy_step_connect

除 TPROXY 的另外的选择:REDIRECT 模式

相比于 TPROXY 复杂的 iptables 规则,REDIRECT 模式要简单得多。只需要一条规则即可实现:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
iptables -t nat -A PREROUTING -p tcp -j REDIRECT --to-ports 15006

然而,这里存在一个大问题:经过 NAT 后,流量被劫持到 15006 端口的服务时,在代理应用中获取到的 TCP 连接的目标端口变为了我们监听的 15006 端口。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
accept new connection, peer[172.100.36.0:39882]->local[172.100.1.2:15006]

这样一来,我们如何知道将这个请求转发到后端服务的哪个端口呢?

使用 conntrack 获取原始目标端口

NAT 的功能实际上是通过 conntrack 实现的,我们可以通过 conntrack 来获取原始的目标端口。

通过查看 conntrack,我们可以看到如下映射:172.100.36.0:39882<->172.100.1.2:8080 被 NAT 到 172.100.1.2:15006 <-> 172.100.36.0:39882

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
conntrack -L
tcp      6 431997 ESTABLISHED src=172.100.36.0 dst=172.100.1.2 sport=39882 dport=8080 src=172.100.1.2 dst=172.100.36.0 sport=15006 dport=39882 [ASSURED] mark=0 use=1

通过这种方式,我们可以确定将请求转发到后端服务的正确端口。

不过我们不需要直接操作 conntrack,socket 提供了一个 api 可以用来获取,典型的用法如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
struct sockaddr_in orig_dst;
socklen_t orig_dst_len = sizeof(orig_dst);
getsockopt(sock, SOL_IP, SO_ORIGINAL_DST, &orig_dst, &orig_dst_len);

printf("Original destination: %s:%d\n", inet_ntoa(orig_dst.sin_addr), ntohs(orig_dst.sin_port));

这个功能是由内核 netfilter conntrack 提供,对应的与源码如下。

于是我们可以修改对应的 rust 代码:

这样我们就可以实现了代理的功能,不过这个时候后端获取的来源 ip 是本地地址 127.0.0.1。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
$ curl http://172.100.1.2:8080/hello

method: GET
url: /hello
peer addr: 127.0.0.1:41757

完整的代码见:https://github.com/arthur-zhang/mesh-proxy-demo/tree/main/redirect

至此,REDIRECT 模式就介绍结束了。

后记

TPROXY 相比于 REDIRECT 的优势是少了一个 DNAT 的过程,且后端可以获取到真实的客户端 IP,但是实现相对复杂一点点。istio 两个模式都提供了,实测 istio 这两种模式性能差别不大。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2024-07-03,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 张师傅的博客 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
暂无评论
推荐阅读
进阶数据库系列(十五):PostgreSQL 主从同步原理与实践
在正式介绍 PostgreSQL 主从同步复制 之前,我们先了解一下 PostgreSQL 的预写日志机制(WAL)。
民工哥
2023/08/22
5.2K0
进阶数据库系列(十五):PostgreSQL 主从同步原理与实践
聊聊PostgreSQL的Replication
CAP理论 consistency:在整个集群角度来看,每个节点是看到的数据一致的;不能出现集群中节点出现数据不一致的问题 vailability:集群中节点,只有有一个节点能提供服务 partitioning:集群中的节点之间网络出现问题,造成集群中一部分节点和另外一部分节点互相无法访问 基本术语 Master节点:提供数据写的服务节点 Standby节点:根据主节点(master节点)数据更改,这些更改同步到另外一个节点(standby节点) Warm Standby节点:可以提升为master节点的s
用户4700054
2022/08/17
1.5K0
聊聊PostgreSQL的Replication
PostgreSQL 12 的同步流复制搭建及主库hang问题处理与分析
主备流复制,是PostgreSQL最常用、最简单的一种高可用、读写分离的形式,类似于Oracle的ADG,主库用于读写,备库可以只读。
数据和云
2021/07/30
1.7K0
Postgresql主从复制
Postgresql主从复制 主备数据库启动,备库启动wal_receiver进程,wal进程向主库发送连接请求; 主库收到连接请求后启动wal_sender进程,并与wal_receiver进程建立tcp连接; 备库wal_receiver进程发送最新的wal lsn 给主库; 主库进行lsn 对比,定期向备库发送心跳信息,来确认备库的可用性,并且将没有传递的wal日志文件进行发送,同时调用SyncRepWaitForLSN()函数来获取锁存器,并且等待备库响应;锁存器的释放时机和主备同步模式的选择有
用户7353950
2022/05/11
8500
Postgresql主从复制
PostgreSQL从小白到高手教程 - 第44讲:pg流复制部署
PostgreSQL从小白到专家,是从入门逐渐能力提升的一个系列教程,内容包括对PG基础的认知、包括安装使用、包括角色权限、包括维护管理、、等内容,希望对热爱PG、学习PG的同学们有帮助,欢迎持续关注CUUG PG技术大讲堂。
用户5892232
2024/02/02
4410
PostgreSQL从小白到高手教程 - 第44讲:pg流复制部署
PostgreSQL主备环境搭建
PG学习初体验--源码安装和简单命令(r8笔记第97天) 记得在2年前写过一篇PostgreSQL的文章,当时处于兴趣,本来想在工作中接一下PG的业务,最后因为各种各样的原因就搁置了。 今天整理了下PostgreSQL的一些基础内容,参考的书是唐成老师的那本《PostgreSQL修炼之道》,有了Oracle和MySQL的基础,看起来会比从零开始要容易一些,总体的感觉,PG功能确实很多很全,功能上像Oracle看齐,技术风格和MySQL很像,在做一些总结的时候,不停的在两个数据库之间来回切换。 关于主备环
jeanron100
2018/03/30
1.9K0
PostgreSQL主备环境搭建
pg_rewind到底能做什么?
我们知道postgresql的主从切换有点麻烦,或者说操作步骤要求很严格。可能我们经常遇到这种情况,在没有将主库杀死的情况下将备库提升为主,这时主备库可能由于某种原因都在提供写入操作,这时发生脑裂,如果不考虑数据丢失因素,这时我们可能想将原来的主库以备库的模式重新加入集群,但是主备库此时的时间线已经偏离了,这时就需要我们的pg_rewind工具了。
数据库架构之美
2019/12/18
7820
【DB宝91】PG高可用之主从流复制+keepalived 的高可用
通过keepalived 来实现 PostgreSQL 数据库的主从自动切换,以达到高可用。当主节点宕机时,从节点可自动切换为主节点,继续对外提供服务。
AiDBA宝典
2022/02/23
2.8K0
【DB宝91】PG高可用之主从流复制+keepalived 的高可用
PostgresSQL 主从搭建步骤
由于工作需要,最近开始接触各种数据库,并尝试各种数据库产品的高可用方案。今天分享的是postgresSQL的主从配置,其实还是蛮简单的,跟随本文的步骤,保证能实现PG主从的搭建。
星哥玩云
2022/08/13
2.5K0
PostgreSQL13.0流复制尝鲜
postgresql13.0于2020年9月21日正式发布,话说现在pg的大版本从10开始发生了变化,以第一个数字代表一个大版本更新,而9之前的版本则是以9.1->9.2->9.x这样代表大版本更新。所以现在看起来pg的更新好像越来越快了,每个版本其实更新的内容不是很多。13发布后下载来了源码尝尝鲜,源码编译上没有什么改变,依旧很简单很亲和,四条简单的命令完成编译安装,对平台兼容性也很好。
数据库架构之美
2021/02/26
8500
PostgreSQL13.0流复制尝鲜
PostgreSQL基础(十五):PostgreSQL的主从操作
PostgreSQL自身只支持简单的主从,没有主从自动切换,仿照类似Nginx的效果一样,采用keepalived的形式,在主节点宕机后,通过脚本的执行完成主从切换。
Lansonli
2024/10/04
8940
PostgreSQL基础(十五):PostgreSQL的主从操作
【DB宝60】PG12高可用之1主2从流复制环境搭建及切换测试
PostgreSQL在9.x之后引入了主从的流复制机制,所谓流复制,就是备服务器通过tcp流从主服务器中同步相应的数据,主服务器在WAL记录产生时即将它们以流式传送给备服务器,而不必等到WAL文件被填充。
AiDBA宝典
2021/07/29
3.3K0
[实时数仓]玩转PostgreSQL主从流复制
PostgreSQL 在 9.0 以后引入了流复制(Streaming Replication)。流复制提供了将 WAL 记录连续发送并应用到从服务器以使其保持最新状态的功能。通过流复制,从服务器不断从主服务器同步相应的数据,同时,从服务器作为主服务器的一个备份。
宇宙无敌暴龙战士之心悦大王
2023/03/21
1.5K0
PostgreSQL 流复制搭建和原理理解
最近随着学习PostgreSQL 的深入,越发的喜欢这个数据库,之前曾经写过关于PostgreSQL 关于模糊查询的文字,在我使用的时候,的确是惊艳到了,ORACLE ,SQL SERVER 这样的收费数据库不能做的,PG轻易的化解,无愧是世界上最好的开源数据库了(其实去掉开源那两个字也不是担当不起)。
AustinDatabases
2019/06/21
2.7K0
PostgreSQL 流复制搭建和原理理解
基于PostgreSQL流复制的容灾库架构设想及实现
这几天在对PostgreSQL流复制的架构进行深入研究,其中一个关键的参数:recovery_min_apply_delay引起了我的注意,设置该参数的大概意思是:在进行流复制的时候,备库会延迟主库recovery_min_apply_delay的时间进行应用。比如说,我们在主库上insert10条数据,不会立即在备库上生效,而是在recovery_min_apply_delay的时间后,备库才能完成应用。
数据和云
2021/07/09
9710
docker 部署 postgresql的主从数据库
docker exec -it -u postgres pgsslave /bin/bash
liuyunshengsir
2021/09/17
1.9K0
PostgreSQl 12主从流复制及归档配置
上一篇文章说道PostgreSQL 12 的源码部署,这里我们说一下PostgreSQl 12的主从流复制和归档配置。
没有故事的陈师傅
2022/02/09
2.6K0
postgresql主从复制配置「建议收藏」
postgresql主从复制是一种高可用解决方案,可以实现读写分离。postgresql主从复制是基于xlog来实现的,主库开启日志功能,从库根据主库xlog来完成数据的同步。
全栈程序员站长
2022/09/22
3.4K0
postgresql主从复制配置「建议收藏」
Postgresql总结几种HA的部署方式
第二步:pg_basebackup -Fp -P -x -D ~/app/data/pg_root21 -l basebackup21
mingjie
2022/05/12
1.5K0
Postgresql总结几种HA的部署方式
再不了解PostgreSQL,你就晚了之PostgreSQL主从流复制部署
在MySQL被收购之后,虽然有其替代品为: MariaDB,但是总感觉心里有点膈应。大家发现了另一款开源的数据库: PostgreSQL。
sanshengshui
2019/09/11
2.5K0
再不了解PostgreSQL,你就晚了之PostgreSQL主从流复制部署
推荐阅读
相关推荐
进阶数据库系列(十五):PostgreSQL 主从同步原理与实践
更多 >
LV.1
NNW高级DBA
目录
  • 实验环境介绍
  • 透明代理的需求
  • TPROXY(Transparent Proxy)
  • IP_TRANSPARENT 介绍
  • IP_TRANSPARENT 的内核代码介绍
  • connect 如何指定源 ip(伪装 IP 地址)
  • 策略路由(Policy-based Routing)
  • SO_MARK 选项
  • 包在 iptables 规则链中的流转全过程
  • 除 TPROXY 的另外的选择:REDIRECT 模式
  • 使用 conntrack 获取原始目标端口
  • 后记
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档