通常情况下,我会将法币资产通过中心化交易所换成 USDT,再将 USDT 交易成其他加密货币,例如 BTC,ETH … 最后看情况将 BTC 等货币转入我自己的钱包。
为了分散风险,我不会将所有资产都存入一个钱包中,避免这个钱包私钥泄漏丢失所有资产。为此我创建了多个钱包,将各个钱包的私钥分开保管。(目前是将每个钱包的助记词存在助记词钢板或者胶囊中,没有做隔离,也就是说万一钢板丢失,还是有泄漏的风险。未来可能考虑将助记词拆开存放,比如 24 位的助记词,拆成 3 ~ 4 份,每份存储 12 ~ 16 个助记词。这样就算有一个丢失,也不会泄漏钱包,而且也能通过另外几份助记词将钱包恢复出来)
同时为了考虑安全性,我不会选择把资金存入软件钱包中(尽管它们用来很方便)。用于存放资金的钱包必须是硬件钱包。这里我使用了 ledger 和 tangem。
ledger 不必多说,是非常老牌的硬件钱包品牌。关于 tangem,tangem 是一种卡片式的硬件钱包,它将私钥等敏感信息存储在卡片里,通过它的 app 和 nfc 完成数据的签名。它的好处是,私钥对用户不可见,所以用户不需要担心私钥泄漏的问题。但是也是因为这个问题,如果卡片全部丢失(它默认提供了 2 张备份卡片),那么用户将永远失去这个钱包。并且卡片需要配合 tengem 的 app 使用,如果 tengem 跑路,也会有一定的影响。
我将这些钱包分成了三种类型。
鉴于这种拆分钱包管理方式,它带来了安全性上的好处,但同时也造成一些困扰。我的资产分散在各个地方,中心化交易所,已经 n 个数字钱包。这使得做资产统计变成一个很麻烦的事情。
早期我使用了一些传统的资产统计工具,比如 Percento,它们很强大,能够统计不仅仅是加密货币,甚至包括银行账户,股票等等多种资产,也能做到实时更新各个数字货币以及股票的价格。但他们也存在着一些缺点,比如所有的加密货币持仓数据都需要手动录入。
手动录入数据在我的场景看来非常麻烦。首先是每个月发工资买入数字货币之后或者是换仓之后,我时常需要来手动更新我的各种加密货币资产,这花费了我很多时间。所以我决定每月只在特定的一天更新各个加密货币的数量,这也导致了数据不一定是实时的。再加上我有很多钱包,有时候甚至会在统计过程中漏掉一些资产,导致数据出现混乱。
因此我理想中的加密资产统计工具,应该是只需要输入钱包地址,或者连接到中心化交易所,它就能自动读取这些钱包里所有的加密货币资产数量,以及它们的实时价格。出此之外,我还希望能看到对某个数字货币持仓数量的变化,我的所有数字货币持仓的占比,以及它们排名,总价格和单价变化等等数据。
这样我能清楚的看到我各个阶段的资产详情和持仓变化。此外,我还希望能够对比两天的详细数据,比如我想知道 6 月 1 日的持仓数据和 5 月 1 日比发生了哪些变化,哪些币的价格上涨了,哪些币的持仓数量下降了。这样能帮助我分析出我的换仓决策是否正确。
所以基于这些需求,我开发出了 track3,一个专注于加密资产统计的工具。它目前支持了几个主流数字货币的资产统计,比如 btc, erc20 token ( eth, eth layer2, bnb 等 evm 兼容的货币),doge coin, sol,以及支持了两大主流中心化加密货币交易所 binance 和 okex。
我可以只输入我的钱包地址,以及各个中心化加密货币交易所的 api key ( 只读权限即可)。
在我每次打开应用点击刷新的时候,track3 就会自动读取这些交易所或者钱包里所有主流货币( coingecko 可以查到的货币)的数量和价格,并对这些资产做分析。
在资产分析功能上,我可以看到
我当前总资产的法币价格(支持 usd, eur, cny 等 150+ 主流法币)
我的前 10 大货币持仓占比
我的总资产的价格变化趋势
我的每种货币的数量和它的总价格变化趋势
我的前 10 大货币排名变化
我的各个货币较其实日期的总价格和单价的百分比变化
在资产对比功能上,我可以看到两个日期之间
并且,它还支持云同步,所以我可以在多个设备之间来回使用,也不用担心数据不同步的问题。(如果不开启云同步功能,所有数据永远只会存储在本地)
但是为了保证安全性,track3 只会将资产数据同步到云上。因此不需要担心钱包地址或者交易所的 api key 泄漏的问题,它们永远只会被加密之后保存在本地。为了保证隐私性,即使是云上资产数据也无法被除了本人以外的其他人查看。也就是说即使是云上数据库的 admin,也无法查看到用户的数据。能这样实现,这是因为云数据同步功能使用了 polybase.xyz 作为数据库,它是一种去中心化的数据库,感兴趣的朋友可以去他们的官网查看功能文档,了解更多。
因为 track3 的诞生,此后我统计加密货币资产的方式变得无比轻松。我只需要在我想要查看最新数据的时候,点击下刷新按钮。之后 track3 遍会自动帮我更新所有钱包和交易所内的资产数据。我也只需要在创建了新的钱包之后,将钱包地址配置到 track3 即可。不需要再和以前一样,一个个查询每个钱包内的资产,手动计算它们数量的总和,并一个个录入到资产统计系统中。
如果你也有类似的困扰,不妨尝试使用 track3 来优化加密货币资产管理。
]]>Cilium 1.10
编写这篇文章会和大家分享一些 Cilium 相关的知识,包括以下几个主要部分
当然在开始将 eBPF 和 Cilium 之前,会还是会简单介绍一下以下一些基础知识
那么接下来开始这篇文章的主要内容
这里会简单介绍接收数据包的过程,即数据包是如何从网络被送到内核协议栈的
ksoftirqd
进程调用 Driver 中的 poll 函数读取 NIC DMA 到内存的数据skb
格式(内核规定的格式)skb
合并成大小相同的 skbskb
数据被被协议栈相关的函数处理成协议层的格式skb
后会被放到网卡的发送队列中这里会简单介绍下在 Kubernetes 中,Pod 和 Pod 之间是如何通信的。那么在 K8S 中存在两种 Pod 之间通信的情况,同节点 Pod 通信
和 跨节点 Pod 通信
在讨论 Pod 和 Pod 通信之前,先了解下 Pod 的数据包是如何发送到 Host 节点上的。
Pod 在 Kubernetes 中是指一组 Containers 的统称,Container 技术本质上是使用了 Linux 的 Namespace 技术。在 Pod 中的所有 Containers 通过共享同一个 Namespace 来实现它们之间的网络共享以及和外部网络的隔离
但被网络隔离起来 Containers 总有访问其他服务的需求,因此它们发出的数据包必定是要能送达到外部的,为了满足这种需求当然有很多方案都能做到,比如将物理网卡或者虚拟网卡 attach 到 Namespace 中
但这里主要介绍的还是大部分 CNI 所采用的方案,那就是 veth-pair
veth-pair 就是一对的虚拟设备接口,它都是成对出现的,两端彼此相连,一端发送的数据会被另一端收到。正因为有这个特性,它常常充当着一个桥梁,连接着各种虚拟网络设备
如果想要让 Namespace 中的服务能够把数据报文发送到外部,可以创建出一对 veth-pair,一端放在 Namespace 中,另一端放在 Host 中,这样当 Namespace 中的服务发送报文时,报文就会被发送到 Root Namespace ( Host )中,这样 Host 可以根据 route 表中的路由规则将报文转发到目标服务
当然上面这段只是理想情况,实际上还有很多东西没有考虑进去,比如网络通信的四要素,src_ip
, src_mac
, dst_ip
和 dst_mac
,没有这四种信息数据报文自然是无法送达到目的地的。在上述场景中 src_ip
(Namespace 中被 attach 的 veth-pair 的网卡的 ip), src_mac
(同上) 和 dst_ip
都是已知的,所以接下来的问题就是如何获取 dst_mac
在网络通信中
dst_mac
是指 nextHop 的地址,如果走 L3 转发那么 dst_mac 则为网关地址,如果走 L2 交换,dst_mac
则为目的地址
如上面所说,同节点 Pod 之间的通信的问题就是如何解决 dst_mac
的问题
不同的 CNI 有不同的解决方案。
比如在 Flannel
中,Flannel 会在 Host 上创建一个 NetworkBridge
(cni0
),然后将 Pod 的 IP 设置成 24
位,veth-pair
的一端 attach 到 Pod 中,另一端 attach 到 cni0 上。因为同节点上的 Pod 都是 24 位 IP,所以 Pod 之间通信走二层交换,src Pod 可以通过 arp 请求获取到 dst Pod 的 mac 地址,这样也就解决了 dst_mac
的问题
NetworkBridge 可以看作是虚拟的交换机,attach 到 network bridge 上的设备之间可以相互通信
但这篇文章主要会介绍另一种模式(之后所有内容也是基于该模式作为基础),该模式也在 Calico
或者是 Cilium
中使用
在 Calico
或者 Cilium
中,Pod 的 IP 会被设置成 32 位。因此在这种情况下,Pod 访问任何其他 IP 都是走 L3 路由
上面是我从 kubernetes 集群中获取到的数据,cni 为 Calico
从 Pod 中的路由表可以看到,默认网关的地址为 169.254.1.1
,因此如果要从 Pod1 访问其他服务需要先获取到 169.254.1.1
的 mac 地址,但很明显这个 mac 地址是无法通过 arp 请求获取到的
那么如何解决这个问题呢?事实上 Calico 和 Cilium 采用了不同的方案
Calico
Calico 使用了 proxy_arp
来解决,简单来说开启 proxy_arp 的网络设备可以被当作是一个 arp 网关,当它接收到 arp 请求时,它会把自己的 mac 地址回复给请求者。因为 veth-pair 的缘故,Pod 中 eth0 发出的请求都会被送到它所绑定的 veth*
中。
因此给该 veth*
开启 proxy_arp 之后,veth* 就能够把它的 mac 回复给 Pod,这样数据报文就能被送出来,当数据包文被送到 Host 中后,再根据 Host 内的本地路由表,将数据报文送到对应的 Pod 挂在 Host 上的 veth-pair 设备上了
Cilium
Cilium 的做法则是过在 veth*
上 attach tc ingress bpf 程序
,为 Pod 的所有 arp 请求都返回 veth* 的 mac
本篇文章不会着重去介绍跨节点通信时用到的,比如 Overlay
或者是 Underlay
之类这些网络技术
而且 Cilium 着重解决的也不是这方面的问题,且 Cilium 在解决跨节点传输的问题上用的也都是非常主流的技术,比如 VxLan
或者是 BGP
,这里就不展开说了
如果对这部分感兴趣的话,推荐大家去看 Calico 这部分的文档
在结束了上面的铺垫之后,接下来开始介绍本次的主角 eBPF
目前谈到的 BPF 技术分两种,cBPF
和 eBPF
cBPF
诞生于 1997 年,内核 2.1.75 版本,是类 Unix 系统上数据链路层的一种原始接口,提供原始链路层封包的收发。tcpdump
的底层正是采用 cBPF 作为底层包过滤技术
eBPF
诞生于 2014 年,内核 3.18 版本,eBPF 新的设计针对现代硬件进行了优化,所以 eBPF 生成的指令集比旧的 BPF 解释器生成的机器码执行得更快
cBPF 现在已经基本废弃,目前内核只会运行 eBPF,内核会将加载的 cBPF 字节码透明地转换成 eBPF 再执行(本文以下皆用 BPF 指代 eBPF)
BPF 拥有以下优点:
同时 BPF 拥有以下几种特性:
接下来会对这些特性做介绍
为了让 BPF 能够持久化状态,内核提供了驻留在内核空间的高效 kv 存储器(BPF map),BPF map 可以被 BPF 程序访问,可以在多个 BPF 程序之间共享(共享的程序之间不一定要求是相同的类型),也可以通过 fd 的形式被用户空间的程序访问。因此用户程序可以使用 fd 相关的 api 方便的操作 map
共享的程序之间不一定要求是相同的类型指: tracing programs 也可以和 networking programs 程序共享 map
但 fd 受到进程的生命周期的影响,使得 map 的共享等操作实现起来变得复杂,为了解决这个问题,内核开发了 object pinning 功能,该功能能将 map 的 fd 能保留住,不会随着进程退出而被删除
使得 BPF 能够通过一组内核定义的函数调用来从内核中查询数据,或者将数据推送到内核
不同类型的 BPF 程序能够使用的辅助函数可能是不同的
BPF 支持一个 BPF 程序可以调 用另一个 BPF 程序,并且调用完成后不用返回到原来的程序(只有相同类型的 BPF 程序才可以尾调用)
和普通函数调用相比,这种调用方式开销最小,因为它是用长跳转(longjump)实现的,复用了原来的栈帧
使用场景
BPF 网络程序,尤其是 tc 和 XDP BPF 程序在内核中都有一个 offload 到硬件的接口,这 样就可以直接在网卡上执行 BPF 程序
本文接下来会讨论在 Cilium 得到广泛使用的两个 BPF 子系统, XDP 和 tc 子系统
XDP 为Linux内核提供了高性能、可编程的网络数据路径
XDP hook 位于网络驱动的快速路径上,XDP 程序直接从接收缓冲区中将包拿下来,此时 Driver 还没有将数据包转换成 skb
,因此该数据包的元信息还没有被解析出。理论上这是软件层最早可以处理包的位置
同时因为 XDP hook 运行在网络驱动的快速路径的原因,运行 XDP BPF 程序必需得到网络驱动的支持
xdp program 会在第 7 步到第 8 步之间执行
同时 XDP 运行在内核态,并不会绕过内核,这带来了以下好处
不停轮询
或者是 中断驱动
的模式XDP BPF 程序能够修改数据包的内容。同时 XDP 为每个数据包提供了 256 个字节的 headroom,XDP BPF 程序可以对这部分进行修改,比如在数据包前面添加自定义元数据,该部分数据对内核协议栈不可见,但是对 tc BPF 程序可见
struct xdp_buff { void *data; void *data_end; void *data_meta; void *data_hard_start; struct xdp_rxq_info *rxq;};
这个结构是 XDP 程序获取到的数据包的格式
data
: 指向数据包起始位置data_end
: 指向数据包结尾data_hard_start
: 指向 hardroom 开始位置data_meta
: 指向 meta
信息开始, 刚开始 data_meta
和 data
相同,随着 meta 信息增加,data_meta
开始向 data_hard_start
靠近rxq
: 字段指向某些额外的、和每个接收队列相关的元数据从上面可以得出 xdp_buff
的结构
data_hard_start |___| data_meta |___| data |___| data_end
以及这些 data 字段之间的关系
data_hard_start <= data_meta <= data < data_end
XDP BPF 程序返回码
XPD BPF 程序运行结束后会返回一个状态码,告诉驱动如何处理这个数据包
XDP_DROP
: 在 Driver 层将该数据包丢弃XDP_PASS
: 将这个包送到内核网络协议栈,这和没有 XDP 时默认的包处理行为是一样的XDP_TX
: 在收到该数据包的网卡上,将该数据包再发出去(数据包一般会被修改)XDP_REDIRECT
: 和 XDP_TX
类似,不过是在另一张网卡上将数据包发出去XDP_ABORTED
: 表示程序发生异常,行为和 XDP_DROP 一致,但是会经过 trace_xdp_exception tracepoint,可以通过 tracing 工具来监控这种非正常行为XDP 使用案例
DDoS 防御、防火墙
: 得益于 XDP 能够在最早的位置拿到数据包,然后用 XDP_DROP
命令驱动将包丢弃,XDP 能够实现非常高效的防火墙策略,对于 DDoS 攻击的场景来说非常理想转发和负载均衡
: 通过 XDP_TX
和 XDP_REDIRECT
两个动作实现。XDP_TX
能够实现发卡模式的负载均衡器栈前过滤/处理
: 对于不符合要求的流量可以尽早使用 XDP_DROP
丢弃,比如节点只接受 TCP 流量,那么对于 UDP 请求可以直接丢弃掉。同时 XDP 能够在 NIC Driver 分配 skb 之前修改数据包的内容,对于某些 Overlay 场景(需要封装和解封数据包)来说很有用,并且 XDP 能够在数据包的前面 push 元数据,且该部分对内核协议栈不可见流抽样和监控
:XDP 可以将流量进行分析,对于异常流量可以放到 BPF map 中,提供给其他进程用于分析一些智能网卡(例如支持 Netronome’s nfp 驱动的网卡)实现了 xdpoffload 模式,允许将整个 BPF/XDP 程序 offload 到硬件,因此程序在网卡收到包时就直接在网卡进行处理,不过在该模式中某些 BPF map 类型 和 BPF 辅助函数是不能用的
除了 XDP 等类型的程序之外,BPF 还可以用于内核数据路径的 tc (traffic control,流量控制)层
tc 和 XDP 主要有以下三点不同之处:
输入上下文
相较于 XDP,tc 在流量路径中位于更加靠后的位置(skb 分配之后)。因此对 tc BPF 程序而言,它的输入的上下文是 sk_buff
, 而非 xdp_buff
,所以 tc ingress BPF 程序可以利用 sk_buff
中内核处理好的包的元数据。当然内核处理这些元数据是需要开销的,包括协议栈执行的缓冲区分配,元数据的提取和其他处理等过程,而 xdp_buff 不需要访问这些元数据,因为 XDP hook 在这之前被调用,所以这是 XDP 和 tc hook 性能差距的重要原因之一
是否依赖驱动支持
因为 tc BPF 程序运行在网络栈通用层中的 hook 点,所以它们不需要驱动做任何支持
触发点
tc BPF 程序在数据路径上的 ingress 和 egress 都可以触发
XDP BPF 程序只能在 ingress 点触发
tc ingress
tc ingress hook 位于 GRO 之后,处理协议之前最早的处理点
tc egress
tc egress hook 运行的位置是,内核将数据包交给 NIC Driver 之前最晚的位置,这个地方在传统 iptables 防火墙 POSTROUTING
链之后,但是在 GSO 引擎处理之前
tc BPF 程序执行模式
在 tc 中,有以下 4 种组件
qdisc
: Linux 排队规则,根据某种算法完成限速、整形等功能class
: 用户定义的流量类别classifier
: 分类器,分类规则。传统的 tc 方案中,classifier 和 action modules 之间是分开的,每个分类器能 attach 多个 action,当匹配到这个分类器时这些 action 就会执行。除此之外,它不仅能够读取 skb 元数据和包数据,还能任意修改这两者,最后结束 tc 处理过程,返回一个返回码action modules
: 要对包执行什么动作当需要给某个网络设备挂载 tc BPF 程序时,需要执行以下操作
qdisc
class
,并 attach 到 qdisc
filter (classifier)
,并 attach 到 qdisc
0
: 表示 mismatch,如果后面有其他 filters,则继续向下执行-1
: 执行这个 filter 上的默认 class
其他
: 表示一个 classid,接下来系统会将数据包送到这个 class
filter
添加 action
cls_bpf classifier
cls_bpf 是一种分类器,相比于其它类型的 tc 分类器,它有一个优势:能够使用 direct-action 模式
Cilium 中就使用了 cls_bpf 分类器,它在部署 cls_bpf 服务时,对于给定的 hook 点只会 attach 一个程序,且用的正是 direct-action模式
direction-action
如上文所说,传统 tc BPF 程序的执行模式是 classifier 分类和 action modules 执行是分开的,一个 classifier attach 多个 actions,classifier 负责匹配流量,然后将匹配到的流量交给 action 执行
但是对于很多场景,eBPF classifier 已经有足够的能力完成完成任务处理,无需再 attach 额外的 qdisc 或 class 了
所以,为了
社区为 tc 引入了一个新的 flag:direct-action,简写 da。 这个 flag 用在 filter 的 attach time,告诉系统: classifier 的返回值应当被解读为 action 类型的返回值。这意味着 classifier 加载的 eBPF 现在可以返回 action code 了。比如在要求丢包的场景中,就不需要再引入一个 action 执行 drop 操作,可以直接在 classifier 中完成
这样带了的好处
tc BPF 程序返回码
TC_ACT_UNSPEC
: 结束当前程序的处理,不指定接下来的操作(内核会根据情况执行下一步操作)。对于以下三种情况,默认操作分别为。cls_bpf
被 attach 了多个 tc BPF 程序时,继续下一个 tc BPF 程序cls_bpf
被 attach 了 offloaded tc BPF 程序(和 offloaded XDP 程序类似)时,cls_bpf 会返回 TC_ACT_UNSPEC
,内核会执行下一个没有被 offloaded BPF 程序(一张 NIC 只能 offloaded 一个程序)TC_ACT_OK
: 结束当前程序的处理,并告诉内核下一个执行的 tc BPF 程序TC_ACT_SHOT
: 通知内核丢弃数据包,返回 NET_XIT_DROP
给调用方表示包被丢弃TC_ACT_STOLEN
: 通知内核丢弃数据包,返回 NET_XMIT_SUCCESS
给调用方,假装这个包被正确发送TC_ACT_REDIRECT
: 使用这个返回码并加上 bpf_redirect()
辅助函数,允许重定向一个 skb
到同一个或另一个网络设备的 ingress 或 egress 路径。对目标网络设备没有额外的要求,只要本身是 一个网络设备就行了,在目标设备上不需要运行 cls_bpf
实例或其他限制tc 使用案例
为容器落实策略
: 在容器网络中,veth-pair 一端连接着 Namespace,另一段连接着 Host。容器内所有的网络都会经过 Host 端的 veth 设备,因此在 veth 设备上的 tc ingress 和 egress hook 点可以 attach tc BPF 程序。此时容器内发出的流量都会经过 veth 的 tc ingress hook,进入容器的流量都会经过 veth 的 tc egress hook(对于 veth 这样的虚拟设备,XDP 在该场景下并不合适,因为内核在这里只操作 skb,而通用 XDP 有几个限制,导致无法操作克隆的 skb。而且 XDP 无法处理 egress 流量)转发和负载均和
: 使用场景和 XDP 很类似,只是目标更多的是在东西向容器流量而不是南北向。tc 还可以在 egress 方向使用,例如对容器的 egress 流量做 NAT 和 负载均衡,整个过程对容器是透明的。由于在内核网络栈的实现中,egress 流量已经是 sk_buff 形式的了,因此很适合 tc BPF 对其进行重写(rewrite)和重定向(redirect)。 使用 bpf_redirect() 辅助函数,BPF 就可以接管转发逻辑,将包推送到另一个网络设 备的 ingress 或 egress 路径上流抽样和监控
: tc BPF 程序可以同时 attach 到 ingress 和 egress,另外,这两个 hook 都在(通用)网络栈的更低层,这使得可以监控每台节点 的所有双向网络流量。Cilium 中会使用 tc BPF 能够给数据包添加自定义 annotations 的特性,对被 drop 的包打上 annotations,标记它所属的容器以及被 drop 的原因,提供了丰富的信息上文已经描述了 Pod 中的流量是如何顺利发送到 Host 的 veth 上的,接下来会介绍在 Cilium 中流量的完整路径
当前环境基于 Legacy Host Routing 模式
在开始之前,先观察一下当集群中安装了 Cilium 之后,会多出来哪些东西,这里可以直接进入 cilium agent 的 Pod,执行网络命令,因为 agent 使用的是 hostNetwork: true
,所以它和主机是共享网络的,因此在 Pod 中查看到的网络设备也就是主机上的网络设备
$ k exec -it -n kube-system cilium-qv2cb -- ip a18: cilium_net@cilium_host: <BROADCAST,MULTICAST,NOARP,UP,LOWER_UP> mtu 9001 qdisc noqueue state UP group default qlen 1000 link/ether fa:f3:f0:8d:5e:ae brd ff:ff:ff:ff:ff:ff inet6 fe80::f8f3:f0ff:fe8d:5eae/64 scope link valid_lft forever preferred_lft forever19: cilium_host@cilium_net: <BROADCAST,MULTICAST,NOARP,UP,LOWER_UP> mtu 9001 qdisc noqueue state UP group default qlen 1000 link/ether 3e:87:e2:f3:58:47 brd ff:ff:ff:ff:ff:ff inet 10.0.1.118/32 scope link cilium_host valid_lft forever preferred_lft forever inet6 fe80::3c87:e2ff:fef3:5847/64 scope link valid_lft forever preferred_lft forever
$ k exec -it -n kube-system cilium-qv2cb -- iptables -LChain CILIUM_FORWARD (1 references)target prot opt source destinationACCEPT all -- anywhere anywhere /* cilium: any->cluster on cilium_host forward accept */ACCEPT all -- anywhere anywhere /* cilium: cluster->any on cilium_host forward accept (nodeport) */ACCEPT all -- anywhere anywhere /* cilium: cluster->any on lxc+ forward accept */ACCEPT all -- anywhere anywhere /* cilium: cluster->any on cilium_net forward accept (nodeport) */Chain CILIUM_INPUT (1 references)target prot opt source destinationACCEPT all -- anywhere anywhere mark match 0x200/0xf00 /* cilium: ACCEPT for proxy traffic */Chain CILIUM_OUTPUT (1 references)target prot opt source destinationACCEPT all -- anywhere anywhere mark match 0xa00/0xfffffeff /* cilium: ACCEPT for proxy return traffic */MARK all -- anywhere anywhere mark match ! 0xe00/0xf00 mark match ! 0xd00/0xf00 mark match ! 0xa00/0xe00 /* cilium: host->any mark as from host */ MARK xset 0xc00/0xf00
可以看到分别多出来了 2 个网络设备 cilium_host
和 cilium_net
(如果使用了 overlay 模式,还会有 cilium_vxlan
),以及 3 条 iptables 规则 CILIUM_FORWARD
, CILIUM_INPUT
和 CILIUM_OUTPUT
cilium_host 和 cilium_net
cilium_host
和 cilium_net
是一对 veth pair
通过 route -n
查看路由表发现,cilium_host
负责处理本机 pod 流量之间的路由
10.0.1.0 10.0.1.118 255.255.255.0 UG 0 0 0 cilium_host10.0.1.118 0.0.0.0 255.255.255.255 UH 0 0 0 cilium_host
那么当流量从同节点的 Pod1 发送到 Pod2 时,需要用到这个路由表吗?答案是不需要,之前讨论 tc 的时候有说到,tc 可以使用辅助函数获取路由表的内容,并且支持直接 redirect 流量到另一张网卡,那么只需要在 Pod1 的 lxc (veth pair) 上 attach tc ingress BPF 程序就可以直接将流量发送到 Pod2 的 lxc
那么 cilium_host
这个虚拟网络设备的用处是什么呢?那就是用来接收跨节点的 Pod 或者是外网到 Pod 的流量。当有非本地发送过来到 Pod 的流量时,Host 上的 eth0 网卡上的 tc ingress 程序会对流量做某些处理(如果是 Pod 访问外网的回程报文则是 SNAT 还原,如果是 Pod 之间的访问则不需要操作),处理完成后将数据报文交给内核路由系统,最终送到 cilium_host
中,再由 cilium_host 上的 tc BPF 程序将流量 redirect 到 Pod 的 lxc
除此之外,Cilium 还在 Host 的出口网卡上 attach 了 tc ingress 和 egress hook
以上了解了在 Cilium 中通信会依赖三个网络设备,分别是 eth0
, cilium_host
和 lxc
那么在来看下这些设备上都被 attach 了哪些 tc hook
通过 tc filter show dev <dev_name> (ingress|egress)
命令可以查看到 attach 的 program 名称
lxc
to-container
from-container
to-container
from_container
cilium_host
to-host
from-host
to-host
from-host
eth0
from-netdev
to-netdev
from-netdev
to-netdev
因此基于以上的前提,大概可以画出集群内的流量路径
那么上面提到的 Cilium 在 iptables 中创建的三条链是用来做什么的呢?
因为在 Legacy
模式中数据包在某些情况下仍然需要进入内核协议栈,因此仍然需要用到 iptables。通过 FORWARD
+ POSTROUTING
链将数据包从 lxc 接口送到 cilium_host
,而 INPUT
链则是为了处理 L7 Network Policy Proxy 的情况,这个部分下面会讲
在 5.10 内核以后,Cilium 新增了 eBPF Host-Routing 功能,该功能更加速了 eBPF 数据面性能,新增了 bpf_redirect_peer
和 bpf_redirect_neigh
两个 redirect 方式
bpf_redirect_peer
bpf_redirect
的升级版,其将数据包直接送到 veth pair Pod 里面接口 eth0
上,而不经过宿主机的 lxc
接口,这样实现的好处是数据包少进入一次 cpu backlog queue 队列,该特性引入后,路由模式下,Pod -> Pod 性能接近 Node -> Node 性能bpf_redirect_neigh
ip_route_output_flow()
skb_dst_set()
neighbor subsystem
,ip_finish_output2()
src/dst MAC
地址skb->sk
信息,因此物理网卡上的 qdisc 都能访问到这个字段在引入了该特性之后,流量路径发生了较大的变化
可以看到在该场景下,除了本机访问本地 Pod,其他流量均不会经过内核协议栈,cilium_host
和 cilium_net
上
redirect_neigh
直接通过 eth0 的 egress 发出到达 Node2 的 eth0redirect_peer
直接把数据送到 Pod2 的 eth0以下这张图会更加直观的表示在 BPF Host Routing 模式下数据路径的简单
Cilium 支持基于 Layer 3/4/7
的网络策略,同时支持 allow
和 deny
两种模式
相同的规则,deny 的优先级更高
Cilium 除了兼容 Kuberentes 的 NetworkPolicy
API,也提供了 CRD 用于定义网络策略 CiliumNetworkPolicy
(具体字段请看官方文档,这里不做介绍)
Cilium 提供了三种网络策略模式
default
: 默认模式,如果一个 endpoint
设置了一个 ingress
,那么不符合这个规则的 ingress 请求都会被拒绝。egress
同理。不过 deny
规则不同,如果一个 endpoint
只设置了 deny,那么只有命中 deny 规则的请求会被拒绝,其他还是会被放行,如果一个 endpoint
没有设置任何 rule,那么它的网络不受任何限制always
: 如果一个 endpoint
没有设置任何 rule,那么它无法访问其他服务,也无法被其他服务访问never
: 不启动 Cilium 的网络策略,所有服务之间都能互相或者和外部访问Layer 3
Labels Based
: 根据 Pod labels 选择对应的 ip 然后过滤Services Based
: 根据 Service labels 选择对于的 ip 然后过滤Entities Based
: Cilium 内置了以下字段来指定特定的流量host
: 本节点的流量remote-node
: 集群内其他节点的 Endpointcluster
: 集群内部 Endpointinit
: 在引导阶段还没有被解析的 Endpointhealth
: Cilium 用来检查集群状态的 Endpointunmanaged
: 不是由 Cilium 管理的 Endpointworld
: 所有外部流量,允许 world 流量相当于允许 0.0.0.0/0
all
: 以上所有IP/CIDR Based
: 基于 IP 过滤DNS Based
: 基于 dns 解析后的 IP 过滤,Cilium 会在 agent 中运行 dns proxy,然后缓存 dns 解析后的 ip list,如果 dns name 满足 allow 或者 deny 规则,那么所有发往这些 ip list 的请求都会被过滤Layer 4
Port
: 按端口号过滤Protocol
: 按协议过滤,支持 TCP
, UDP
和 ANY
Layer 7
HTTP
: 支持根据请求 Path
, Method
, Host
, Headers
过滤DNS
: 不同于 Layer 3 中的 DNS Based
过滤,这是直接过滤 DNS 请求(因为是在 Layer 7 的缘故)Kafka
: 支持根据 Role
(produce
, consume
), Topic
, ClientID
, APIKey
和 APIVersion
过滤注意:使用了 Layer 7 Network Policy 之后,所有请求都会被转发到 Cilium agent 的 Proxy 中,该 Proxy 由 Envoy 提供,在 Legacy Host Routing 模式下,数据路径会变成下面这样
上图描述了 Endpoint to Endpoint 使用了 Layer 7 NetworkPolicy 时的数据路径,上半部分为默认情况下的数据路径,下半部分为使用了 socket 增强之后数据的路径
Cilium 还提供了带宽限制的功能
可以通过在 Pod annotation 中添加 kubernetes.io/ingress-bandwidth: "10M"
或者 kubernetes.io/egress-bandwidth: "10M"
来限制单个 Pod 的带宽。这样 Pod 的出口和入口带宽会被限制在 10Mbit/s
不过目前该功能还无法和 Layer 7 NetworkPolicy 同时工作,两者都使用的话会导致带宽限制失效
这部分会介绍一些工具来方便排查网络问题,主要是 cilium cli 的使用
可以 exec 到 cilium pod 中使用 cilium 命令行进行调试
查看集群网络状态
cilium status
KVStore: Ok DisabledKubernetes: Ok 1.21+ (v1.21.2-eks-0389ca3) [linux/amd64]Kubernetes APIs: ["cilium/v2::CiliumClusterwideNetworkPolicy", "cilium/v2::CiliumEndpoint", "cilium/v2::CiliumNetworkPolicy", "cilium/v2::CiliumNode", "core/v1::Namespace", "core/v1::Node", "core/v1::Pods", "core/v1::Service", "discovery/v1::EndpointSlice", "networking.k8s.io/v1::NetworkPolicy"]KubeProxyReplacement: DisabledCilium: Ok 1.10.3 (v1.10.3-4145278)NodeMonitor: Listening for events on 8 CPUs with 64x4096 of shared memoryCilium health daemon: OkIPAM: IPv4: 1/254 allocated from 10.0.1.0/24,BandwidthManager: DisabledHost Routing: LegacyMasquerading: DisabledController Status: 30/30 healthyProxy Status: OK, ip 10.0.1.118, 0 redirects active on ports 10000-20000Hubble: Ok Current/Max Flows: 4095/4095 (100.00%), Flows/s: 12.47 Metrics: OkEncryption: DisabledCluster health: 5/5 reachable (2021-10-18T15:01:07Z)
抓包
cilium monitor
在 BPF 场景下因为数据路径和传统网络栈发生较大改变,如果不熟悉这套模式在使用比如 tcpdump
等工具抓包调试时可以产生一些问题。(比如在 BPF Host Routing 下,lxc 接口是无法抓到回程报文的)
好在 cilium 提供了一套工具用于分析数据包,方便开发者进行问题排查
NetworkPolicy Tracing
如果集群中使用了较多的网络策略,有可能导致某些情况下请求命中了意料之外的 NetworkPolicy 导致失败
Cilium 也提供了 policy tracing 的功能用来追踪请求命中 NetworkPolicy
的情况
cilium policy trace
# 验证从 default ns 下 xwing 发出的流量,到 default ns 下带有 deathstar label 的 endpoint 的 80 端口的流量会命中哪些 cnp$ cilium policy trace --src-k8s-pod default:xwing -d any:class=deathstar,k8s:org=expire,k8s:io.kubernetes.pod.namespace=default --dport 80level=info msg="Waiting for k8s api-server to be ready..." subsys=k8slevel=info msg="Connected to k8s api-server" ipAddr="https://10.96.0.1:443" subsys=k8s----------------------------------------------------------------Tracing From: [k8s:class=xwing, k8s:io.cilium.k8s.policy.serviceaccount=default, k8s:io.kubernetes.pod.namespace=default, k8s:org=alliance] => To: [any:class=deathstar, k8s:org=empire, k8s:io.kubernetes.pod.namespace=default] Ports: [80/ANY]Resolving ingress policy for [any:class=deathstar k8s:org=empire k8s:io.kubernetes.pod.namespace=default]* Rule {"matchLabels":{"any:class":"deathstar","any:org":"empire","k8s:io.kubernetes.pod.namespace":"default"}}: selected Allows from labels {"matchLabels":{"any:org":"empire","k8s:io.kubernetes.pod.namespace":"default"}} Labels [k8s:class=xwing k8s:io.cilium.k8s.policy.serviceaccount=default k8s:io.kubernetes.pod.namespace=default k8s:org=alliance] not found1/1 rules selectedFound no allow ruleIngress verdict: deniedFinal verdict: DENIED
Dns Based NetworkPolicy Debug
随着 DNS 查询结果的变化,FQDN Policy 的拦截结果也在变,导致这部分难以 debug
可以使用 cilium fqdn cache list
查看当前 dns proxy 中缓存了哪些 dns-ip
如果流量是允许的,那么这些 IPs 应该存在于 local identities,cilium identity list | grep <IP>
应该返回结果
Hubble
Cilium 还提供了 Hubble 用来加强网络监控和报警,Hubble 提供了以下功能
4xx
和 5xx
HTTP Response code 出现的频率,出现在哪些服务latency
Kubernetes Gateway API(以下简称 KGA
)是由 sig-network 小组提出的,用于帮助用户在 K8S 中对服务网络进行建模的资源的集合。
它通过提供标准化的接口,允许各个供应商提供自己的实现方式,来帮助用户规划集群内的服务网络。
最初 K8S 提供了 Ingress
API 来让用户可以定义集群的入口网络。随着流量管理(熔断,限流,灰度)等需求的增加,最初的 Ingress
的定义已经无法满足,因此社区开始分裂出了不同的实现方式。
CRD
扩展 Ingress
,例如:https://dave.cheney.net/paste/ingress-is-dead-long-live-ingressroute.pdfAnnotation
扩展 Ingress
,例如:nginx-controller这些方式虽然能解决现有的问题,但也导致了
因此 KGA 应运而生,KGA 需要能让用户很方便的使用以下功能
Header
, URI
路由为满足上述功能,KGA 提供了以下几种 API
GatewayClass
指定 KGA 的实现方式,(比如 istio
or nginx
),类似于 StorageClassess
Gateway
指定 Route 应该使用哪个 GatewayClass
Route
定义路由的具体规则,有 HTTPRoute
, TCPRoute
, UDPRoute
指定符合规则的流量应该路由到哪个 Service
关于各个 API 的具体字段,可以查看该文档
有以下常用的情况
foo 小组需要将多个服务部署在 foo
namespace 中,并要求按照 http 请求能按照 uri 路由到不同的 Service
kind: HTTPRouteapiVersion: networking.x-k8s.io/v1alpha1metadata: name: foo-route namespace: foo labels: gateway: external-https-prodspec: hostnames: - "foo.example.com" rules: - matches: - path: type: Prefix value: /login forwardTo: - serviceName: foo-auth port: 8080 - matches: - path: type: Prefix value: /home forwardTo: - serviceName: foo-home port: 8080 - matches: - path: type: Prefix value: / forwardTo: - serviceName: foo-404 port: 8080
bar 小组将服务部署在 bar
namespace 中,并需要 canary 发布他们的服务,将流量按照 9:1 的比例分别发送到, bar-v1 和 bar-v2,且当请求的 Header 中带有 env: canary
时,将流量发布到 bar-v2
kind: HTTPRouteapiVersion: networking.x-k8s.io/v1alpha1metadata: name: bar-route namespace: bar labels: gateway: external-https-prodspec: hostnames: - "bar.example.com" rules: - forwardTo: - serviceName: bar-v1 port: 8080 weight: 90 - serviceName: bar-v2 port: 8080 weight: 10 - matches: - headers: values: env: canary forwardTo: - serviceName: bar-v2 port: 8080
当两个团队创建好了 HTTPRoute
后,该如何将入口流量匹配到这些规则上呢?这里就需要用到 Gateway
了
infra 团队可以创建如下 Gateway
来绑定 Routes
kind: GatewayapiVersion: networking.x-k8s.io/v1alpha1metadata: name: prod-webspec: gatewayClassName: acme-lb listeners: - protocol: HTTPS port: 443 routes: kind: HTTPRoute selector: matchLabels: gateway: external-https-prod namespaces: from: All tls: certificateRef: name: admin-controlled-cert
可以看到,Gateway
中使用 selector.matchLabels
来选中所有 namespace 下带有 gateway: external-https-prod
Label 的 HTTPRoute
同时 Gateway
中指定了 gatewayClassName
为 acme-lb
,所以这些 route 规则就会被同步到 acme-lb 上
因此只要将 foo.example.com
和 bar.example.com
的 dns 解析到 acme-lb 的 externalIP 上,就可以完成流量的路由
以下是这些 API 的拓补图
WebSocket
服务,在运行了一段时间之后有同事反应该服务卡顿,时常获取不到数据。于是我尝试开始复现该情况。
打开浏览器跳转到相应的页面后,发现数据立刻就加载了出来,但是多次刷新页面之后,就开始出现无法加载数据的情况。打开控制台,发现报了如下的错误。
WebSocket connection to 'ws://xxxxx/' failed: Error in connection establishment: net::ERR_CONNECTION_FAILED
一般来说出现该问题可能是因为网络问题或者连接超时引起的,在排除了自身网络问题的前提下,我调大了 WebSocet 的连接超时时间。但还是出现了上述的情况。
接着我猜想是不是我们的长连接网关出了问题。于是我查看了网关的日志,发现网关压根就没接收到出现 ERR_CONNECTION_FAILED
错误的连接。
于是我确定是之前重构的项目问题。
我开始查看 Pod
的日志,发现在连接建立成功并断开的时候没有将日志打印出来。
该项目用了
echo
框架,并使用了LogMiddleware
,它会在每条请求结束后将请求信息打印出来。
那应该就是在客户端断开 WebSocket
连接的时候,服务端并没有断开,导致了 WebSocket
连接泄露。
进入到 Pod
中查看连接数
netstat -na|grep ESTABLISHED|wc -l> 19
成功建立连接之后
netstat -na|grep ESTABLISHED|wc -l> 23
客户端退出之后
netstat -na|grep ESTABLISHED|wc -l> 21
显然是连接泄露了。浏览器会对同一域名的连接数做限制,所以有可能是因为连接泄露导致连接数过大,使之后的连接无法建立才出现的 ERR_CONNECTION_FAILED
的情况。
于是开始定位代码中的问题,以下是大概的代码逻辑。
这是一个和 K8S
管理平台相关的项目。服务会监听 Pod
的状态变化,并通过 WebSocket
将变化结果通知给前端。
func handler(c echo.Context) error { // ... // 使用 request.Context() 作为全局 context ctx := c.Request().Context() websocket.Handler(func(ws *websocket.Conn) { defer ws.Close() if err := watchPodStatus(ctx, param.Namespace, func(e watch.Event) (bool, error) { // 将变化发给前端 websocket.Message.Send(ws, ...) }); err != nil { websocket.Message.Send(ws, err.Error()) } }).ServeHTTP(c.Response(), c.Request()) return nil}// WatchPodStatus// name: deployment namefunc watchPodStatus(ctx context.Context, namespace string, conditionFunc func(e watch.Event) (bool, error)) error { // ... // 这步会列出变化的 pods // 通过 conditionFunc 方法处理事件 // 注:该方法是阻塞的,除非 ctx 被 cancel 或者 conditionFunc 返回 true 否则不会结束 _, err = watchtools.UntilWithSync(ctx, listAndWatchFunction, &corev1.Pod{}, preconditionFunc, conditionFunc) return err}
这是我使用了 echo.Request().Context()
作为 watchtools.UntilWithSync
的 context,原本设想的是在请求结束后 context 就会 cancel
,从而终止 watch
事件,并退出整个请求。
结果 request.Context()
,并没有在前端调用 socket.close()
的时候退出。导致 watch
也没有退出,所以 defer ws.Close()
也就无法触发,导致服务端无法结束该次请求。
在知道是该原因后问题也好解决了。以 request.Context()
作为父 context
创建一个新的 context
。使用一个 goroutine
监听 WebSocketConnection
的状态。(通过不停的 conn.Receive()
),因为该场景没有客户端向服务端发送数据的场景,所以该调用会被一直阻塞,直到客户端调用 socket.close()
之后, conn.Receive()
会接收到一个错误信息,这时再 cancel()
掉上述的 context
退出 watch
即可,以下是修改后的代码逻辑。
func handler(c echo.Context) error { // ... // 使用 request.Context() 作为全局 context ctx := c.Request().Context() cancelctx, cancel := context.WithCancel(ctx) websocket.Handler(func(ws *websocket.Conn) { go func(){ for { // receive 是阻塞方法,如果没有收到消息就会一直等待 // 收到错误说明连接断开了或者出现异常了,这是取消掉 ctx,watch 行为也会推出 if err := websocket.Message.Receive(ws, &res); err != nil{ cancel() } } }() defer ws.Close() if err := watchPodStatus(cancelctx, param.Namespace, func(e watch.Event) (bool, error){ // 将变化发给前端 websocket.Message.Send(ws, ...) }); err != nil { websocket.Message.Send(ws, err.Error()) } }).ServeHTTP(c.Response(), c.Request()) return nil}
完!
]]>Drone CI
构建项目时出现一个异常奇怪的错误。
在 clone
某分支代码时构建直接失败退出了。
首先我排查了 rpc error
,发现这个错误出现的原因是正在获取 Pod
的 Log
的过程中,Pod 被删除了。
当时我就觉得很奇怪,就算因为 Step
执行失败要删除 Pod,那也会先停止 getLog
,而不是在 getLog 的过程中直接删除 Pod。
于是使用了 kubectl describe
了这个 Pod,发现该 Container 的 Exit Code
是 128。说明是该 Container 执行时出错,自己退出的。
但是从上图的日志中来看 Container 应该是执行成功了,并没有出现错误的日志。那为什么该 Container 的退出码是 128 而非 0 呢。
于是我开始怀疑是不是 git checkout -b master
时出现了错误,于是我在 Docker 容器中模拟了该 Step 的步骤,git checkout -b master | echo $?
,发现即使是显示 Already on master
它的退出码也是 0。于是排除了是 git 出错的可能性。
那么问题出在哪呢?
经过一段时间排查,我发现只有当 Image
为 registry.cn-hangzhou.aliyuncs.com/xxx/xxxx:xxx
是才会出现这种不正常情况。
原来是因为我们集群里还运行着另一个服务,该服务会在 Pod 被创建时判断 Image 的类型,将这种使用了公网地址的 Image,替换成使用内网地址 registry-vpc.cn-hangzhou.aliyuncs.com/xxx/xxxx:xxx
。再加上我刚好对 drone-runner-kube
做过一些优化(提前设置初始镜像,减少 pod update 次数)。导致了这两个服务冲突。
在 runner 创建 pod 时,先将 image 设为了公网地址(Step 1),接着 image 被另一个服务设置成了内网地址,并提交到 k8s 中运行了。然后 runner 通过对比发现 pod 的 image 不是自己刚开始设置的 image,于是就 update 了这个 pod(Step 4)。
因为 Update Image 的缘故导致了该 Step 没有按期望运行。所以出现了这个默默奇妙的错误。
解决方法:将 Image 的初始地址设置成内网地址,这样另一个服务就不会去修改该 Pod 的 Image,也就不会出现该错误。
]]>以下是来自 Virtual Kubelet 项目的文档的中文翻译。
Virtual Kubelet 是开源的 Kubernetes kubelet 的实现,它可以伪装成 Kubelet 将 Kubernetes 连接到其他 API。这样就允许 Node 的背后由其他服务支撑,例如:ACI, AWS Fargate, IoT Edge。Virtual Kubelet 的主要作用是扩展无服务器(Serverless)平台,让它能够与 Kubernetes 通信。
以 阿里云ECI
为例(以下均以 ECI 代指 Virtual Kubelet),ECI 是阿里云的弹性容器实例。可以将每台 ECI 实例看作是一个 Container
,所以它的创建和销毁是很廉价的。同时它拥有启动快(秒级)、成本低(按运行的秒数收费)、弹性能力强等特点。通过 Virtual Kubelet 提供的 Kubernetes API,ECI 就能和 K8S 交互,我们就可以在 ECI 上执行创建 Pod 或者删除 Pod 等操作。
在使用 virtual kubelet 之前,为了不影响业务的稳定性。我们的 K8S 集群中开了几台固定的 ECS 实例专门给 CI 使用(给这些 Node 打上了污点,所以业务的服务不会调度到上面)。
这种做法虽然在不影响业务的情况下也保证了 CI 的稳定运行,但是它会造成一定程度的浪费。因为 CI 本身不像大多数业务服务,需要一天 24 小时的运行。CI 的场景是白天需要很多资源,但是到了晚上几乎不消耗任何资源。所以可以说这些机器有接近 1/3 的时间是在浪费 💰 的。
虽说 K8S 本身也提供了动态扩容机制,可以设置很少的固定资源再通过 CA 动态扩容集群来减少资源消耗。但是 CA 的启动速度(分钟级)满足不了 CI 这种时效要求高的场景。
关于 Drone In K8S 的运行模式,可以翻看我之前写的文章 Drone 在 K8S 中执行一次构建都经历了什么。
简单来说,Drone Server
接收到构建任务后,会在其运行的 Namespace
(假设为 CICD)下创建一个 Job
,该 Job 会创建一个随机名称的 Namespace,再在创建出来的 Namespace 按配置文件中的顺序执行每个 Step,每个 Step
就是一个 Pod
。这些 Pods 之间通过 HostPath
类型的 Volume
来交换文件。
那么问题来了,ECI 是不支持 HostPath Volume 的。它只支持 EmptyDir、NFS 和 ConfigFile(也就是 ConfigMap 和 Secret)。
所以要如何解决之前提的 Pods 之间使用 HostPath 交换文件的问题呢?首先想到了是通过 Mutating Webhook
将 HostPath 替换成 NFS,这样每个 Pod 之间使用 NFS 共享文件,这样带来了 NFS 文件的清理问题,不像之前 Pipeline
执行完之后可以直接使用 os.Remove(path)
来清理文件,使用 NFS 后需要实现额外 Cornjob
来清理 NFS 上的琐碎的文件。这样便增添了服务之间的关系复杂度。
好在在浏览 Drone 社区相关信息后发现了 Drone 发布了 1.6 版本。在 1.6 版本之后,Drone 为 K8S 实现了单独的 Runner。
与之前执行 Job 的方式不同,新的执行方式是 Drone Server 接收到构建信息后会将构建信息存入基于内存的 Queue
中,runner 会向 Server 拉取构建信息,然后将构建信息解析成一个 Pod
,每个 Step
是一个 Container
。为了保证 Step 执行的顺序行(因为 Pod 创建的时候 Container 执行是无序的),Kube-Runner 将每个还未轮到执行的 Step 的 Image
设置成了 placeholder
,placeholder 是一个占位 image,该 image 不停地 sleep 不作任何操作。等到前置 Step 执行完成了,Kube-Runner 会将下个要执行的 Step 的 Image 由 placeholder 改成其对应的 image。通过上述操作来完成执行的顺序性。
因为所有的 Step 都在一个 Pod 内,所以它们的数据就可以使用 EmptyDir 来共享。这便解决了之前的 HostPath 兼容性的问题。
在执行 CI 时,重要一步就是构建镜像。以 docker 为例。使用 docker 构建镜像就需要用到 Docker Deamon
,Docker Deamon 可以使用宿主机上的或者可以在 Container 内启动一个 Docker Deamon,这里就形成了两种不同的模式,也就是 Docker Outside Docker
和 Docker In Docker
。
因为 Docker Outside Docker 需要挂载宿主机的文件,所以自然在这种情况下是无法使用的。而 Docker In Docker 因为需要在容器内启动 Docker Deamon,所以需要 Privileged 权限,遗憾的是目前 ECI 中并不支持 Container 使用 Privileged Context。所有这两种方法在当前情况下都无法有效地构建镜像。
那么如何解决这种问题?
这里,我们选用了 kaniko 作为构建工具。
Knaiko 是从容器或 Kubernetes 集群内部的 Dockerfile 构建容器映像的工具。不依赖 Docker 守护程序,而是完全在用户空间中执行 Dockerfile 中的每个命令。这样就可以在无法轻松或安全地运行 Docker 守护程序的环境(例如标准Kubernetes集群)中构建容器映像。
Kaniko 执行器首先根据 Dockerfile 中的 FROM
一行命令解析基础镜像,按照 Dockerfile 中的顺序来执行每一行命令,在每执行完一条命令之后,会在用户目录空间中产生一个文件系统的快照,并与存储于内存中的上一个状态进行对比,若有改变,则将其认为是对基础镜像进行的修改,并以新层级的形式对文件系统进行增加扩充,并将修改写入镜像的元数据中。在执行完 Dockerfile 中的每一条指令之后, Kaniko 执行器将最终的镜像文件推送到指定的镜像仓库。
Kaniko 可以在不具有 ROOT
权限的环境下,完全在用户空间中执行解压文件系统,执行构建命令以及生成文件系统的快照等一系列操作,以上构建的过程完全没有引入 docker 守护进程以及CLI的操作。
到这,构建镜像问题也解决了。接下来就可以将整个构建调度到 ECI 了。
虽说上面已经为 Drone 兼容 ECI 的目标做了很多事情,但也只是理论上的。实际在操作中仍然遇到了很多问题。
因为 Drone 在执行一次构建时需要不断的 Update Pod,而目前看来似乎 ECI 的 Update 机制做的不是很完善,在 Update 过程中出现了很多问题。
在 Update 时,所有 Pod 的 Environment
都丢失。
> k exec -it -n beta nginx envPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binNGINX_VERSION=1.17.5NJS_VERSION=0.3.6PKG_RELEASE=1~busterKUBERNETES_PORT_443_TCP_PROTO=tcpKUBERNETES_PORT_443_TCP_ADDR=172.21.0.1KUBERNETES_PORT_443_TCP_PORT=443KUBERNETES_PORT_443_TCP=tcp://172.21.0.1:443KUBERNETES_SERVICE_HOST=172.21.0.1TESTHELLO=testKUBERNETES_SERVICE_PORT_HTTPS=443KUBERNETES_SERVICE_PORT=443KUBERNETES_PORT=tcp://172.21.0.1:443TERM=xtermHOME=/root> k edit pod -n beta nginxpod/nginx edited> k exec -it -n beta nginx envPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binTERM=xtermHOME=/root
Pod 中每个 Container 的 Environment 数量不能超过 92 个。因为 Drone 将每个 Step 执行的关键信息都保存在 Env 中,导致每个 Container 需要包含大量 Env,因此在调度 Pod 到 ECI 上时出现了 ExceedParamError
。
Pod Update 时 EmptyDir 中的文件会丢失。Drone 通过 EmptyDir 来共享每步获得或者修改的文件,EmptyDir 丢失导致 CI 失败。
Pod Update 某个 Image
时,K8S 内显示 Pod 更新成功,但 ECI 的接口返回了失败。
Normal SuccessfulMountVolume 2m5s kubelet, eci MountVolume.SetUp succeeded for volume "drone-dggd12eq2mlm5zeevff9"Normal SuccessfulMountVolume 2m5s kubelet, eci MountVolume.SetUp succeeded for volume "drone-sw7d8bri9rpn0tjr90bp"Normal SuccessfulMountVolume 2m5s kubelet, eci MountVolume.SetUp succeeded for volume "default-token-l6jsc"Normal Started 2m4s (x4 over 2m4s) kubelet, eci Started containerNormal Pulled 2m4s kubelet, eci Container image "registry-vpc.cn-hangzhou.aliyuncs.com/drone-git:latest" already present on machineNormal Created 2m4s (x4 over 2m4s) kubelet, eci Created containerNormal Pulled 2m4s (x3 over 2m4s) kubelet, eci Container image "drone/placeholder:1" already present on machineWarning ProviderInvokeFailed 104s virtual-kubelet/pod-controller SDK.ServerErrorErrorCode: UnknownErrorRecommend:RequestId: 94D6A5E0-9F90-47EF-99E9-64DFAE37XXXX
ECI 中 Pod Update 的策略和 K8S 中的不一致,K8S 中 Update Pod 时,只会 Kill 掉更改过的 Container,并进行替换。而在 ECI 上,会 Kill 掉所有正在运行的 Container,在进行替换。
Type Reason Age From Message---- ------ ---- ---- -------Normal Pulling 111s kubelet, eci pulling image "nginx"Normal Pulled 102s kubelet, eci Successfully pulled image "nginx"Normal Pulling 101s kubelet, eci pulling image "redis"Normal Pulled 97s kubelet, eci Successfully pulled image "redis"Normal SuccessfulMountVolume 69s (x2 over 112s) kubelet, eci MountVolume.SetUp succeeded for volume "test-volume"Normal Killing 69s kubelet, eci Killing container with id containerd://image-2:Need to kill PodNormal Killing 69s kubelet, eci Killing container with id containerd://image-3:Need to kill PodNormal Pulled 69s (x2 over 111s) kubelet, eci Container image "busybox" already present on machineNormal SuccessfulMountVolume 69s (x2 over 112s) kubelet, eci MountVolume.SetUp succeeded for volume "default-token-l6jsc"Normal Pulling 68s kubelet, eci pulling image "mongo"Normal Started 46s (x5 over 111s) kubelet, eci Started containerNormal Created 46s (x5 over 111s) kubelet, eci Created containerNormal Pulled 46s kubelet, eci Successfully pulled image "mongo"
感觉它的更新方式就是把 Pod 删了重新创建,这里只更新了 image-2 ,但是它把 image-3 也 Kill 掉了,还重新 Pull 了 image-1 的镜像。
尽管上述问题在我们向阿里云反馈之后,一部分得到修复。但是 Drone 没执行一步都需要 Update Pod 的操作,对 ECI 来说都需要很大的代价。
所以我想是否能改变 Kube Runner 的执行方式来优化整个 CI 执行的流程。
ps: 还不是要改代码。。。
在 drone-runner-eci 中,我通过在 CI 启动时就确定好所有的镜像,然后在执行每个 Step
时,Attach
到容器中执行 Commands
的方式来避免 Pod Update
的巨大开销。
但是这样做就要求每个 Image
都必须有 shell
,好在我们的配置是中心化管理的,所以改起来还算方便。
实际执行时,仍然遇到一个尴尬的问题,就是 Pod 的 Spec 过大(每个 Step 大概都有 100 多个 Env,大概有 10 多个 Step),在 virtual kubelet 向阿里云 ECI 提交创建请求时被阿里云的网关拒绝,返回了 414 。。。。
于是只能接着优化。因为 Pod 中大多数 Env 都是相同的,所以我将他们合并在一起放到了 Pod 的 Annotations 中,再通过 DownwardAPI 将 Pod Annotations 映射到文件,再在执行每步 Step 之前将它们 export 成环境变量。
我太难了
于是 Pod 的 Spec 瞬间少了许多,也就解决了上面的 414 问题。
目前 drone-runner-eci 已在我们生产环境稳定运行,想要体验弹性 CI 的朋友也可以尝试尝试。
解决了上面提到的问题,Drone 也算能在 ECI 上运行了。但仍然有许多优化的空间。
不同与在宿主机上创建 Pod,每次创建时可以使用宿主机上的镜像缓存。每次在 ECI 上创建 Pod 都需要拉取镜像,这便加慢了构建的速度,为了解决这一现象,阿里云也提供了方案 imagecache。
imagecache 运行用户事先将需要用到的镜像作为云盘快照缓存,在创建ECI容器组实例时基于快照创建,避免或减少镜像层下载,从而提升ECI容器组实例创建速度。
ECI 给 Drone 提供了一个巨大的便利,就是在限制运行资源时不需要为每个 Container 设置 resources。
比如有两个 Step(Container),每个都需要 1vCPU 和 1Gi Mem,那么调度这个 Pod 就需要 node 上有超过 2vCPU 和 2Gi Mem 的资源,但又因为大多数时候 CI 构建任务是串行的,这两个 Step 不需要同时执行,也就是说在这种场景下其实只需要 1vCPU 和 1Gi Mem 就足够了,上面这种情况就造成了资源的浪费,但在宿主机上并没有办法很好的解决这个问题。不过在 ECI 中每个 Pod 会独占一台 ECI 实例,所以所有 Pod 内的所有 Container 都可以享受 ECI 的全额配置。
这种资源分配方式和资源大小可以通过 Pod Annotations
来指定:
annotations: k8s.aliyun.com/eci-cpu: 1 k8s.aliyun.com/eci-memory: 1Gi
这样 Pod 就能在一个 1vCPU 和 1Gi Mem 的 ECI 实例中稳定运行完两个 Step。
总的来说,Virtual Kubelet
可能是目前最好的弹性 CI 解决方案之一。(ps: 在 ECI 的产品文档中也包含了 Jenkins 和 Gitlab CI 的最佳实践)
drone 由 3 个主要的部分组成,分别是 drone-server
、drone-controller
和 drone-agent
。
顾名思义,drone-server
是 drone 的服务端,它会启动一个 http
服务来处理各种请求,如 github 每次 push 或者其他操作触发的 webhook 亦或者是 drone-web-ui
的每个请求。
controller 的作用是初始化 pipeline 信息,它会定义好 pipeline 的每个 step 在执行之前、执行之后以及获取和写入执行日志的函数,并保证每个 pipeline 能按顺序执行 step。
可以将 drone-agent
在 drone
中的作用简单的理解为是 kubelet
在 k8s
中的作用,因为本文主要讨论的是 drone 在 k8s 中的执行过程,在 k8s 中,drone 的执行并不依赖 drone-agent
,所以本文并不会对该组件做详细介绍。
/hook
,drone-server
收到了请求之后,会将这些信息解析成 core.Hook
和 core.Repository
。namespace
和 name
在 drone 的数据库中查找该仓库,如果找不到或者项目不是 active 状态,则直接结束构建并返回错误信息。否则会将接下来的任务交给 trigger
来完成。trigger
接收到 core.Hook
和 core.Repository
后会检查 commit message 中是否存在 [ci skip]
等跳过执行 ci 的字段,如果存在则直接结束。trigger
会验证 repo
和 owner
是否合法,commit message 是否为空,如果为空 trigger
会调用相关 api 获取上次提交的 commit message。trigger
会向 ConfigService
请求构建的配置,一般情况下也就是 .drone.yml
中的内容。ConfigService
可以通过 DRONE_YAML_ENDPOINT
这一环境变量变量扩展,如果不扩展会使用默认的 FileService
也就是调用 github 的相关接口来获取 file data。ConvertService
来转换 config,将 config 转换成 yaml
(因为配置文件并非都是 yaml
,可能是 jsonnet
或者 script
等其他格式)。ConvertService
目前支持 jsonnet
和 starlark
。其中 starlark 需要使用外部扩展,也就是 DRONE_CONVERT_PLUGIN_ENDPOINT
来配置。gitlab-ci
,这一步可以将 gitlab-ci 的配置格式转换为 drone 的配置格式。yaml.Manifest
结构体。之后会调用 ValidateService
来验证 config
、core.Build
、repo owner
和 repo
,ValidateService
由 DRONE_VALIDATE_PLUGIN_ENDPOINT
环境变量配置,如果没有则不会这一步验证。trigger
会验证 yaml.Manifest
中每个 pipeline
是否合法。会检查是否有 重名 pipeline
,是否 step 中有 自我依赖
是否有 依赖不存在
以及 权限
等信息。trigger
会使用 directed acyclic graph
也就是有向无环图来检查各个 pipeline 之间是否有 循环依赖
,并检查有哪些 piepline 不满足执行条件。branch
、event
、action
、ref
等是否满足。trigger
会更新数据库中的信息。然后将每个 pipeline 构建成 core.Stage
,stage 没有依赖,那么它的 status
就会被设置为 Pending
,这意味着它可以被执行了,如果有依赖,那么 status
就会被设置成 Waiting
。trigger
会在数据库中创建好 build
信息,会向 github 发送构建状态,此时在 github 中我们就能看到那个小黄点了。trigger
会遍历每个 stage
,并将 status 是 Pending
的 stage 进行调度。DRONE_WEBHOOK_ENDPOINT
配置的地址。至此 trigger
的工作就结束了。
因为 k8s
带来的便利性,调度 stage
仅仅需要创建一个 job
,在创建 job 之前, scheduler
会将 drone-server
中的大部分环境变量注入到该 job 也就是 drone-job-stage.ID-randomString
(因为 k8s 对于每个资源的名称都有规范(不能以 . _ -
开头或结尾),而在 drone
的其他 runtime 中并没有这一要求,所以为了符合 k8s 的命名规范,drone
使用了随机字符来作为资源名称。在创建 job 时,处于某些原因(后面会提到),drone 还会该 job 挂载一个 HostPath
的 volume
,路径为 /tmp/drone
。该 job
的 image name
就是 drone-controller
。
drone-controller
会初始化外部的 SecretService
,该 service 有 DRONE_SECRET_ENDPOINT
配置。drone-controller
会初始化三个 registryService
,分别是 两个外部定义(由 DRONE_SECRET_ENDPOINT
和 DRONE_REGISTRY_ENDPOINT
配置) 和 本地文件(路径由 DRONE_DOCKER_CONFIG
定义)。drone-controller
还会初始化 rpc client
用于和 drone-server
通信。k8s engine
,至此 controller 的初始化工作就完成了,接下来的工作会交给 runner
这个内部组件来执行。runner
会首先根据 stage
的 id 向 drone-server
获取 stage 的详细信息。token
。drone-server
接收到该请求后会先验证 repo 和 user,通过后会向 github 获取 token,用于拉取项目。killed
或者 skipped
就会执行构建。runner
会再次解析一次 yaml
的格式,这一步和 trigger 中执行的步骤一样。${}
内的数据替换成对应的环境变量。trigger
中第 7
步的操作是一样的,旨在保证元数据的合法性。transform function
,包括 registry
、secret
、volume
等函数,这些函数会在之后的 Compile
中为 Spec
注入对应的资源,比如 secret function
会获取相应的 secret 并添加到 spec
中。compiler
模块开始编译 pipeline
。Compile
开始时,compiler 会先确认 pipeline 的所有 steps 是否是 serial
的(如果 step 中存在依赖,则不是 serial)。然后会为 pipeline 挂载工作目录,也就是向 spec
中注入 volume
,该 volume 为 EmptyDir
。clone: disable: true
的话,compiler 会在 spec 中注入 clone-step
,compiler 会初始化好该步骤信息,如 step name
、image
、mount workspace
。
4. 处理完 clone step 后,compiler 会处理 pipeline 中所有的 Services
,每个 Service 也会被转换成 step 注入到 spec 中,不过与普通 step 不同的是 service 会被设置为单独运行,换个说法它们不依赖任何 step,同样 compiler 会为每个 service 相关的 step 做好和 clone step 类似的初始化工作。
5. 接下来就是处理不同的 steps,处理普通 step 分两种情况,一种是 step 中使用了 build 配置,使用了这种配置 drone 会在执行该 step 的过程中自动为 repo 打包,因为打包过程中需要使用到 docker,所有在处理该 step 时,需要额外
将docker.sock
文件挂在到 container
,另一种情况则是按正常的流程处理。
正常的处理流程(包括clone,services 和 steps):1. 将 yaml 中的数据能复制的都复制到 spec 2. 为 spec 注入 yaml 中配置的 volumes 3. 为 spec 注入 yaml 中配置的 envs 4. 将 yaml 中的 setting 中的配置作为环境变量 “PLUGIN_:"+key: value 注入 spec (有些 env 和 setting 的值可能为 from_secret,这里就为会 spec 注入 secret)5. 将 yaml 中定义的 command 转换为 file,路径为 “/usr/drone/bin/init” 并注入 spec(之后运行时只需要运行该脚本即可)
docker auth
、来自 controller 和 DRONE_RUNNER_*
定义的环境变量,网络规则和 SecretService
中获取的 secret 等资源。至此 spec 的所有信息已经生成完毕。Pending
并保存到执行列表中,并初始化 runtime.Hook
,在 hook 中会定义好执行每个 step 之前之后需要执行哪些步骤。在执行每个 step 之前,会创建一个 streamer
用于接收 log,并在数据库中更新 step 的状态,然后将 repo 的信息通过长连接推送给绑定的客户端。每次 step 执行完之后,会更新数据库中的信息,推送事件,并将之前创建的 streamer
删除。hook 还定义了写入日志函数,这样会将获取到的日志写入日志库。Running
。在开始 build 之前,runner 会更新 stage 的状态,并将每个 step 保存到数据库,然后更新整个 build 的信息。runner
初始化好 runtime
的信息后就会调用 runtime.Run
来执行构建,这一步才是真正开始构建。runtime 先调用先前定义好的 Before
函数创建好 streamer,接着 k8s 会创建出一个随机字符串为 name 的 namespace,接下来所有的 step 都会在该 namespace 完成。创建好 namespace 之后会创建构建所需的 secret
,接着会将每个 step 中 command
内的信息创建为一个 configmap
。HostPath Volume
,而这台机器也就是 drone-job-stage.ID-randomString
被调度到的那台机器。当每个 pod 运行后,runtime 会注册一个回调函数来监听 pod 的变化,如果 pod 的状态变为 running
或者 succeed
或者 failed
之后,runtime 就会去获取该 pod 的日志,并把日志写入 streamer
中。最后 pod 运行结束后,runtime 会收集 pod 的退出状态,以判断是正常退出还是非正常退出。 调度
该 pipeline。]]>
本地验证,确保不合法的请求(比如:创建不支持的资源或者格式不对等等)会快速失败,不会发给 api-server,减轻服务端压力
准备向 api-server 发送 HTTP 请求,将修改后的数据进行序列化。但是 URI PATH 是什么呢?这里就依赖资源内的 apiVersion 这个值,再加上资源类型,kubectl 就能在 api 列表中找到应该发往的地址。api 列表可以通过 api-server 的 /apis 这个 URL 获取,获取之后会在本地缓存一份,提高效率。
api-server 肯定不会接受不合法的请求,所以 kubectl 还要在请求之前设置好认证信息。认证信息一般可以从 ~/.kube/config 中获取,它支持 4 种。
这时 api-server 已经成功接收到了请求。它会判断我们是不是有权限操作这个资源。那么怎么验证呢,在 api-server 启动的时候,可以通过参数 –authorization_mode 进行设置,这个值有 4 种。
如果配置了多种授权方式,只要其中一种能通过那么请求就能继续。
授权通过了,但是现在仍然还不能够向 etcd 中写数据,还需要经过准入控制链
这道关卡。准入控制链由 Admission Controller
控制。官方标准的准入控制链有近 10 个之多,而且支持自定义扩展。不同于授权,准入控制链一旦有一个验证失败,那么请求就会被拒绝。以下介绍三个准入控制器。
通过上面所有验证后,api-server 将 kubectl 提交的数据反序列化,然后保存到 etcd 中。
Initializers
。Initializers
会在资源对外可用之前执行某些逻辑。比如 将 Sidecar
注入到暴露 80 端口的 Pod 中,或者加上特定的 annotation
等。InitializerConfiguration
资源对象允许你声明某些资源类型应该运行哪些Initializers。数据已经保存到 etcd 中了,并且初始化逻辑也完成了,下面就需要 k8s 中的 Controller
来完成资源的创建了。各个 Controller 会监听各自负责的资源,比如 Deployment Controller
就会监听 Deployment
资源的变化。当 api-server 将资源保存到 etcd 后,Controller 发现了资源的变化,然后就根据变化类型会调用相应回调函数。每个 Controller 都会尽力将资源当前的状态逐步转化为 etcd 中保存的状态。
当所有的 Controller 正常运行后,etcd 中就会保存一个 Deployment、一个 ReplicaSet 和 三个 Pod 资源记录,并且可以通过 kube-apiserver 查看。然而,这些 Pod 资源现在还处于 Pending 状态,因为它们还没有被调度到集群中合适的 Node 上运行。这个问题最终要靠调度器(Scheduler
)来解决。
Scheduler 会将待调度的 Pod 按照特定的算法和调度策略绑定到集群中某个合适的 Node 上,并将绑定信息写入 etcd 中(它会过滤其 PodSpec 中 NodeName 字段为空的 Pod)。
Scheduler 一旦找到了合适的节点,就会创建一个 Binding
对象,该对象的 Name
和 Uid
与 Pod 相匹配,并且其 ObjectReference
字段包含所选节点的名称,然后通过 POST 请求发送给 apiserver。
当 kube-apiserver 接收到此 Binding 对象时,会更新 Pod 资源中的以下字段:
将 NodeName
的值设置为 ObjectReference
中的 NodeName
。
添加相关的注释。
将 PodScheduled
的 status
值设置为 True
。
在 Kubernetes 集群中,每个 Node 节点上都会启动一个 Kubelet 服务进程,该进程用于处理 Scheduler 下发到本节点的任务,管理 Pod 的生命周期,包括挂载卷、容器日志记录、垃圾回收以及其他与 Pod 相关的事件。
Kubelet 每隔 20s,会通过 NodeName
向 api-server 发送查询请求来获取自身 Node 上所要运行的 Pod 清单。获取到数据后会和自身内部缓存比较来获取有差异的 Pod 列表。并开始同步这些 Pod。
记录 Pod 启动相关的 Metrics
生成一个 PodStatus
对象,它表示 Pod 当前阶段的状态。PodStatus 的值取决于:一、PodSyncHandlers
会检查 Pod 是否应该运行在 Node,如果不应该 PodStatus
会有 Phase
变成 PodFailed
。二、接下来 PodStatus 会由 init 容器
和应用容器
的状态共同来决定。
生成 PodStatus 之后(Pod 中的 status 字段),Kubelet 就会将它发送到 Pod 的状态管理器,该管理器的任务是通过 apiserver 异步更新 etcd 中的记录。
接下来运行一系列准入处理器来确保该 Pod 是否具有相应的权限,被准入控制器拒绝的 Pod 将一直保持 Pending 状态。
如果 Kubelet 启动时指定了 cgroups-per-qos
参数,Kubelet 就会为该 Pod 创建 cgroup 并进行相应的资源限制。这是为了更方便地对 Pod 进行服务质量(QoS)管理。
然后为 Pod 创建相应的目录,包括 Pod 的目录(/var/run/kubelet/pods/<podID>
),该 Pod 的卷目录(<podDir>/volumes
)和该 Pod 的插件目录(<podDir>/plugins
)。
卷管理器会挂载 Spec.Volumes
中定义的相关数据卷,然后等待是否挂载成功。根据挂载卷类型的不同,某些 Pod 可能需要等待更长的时间(比如 NFS 卷)。
从 apiserver 中检索 Spec.ImagePullSecrets
中定义的所有 Secret,然后将其注入到容器中。
Docker
)交互。第一次启动 Pod 时,Kubelet 会创建 sandbox
,sandbox 作为 Pod 中所有的容器的基础容器,为 Pod 中的每个业务容器提供了大量的 Pod 级别资源,这些资源都是 Linux 命名空间(包括网络命名空间,IPC 命名空间和 PID 命名空间)。Pod 和 Pod
,Pod 和 Service
的通信。当 Kubelet 为 Pod 创建网络时,它会将创建网络的任务交给 CNI
插件。CNI 表示容器网络接口(Container Network Interface),和容器运行时的运行方式类似,它也是一种抽象,允许不同的网络提供商为容器提供不同的网络实现。不同的 CNI 插件运行原理会有不同,可以参考对应的文章。所有网络都配置完成后,接下来就开始真正启动业务容器了!
一旦 sanbox
完成初始化并处于 active 状态,Kubelet 就可以开始为其创建容器了。首先启动 PodSpec
中定义的 init 容器,然后再启动业务容器。
首先拉取容器的镜像。如果是私有仓库的镜像,就会利用 PodSpec
中指定的 Secret
来拉取该镜像。
然后通过 CRI
接口创建容器。Kubelet
向 PodSpec
中填充了一个 ContainerConfig
数据结构(在其中定义了命令,镜像,标签,挂载卷,设备,环境变量等待),然后通过 protobufs 发送给 CRI 接口。对于 Docker 来说,它会将这些信息反序列化并填充到自己的配置信息中,然后再发送给 Dockerd 守护进程。在这个过程中,它会将一些元数据标签(例如容器类型,日志路径,dandbox ID 等待)添加到容器中。
接下来会使用 CPU
管理器来约束容器,这是 Kubelet 1.8 中新添加的 alpha 特性,它使用 UpdateContainerResources CRI
方法将容器分配给本节点上的 CPU 资源池。
最后容器开始真正启动。
如果 Pod
中配置了容器生命周期钩子(Hook
),容器启动之后就会运行这些 Hook。Hook 的类型包括两种:Exec(执行一段命令) 和 HTTP(发送HTTP请求)。如果 PostStart Hook 启动的时间过长、挂起或者失败,容器将永远不会变成 running
状态。
上文所述的创建 Pod 整个过程的流程图如下所示:
参考:
]]>在说两个 Service 之前,我们先来了解一下在k8s中域名是如何被解析的。
我们都知道,在 k8s 中,一个 Pod 如果要访问同 Namespace 下的 Service(比如 user-svc),那么只需要curl user-svc。如果 Pod 和 Service 不在同一域名下,那么就需要在 Service Name 之后添加上 Service 所在的 Namespace(比如 beta),curl user-svc.beta。那么 k8s 是如何知道这些域名是内部域名并为他们做解析的呢?
resolv.conf 是 DNS 域名解析的配置文件。每行都会以一个关键字开头,然后跟配置参数。这里主要使用到的关键词有3个。
.
的数量少于 options.ndots
的值时,会依次匹配列表中的每个值那么我们进入一个 Pod 查看它的 resolv.conf
nameserver 100.64.0.10search default.svc.cluster.local svc.cluster.local cluster.localoptions ndots:5
这里的 nameserver、search 和 options 都是可以通过 dnsConfig 字段进行配置的,官方文档中已有详细的讲述
上述配置文件 resolv.conf 是 dnsPolicy: ClusterFirst
情况下,k8s 为 Pod 自动生成的,这里的 nameserver 所对应的地址正是 DNS Service 的Cluster IP(该值在启动 kubelet 的时候,通过 clusterDNS 指定)。所以,从集群内请求的所有的域名解析都需要经过 DNS Service 进行解析,不管是 k8s 内部域名还是外部域名。
可以看到这里的 search 域默认包含了 namespace.svc.cluster.local、svc.cluster.local 和 cluster.local 三种。当我们在 Pod 中访问 a
Service时( curl a
),会选择nameserver 100.64.0.10 进行解析,然后依次带入 search 域进行 DNS 查找,直到找到为止。
# curl aa.default.svc.cluster.local
显然因为 Pod 和 a
Service 在同一 Namespace 下,所以第一次 lookup 就能找到。
如果 Pod 要访问不同 Namespace(例如: beta
)下的 Service b
( curl b.beta
),会经过两次 DNS 查找,分别是
# curl b.betab.beta.default.svc.cluster.local(未找到)b.beta.svc.cluster.local(找到)
正是因为 search 的顺序性,所以访问同一 Namespace 下的 Service, curl a
是要比 curl a.default
的效率更高的,因为后者多经过了一次 DNS 解析。
# curl aa.default.svc.cluster.local# curl a.defaultb.default.default.svc.cluster.local(未找到)b.default.svc.cluster.local(找到)
这个答案,不能说肯定也不能说否定,看情况,可以说,大部分情况要走 search 域。
以 domgoer.com
为例,通过抓包的方式,在某一个Pod中访问 domgoer.com ,可以看到 DNS 查找的过程,都产生了什么样的数据包。首先先进入 DNS 容器的网络。
ps: 由于 DNS 容器往往不具备 bash,所以不能通过 docker exec 的方式进入容器抓包,需要采用其他方法
// 1.找到容器ID,打印它的NS IDdocker inspect --format "{{.State.Pid}}" container_id// 2.进入此容器的Namespacensenter -n -t pid// 3.DNS抓包tcpdump -i eth0 -N udp dst port 53
在其他容器中进行domgoer.com域名查找
nslookup domgoer.com dns_container_ip
指定 dns_container_ip,是为了避免有多个DNS容器的情况,DNS请求会分到各个容器。这样可以让 DNS 请求只发往这个地址,这样抓包的数据才会完整。
可以看到如下的结果:
17:01:28.732260 IP 172.20.92.100.36326 > nodexxxx.domain: 4394+ A? domgoer.com.default.svc.cluster.local. (50)17:01:28.733158 IP 172.20.92.100.49846 > nodexxxx.domain: 60286+ A? domgoer.com.svc.cluster.local. (45)17:01:28.733888 IP 172.20.92.100.51933 > nodexxxx.domain: 63077+ A? domgoer.com.cluster.local. (41)17:01:28.734588 IP 172.20.92.100.33401 > nodexxxx.domain: 27896+ A? domgoer.com. (27)17:01:28.734758 IP nodexxxx.34138 > 192.168.x.x.domain: 27896+ A? domgoer.com. (27)
可以看到在真正解析 domgoer.com 之前,经历了 domgoer.com.default.svc.cluster.local. -> domgoer.com.svc.cluster.local. -> domgoer.com.cluster.local. -> domgoer.com.
这样也就意味着有3次DNS请求是浪费的,没有意义的。
在研究如何避免之前可以下思考一下造成这种情况的原因。在 /etc/resolv.conf 文件中,我们可以看到 options
中有个配置项 ndots:5 。
ndots:5,表示:如果需要 lookup 的 Domain 中包含少于5个 .
,那么将使用非绝对域名,如果需要查询的 DNS 中包含大于或等于5个 .
,那么就会使用绝对域名。如果是绝对域名则不会走 search 域,如果是非绝对域名,就会按照 search 域中进行逐一匹配查询,如果 search 走完了都没有找到,那么就会使用 原域名.(domgoer.com.)
的方式作为绝对域名进行查找。
综上可以找到两种优化的方法
直接使用绝对域名
这是最简单直接的优化方式,可以直接在要访问的域名后面加上 .
如:domgoer.com. ,这样就会避免走 search 域进行匹配。
配置ndots
还记得之前说过 /etc/resolv.conf 中的参数都可以通过k8s中的 dnsConfig
字段进行配置。这就允许你根据你自己的需求配置域名解析的规则。
例如 当域名中包含两个 .
或以上时,就能使用绝对域名直接进行域名解析。
apiVersion: v1kind: Podmetadata:namespace: defaultname: dns-examplespec:containers:- name: test image: nginxdnsConfig: options: - name: ndots value: 2
在k8s中,有4中DNS策略,分别是 ClusterFirstWithHostNet
、ClusterFirst
、Default
、和 None
,这些策略可以通过 dnsPolicy
这个字段来定义
如果在初始化 Pod、Deployment 或者 RC 等资源时没有定义,则会默认使用 ClusterFirst
策略
ClusterFirstWithHostNet
当一个 Pod 以 HOST 模式(和宿主机共享网络)启动时,这个 POD 中的所有容器都会使用宿主机的/etc/resolv.conf 配置进行 DNS 查询,但是如果你还想继续使用 Kubernetes 的 DNS 服务,
就需要将 dnsPolicy 设置为 ClusterFirstWithHostNet。
ClusterFirst
使用这是方式表示 Pod 内的 DNS 优先会使用 k8s 集群内的DNS服务,也就是会使用 kubedns 或者 coredns 进行域名解析。如果解析不成功,才会使用宿主机的 DNS 配置进行解析。
Default
这种方式,会让 kubelet 来绝定 Pod 内的 DNS 使用哪种 DNS 策略。kubelet 的默认方式,其实就是使用宿主机的 /etc/resolv.conf 来进行解析。你可以通过设置 kubelet 的启动参数,
–resolv-conf=/etc/resolv.conf 来决定 DNS 解析文件的地址
None
这种方式顾名思义,不会使用集群和宿主机的 DNS 策略。而是和 dnsConfig 配合一起使用,来自定义 DNS 配置,否则在提交修改时报错。
kubeDNS由3个部分组成。
client-go
中的 informer
机制监视 k8s 中的 Service
和 Endpoint
的变化,并将这些结构维护进内存来服务内部 DNS 解析请求。以下是结构图
在 kubedns 包含两个部分, kubedns 和 skydns。
其中 kubedns 是负责监听 k8s 集群中的 Service
和 Endpoint
的变化,并将这些变化通过 treecache
的数据结构缓存下来,作为 Backend 给 skydns 提供 Record。
而真正负责dns解析的其实是 skydns
(skydns 目前有两个版本 skydns1 和 skydns2,下面所说的是 skydns2,也是当前 kubedns 所使用的版本)。
我们可以先看下 treecache,以下是 treecache 的数据结构
// /dns/pkg/dns/treecache/treecache.go#54type treeCache struct { ChildNodes map[string]*treeCache Entries map[string]interface{}}
我们再看一组实际的数据
{ "ChildNodes": { "local": { "ChildNodes": { "cluster": { "ChildNodes": { "svc": { "ChildNodes": { "namespace": { "ChildNodes": { "service_name": { "ChildNodes": { "_tcp": { "ChildNodes": { "_http": { "ChildNodes": {}, "Entries": { "6566333238383366": { "host": "service.namespace.svc.cluster.local.", "port": 80, "priority": 10, "weight": 10, "ttl": 30 } }, } }, "Entries": {} } }, "Entries": { "3864303934303632": { "host": "100.70.28.188", "priority": 10, "weight": 10, "ttl": 30 } } } }, "Entries": {} } }, "Entries": {} } }, "Entries": {} } }, "Entries": {} } }, "Entries": {}}
treeCache 的结构类似于目录树。从根节点到叶子节点的每个路径与一个域名是相对应的,顺序是颠倒的。它的叶子节点只包含 Entries,非叶子节点只包含 ChildNodes。叶子节点中保存的就是 SkyDNS 定义的 msg.Service 结构,可以理解为 DNS 记录。
在 Records 接口方法实现中,只需根据域名查找到对应的叶子节点,并返回叶子节点中保存的所有msg.Service 数据。K8S 就是通过这样的一个数据结构来保存 DNS 记录的,并替换了 Etcd( skydns2 默认使用 etcd 作为存储),来提供基于内存的高效存储。
我们可以直接阅读代码来了解 kubedns 的启动流程。
首先看它的结构体
// dns/cmd/kube-dns/app/server.go#43type KubeDNSServer struct { // DNS domain name. = cluster.local. domain string healthzPort int // skydns启动的地址和端口 dnsBindAddress string dnsPort int nameServers string kd *dns.KubeDNS}
下来可以看到一个叫 NewKubeDNSServerDefault
的函数,它初始化了 KubeDNSServer。并执行 server.Run()
启动了服务。那么我们来看下 NewKubeDNSServerDefault
这个方法做了什么。
// dns/cmd/kube-dns/app/server.go#53func NewKubeDNSServerDefault(config *options.KubeDNSConfig) *KubeDNSServer { kubeClient, err := newKubeClient(config) if err != nil { glog.Fatalf("Failed to create a kubernetes client: %v", err) } // 同步配置文件,如果观察到配置信息改变,就会重启skydns var configSync dnsconfig.Sync switch { // 同时配置了 configMap 和 configDir 会报错 case config.ConfigMap != "" && config.ConfigDir != "": glog.Fatal("Cannot use both ConfigMap and ConfigDir") case config.ConfigMap != "": configSync = dnsconfig.NewConfigMapSync(kubeClient, config.ConfigMapNs, config.ConfigMap) case config.ConfigDir != "": configSync = dnsconfig.NewFileSync(config.ConfigDir, config.ConfigPeriod) default: conf := dnsconfig.Config{Federations: config.Federations} if len(config.NameServers) > 0 { conf.UpstreamNameservers = strings.Split(config.NameServers, ",") } configSync = dnsconfig.NewNopSync(&conf) } return &KubeDNSServer{ domain: config.ClusterDomain, healthzPort: config.HealthzPort, dnsBindAddress: config.DNSBindAddress, dnsPort: config.DNSPort, nameServers: config.NameServers, kd: dns.NewKubeDNS(kubeClient, config.ClusterDomain, config.InitialSyncTimeout, configSync), }}// 启动skydns serverfunc (d *KubeDNSServer) startSkyDNSServer() { skydnsConfig := &server.Config{ Domain: d.domain, DnsAddr: fmt.Sprintf("%s:%d", d.dnsBindAddress, d.dnsPort), } if err := server.SetDefaults(skydnsConfig); err != nil { glog.Fatalf("Failed to set defaults for Skydns server: %s", err) } // 使用d.kd作为存储的后端,因为kubedns实现了skydns.Backend的接口 s := server.New(d.kd, skydnsConfig) if err := metrics.Metrics(); err != nil { glog.Fatalf("Skydns metrics error: %s", err) } else if metrics.Port != "" { glog.V(0).Infof("Skydns metrics enabled (%v:%v)", metrics.Path, metrics.Port) } else { glog.V(0).Infof("Skydns metrics not enabled") } d.kd.SkyDNSConfig = skydnsConfig go s.Run()}
可以看到这里 dnsconfig
会返回一个 configSync
的 interface 用来实时同步配置,也就是 kube-dns
这个 configmap,或者是本地的 dir(但一般来说这个 dir 也是由 configmap 挂载进去的)。在方法的最后 dns.NewKubeDNS
返回一个 KubeDNS 的结构体。那么我们看下这个函数初始化了哪些东西。
// dns/pkg/dnsdns.go#124func NewKubeDNS(client clientset.Interface, clusterDomain string, timeout time.Duration, configSync config.Sync) *KubeDNS { kd := &KubeDNS{ kubeClient: client, domain: clusterDomain, cache: treecache.NewTreeCache(), cacheLock: sync.RWMutex{}, nodesStore: kcache.NewStore(kcache.MetaNamespaceKeyFunc), reverseRecordMap: make(map[string]*skymsg.Service), clusterIPServiceMap: make(map[string]*v1.Service), domainPath: util.ReverseArray(strings.Split(strings.TrimRight(clusterDomain, "."), ".")), initialSyncTimeout: timeout, configLock: sync.RWMutex{}, configSync: configSync, } kd.setEndpointsStore() kd.setServicesStore() return kd}
可以看到kd.setEndpointsStore()
和 kd.setServicesStore()
这两个方法会在 informer
中注册 Service
和 Endpoint
的回调,用来观测这些资源的变动并作出相应的调整。
下面我们看下当集群中新增一个 Service,kubedns 会以怎样的方式处理。
// dns/pkg/dns/dns.go#499func (kd *KubeDNS) newPortalService(service *v1.Service) { // 构建了一个空的叶子节点, recordLabel是clusterIP经过 FNV-1a hash运算后得到的32位数字 // recordValue 的结构 // &msg.Service{ // Host: service.Spec.ClusterIP, // Port: 0, // Priority: defaultPriority, // Weight: defaultWeight, // Ttl: defaultTTL, // } subCache := treecache.NewTreeCache() recordValue, recordLabel := util.GetSkyMsg(service.Spec.ClusterIP, 0) subCache.SetEntry(recordLabel, recordValue, kd.fqdn(service, recordLabel)) // 查看service的ports列表,将每个port信息转换成skydns.Service并加入上面构建的叶子节点 for i := range service.Spec.Ports { port := &service.Spec.Ports[i] if port.Name != "" && port.Protocol != "" { srvValue := kd.generateSRVRecordValue(service, int(port.Port)) l := []string{"_" + strings.ToLower(string(port.Protocol)), "_" + port.Name} subCache.SetEntry(recordLabel, srvValue, kd.fqdn(service, append(l, recordLabel)...), l...) } } subCachePath := append(kd.domainPath, serviceSubdomain, service.Namespace) host := getServiceFQDN(kd.domain, service) reverseRecord, _ := util.GetSkyMsg(host, 0) kd.cacheLock.Lock() defer kd.cacheLock.Unlock() // 将构建好的叶子节点加入treecache kd.cache.SetSubCache(service.Name, subCache, subCachePath...) kd.reverseRecordMap[service.Spec.ClusterIP] = reverseRecord kd.clusterIPServiceMap[service.Spec.ClusterIP] = service}
再看一下当 Endpoint 添加到集群时,kubedns 会如何处理
// dns/pkg/dns/dns.go#460func (kd *KubeDNS) addDNSUsingEndpoints(e *v1.Endpoints) error { // 获取ep所属的svc svc, err := kd.getServiceFromEndpoints(e) if err != nil { return err } // 判断这个svc,如果这个svc不是 headless,就不会处理此次添加,因为 svc 有 clusterIP 的情况,在处理 // svc 的增删改时已经都被处理了。所以当 ep 属于 headless svc 时,需要将这个 ep 加入到 cache if svc == nil || v1.IsServiceIPSet(svc) || svc.Spec.Type == v1.ServiceTypeExternalName { // No headless service found corresponding to endpoints object. return nil } return kd.generateRecordsForHeadlessService(e, svc)}// 把 endpoint 添加到它所属的 headless service 的缓存下func (kd *KubeDNS) generateRecordsForHeadlessService(e *v1.Endpoints, svc *v1.Service) error { subCache := treecache.NewTreeCache() generatedRecords := map[string]*skymsg.Service{} // 遍历这个 ep 下所有的 ip+port,并将它们添加到 treecache 中 for idx := range e.Subsets { for subIdx := range e.Subsets[idx].Addresses { address := &e.Subsets[idx].Addresses[subIdx] endpointIP := address.IP recordValue, endpointName := util.GetSkyMsg(endpointIP, 0) if hostLabel, exists := getHostname(address); exists { endpointName = hostLabel } subCache.SetEntry(endpointName, recordValue, kd.fqdn(svc, endpointName)) for portIdx := range e.Subsets[idx].Ports { endpointPort := &e.Subsets[idx].Ports[portIdx] if endpointPort.Name != "" && endpointPort.Protocol != "" { srvValue := kd.generateSRVRecordValue(svc, int(endpointPort.Port), endpointName) l := []string{"_" + strings.ToLower(string(endpointPort.Protocol)), "_" + endpointPort.Name} subCache.SetEntry(endpointName, srvValue, kd.fqdn(svc, append(l, endpointName)...), l...) } } // Generate PTR records only for Named Headless service. if _, has := getHostname(address); has { reverseRecord, _ := util.GetSkyMsg(kd.fqdn(svc, endpointName), 0) generatedRecords[endpointIP] = reverseRecord } } } subCachePath := append(kd.domainPath, serviceSubdomain, svc.Namespace) kd.cacheLock.Lock() defer kd.cacheLock.Unlock() for endpointIP, reverseRecord := range generatedRecords { kd.reverseRecordMap[endpointIP] = reverseRecord } kd.cache.SetSubCache(svc.Name, subCache, subCachePath...) return nil}
整体流程其实和 Service 差不多,只不过在添加 cache 之前会先去查找Endpoint所属的 Service,然后不同的是 Endpoint 的叶子节点中的host存储的是 EndpointIP,而 Service 的叶子节点的 host 中存储的是 fqdn。
kubedns 有两个模块,kubedns和skydns,kubedns负责监听Service
和Endpoint
并将它们转换为 skydns 能够理解的格式,以目录树的形式存在内存中。
因为 skydns 是以 etcd 的标准作为后端存储的,所以为了兼容 etcd ,kubedns 在错误信息方面都以 etcd 的格式进行定义的。因此 kubedns 的作用其实可以理解为为 skydns 提供存储。
dnsmasq 由两个部分组成
1.dnsmasq-nanny,容器里的1号进程,不负责处理 DNS LookUp 请求,只负责管理 dnsmasq。
2.dnsmasq,负责处理 DNS LookUp 请求,并缓存结果。
dnsmasq-nanny 负责监控 config 文件(/etc/k8s/dns/dnsmasq-nanny,也就是kube-dns-config这个 configmap 所挂载的位置)的变化(每 10s 查看一次),如果 config 变化了就会Kill掉 dnsmasq,并重新启动它。
// dns/pkg/dnsmasq/nanny.go#198// RunNanny 启动 nanny 服务并处理配置变化func RunNanny(sync config.Sync, opts RunNannyOpts, kubednsServer string) { // ... configChan := sync.Periodic() for { select { // ... // 观察到config变化 case currentConfig = <-configChan: if opts.RestartOnChange { // 直接杀掉dnsmasq进程 nanny.Kill() nanny = &Nanny{Exec: opts.DnsmasqExec} // 重新加载配置 nanny.Configure(opts.DnsmasqArgs, currentConfig, kubednsServer) // 重新启动dnsmasq进程 nanny.Start() } else { glog.V(2).Infof("Not restarting dnsmasq (--restartDnsmasq=false)") } break } }}
让我们看下 sync.Periodic() 这个函数做了些什么
// dns/pkg/dns/config/sync.go#81func (sync *kubeSync) Periodic() <-chan *Config { go func() { // Periodic函数中设置了一个Tick,每10s会去load一下configDir下 // 所有的文件,并对每个文件进行sha256的摘要计算 // 并将这个结果返回。 resultChan := sync.syncSource.Periodic() for { syncResult := <-resultChan // processUpdate函数会比较新的文件的版本和旧的 // 文件的版本,如果不一致会返回changed。 // 值得注意的是有三个文件是需要单独处理的 // federations // stubDomains // upstreamNameservers // 当这三个文件变化是会触发单独的函数(打印日志) config, changed, err := sync.processUpdate(syncResult, false) if err != nil { continue } if !changed { continue } sync.channel <- config } }() return sync.channel}
dnsmasq 中是如何加载配置的呢?
// dns/pkg/dnsmasq/nanny.go#58// Configure the nanny. This must be called before Start().// 这个函数会配置 dnsmasq,Nanny 每次 Kill 掉 dnsmasq 后,调用 Start() 之前都会调用这个函数// 重新加载配置。func (n *Nanny) Configure(args []string, config *config.Config, kubednsServer string) { // ... for domain, serverList := range config.StubDomains { resolver := &net.Resolver{ PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) { d := net.Dialer{} return d.DialContext(ctx, "udp", kubednsServer) }, } // 因为 stubDomain 中可以是以 host:port 的形式存在,所以这里还要做一次 上游的 dns 解析 for _, server := range serverList { if isIP := (net.ParseIP(server) != nil); !isIP { switch { // 如果 server 是以 cluster.local(不知道为什么这里是 hardCode 的)结尾的,就会发往 kubednsServer 进行 DNS 解析 // 因为上面已经配置了 d.DialContext(ctx, "udp", kubednsServer) case strings.HasSuffix(server, "cluster.local"): // ... resolver.LookupIPAddr(context.Background(), server) default: // 如果没有以 cluster.local 结尾,就会走外部解析 DNS // ... net.LookupIP(server) } } } } // ...}
sidecar 启动后会在内部开启一个协程,并在循环中每默认 5s 向 kubedns 发送一次 dns 解析。并记录解析结果。
sidecar 提供了两个http的接口 /healthcheck/kubedns
和 /healthcheck/dnsmasq
给 k8s 用作 livenessProbe
的健康检查。每次请求,sidecar 会将上述记录的 DNS 解析结果返回。
因为 dnsmasq-nanny 重启 dnsmasq 的方式,先杀后起,方式比较粗暴,有可能导致这段时间内大量的 DNS 请求失败。
dnsmasq-nanny 检测文件的方式,可能会导致以下问题:
dnsmasq-nanny 每次遍历目录下的所有文件,然后用 ioutil.ReadFile 读取文件内容。如果目录下文件数量过多,可能出现在遍历的同时文件也在被修改,遍历的速度跟不上修改的速度。
这样可能导致遍历完了,某个配置文件才更新完。那么此时,你读取的一部分文件数据并不是和当前目录下文件数据完全一致,本次会重启 dnsmasq。进而,下次检测,还认为有文件变化,到时候,又重启一次 dnsmasq。这种方式不优雅,但问题不大。
文件的检测,直接使用 ioutil.ReadFile 读取文件内容,也存在问题。如果文件变化,和文件读取同时发生,很可能你读取完,文件的更新都没完成,那么你读取的并非一个完整的文件,而是坏的文件,这种文件,dnsmasq-nanny 无法做解析,不过官方代码中有数据校验,解析失败也问题不大,大不了下个周期的时候,再取到完整数据,再解析一次。
CoreDNS 是一个高速并且十分灵活的DNS服务。CoreDNS 允许你通过编写插件的形式去自行处理DNS数据。
CoreDNS 使用Caddy作为底层的 Web Server,Caddy 是一个轻量、易用的Web Server,它支持 HTTP、HTTPS、HTTP/2、GRPC 等多种连接方式。所有 coreDNS 可以通过四种方式对外直接提供 DNS 服务,分别是 UDP、gRPC、HTTPS 和 TLS
CoreDNS 的大多数功能都是由插件来实现的,插件和服务本身都使用了 Caddy 提供的一些功能,所以项目本身也不是特别的复杂。
CoreDNS 定义了一套插件的接口,只要实现 Handler 接口就能将插件注册到插件链中。
type ( // 只需要为插件实现 ServeDNS 以及 Name 这两个接口并且写一些用于配置的代码就可以将插件集成到 CoreDNS 中 Handler interface { ServeDNS(context.Context, dns.ResponseWriter, *dns.Msg) (int, error) Name() string })
现在在 CoreDNS 中已经支持40种左右的插件。
该插件可以让 coreDNS 读取到k8s集群内的 endpoint 以及 service 等信息,从而替代 kubeDNS 作为 k8s 集群内的 DNS 解析服务。不仅如此,该插件还支持多种配置如:
kubernetes [ZONES...] { ; 使用该配置可以连接到远程的k8s集群 kubeconfig KUBECONFIG CONTEXT endpoint URL tls CERT KEY CACERT ; 可以设置需要暴露Service的namespace列表 namespaces NAMESPACE... ; 可以暴露带有特定label的namespace labels EXPRESSION ; 是否可以解析10-0-0-1.ns.pod.cluster.local这种domain(为了兼容kube-dns) pods POD-MODE endpoint_pod_names ttl TTL noendpoints transfer to ADDRESS... fallthrough [ZONES...] ignore empty_service}
提供上游解析功能
forward FROM TO... { except IGNORED_NAMES... ; 强制使用tcp进行域名解析 force_tcp ; 当请求是tcp时,先尝试一次udp解析,失败了再使用tcp prefer_udp expire DURATION ; upstream的healthcheck失败的最多次数,默认2,超过的话upstream就会被下掉 max_fails INTEGER tls CERT KEY CA tls_servername NAME ; 选择nameserver的策略,random、round_robin、sequential policy random|round_robin|sequential health_check DURATION}
更多的插件可以到 CoreDNS 的插件市场查看
CoreDNS 提供了一种简单易懂的 DSL 语言,它允许你通过 Corefile 来自定义 DNS 服务。
coredns.io:5300 { file db.coredns.io}example.io:53 { log errors file db.example.io}example.net:53 { file db.example.net}.:53 { kubernetes proxy . 8.8.8.8 log errors cache}
通过以上的配置,CoreDNS 会开启两个端口 5300 和 53 ,提供 DNS 解析服务。对于 coredns.io 相关的域名会通过 5300 端口进行解析,其他域名会被解析到 53 端口,不同的域名会应用不同的插件。
在 CoreDNS 中 Plugin
其实就是一个出入参数都是 Handler
的函数
// 所谓的插件链其实是一个Middle layer,通过传递链中的下一个Handler,将一个Handler链接到下一个Handler。type Plugin func(Handler) Handler
同时 CoreDNS 提供了 NextOrFailure
方法,供每个插件在执行完自身的逻辑之后执行下一个插件
// NextOrFailure calls next.ServeDNS when next is not nil, otherwise it will return, a ServerFailure and a nil error.func NextOrFailure(name string, next Handler, ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { // nolint: golint if next != nil { if span := ot.SpanFromContext(ctx); span != nil { child := span.Tracer().StartSpan(next.Name(), ot.ChildOf(span.Context())) defer child.Finish() ctx = ot.ContextWithSpan(ctx, child) } return next.ServeDNS(ctx, w, r) } return dns.RcodeServerFailure, Error(name, errors.New("no next plugin found"))}
如果 next 为 nil,说明插件链已经调用结束,直接返回 no next plugin found
的 error 即可。
每个 Plugin
也可以调用 (dns.ResponseWriter).WriteMsg(*dns.Msg)
方法来结束整个调用链。
CoreDNS 正是通过 kubernetes 插件实现了解析 k8s 集群内域名的功能。那么我们看下这个插件做了些什么事情。
// coredns/plugin/kubernetes/setup.go#44func setup(c *caddy.Controller) error { // 检查了 corefile 中 kubernetes 配置的定义,并配置了一些缺省值 k, err := kubernetesParse(c) if err != nil { return plugin.Error("kubernetes", err) } // 启动了对 pod, service, endpoint 三种资源增、删、改的 watch,并注册了一些回调 // 注意:pod 是否启动 watch 是根据配置文件中 pod 的值来决定的,如果值不是 verified 就不会启动 pod 的 watch // 这里的 watch 方法观测到变化后,仅仅只改变 dns.modified 这个值,它会将该值设置为当前时间戳 err = k.InitKubeCache() if err != nil { return plugin.Error("kubernetes", err) } // 将插件注册到 Caddy,让 Caddy 启动时能够同时启动该插件 k.RegisterKubeCache(c) // 注册插件到调用链 dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { k.Next = next return k }) return nil}
// coredns/plugin/kubernetes/controller.go#408// 这三个方法就是 watch 资源时的回调func (dns *dnsControl) Add(obj interface{}) { dns.detectChanges(nil, obj) }func (dns *dnsControl) Delete(obj interface{}) { dns.detectChanges(obj, nil) }func (dns *dnsControl) Update(oldObj, newObj interface{}) { dns.detectChanges(oldObj, newObj) }// detectChanges detects changes in objects, and updates the modified timestampfunc (dns *dnsControl) detectChanges(oldObj, newObj interface{}) { // 判断新老对象的版本 if newObj != nil && oldObj != nil && (oldObj.(meta.Object).GetResourceVersion() == newObj.(meta.Object).GetResourceVersion()) { return } obj := newObj if obj == nil { obj = oldObj } switch ob := obj.(type) { case *object.Service: dns.updateModifed() case *object.Endpoints: if newObj == nil || oldObj == nil { dns.updateModifed() return } p := oldObj.(*object.Endpoints) // endpoint updates can come frequently, make sure it's a change we care about if endpointsEquivalent(p, ob) { return } dns.updateModifed() case *object.Pod: dns.updateModifed() default: log.Warningf("Updates for %T not supported.", ob) }}func (dns *dnsControl) Modified() int64 { unix := atomic.LoadInt64(&dns.modified) return unix}// updateModified set dns.modified to the current time.func (dns *dnsControl) updateModifed() { unix := time.Now().Unix() atomic.StoreInt64(&dns.modified, unix)}
上面展示的就是 kubernetes 这个 Plugin Watch 各个资源变化后的回调。可以看到它仅仅只改变 dns.modified 一个值,那么当 Service 发生变化后,kubernetes 插件如何感知,并将它们更新到内存呢。其实并没有或者说并不需要。。。因为这里使用了 client-go
中的 informer
机制,kubernetes 在解析 Service DNS 时会根据直接列出所有 Service(这里其实这么说并不准确,如果查找的是泛域名,那么才会列出所有 Service,如果是正常的 servicename.namespace,那么插件会使用 client-go
的 Indexer
机制,根据索引查找符合的 ServiceList),再进行匹配,直到找到匹配的 Service 再根据它的不同类型,决定返回结果。如果是 ClusterIP 类型,则返回 svc 的 ClusterIP,如果是 Headless 类型,则返回它所有的 Endpoint 的IP,如果是 ExternalName 类型,且 external_name 的值为 CNAME 类型,则返回 external_name 的值。整个操作仍然是在内存中进行的,效率并不会很低。
// findServices returns the services matching r from the cache.func (k *Kubernetes) findServices(r recordRequest, zone string) (services []msg.Service, err error) { // 如果 namespace 为 * 或者 为 any,或者该 namespace 在配置文件中没有被 namespace: 这个配置项中配置 // 则返回 NXDOMAIN if !wildcard(r.namespace) && !k.namespaceExposed(r.namespace) { return nil, errNoItems } // 如果 lookup 的 service 为空 if r.service == "" { // 如果 namepace 存在 或者 namespace 是通配符就返回空的 Service 列表 if k.namespaceExposed(r.namespace) || wildcard(r.namespace) { // NODATA return nil, nil } // 否则返回 NXDOMAIN return nil, errNoItems } err = errNoItems if wildcard(r.service) && !wildcard(r.namespace) { // If namespace exists, err should be nil, so that we return NODATA instead of NXDOMAIN if k.namespaceExposed(r.namespace) { err = nil } } var ( endpointsListFunc func() []*object.Endpoints endpointsList []*object.Endpoints serviceList []*object.Service ) if wildcard(r.service) || wildcard(r.namespace) { // 如果 service 或者 namespace 为 * 或者 any,列出当前所有的 Service serviceList = k.APIConn.ServiceList() endpointsListFunc = func() []*object.Endpoints { return k.APIConn.EndpointsList() } } else { // 根据 service.namespace 获取 index idx := object.ServiceKey(r.service, r.namespace) // 通过 client-go 的 indexer 返回 serviceList serviceList = k.APIConn.SvcIndex(idx) endpointsListFunc = func() []*object.Endpoints { return k.APIConn.EpIndex(idx) } } // 将 zone 转化为 etcd key 的格式 // /c/local/cluster zonePath := msg.Path(zone, coredns) for _, svc := range serviceList { if !(match(r.namespace, svc.Namespace) && match(r.service, svc.Name)) { continue } // If request namespace is a wildcard, filter results against Corefile namespace list. // (Namespaces without a wildcard were filtered before the call to this function.) if wildcard(r.namespace) && !k.namespaceExposed(svc.Namespace) { continue } // 如果查找的 Service 没有 Endpoint,就返回 NXDOMAIN,除非这个 Service 是 Headless Service 或者 External name if k.opts.ignoreEmptyService && svc.ClusterIP != api.ClusterIPNone && svc.Type != api.ServiceTypeExternalName { // serve NXDOMAIN if no endpoint is able to answer podsCount := 0 for _, ep := range endpointsListFunc() { for _, eps := range ep.Subsets { podsCount = podsCount + len(eps.Addresses) } } // No Endpoints if podsCount == 0 { continue } } // lookup 的 Service 是 headless Service 或者是使用 Endpoint lookup if svc.ClusterIP == api.ClusterIPNone || r.endpoint != "" { if endpointsList == nil { endpointsList = endpointsListFunc() } for _, ep := range endpointsList { if ep.Name != svc.Name || ep.Namespace != svc.Namespace { continue } for _, eps := range ep.Subsets { for _, addr := range eps.Addresses { // See comments in parse.go parseRequest about the endpoint handling. if r.endpoint != "" { if !match(r.endpoint, endpointHostname(addr, k.endpointNameMode)) { continue } } for _, p := range eps.Ports { if !(match(r.port, p.Name) && match(r.protocol, string(p.Protocol))) { continue } s := msg.Service{Host: addr.IP, Port: int(p.Port), TTL: k.ttl} s.Key = strings.Join([]string{zonePath, Svc, svc.Namespace, svc.Name, endpointHostname(addr, k.endpointNameMode)}, "/") err = nil // 遍历 Endpoints 并将结果添加到返回列表 services = append(services, s) } } } } continue } // External service // 如果 svc 是 External Service if svc.Type == api.ServiceTypeExternalName { s := msg.Service{Key: strings.Join([]string{zonePath, Svc, svc.Namespace, svc.Name}, "/"), Host: svc.ExternalName, TTL: k.ttl} // 只有当 External Name 是 CNAME 时,才会添加该 Service 到结果 if t, _ := s.HostType(); t == dns.TypeCNAME { s.Key = strings.Join([]string{zonePath, Svc, svc.Namespace, svc.Name}, "/") services = append(services, s) err = nil } continue } // ClusterIP service // 正常情况,返回的 msg.Service 的 Host 为 ClusterIP for _, p := range svc.Ports { if !(match(r.port, p.Name) && match(r.protocol, string(p.Protocol))) { continue } err = nil s := msg.Service{Host: svc.ClusterIP, Port: int(p.Port), TTL: k.ttl} s.Key = strings.Join([]string{zonePath, Svc, svc.Namespace, svc.Name}, "/") services = append(services, s) } } return services, err}
在 CoreDNS 的官网中已有详细的性能测试报告,地址
package mainimport ( "fmt" "sync")func main() { var m = make(map[string]string,100) var wait sync.WaitGroup for i := 0 ;i < 100 ; i ++ { wait.Add(1) go func() { m[fmt.Sprintf("%d",i)] = "test" wait.Done() }() } wait.Wait()}
fatal error: concurrent map writesfatal error: concurrent map writesgoroutine 11 [running]:runtime.throw(0x10c6370, 0x15) /usr/local/go/src/runtime/panic.go:608 +0x72 fp=0xc00002cf08 sp=0xc00002ced8 pc=0x1026e52runtime.mapassign_faststr(0x10a9860, 0xc00006e120, 0xc0000a0010, 0x2, 0x1) /usr/local/go/src/runtime/map_faststr.go:199 +0x3da fp=0xc00002cf70 sp=0xc00002cf08 pc=0x100fadamain.main.func1(0xc00006e120, 0xc000016078, 0xc000016080)...
因此当我们需要并发修改map时,第一种方法是在每次修改map值时使用互斥锁,第二种则是直接使用golang内置的线程安全的map,也就是sync.Map。
本文则从sync.Map的源码层面介绍,sync.Map是如何实现线程安全的。
首先我们看下sync.Map的数据结构
// Map 类似于map[interface{}]interface{},但是对于多个goroutine并发使用是安全的,不需要额外的lock// // 该类型是专门为并发操作提供的,你应该使用该类型来替代需要对原生map加锁的场景,// 以此获得更好地安全性,并使得操作更加简便//// Map针对了两个常见的场景进行了优化(1)对一个key只写入以此,但是读很多次。(2)在并发场景下,goroutine不会// 同时去操作一个key(也就是不会发生竞态)。// 在这两种情况下使用Map会比使用RWMutex加锁的map效率更高//// 你可以直接var x sync.Map就能使用,不需要去执行New...,Map不能被copytype Map struct { mu Mutex // read中保存了Map中部分内容,这些内容是只读的,所以是线程安全的 // 其中保存的数据类型是readOnly read atomic.Value // readOnly // 所有对dirty的操作都是需要加锁的 // 如果dirty为空,下一次写操作会复制read中没被删除的数据到dirty dirty map[interface{}]*entry // 当从Map中读取entry时,会先去read中读取,如果read中读不到则到dirty中读取,这是 // 会将该值+1,当该值到达一定大小后就会将read中所有值更新为dirty中保存的值 misses int}
从Map的数据结构看,就不难发现Map是如何保证线程安全的
当往Map中添加新的值时,不会往read中插入数据,而是直接将数据保存在dirty中。当需要从Map中读取数据时,
会先从read中读取数据,如果读到直接返回,如果读不到那么就会从dirty中读取,并更新misses的值,当misses值到达一定数值之后,
就会将dirty的值赋给read。(所有对dirty的操作都是加锁的,这就保证了这个类是线程安全的,同时因为read-only的存在,提升了并发读取的效率)
// readOnly is an immutable struct stored atomically in the Map.read field.type readOnly struct { m map[interface{}]*entry amended bool // 如果dirty中存在一些m中没有的key,该值则为true}
// An entry is a slot in the map corresponding to a particular key.type entry struct { // p points to the interface{} value stored for the entry. // // p有三种情况 // p == nil: entry已经被删除,且m.dirty为nil // expunged: entry已经被删除,但是m.dirty不是nil,并且这个entry不在m.dirty中 // 其他: entry是个正常值 p unsafe.Pointer // *interface{}}
Load方法用于根据Key读取Map中的值
// Load 根据所给的key返回Map中的值,如果不存在返回nil//// ok 表示Map中是否包含keyfunc (m *Map) Load(key interface{}) (value interface{}, ok bool) { // 直接从readonly中读取,如果读到直接返回,因为是readonly所以不用加锁 read, _ := m.read.Load().(readOnly) e, ok := read.m[key] // 如果readonly中没有值,并且dirty中存在read中不存在的值时 if !ok && read.amended { m.mu.Lock() // 加锁,双检查 read, _ = m.read.Load().(readOnly) e, ok = read.m[key] // 如果read中仍然不存在该key,且dirty中有read中不存在的值 if !ok && read.amended { // 从dirty中检查是否有该key e, ok = m.dirty[key] // 不管dirty中是否有改key,都将misses+1 // 当misses到达一定值之后,m.dirty会被提升为read m.missLocked() } m.mu.Unlock() } if !ok { return nil, false } return e.load()}
]]>Incr
可以很容易的实现一个限速器在redis的官方文档中也有详细的示例
FUNCTION LIMIT_API_CALL(ip)ts = CURRENT_UNIX_TIME()keyname = ip+":"+tscurrent = GET(keyname)IF current != NULL AND current > 10 THEN ERROR "too many requests per second"ENDIF current == NULL THEN MULTI INCR(keyname, 1) EXPIRE(keyname, 1) EXECELSE INCR(keyname, 1)ENDPERFORM_API_CALL()
现有一个服务,1分钟内只能接受一个用户不超过10次的请求,这时我们可以将用户的ip地址设置为key,用户每次时程序去redis中获取该key的值,如果大于等于10则返回错误,否则给key对应的value+1即可,如果value为0,那么再将该key设置1分钟的过期时间。
但是现在有一个需求,我们可以在一个指定的时间内给用户推送一条消息,但是要求用户每分钟内只能接受1条消息,每小时内接受的消息不超过5条,一天内接受的消息不超过10条。
比如,我现在向这个接口提交了一条数据,要求在2019-11-11 11:11:11时向一个用户发送一条数据,那么当我再提交一条数据,要求在2019-11-11 11:11:12是向同样的用户发送一条数据,那么接口就会返回错误。
此时,仅使用Incr
是无法满足该需求的。
使用set
或者zset
将用户的ip+发送日期
作为key,发送时间转换为当天的秒数作为value,插入到set
或者zset
中,每次向用户提交信息时,可以获取到set
中的所有发送时间,然后再一一比对,如果不满足条件就返回错误。
以下是伪代码实现
function RateLimit(ip,sendtime){ // 根据发送时间得到发送的日期 sendDate = getSendDate(sendtime) // 获取发送时间和当天0点之间的秒数差值 second = getSendSecond(sendtime) // 列出该发送日期中的所有发送时间 secondList = listSeconds(ip + sendDate) // 检验成功 if check(second,secondList) { // 将发送时间添加到缓存 return addToList(ip + sendDate,second) } return false}
以下是golang的实现
func RateLimit(ctx context.Context, ip string, sendTime time.Time) error { sendTime = sendTime.UTC() if sendTime.Before(time.Now()) { return nil } zeroStr := sendTime.Format("2006-01-02") key := ip + zeroStr zero, _ := time.Parse("2006-01-02", zeroStr) // 获取发送时间距当天时间的秒数 second := int(sendTime.Sub(zero).Seconds()) ress, err := cache.SMembers(ctx, key) if err != nil { return err } // 处理返回参数,将[]string转换为[]int var sends []int for _, v := range ress { if vv, err := strconv.Atoi(v); err == nil { sends = append(sends, vv) } } if len(sends) >= 10 { return errors.ErrMsg1DayLimit } var expire bool if len(sends) == 0 { expire = true } var hourCount int for _, s := range sends { if math.Abs(float64(second-s)) < 60 { return errors.ErrMsg1MinuteLimit } if math.Abs(float64(second-s)) < 3600 { hourCount++ } } if hourCount > 5 { return errors.ErrMsg1HourLimit } // 异步更新 go func() { // 开启事务 t := cache.Pipeline() defer t.Close() t.SAdd(context.Background(), key, []byte(strconv.Itoa(second))) if expire { ttl := zero.Add(time.Hour*24).Sub(time.Now()).Seconds() if ttl <= 0 { t.Rollback() return } t.Expire( key, int64(ttl)) } t.Commit() }() return nil}
以上就可以实现一个指定时间的限速器。
]]>// 自动转换成docker中的ip导致无法连接服务time="2019-02-15T20:04:26+08:00" level=error msg="Head http://172.17.0.2:9200: context deadline exceeded"
client, _ := elastic.NewClient( // ... // 将sniff设置为false后,便不会自动转换地址 elastic.SetSniff(false),)
// sniff 会请求http://ip:port/_nodes/http,将其返回的url list作为新的url list。// 如果snifferEnabled被设置为false,那么则不启动该功能。func (c *Client) sniff(parentCtx context.Context, timeout time.Duration) error { c.mu.RLock() if !c.snifferEnabled { c.mu.RUnlock() return nil } // Use all available URLs provided to sniff the cluster. var urls []string urlsMap := make(map[string]bool) // Add all URLs provided on startup for _, url := range c.urls { urlsMap[url] = true urls = append(urls, url) } c.mu.RUnlock() // Add all URLs found by sniffing c.connsMu.RLock() for _, conn := range c.conns { if !conn.IsDead() { url := conn.URL() if _, found := urlsMap[url]; !found { urls = append(urls, url) } } } c.connsMu.RUnlock() if len(urls) == 0 { return errors.Wrap(ErrNoClient, "no URLs found") } // Start sniffing on all found URLs ch := make(chan []*conn, len(urls)) ctx, cancel := context.WithTimeout(parentCtx, timeout) defer cancel() for _, url := range urls { // sniffNode 方法使用http,请求了对应的url,将结果封装后返回 go func(url string) { ch <- c.sniffNode(ctx, url) }(url) } // Wait for the results to come back, or the process times out. for { select { case conns := <-ch: if len(conns) > 0 { c.updateConns(conns) return nil } case <-ctx.Done(): if err := ctx.Err(); err != nil { switch { case IsContextErr(err): return err } return errors.Wrapf(ErrNoClient, "sniff timeout: %v", err) } // We get here if no cluster responds in time return errors.Wrap(ErrNoClient, "sniff timeout") } }}
]]>因此如果需要数据从history中删除,可以使用git filter-branch
命令或BFG Repo-Cleaner开源工具。
本文主要介绍如何使用git filter-branch
命令清除history中指定文件的内容。
值得注意的是,如果我们清除了history中的记录,后续我们将无法使用其他命令查看该文件的提交记录和变化。
下面开始使用git filter-branch清除文件提交记录
1、进入项目根路径
cd YOUR-REPOSITORY
2、执行git filter-branch
注意执行该命令后相应的文件也会被删除,如果不希望文件被删除可以先做好备份,后续再恢复
git filter-branch --force --index-filter \'git rm --cached --ignore-unmatch PATH-TO-YOUR-FILE-WITH-SENSITIVE-DATA' \--prune-empty --tag-name-filter cat -- --all
将PATH-TO-YOUR-FILE-WITH-SENSITIVE-DATA
替换成文件相对于项目的路径
3、将文件添加到.gitignore
防止文件再次被提交
echo "YOUR-FILE-WITH-SENSITIVE-DATA" >> .gitignoregit add .gitignoregit commit -m "Add YOUR-FILE-WITH-SENSITIVE-DATA to .gitignore"
4、如果你确保不会push
不会发生冲突,你可以使用
git push origin --force --all
来更新你的所有分支
5、如果需要从标记的版本删除敏感信息你可以使用
git push origin --force --tags
至此即可将敏感文件移除
]]>package mainimport ( "net/http" "strings" )func main(){ reader := strings.NewReader("hello") req,_ := http.NewRequest("POST","http://www.abc.com",reader) client := http.Client{} client.Do(req) // 第一次会请求成功 client.Do(req) // 请求失败}
第二次请求会出错
http: ContentLength=5 with Body length 0
原因是第一次请求后 req.Body 已经读取到结束位置,所以第二次请求时无法再读取 body,
解决方法:重新定义一个 ReadCloser 的实现类替换 req.Body
package readerimport ( "io" "net/http" "strings" "sync/atomic")type Repeat struct{ reader io.ReaderAt offset int64}// Read 重写读方法,使每次读取request.Body时能从指定位置读取func (p *Repeat) Read(val []byte) (n int, err error) { n, err = p.reader.ReadAt(val, p.offset) atomic.AddInt64(&p.offset, int64(n)) return}// Reset 重置偏移量func (p *Repeat) Reset(){ atomic.StoreInt64(&p.offset,0)}func (p *Repeat) Close() error { // 因为req.Body实现了readcloser接口,所以要实现close方法 // 但是repeat中的值有可能是只读的,所以这里只是尝试关闭一下。 if p.reader != nil { if rc, ok := p.reader.(io.Closer); ok { return rc.Close() } } return nil}func doPost() { client := http.Client{} reader := strings.NewReader("hello") req , _ := http.NewRequest("POST","http://www.abc.com",reader) req.Body = &Repeat{reader:reader,offset:0} client.Do(req) // 将偏移量重置为0 req.Body.(*Repeat).Reset() client.Do(req)}
这样就不会报错了,因为也重写了 Close()方法,所以同时也解决了 request 重用时,req.Body 自动关闭的问题。
]]>