大家好,我是二哥。
上一篇文章本意是给大家一个新的视角来研究 istio route 的细节。不过后台不少同学私信我说,一直没有办法理解 iptables ,也就不想细看那篇文章了。二哥一看就慌了,为了让大家能安心地研究那篇文章,我就先来聊聊 iptables ,准确地说我们需要聊的是 netfilter 。
理解 iptables 其实不难,难的是看懂 iptables 是如何配合协议栈处理流量的。本篇除了聊 iptables 之外,更重要的是二哥会带大家一探协议栈和 iptables 密切配合过程。最后我以 istio route 为例来看看它是如何利用 iptables 将网络包透明地劫持到了 Envoy 的 Outbound hanlder 15001 端口。
本文会混用流量、数据包、网络包这些名词,准确地说,它们指代的是内核数据结构 skb (sk_buffer)。
这是一篇长文,诚意满满,干货满满,掉发也满满。如果你已经对 iptables/netfilter 很熟悉了,那可以跳过前两部分。
在开始之前,我们先区分两个概念:
- netfilter:内核中对数据包进行控制、修改和过滤(manipulation and filtering)的框架 。一个著名的实现是内核模块 ip_tables 。
- iptables:客户端命令行,用于操作(CRUD)各种规则来干预内核协议栈的行为。
大家日常工作中,碰到直接上手操作 netfilter 的机会越来越少了。但这不表示 netfilter 不重要。实际上 netfilter 是 K8s 网络的基础,即使在kubernetes v1.8 中引入了 ipvs 模式,ipvs 的着力点也是 netfilter 。不信你看下面的一段规则。ipvs 介入工作的前提是它得作为规则的一部分,让 netfilter 框架在合适的时机点运行。
肯定有另一部分同学有疑惑:既然平时都不怎么用它了,为什么还要学习 netfilter 呢?
不知道大家有没有另外一个疑惑:既然整个小学都不可能用到微积分,为什么小学的数学教师需要学高等数学呢?这其实涉及到处理问题时的一个角度问题:如果解决一个问题只需要 3 分功力,你最好得具有 6 成内功。只有这样你才能俯视而不是仰视或者平视它,唯有俯视方得从容。
1、只看 iptables
文首提到 iptables 是客户端命令,用于操作各种规则来干预内核协议栈的行为。那它具体是如何使用的呢?
先来看命令长什么样子的:
再来看一个使用示例:
(1)四表五链
图 1:四表五链(横链竖表,横为链,竖为表) 图片来源:公众号“开发内功修炼”
图中横向的四个框表示四表,对应于 iptables 命令里面的 -t <table-name> 。如果不指定,那么默认的 table 是 filter 。其实还有一个 security 表,用于在数据包上应用 SELinux ,这张表并不常用,故本篇我们略过。
图中第 1 列表示的五个框叫做五链,对应于命令里面的 <chain-name> 。每个链像竹签一样串着不少肉串。这些肉串叫规则,它们的种类不同,且由不同的表提供。比如 mangle 表可能提供的是羊肉串,而 nat 表提供的是牛肉串,filter 表提供的是鸡肉串。
这四个表的具体作用我就不细讲了,大家可以到网上搜索出更详细的答案。但下面这两点是重点(重点,重点,重点),你一定要记得。
- 这五个预置的链直接源自于 Netfilter 的钩子,它们与四张规则表的关系是固定的。用户即不能增加自定义表也不能修改图 1 中已有的表与链的关系,但可以增加自定义的链(见下文)。
新增的自定义链与 Netfilter 的钩子没有天然的对应关系。自定义链不会被自动触发,只有显式地使用 JUMP 行为(见后文),才能从默认的五条链中跳转过去执行它们。 - 每个命名空间都是有自己独立的 iptables 规则,这当然也包括四表五链。
表、链和规则之间的关系,一句话总结就是:规则是执行的最小单元,链决定了规则被执行的时机,而表则限定了规则的类别。链的执行时机详见后文。
(2) command
命令中的 command 是啥?它是一些由大写字母表示的动作。见图 2 所示。比如 -A 用于将一个新规则插入到链上,嗯,就是把肉串插到竹签上。每一次用 -A 这样的 command 调用 iptables ,都会在对应的链和表所形成的宫格里面插入一个新的规则。
图 2:iptables command 列表
(3)自定义链
我们可以用 -N 来创建一个新链。如果不用 -t 来指定 table 的话,新建的 chain 默认使用 filter table 。熟悉自定义 chain 的创建过程非常重要,因为后文我们要分析的 istio route 就自建了不少链。
二哥再强调一遍:自定义链不能被 netfilter 自动执行,只有从五大入口链那里通过 -j target 才能跳转到自定义链(例见后文)。
下面的例子里,自定义了一个 chain LANCE-OUTPUT ,可以看到它被放到了 table filter 里面。
然后用 -A 来追加一个规则到这个自定义链里面。
(4)parameter / option
光有 command 还不行,它太粗犷了,得细腻、得精准控制。这就需要通过 parameter 来实现。<parameter-1> <option-1> 里面填什么呢?看你喜欢,你有若干个选择,比如文首示例里面的 -s 1.2.3.4 和 -p tcp --dport 22 。有一些 parameter 还提供了额外的以 -- 开头的 额外 match option ,比如对 -p tcp ,你可以添加 --dport 22 这样的额外 match option ,用以更精准地控制要命中的规则。除了 tcp 外你还有 -p udp -p icmp 可供选择。
下面是可供使用的 parameter 列表。
图 3:iptables parameter 列表
(5)额外的 match option
这就结束了吗?不,还有大招没有放。我们来看下面这个例子。例子很平淡,重点看 -m 。-m comment 表示这个规则需要加载 comment 模块,从字面意思你大概能猜得出来它可以干啥。对,就是给这条规则加点注释。通过 --comment xxx 这个 option ,你可以添加最多 265 个字符的注释,前文在介绍用 -A 命令追加规则到自定义链时,从 iptables -L -t filter 的输出里面你可以体验到这些注释的作用。
通过 -m 我们可以调用包括 set / ipvs 在内的各种扩展模块。有多少模块可以选择呢?多到没朋友,不信你到这个链接里面去看:https://ipset.netfilter.org/iptables-extensions.man.html#lbAD 。我估计应该有 60 个左右。
(6)跳转到特点的目标 -j
我们设置的规则匹配上数据包后,总得干点啥是吧,不然不是白废老大劲了么。当然, -j 不是必填项,但你非得说我就不想让这个规则干具体的事情,也行!
我们可以给 -j 指定像 ACCEPT / DROP / QUEUE / RETURN 这样的 netfilter 自带的标准 target ,也可以给它指定我们自定义的链,除此之外还有若干个像 SNAT / REDIRECT / SET 这样的扩展 target 可供我们使用。
比如下面这个例子中,就通过 -j KUBE-SERVICES 跳转到自定义链 KUBE-SERVICES 去了。
流量通过 -j 跳转到指定 target 之后会发生什么?这取决于 target 会对流量做啥:
- 比如对于 DROP target ,你也能猜出结局是什么:不但流量会丢弃了,它更加不会到达传输层(见后文)。
- 而对于 KUBE-SERVICES 这样的 target,netfilter 会去执行这个链所定义的各种规则。
还记得前文我们说到的那默认的五条链吗?它们既是默认的五条链更是 netfilter 施展拳脚的入口。从这些入口进去,netfilter 可能会调用到若干个自定义链以及串在链上的多种多样的规则。假如所有的规则都不会下流量下死手,那么这些规则执行完后,就又回到入口处,也就是这五个默认的链。
2、不能单看 iptables
其实读懂和理解 iptables 规则并不难,难的是理解 netfilter 是如何和 TCP/IP 协议栈紧密集成和协作以控制流量的行为的。你们见过机场行李托运输送系统吗?我们在值机口托运的行李会穿过行李分拣大厅的各条分叉,兜兜转转才来到飞机货舱里面。
无论是入口流量还是本地进程产生的出口流量都如同我们在值机口托运的行李,而 netfilter 和 TCP/IP 协议栈则扮演了那个行李分拣系统。
图 4:四表五链与协议栈集成细节 图片来源:公众号“开发内功修炼”
既然说到 netfilter 和 TCP/IP 协议栈则的紧密合作,那我们先看看协议栈部分。
图中 ip_rcv() 是流量进入 IP 层的入口,ip_forward() 是转发流量的入口,而流量通过 ip_output() 离开 IP 层。当 IP 层决定要把流量送往传输层的时候,它通过 ip_local_deliver() 来完成,相对应地,本地进程想要把数据发送出去,需要借助 __ip_local_out() 。注意所有这些函数都在 IP 层。
协议栈在执行这些不同的入口函数时,会有选择地查看四表五链里面的链和相应的规则并执行这些规则。而规则里面所定义的 target 也反过来影响协议栈下一步的行为。
(1)过客和山海
从图 4 我们可以看得出来,四表五链以及路由选择其实是协议栈留出来给大家自由发挥的空间和口子。我们以图中标号 ④ 这一步的 __ip_local_out() 为例,看看内核是如何与这些开口打交道的:
可以看到在这个函数的最后一步,协议栈就开始通过 nf_hook() 去遍历 OUTPUT 链里面的规则了。这也是为什么我们说 OUTPUT 链是五链之一的原因。
nf_hook() 在遍历完 OUTPUT 链之后,就调用 dst_output() 来送别网络包。而网络包从此则需独自一人进入下一段旅程,过一会儿它将会遇到 ip_output() ,从那里离开 IP 层。
我们也可以看得出来对于发送流程, OUTPUT 链只是一个过客,网络包在这一站稍作停留后还是要继续奔赴山海,在后面的旅途中它会碰到协议栈其它代码和其它链,比如在 ip_output() 里面,它会遇到 POSTROUTING 链。
(2)PREROUTING 链
让我们把图 4 仔细看一遍。
对于 ① ,当流量从外部进入网卡,ip_rcv() 负责将其接入 IP 层,PREROUTING 链先于路由选择介入对流量的处理流程。比如下面的例子里,每一个原本想访问 8022 端口的流量的 dest IP 和 dest Port 全部都被改成 127.0.0.1:22 。
dest IP 和 dest Port 全部都改成 127.0.0.1:22 ,你很容易就猜到:在接下来的路由选择这一步,协议栈会把修改过之后的流量通过 ② 送往本地进程。
如果 dest IP 和 dest Port 被改成了 39.156.66.10:443 呢?流量不会被送往本机,而是通过 ③ 被 forward 离开本机。当然前提是本机 forward 功能已经开启了。
(3)INPUT 链
我们刚才说流量最终是通过 ip_local_deliver() 离开 IP 层并进入传输层的。不过在这之前,还有一个 INPUT 链等着它。流量能否被传输层处理还得看 INPUT 链是否允许。
比如对于下面这条规则,它存放在 INPUT 链的 filter 表里面,当发现流量是 tcp 协议,且访问的是本机 22 端口,就把流量丢弃掉,说白话就是不允许任何人通过 ssh 访问本机。于是对于任何进来的流量,② 这条路就算走到头了。ip_local_deliver() 不会被执行,sshd 也就没有机会收到这个请求。
又或者如果一切都很正常,不出意外的话,位于传输层的函数 tcp_v4_rcv() 会接收到这个可能已经被修改过之后的流量,从此流量开始了它在传输层的旅程。
(4)FORWARD 链
当路由选择决定要把流量 forward 后,会调用 ip_forward() 开始后续的 forward 处理流程,这个流程如 ③ 所示。如果你喜欢,你可以在 FORWARD 链中加入你喜欢的规则来控制流量从命运。比如下面的例子:
(5)OUTPUT 链
① ② ③ 这三条数据流里面涉及到的三个链都是因网卡接收到了外部的流量引起的,它们都是被动被执行。而 OUTPUT 链则是因为本地程序向主动发送流量而触发执行流程。
当网络包从传输层通过 tcp_write_xmit() 来到 IP 层时,首先迎接它的还是路由选择。这一步会产生两个重要的决定:
- next-hop 是谁,也即由谁来接收网络包
- 从本机哪个 interface 离开
你可能会困惑,不是由应用程序写好的 dest IP 来接收网络包吗?没错,不过那是最终接收者,在这中间还会有若干个设备会经手并传递这个网络包。这就好像你从南京快递一个 iPhone 给远在北京的女友。当然最终是你的女友负责接收、拆开这个包裹,但在她拿到包裹之前,有非常多的快递站中转站、快递小哥也会触碰到它。这里所说的 next-hop 就是负责收取快递的第一个人。
如果这台设备有多个网卡的话,得选择其中一个网卡来将网络包传送出去。
ip_output() 负责将网络包送离 IP 层,但且慢,看到 ④ 那里的 OUTPUT 链了吗?是的,这次轮到它大显身手了。我们可以在这里对包做一次 SNAT ,使得它离开本机的时候,源地址使用本机的 IP 地址。关于 OUTPUT 链具体的例子我们留到最后聊 istio route 的时候再细说。
3、通过 loopback 通信
问大家一个问题:现有下面这两个网络通信场景:
场景一:本机同一个 network namespace 下面的两个进程之间通过 loopback(后文简称 lo) 设备进行网络通信,如图 5 所示。
场景二:一个局域网内,连接在同一个交换机上的两台主机上的两个进程相互进行网络通信,如图 6 所示。
这两个场景下,除去链路层设备的不同所带来的二层收发数据的区别外,内核协议栈对数据包的处理过程有本质的不同吗?从图中你也可以看出来,无论是图 5 还是图 6,数据均需要走完如下的过程:
发端应用层 -> 发端传输层 -> 发端网络层 -> 发端链路层 ->(物理层数据收发)-> 收端链路层 -> 收端网络层 -> 收端传输层 -> 收端应用层
对于图 6,上述这个过程大家应该没有任何异议。重点是对于图 5 所示的场景:即便通过 loopback 设备通信,网络包还是要两次完整地穿越协议栈。注意我这里的用词:完整地。理解这点非常重要,因为后面要用。
图 5:通信场景一:两个进程相互之间通过 loopback 设备通信
图 6:通信场景二:LAN 通信
4、简单过下 istio route
二哥需要强调一个重点:图 7 所示的包括四表五链、conntrack 表、供路由选择的路由表、接口 eth0 和 loopback 在内的信息都是 network namespace 的一部分。对于一个进程来说,这些要素其实就构成了它发起和响应网络请求的基本环境。在正常情况下,一个 Pod 里面所有的 container 都共享一个 network ns,也就共享着这个基本环境。
一个 network ns 如同一座围城,围住了所有的数据。
我们都知道 Linux 支持多个 network namespace,这也就意味着类似这样的基本环境会有若干份。当然,在每个基本环境里面,像四表五链、路由表之类的数据各有千秋。
我们可以将 TCP/IP 协议栈看成是程序的代码部分,而将上述的基本环境看成是程序的数据部分。很显然 TCP/IP 栈应该是被这个 OS 上所有人共享的,无论是进程还是容器,甚至是基于 qemu-kvm 的虚拟机都共享着宿主机的协议栈,但 network ns 所围起来的数据却是各个 network ns 独享的。
图 7:Envoy 劫持网络流量全景图
下面我们将以 App container 想要访问 www.baidu.com 443 端口为例来带大家过一下 istio route 。
这个过程在图 7 上来看,就是始于 App container internal logic 的,标号为 ⑨ ~ ⑫ ~ ⑬ 的数据流。我们沿着箭头的方向会发现网络包被透明地劫持到了 Envoy 的 Outbound hanlder 15001 端口。我们重点分析 Envoy 是如何通过 iptables 来做到这一点的。
(1)相关 iptables
首先我们来看下与这个流程相关的 iptables 。为节省篇幅,突出重点,二哥省去了其余的部分,只保留了 OUTPUT 链及其会调用到的自定义链。
首先我们看到这段输出是用命令 iptables -t nat -L -v 得到的。你看到 -t nat 了吗?这表示这些规则全部都存放在 nat 表里面。我相信大家对 NAT 有所耳闻,看到它也就大概猜得出来几分:既然这些规则是与 NAT 表相关的,那么它们干的事情也就涉及到修改 IP 地址或端口这样的操作。
我把这些规则之间的关系用图 8 表示出来了。我们来看看它们是如何协作的。
图 8:istio route 自定义链 ISTIO_OUTPUT 细节
(2)协作细节
首先当 App cotainer 访问 baidu.com 时,请求从传输层出来后,首先需要经过一次路由。这个时候协议栈也仅仅知道这个包的目的 IP(39.156.66.10) 和 目的端口(443),还不知道它的二层信息是什么。为什么呢?得经过路由后,才能知道包需要从本地哪个接口离开,以及谁是 next-hop ,也只有当知晓了这些信息后,才能填充二层头的 src MAC 和 dest MAC。因为 IP 地址是与接口绑在一起的,所以从哪里接口离开也就决定了 src IP 是什么。
路由选择细节就不细讲了。我们先把它看作一个黑盒子,经过它之后,协议栈做了一个决定:去 39.156.66.10 的话,得从接口 eth0 离开。再强调一次,eth0 位于 App cotainer 所在的 network namespace 里面。
按照前文所述的协议栈和 netfilter 配合流程,我们现在知道路由选择后,紧接着需要执行 OUTPUT 链里面的规则。
ⓐ OUTPUT 链是五大入口链之一,可在这里,它啥都没干,直接把活外包给自定义链 ISTIO_OUTPUT 了。我们可以看到链 ISTIO_OUTPUT 上挂了 9 个规则。
图 9:ISTIO_OUTPUT 链上的 9 个规则
规则 1,2,3,5,6 都不满足条件,因为我们需要从接口 eth0 离开,而不是 lo ,当然这两个接口都属于这个 Pod 所使用的 network ns。
规则 4,7 对目的地的 owner UID 和 GID 做了限制,不符合我们的场景。
规则 8 的目的地是 localhost,而我们想要去 39.156.66.10。很抱歉,完美错过。
最后就剩规则 9 了。这个规则非常粗矿,啥都行,碰到这个它,大家就只能全部乖乖地跳转到自定义链 ISTIO_REDIRECT 了。
ⓑ 这一步表示了这样的跳转过程。
ⓒ 自定义链 ISTIO_REDIRECT 也是人狠话不多。只要传输层是 TCP 的流量,全部统一 REDIRECT 到 127.0.0.1:10051 。
我们来看看 target REDIRECT 会对流量干啥事。
说白了,它会把 dest IP 和 dest Port 改成 127.0.0.1:15001 。这好理解,毕竟只有这样,在图 8 ⓗ 处路由的时候, IP 层才会把从 App container 出来的流量路由至 listen 在 15001 的 Outbound hanlder 去处理。
这一步做完后,从 ⓐ 开始的 OUTPUT 链遍历执行过程就结束了。那结束之后下一步协议栈要干什么呢?
跟着 ⓓ ⓔ ⓕ ⓖ ⓗ 走一遍,你会知道全部的答案。不过注意看 ⓓ 那里所标示的目的 IP 和目的端口,它们已经被改掉了。既然目的 IP 已经被改成了 127.0.0.1 了,那在 ⓕ ⓖ 那里发生的就是前文所述的通过 loopback 通信所涉及的技术细节了。