徐亚松

徐亚松 查看完整档案

上海编辑  |  填写毕业院校Baidu  |  RD 编辑 www.xuyasong.com 编辑
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 个人简介什么都没有

个人动态

徐亚松 发布了文章 · 2020-04-03

共识、线性一致性与顺序一致性

etcd 是线性一致性读,而 zk 却是顺序一致性读,再加上各种共识、强弱一致的名词,看的时候总会混淆,这篇文档就列举下分布式系统中的那些"一致性名词",引用了很多其他的文章,不过会多出一些例子来帮助理解。

什么是一致性

在谈到一致性这个词时,你会想到CAP理论的 consistency,或者 ACID 中的 consistency,或者 cache一致性协议的 coherence,还是 Raft/Paxos 中的 consensus?

一致性这个词在不同的领域具有不同的含义,毕竟这个中文词在英文中对应了不同的术语,consistency,coherence,consensus三个单词统一翻译为”一致性”。因此在谈一致性之前,有必要对这几个概念做一个区分,否则很容易让人迷惑

coherence

Coherence 只出现在Cache Coherence 一词中,称为”缓存一致性”,研究多核场景,即怎么保证多个核上的CPU 缓存数据是一致的,一般是单机维度的,不算分布式领域,可以参考这篇文章

consensus

consensus准确的翻译是共识,即多个提议者达成共识的过程,例如Paxos,Raft 就是共识算法,paxos 是一种共识理论,分布式系统是他的场景,一致性是他的目标。

一些常见的误解:使用了 Raft或者 paxos 的系统都是线性一致的(Linearizability 即强一致),其实不然,共识算法只能提供基础,要实现线性一致还需要在算法之上做出更多的努力。

因为分布式系统引入了多个节点,节点规模越大,宕机、网络时延、网络分区就会成为常态,任何一个问题都可能导致节点之间的数据不一致,因此Paxos 和 Raft 准确来讲是用来解决一致性问题的共识算法,用于分布式场景,而非”缓存一致性“这种单机场景。所以很多文章也就简称”Paxos是分布式系统中的一致性算法“,

一致性(Consistency)的含义比共识(consensus)要宽泛,一致性指的是多个副本对外呈现的状态。包括顺序一致性、线性一致性、最终一致性等。而共识特指达成一致的过程,但注意,共识并不意味着实现了一致性,一些情况下他是做不到的。

Paxos与Raft

这里提一下Paxos,Paxos 其实是一类协议,Paxos 中包含 Basic Paxos、Multi-Paxos、Cheap Paxos 和其他的变种。Raft 就是 Multi-Paxos 的一个变种,Raft 通过简化 Multi-Paxos 的模型,实现了一种更容易让人理解和工程实现的共识算法,

Paxos是第一个被证明完备的共识算法,能够让分布式网络中的节点在出现错误时仍然保持一致,当然前提是没有恶意节点,也就是拜占庭将军问题。在传统的分布式系统领域是不需要担心这种问题的,因为不论是分布式数据库、消息队列、分布式存储,你的机器都不会故意发送错误信息,最常见的问题反而是节点失去响应,所以它们在这种前提下,Paxos是足够用的。

复制状态机

consensus共识在实现机制上属于复制状态机(Replicated State Machine)的范畴,复制状态机是一种很有效的容错技术,基于复制日志来实现,每个 Server 存储着一份包含命令序列的日志文件,状态机会按顺序执行这些命令。因为日志中的命令和顺序都相同,因此所有节点会得到相同的数据。

因此保证系统一致性就简化为保证操作日志的一致,这种复制日志的方式被大量运用,如 GSF、HDFS、ZooKeeper和 etcd 都是这种机制。

区块链

共识算法还有一个很重要的领域,就是比较火的区块链,比如工作量证明(POW)、权益证明(POS)和委托权益证明(DPOS)、置信度证明(PoB)等等,都是共识算法,这篇文章就列出来了 30 种

大家熟知的zk、etcd这种之所以叫“传统分布式”,就是相对于区块链这种”新型分布式系统“而言的,都是多节点共同工作,只是区块链有几点特殊:

  1. 区块链需要解决的是拜占庭将军问题,paxos之类的一致性算法无法对抗欺诈节点
  2. 区块链中不存在中央控制方,没有一个节点可以控制或协调账本数据的生成
  3. 区块链中的共识算法如果达不到一致性,则任何人都可以硬分叉,另建一个社区、一条链
  4. 分布式系统的性能理论上可以无限提升,但区块链是以相对的低效率来换取公正,主流的公有链每秒只能处理几笔到几十笔交易

consistency

介绍完了Coherence和consensus共识,我们来看consistency一致性,也就是我们平时说的最多的 CAP、Base、ACID之类。

最简单的,客户端C1将系统中的一个值K由V1更新为V2,客户端C2/C3/C4..需要立即读取到K的最新值

一致性要求的是一致,并不是正确,如果所有节点一致给出一个”错误“的答案,那也叫一致性

对于不同的场景,用户角度对于一致性的要求是不一样的,例如:

  • 银行系统:你在柜台存了一笔钱,同时你的朋友转账给你一笔钱,你的女朋友同时又在淘宝消费了一笔钱,你可能会感觉很乱,但你相信,最后你的余额一定是对的,银行可以慢一点,但不会把钱搞错。
  • 电商系统:你在淘宝看到一个库存为 5 的衣服,然后你快速下单,但是被提示”库存不足,无法购买“,你会觉得自己动作太慢,被人抢走了,不太关心库存为啥显示 5。
  • 论坛小站:你注册一个论坛,需要手机验证码,点完发送之后,一直没有响应,过了一天你才收到了这条短信,不过小站而已,不注册也就罢了。

上面是夸张了的用户情况,在实际业务中,一致性也是分等级的,如强一致性和弱一致性,怎么使用要看具体情况和系统的容忍度。

强一致性和弱一致性只是一种统称,按照从强到弱,可以划分为

  • 线性一致性Linearizability consistency ,也叫原子性
  • 顺序一致性 Sequential consistency
  • 因果一致性 Causal consistency
  • 最终一致性 Eventual consistency

强一致性包括线性一致性和顺序一致性,其他的如最终一致都是弱一致性。

关于强和弱的定义,可以参考剑桥大学的slide

Strong consistency
– ensures that only consistent state can be seen.

* All replicas return the same value when queried for the attribute of an object * All replicas return the same value when queried for the attribute of an object. This may be achieved at a cost – high latency.

Weak consistency
 – for when the “fast access” requirement dominates.

* update some replica, e.g. the closest or some designated replica
* the updated replica sends up date messages to all other replicas.
* different replicas can return different values for the queried attribute of the object the value should be returned, or “not known”, with a timestamp
* in the long term all updates must propagate to all replicas …….

强一致性集群中,对任何一个节点发起请求都会得到相同的回复,但将产生相对高的延迟。而弱一致性具有更低的响应延迟,但可能会回复过期的数据,最终一致性即是经过一段时间后终会到达一致的弱一致性。

背景

如买最后一张车票,两个售票处分别通过某种方式确认过这张票的存在。这时,两家售票处几乎同时分别来了一个乘客要买这张票,从各自“观察”看来,自己一方的乘客都是先到的,这种情况下,怎么能达成对结果的共识呢?看起来很容易,卖给物理时间上率先提交请求的乘客即可。

然而,对于两个来自不同位置的请求来说,要判断在时间上的“先后”关系并不是那么容易。两个车站的时钟时刻可能是不一致的,时钟计时可能不精确的,根据相对论的观点,不同空间位置的时间是不一致的。因此追求绝对时间戳的方案是不可行的,能做的是要对事件的发生进行排序。

这也是解决分布式系统领域很多问题的核心秘诀:把不同时空发生的多个事件进行全局唯一排序,而且这个顺序还得是大家都认可的,排了序,一个一个处理就行了,和单机没有任何区别(不考虑突然故障情况,只考虑共识机制)

如果存在可靠的物理时钟,实现排序往往更为简单。高精度的石英钟的漂移率为 10的-7 次方,最准确的原子震荡时钟的漂移率为 10的-13 次方。Google 曾在其分布式数据库 Spanner 中采用基于原子时钟和 GPS 的“TrueTime”方案,能够将不同数据中心的时间偏差控制在 10ms 置信区间。在不考虑成本的前提下,这种方案简单、有效。然而,计算机系统的时钟误差要大得多,这就造成分布式系统达成一致顺序十分具有挑战,或者说基本不可能。

要实现绝对理想的严格一致性(Strict Consistency)代价很大。除非系统不发生任何故障,而且所有节点之间的通信无需任何时间,此时整个系统其实就等价于一台机器了。因此根据实际需求的不用,人们可能选择不同强度的一致性。

顺序一致性(Sequential Consistency)

虽然强度上 线性一致性 > 顺序一致性,但因为顺序一致性出现的时间比较早(1979年),线性是在顺序的基础上的加强(1990 年)。因此先介绍下 顺序一致性

顺序一致性也算强一致性的一种,他的原理比较晦涩,论文看这里

举例说明1:下面的图满足了顺序一致,但不满足线性一致。

image

  • x 和 y 的初始值为 0
  • Write(x,4)代表写入 x=4,Read(y,2)为读取 y =2

从图上看,进程P1,P2的一致性并没有冲突。因为从这两个进程的角度来看,顺序应该是这样的:

Write(y,2), Read(x,0), Write(x,4), Read(y,2)

这个顺序对于两个进程内部的读写顺序都是合理的,只是这个顺序与全局时钟下看到的顺序并不一样。在全局时钟的观点来看,P2进程对变量X的读操作在P1进程对变量X的写操作之后,然而P2读出来的却是旧的数据0

举例说明 2:

假设我们有个分布式 KV 系统,以下是四个进程 对其的操作顺序和结果:

--表示持续的时间,因为一次写入或者读取,客户端从发起到响应是有时间的,发起早的客户端,不一定拿到数据就早,有可能因为网络延迟反而会更晚。

情况 1:

A: --W(x,1)----------------------
B:  --W(x,2)----------------------
C:                      -R(x,1)-   --R(x,2)-
D:                 -R(x,1)-      --R(x,2)--

情况 2:

A: --W(x,1)----------------------
B:  --W(x,2)----------------------
C:                      -R(x,2)-   --R(x,1)-
D:                 -R(x,2)-      --R(x,1)--

上面情况 1 和 2 都是满足顺序一致性的,C 和 D 拿的顺序都是 1-2,或 2-1,只要CD 的顺序一致,就是满足顺序一致性。只是从全局看来,情况 1 更真实,情况 2 就显得”错误“了,因为情况2 是这样的顺序

B W(x,2) -> A W(x,1) -> C R(x,2) -> D R(x,2) -> C R(x,1) -> D R(x,1)

不过一致性不保证正确性,所以这仍然是一个顺序一致。再加一种情况 3:

情况 3:

A: --W(x,1)----------------------
B:  --W(x,2)----------------------
C:                      -R(x,2)-   --R(x,1)-
D:                 -R(x,1)-      --R(x,2)--

情况 3 就不属于顺序一致了,因为C 和 D 两个进程的读取顺序不同了。

回到情况 2,C 和 D 拿数据发起的时间是不同的,且有重叠,有可能 C 拿到 1 的时候,D 已经拿到了 2,这就导致了不同的客户端在相同的时间获取了不一样的数据,但其实这种模式在现实中的用的挺广泛的:

如:你在Twitter上写了2条推文,你的操作会耗费一定的时间渗透进一层层的缓存系统,不同的朋友将在不同的时间看到你的信息,但每个朋友都会以相同顺序看到了你的2条推文,不会是乱序。只是一个朋友已经看到了第二条,一个朋友才刚看到第一条,不过没关系,他总会看到两条,顺序没错就行,无伤大雅。

但有些时候,顺序一致是不满足要求的,举例说明 3:

image

从时间轴上可以看到,B0 发生在 A0 之前,读取到的 x 值为0。B2 发生在 A0 之后,读取到的 x 值为1。而读操作 B1,C0,C1 与写操作 A0 在时间轴上有重叠,因此他们可能读取到旧的值0,也可能读取到新的值1。注意,C1 发生在 B1 之后(二者在时间轴上没有重叠),但是 B1 看到 x 的新值,C1 反而看到的是旧值。即对用户来说,x 的值发生了回跳。

即要求任何一次读都能读到最新数据,和全局时钟一致。对比例1,既满足顺序一致又满足线性一一致应该是这样的:

![](http://vermouth-blog-image.os...
)

每个读操作都读到了该变量的最新写的结果,同时两个进程看到的操作顺序与全局时钟的顺序一样,都是Write(y,2), Read(x,4), Write(x,4), Read(y,2)

ZooKeeper

一种说法是 ZooKeeper 是最终一致性,因为由于多副本、以及保证大多数成功的 Zab 协议,当一个客户端进程写入一个新值,另外一个客户端进程不能保证马上就能读到这个值,但是能保证最终能读取到这个值。

另外一种说法是 ZooKeeper 的 Zab 协议类似于 Paxos 协议,提供了强一致性。

但这两种说法都不准确,ZooKeeper 文档中明确写明它的一致性是 Sequential consistency即顺序一致。

ZooKeeper中针对同一个Follower A提交的写请求request1、request2,某些Follower虽然可能不能在请求提交成功后立即看到(也就是强一致性),但经过自身与Leader之间的同步后,这些Follower在看到这两个请求时,一定是先看到request1,然后再看到request2,两个请求之间不会乱序,即顺序一致性

其实,实现上ZooKeeper 的一致性更复杂一些,ZooKeeper 的读操作是 sequential consistency 的,ZooKeeper 的写操作是 linearizability 的,关于这个说法,ZooKeeper 的官方文档中没有写出来,但是在社区的邮件组有详细的讨论。ZooKeeper 的论文《Modular Composition of Coordination Services》 中也有提到这个观点。

总结一下,可以这么理解 ZooKeeper:从整体(read 操作 +write 操作)上来说是 sequential consistency,写操作实现了 Linearizability。

线性一致性 (Linearizability)

线性一致性又被称为强一致性、严格一致性、原子一致性。是程序能实现的最高的一致性模型,也是分布式系统用户最期望的一致性。CAP 中的 C 一般就指它

顺序一致性中进程只关心大家认同的顺序一样就行,不需要与全局时钟一致,线性就更严格,从这种偏序(partial order)要达到全序(total order)

要求是:

  • 1.任何一次读都能读到某个数据的最近一次写的数据。
  • 2.系统中的所有进程,看到的操作顺序,都与全局时钟下的顺序一致。

以上面的例 3 继续讨论:

B1 看到 x 的新值,C1 反而看到的是旧值。即对用户来说,x 的值发生了回跳。

在线性一致的系统中,如果 B1 看到的 x 值为1,则 C1 看到的值也一定为1。任何操作在该系统生效的时刻都对应时间轴上的一个点。如果我们把这些时刻连接起来,如下图中紫线所示,则这条线会一直沿时间轴向前,不会反向回跳。所以任何操作都需要互相比较决定,谁发生在前,谁发生在后。例如 B1 发生在 A0 前,C1 发生在 A0 后。而在前面顺序一致性模型中,我们无法比较诸如 B1 和 A0 的先后关系。

image

线性一致性的理论在软件有哪些体现呢?

etcd 与 raft

上面提到ZooKeeper的写是线性一致性,读是顺序一致性。而 etcd读写都做了线性一致,即 etcd 是标准的强一致性保证。

etcd是基于raft来实现的,raft是共识算法,虽然共识和一致性的关系很微妙,经常一起讨论,但共识算法只是提供基础,要实现线性一致还需要在算法之上做出更多的努力如库封装,代码实现等。如raft中对于一致性读给出了两种方案,来保证处理这次读请求的一定是 Leader:

  • ReadIndex
  • LeaseRead

基于 raft 的软件有很多,如 etcd、tidb、SOFAJRaft等,这些软件在实现一致读时都是基于这两种方式。

关于 etcd 的选主架构这里不做描述,可以看这篇文章,这里对ReadIndex和Lease Read做下解释,即etcd 中线性一致性读的具体实现

由于在 Raft 算法中,写操作成功仅仅意味着日志达成了一致(已经落盘),而并不能确保当前状态机也已经 apply 了日志。状态机 apply 日志的行为在大多数 Raft 算法的实现中都是异步的,所以此时读取状态机并不能准确反应数据的状态,很可能会读到过期数据。

基于以上这个原因,要想实现线性一致性读,一个较为简单通用的策略就是:每次读操作的时候记录此时集群的 commited index,当状态机的 apply index 大于或等于 commited index 时才读取数据并返回。由于此时状态机已经把读请求发起时的已提交日志进行了 apply 动作,所以此时状态机的状态就可以反应读请求发起时的状态,符合线性一致性读的要求。这便是 ReadIndex 算法。

那如何准确获取集群的 commited index ?如果获取到的 committed index 不准确,那么以不准确的 committed index 为基准的 ReadIndex 算法将可能拿到过期数据。

为了确保 committed index 的准确,我们需要:

  • 让 leader 来处理读请求;
  • 如果 follower 收到读请求,将请求 forward 给 leader;
  • 确保当前 leader 仍然是 leader;

leader 会发起一次广播请求,如果还能收到大多数节点的应答,则说明此时 leader 还是 leader。这点非常关键,如果没有这个环节,leader 有可能因网络分区等原因已不再是 leader,如果读请求依然由过期的 leader 处理,那么就将有可能读到过去的数据。

这样,我们从 leader 获取到的 commited index 就作为此次读请求的 ReadIndex。

以网络分区为例:

image
如上图所示:

  1. 初始状态时集群有 5 个节点:A、B、C、D 和 E,其中 A 是 leader;
  2. 发生网络隔离,集群被分割成两部分,一个 A 和 B,另外一个是 C、D 和 E。虽然 A 会持续向其他几个节点发送 heartbeat,但由于网络隔离,C、D 和 E 将无法接收到 A 的 heartbeat。默认地,A 不处理向 follower 节点发送 heartbeat 失败(此处为网络超时)的情况(协议没有明确说明 heartbeat 是一个必须收到 follower ack 的双向过程);
  3. C、D 和 E 组成的分区在经过一定时间没有收到 leader 的 heartbeat 后,触发 election timeout,此时 C 成为 leader。此时,原来的 5 节点集群因网络分区分割成两个集群:小集群 A 和 B,A 为 leader;大集群 C、D 和 E,C 为 leader;
  4. 此时有客户端进行读写操作。在 Raft 算法中,客户端无法感知集群的 leader 变化(更无法感知服务端有网络隔离的事件发生)。客户端在向集群发起读写请求时,一般是从集群的节点中随机挑选一个进行访问。如果客户端一开始选择 C 节点,并成功写入数据(C 节点集群已经 commit 操作日志),然后因客户端某些原因(比如断线重连),选择节点 A 进行读操作。由于 A 并不知道另外 3 个节点已经组成当前集群的大多数并写入了新的数据,所以节点 A 无法返回准确的数据。此时客户端将读到过期数据。不过相应地,如果此时客户端向节点 A 发起写操作,那么写操作将失败,因为 A 因网络隔离无法收到大多数节点的写入响应;
  5. 针对上述情况,其实节点 C、D 和 E 组成的新集群才是当前 5 节点集群中的大多数,读写操作应该发生在这个集群中而不是原来的小集群(节点 A 和 B)。如果此时节点 A 能感知它已经不再是集群的 leader,那么节点 A 将不再处理读写请求。于是,我们可以在 leader 处理读请求时,发起一次 check quorum 环节:leader 向集群的所有节点发起广播,如果还能收到大多数节点的响应,处理读请求。当 leader 还能收到集群大多数节点的响应,说明 leader 还是当前集群的有效 leader,拥有当前集群完整的数据。否则,读请求失败,将迫使客户端重新选择新的节点进行读写操作。

这样一来,Raft 算法就可以保障 CAP 中的 C 和 P,但无法保障 A:网络分区时并不是所有节点都可响应请求,少数节点的分区将无法进行服务,从而不符合 Availability。因此,Raft 算法是 CP 类型的一致性算法。

Raft保证读请求Linearizability的方法:

  • 1.Leader把每次读请求作为一条日志记录,以日志复制的形式提交,并应用到状态机后,读取状态机中的数据返回。(一次RTT、一次磁盘写)
  • 2.使用Leader Lease,保证整个集群只有一个Leader,Leader接收到都请求后,记录下当前的commitIndex为readIndex,当applyIndex大于等于readIndex 后,则可以读取状态机中的数据返回。(0次RTT、0次磁盘写)
  • 3.不使用Leader Lease,而是当Leader通过以下两点来保证整个集群中只有其一个正常工作的Leader:(1)在每个Term开始时,由于新选出的Leader可能不知道上一个Term的commitIndex,所以需要先在当前新的Term提交一条空操作的日志;(2)Leader每次接到读请求后,向多数节点发送心跳确认自己的Leader身份。之后的读流程与Leader Lease的做法相同。(一次RTT、0次磁盘写)
  • 4.从Follower节点读:Follower先向Leader询问readIndex,Leader收到Follower的请求后依然要通过2或3中的方法确认自己Leader的身份,然后返回当前的commitIndex作为readIndex,Follower拿到readIndex后,等待本地的applyIndex大于等于readIndex后,即可读取状态机中的数据返回。(2次或1次RTT、0次磁盘写)

Linearizability 和 Serializability

Serializability是数据库领域的概念,而Linearizability是分布式系统、并发编程领域的东西,在这个分布式SQL时代,自然Linearizability和Serializability会经常一起出现。

  • Serializability: 数据库领域的ACID中的I。 数据库的四种隔离级别,由弱到强分别是Read Uncommitted,Read Committed(RC),Repeatable Read(RR)和Serializable。

Serializable的含义是:对并发事务包含的操作进行调度后的结果和某种把这些事务一个接一个的执行之后的结果一样。最简单的一种调度实现就是真的把所有的事务进行排队,一个个的执行,显然这满足Serializability,问题就是性能。可以看出Serializability是与数据库事务相关的一个概念,一个事务包含多个读,写操作,这些操作由涉及到多个数据对象。

  • Linearizability: 针对单个操作,单个数据对象而说的。属于CAP中C这个范畴。一个数据被更新后,能够立马被后续的读操作读到。
  • Strict Serializability: 同时满足Serializability和Linearizability。

举个最简单的例子:两个事务T1,T2,T1先开始,更新数据对象o,T1提交。接着T2开始,读数据对象o,提交。以下两种调度:

  1. T1,T2,满足Serializability,也满足Linearizability。
  2. T2,T1,满足Serializability,不满足Linearizability,因为T1之前更新的数据T2读不到。

因果一致性 Causal consistency

因果一致性,属于弱一致性,因为在Causal consistency中,只对有因果关系的事件有顺序要求。

没有因果一致性时会发生如下情形:

  • 夏侯铁柱在朋友圈发表状态“我戒指丢了”
  • 夏侯铁柱在同一条状态下评论“我找到啦”
  • 诸葛建国在同一条状态下评论“太棒了”
  • 远在美国的键盘侠看到“我戒指丢了”“太棒了”,开始喷诸葛建国
  • 远在美国的键盘侠看到“我戒指丢了”“我找到啦”“太棒了”,意识到喷错人了

所以很多系统采用因果一致性系统来避免这种问题,例如微信的朋友圈就采用了因果一致性,可以参考:https://www.useit.com.cn/thre...

image

最终一致性 Eventual consistency

最终一致性这个词大家听到的次数应该是最多的,也是弱一致性,不过因为大多数场景下用户可以接受,应用也就比较广泛。

理念:不保证在任意时刻任意节点上的同一份数据都是相同的,但是随着时间的迁移,不同节点上的同一份数据总是在向趋同的方向变化。

简单说,就是在一段时间后,节点间的数据会最终达到一致状态。不过最终一致性的要求非常低,除了像gossip这样明确以最终一致性为卖点的协议外,包括redis主备、mongoDB、乃至mysql热备都可以算是最终一致性,甚至如果我记录操作日志,然后在副本故障了100天之后手动在副本上执行日志以达成一致,也算是符合最终一致性的定义。有人说最终一致性就是没有一致性,因为没人可以知道什么时候算是最终。

上边提到的因果一致性可以理解为是最终一致性的变种, 如果进程 A 通知进程 B 它已经更新了一个数据项,那么进程 B 的后续访问将返回更新后的值,并且写操作将被保证取代前一次写入。和进程 A 没有因果关系的 C 的访问将遵循正常的最终一致性规则。

最终一致其实分支很多,以下都是他的变种:

  • Causal consistency(因果一致性)
  • Read-your-writes consistency (读己所写一致性)
  • Session consistency (会话一致性)
  • Monotonic read consistency (单调读一致性)
  • Monotonic write consistency (单调写一致性)

后面要提到的 BASE理论中的 E,就是Eventual consistency最终一致

ACID理论

ACID 是处理事务的原则,一般特指数据库的一致性约束,ACID 一致性完全与数据库规则相关,包括约束,级联,触发器等。在事务开始之前和事务结束以后,都必须遵守这些不变量,保证数据库的完整性不被破坏,因此 ACID 中的 C 表示数据库执行事务前后状态的一致性,防止非法事务导致数据库被破坏。比如银行系统 A 和 B 两个账户的余额总和为 100,那么无论 A, B 之间怎么转换,这个余额和是不变,前后一致的。

这里的C代表的一致性:事务必须遵循数据库的已定义规则和约束,例如约束,级联和触发器。因此,任何写入数据库的数据都必须有效,并且完成的任何事务都会改变数据库的状态。没有事务可以创建无效的数据状态。注意,这与CAP定理中定义的“一致性”是不同的。

ACID 可以翻译为酸,相对应的是碱,也就是 BASE,不过提BASE之前要先说下 CAP,毕竟 BASE是基于 CAP 提出的折中理论

CAP理论

CAP 理论中的 C 也就是我们常说的分布式系统中的一致性,更确切地说,指的是分布式一致性中的一种: 也就是前面讲的线性一致性(Linearizability),也叫做原子一致性(Atomic consistency)。

CAP 理论也是个被滥用的词汇,关于 CAP 的正确定义可参考cap faq。很多时候我们会用 CAP 模型去评估一个分布式系统,但这篇文章会告诉你 CAP 理论的局限性,因为按照 CAP 理论,很多系统包括 MongoDB,ZooKeeper 既不满足一致性(线性一致性),也不满足可用性(任意一个工作中的节点都要可以处理请求),但这并不意味着它们不是优秀的系统,而是 CAP 定理本身的局限性(没有考虑处理延迟,容错等)。

BASE理论

正因为 CAP 中的一致性和可用性是强一致性和高可用,后来又有人基于 CAP 理论 提出了BASE 理论,即基本可用(Basically Available)、软状态(Soft State)、最终一致性(Eventual Consistency)。BASE的核心思想是即使无法做到强一致性,但每个应用都可以根据自身的业务特点,采用适当的方法来使系统达到最终一致性。显然,最终一致性弱于 CAP 中的 线性一致性。很多分布式系统都是基于 BASE 中的”基本可用”和”最终一致性”来实现的,比如 MySQL/PostgreSQL Replication 异步复制。

ACID一致性与CAP一致性的区别

ACID一致性是有关数据库规则,如果数据表结构定义一个字段值是唯一的,那么一致性系统将解决所有操作中导致这个字段值非唯一性的情况,如果带有一个外键的一行记录被删除,那么其外键相关记录也应该被删除,这就是ACID一致性的意思。

CAP理论的一致性是保证同样一个数据在所有不同服务器上的拷贝都是相同的,这是一种逻辑保证,而不是物理,因为光速限制,在不同服务器上这种复制是需要时间的,集群通过阻止客户端查看不同节点上还未同步的数据维持逻辑视图。

当跨分布式系统提供ACID时,这两个概念会混淆在一起,Google’s Spanner system能够提供分布式系统的ACID,其包含ACID+CAP设计,也就是两阶段提交 2PC+ 多副本同步机制(如 Paxos)

image

ACID/2PC/3PC/TCC/Paxos 关系

ACID 是处理事务的原则,限定了原子性、一致性、隔离性、持久性。ACID、CAP、BASE这些都只是理论,只是在实现时的目标或者折中,ACID 专注于分布式事务,CAP 和 BASE是分布式通用理论。

解决分布式事务时有 2pc、3pc、tcc 等方式,通过增加协调者来进行协商,里面也有最终一致的思想。

而Paxos协议与分布式事务并不是同一层面的东西,Paxos用于解决多个副本之间的一致性问题。比如日志同步,保证各个节点的日志一致性,选主的唯一性。简而言之,2PC用于保证多个数据分片上事务的原子性,Paxos协议用于保证同一个数据分片在多个副本的一致性,所以两者可以是互补的关系,不是替代关系。对于2PC协调者单点问题,可以利用Paxos协议解决,当协调者出问题时,选一个新的协调者继续提供服务。原理上Paxos和 2PC相似,但目的上是不同的。etcd 中也有事务的操作,比如迷你事务

参考

关于 raft 的内容也可以看下MIT-6.824

查看原文

赞 3 收藏 0 评论 1

徐亚松 发布了文章 · 2020-03-27

kubelet 原理解析五: exec的背后

概述

线上排查pod 问题一般有两种方式,kubectl log或者kubectl exec调试。如果你的 log 写不够优雅,或者需要排除网络问题必须进容器,就只能 exec 了。

# 在pod 123456-7890的容器ruby-container中运行“date”并获取输出
$ kubectl exec 123456-7890 -c ruby-container date

kubectl exec 可以执行完命令就退出,或者一直保持终端输入,本质是通过docker(或其他运行时) exec 来实现,本文主要介绍 exec 的实现逻辑,以及如何实现 web-console

docker exec

docker exec 的原理,大致概况一句话是:一个进程可以选择加入到某个进程已有的 Namespace 当中,从而达到“进入”这个进程所在容器的目的。

Docker 容器其实就是若干进程构成,容器内 pid为1的进程为容器主进程,如果要加入 exec 运行一个命令,等于是新增一个process为/bin/bash, 其父进程为 Docker Daemon,新的process加入了容器主进程 P1 所在的隔离环境(namespaces),与 P1共享Network、Mount、IPC 等所有资源,且与该容器内的进程一样受到资源限制(cgroup)。

docker exec加入已有namespace的过程:

查看现有容器的 pid

docker inspect --format '{{ .State.Pid }}'  4ddf4638572d
25686

通过查看宿主机的 proc 文件,看到这个 25686 进程的所有 Namespace 对应的文件,
进程的每种 Linux Namespace,都在它对应的 /proc/[进程号]/ns 下有一个对应的虚拟文件,并且链接到一个真实的 Namespace 文件上。

ls -l  /proc/25686/ns
lrwxrwxrwx  cgroup -> cgroup:[4026531835]
lrwxrwxrwx  ipc -> ipc:[4026532278]
lrwxrwxrwx  mnt -> mnt:[4026532276]
lrwxrwxrwx  net -> net:[4026532281]
lrwxrwxrwx  pid -> pid:[4026532279]
lrwxrwxrwx  pid_for_children -> pid:[4026532279]
lrwxrwxrwx  user -> user:[4026531837]
lrwxrwxrwx  uts -> uts:[4026532277]

要加入25686所在的 namespace,执行的是叫 setns()的 Linux系统调用。

int setns(int fd, int nstype);
  • fd参数: 是一个指向一个命名空间的文件描述符,位于/proc/PID/ns/目录。
  • nstype: 指定了允许进入的命名空间,设置为0表示允许进入所有命名空间。
int main(int argc, char *argv[]) {
    int fd;
    
    fd = open(argv[1], O_RDONLY);
    if (setns(fd, 0) == -1) {
        errExit("setns");
    }
    execvp(argv[2], &argv[2]); 
    errExit("execvp");
}

这段代码的操作就是通过 open() 系统调用打开了指定的 Namespace 文件,并把这个文件的描述符 fd 交给 setns() 使用。在 setns() 执行后,当前进程就加入了这个文件对应的 Linux Namespace 当中了。加入后,可以查看两个pid 的 namespace 是同一个。

ls -l /proc/28499/ns/net
lrwxrwxrwx  /proc/28499/ns/net -> net:[4026532281]

$ ls -l  /proc/25686/ns/net
lrwxrwxrwx/proc/25686/ns/net -> net:[4026532281]

setns 操作是docker exec 的基础,加入 namespace 之后,只需要将 std 通过 stream 暴露接口出去,被上层调用。docker 代码:


## moby/daemon/exec.go
attachConfig := stream.AttachConfig{
        TTY:        ec.Tty,
        UseStdin:   cStdin != nil,
        UseStdout:  cStdout != nil,
        UseStderr:  cStderr != nil,
        Stdin:      cStdin,
        Stdout:     cStdout,
        Stderr:     cStderr,
        DetachKeys: ec.DetachKeys,
        CloseStdin: true,
    }
    ec.StreamConfig.AttachStreams(&attachConfig)
    attachErr := ec.StreamConfig.CopyStreams(ctx, &attachConfig)

    // Synchronize with libcontainerd event loop
    ec.Lock()
    c.ExecCommands.Lock()
    systemPid, err := d.containerd.Exec(ctx, c.ID, ec.ID, p, cStdin != nil, ec.InitializeStdio)
    // the exec context should be ready, or error happened.
    // close the chan to notify readiness
    close(ec.Started)

docker 不仅提供 cli 命令行操作,还提供了 HTTP API,这里面涉及协议转换,类似 websocket连接,后面在 webshell 中会详细描述。
如 docker 中的Hijacking

Upgrade: tcp
Connection: Upgrade

nsenter

进入容器还可以使用nsenter,这是一个小工具,安装后可以在主机上直接执行。

在使用nsenter命令之前需要获取到docker容器的进程,然后再使用nsenter工具进去到docker容器中,具体的使用方法如下:

docker inspect -f {{.State.Pid}} 容器名或者容器id 
nsenter --target 上面查到的进程id --mount --uts --ipc --net --pid  

nsenter指令中进程id之后的参数的含义:

  • --mount参数是进去到mount namespace中
  • --uts参数是进入到uts namespace中
  • --ipc参数是进入到System V IPC namaspace中
  • --net参数是进入到network namespace中
  • --pid参数是进入到pid namespace中
  • --user参数是进入到user namespace中

nsenter相当于在setns的示例程序之上做了一层封装,使我们无需指定命名空间的文件描述符,而是指定进程号即可。

nsenter 与 docker exec对比:

  • 二者都是 setns, 共享namespace,没什么大的区别
  • nsenter不会进入 cgroup,因此不受资源限制,调试可能更方便,但远程读写还是建议使用 docker exec
  • nsenter出现时间早于docker exec。只适用于 intel 64 位系统,不过正式版docker 也是一样。

kubectl exec

在k8s中,你可以使用 kubectl exec 来进入 pod 中的容器,如:

$ kubectl exec 123456-7890 -c ruby-container date

执行kubectl exec时首先会向 apiserver 发起请求,由 apiserver 转发给pod 所在机器上的kubelet进程,然后再转发给 runtime 的exec接口

请求时apiserver 中可以看到这种日志:

handler.go:143] kube-apiserver: POST "/api/v1/namespaces/default/pods/exec-test-nginx-6558988d5-fgxgg/exec" satisfied by gorestful with webservice /api/v1
upgradeaware.go:261] Connecting to backend proxy (intercepting redirects) https://192.168.205.11:10250/exec/default/exec-test-nginx-6558988d5-fgxgg/exec-test-nginx?command=sh&input=1&output=1&tty=1
Headers: map[Connection:[Upgrade] Content-Length:[0] Upgrade:[SPDY/3.1] User-Agent:[kubectl/v1.12.10 (darwin/amd64) kubernetes/e3c1340] X-Forwarded-For:[192.168.205.1] X-Stream-Protocol-Version:[v4.channel.k8s.io v3.channel.k8s.io v2.channel.k8s.io channel.k8s.io]]
HTTP 请求中包含了协议升级的请求. 101 upgrade
SPDY 允许在单个 TCP 连接上复用独立的 stdin/stdout/stderr/spdy-error 流。

API Server 收到请求后,找到需要转发的 node 地址,即 nodeip:10255端口,然后开始连接

/ GetConnectionInfo retrieves connection info from the status of a Node API object.
func (k *NodeConnectionInfoGetter) GetConnectionInfo(ctx context.Context, nodeName types.NodeName) (*ConnectionInfo, error) {
        node, err := k.nodes.Get(ctx, string(nodeName), metav1.GetOptions{})
        if err != nil {
                return nil, err
        }

       ....

        return &ConnectionInfo{
                Scheme:    k.scheme,
                Hostname:  host,
                Port:      strconv.Itoa(port),
                Transport: k.transport,
        }, nil
}
....

location, transport, err := pod.ExecLocation(r.Store, r.KubeletConn, ctx, name, execOpts)
        if err != nil {
                return nil, err
        }
        return newThrottledUpgradeAwareProxyHandler(location, transport, false, true, true, responder), nil

kubelet 接到 apiserver 的请求后,调用各运行时的RuntimeServiceServer的实现,包括 exec 接口的实现,给 apiserver返回一个连接端点

 // Exec prepares a streaming endpoint to execute a command in the container.
Exec(context.Context, *ExecRequest) (*ExecResponse, error)

最后,容器运行时在工作节点上执行命令,如:

 if v, found := os.LookupEnv("XDG_RUNTIME_DIR"); found {
                execCmd.Env = append(execCmd.Env, fmt.Sprintf("XDG_RUNTIME_DIR=%s", v))
        }
        var cmdErr, copyError error
        if tty {
                cmdErr = ttyCmd(execCmd, stdin, stdout, resize)
        } else {
                if stdin != nil {

kubectl exec 流程完成。

web-console实现

无论是 docker exec还是 kubectl exec,都是接口操作,用起来比较麻烦。一般公司都会提供一个交互式的界面进行 exec 操作,称之为web-console,或者叫web-terminal、webshell

web-console基于websocket实现,在浏览器和后端之间建立websocket连接后,将用户在浏览器中输入的命令通过websocket协议发送到后端,后端提前使用kubectl exec 或docker exec进入到容器,将收到的命令从exec进程的stdin写入,命令执行后,再从exec进程的stdout中读取输出,通过websocket协议返回浏览器显示给用户,达到交互的目的。

返回路线:

浏览器<------>WebSocket------>SSH------>Linux OS

实现web-console的开源方案有很多,前端一般是xterm.js,主要难点在多租户隔离和权限校验。同时浏览器的以外关闭有可能导致 exec 残留,需要定时清理。

  • web-console
  • wssh
  • KeyBox
  • gotty
  • GateOne
  • dry
  • toolbox
  • xterm.js
  • ttyd

以上方案中,最成熟的是gateone,其次是gotty

  • gateone:cka考试时使用的web shell就是基于gateone实现。含前端,python开发
  • gotty:完整的web shell实现,含前端,golang编写,方便二次开发

最终我们选择gotty作为后端代理所有 container 的 websocket 请求,增加 token申请和过期、校验,exec 命令限制等,保证足够安全性。

参考

查看原文

赞 2 收藏 2 评论 0

徐亚松 发布了文章 · 2020-03-27

kubelet 原理解析二:pleg

场景

如果你的 node 突然 notready,或者 pod状态异常时,你会 describe node 或describe pod 来查看原因,你可能会看到这一行报错:

PLEG is not healthy: pleg was last seen active 3m5.30015447s ago;

pleg 是什么,如何排查并解决上面的问题呢?

逻辑

PLEG:全称是Pod Lifecycle Event Generator,主要负责将Pod状态变化记录event以及触发Pod同步

可以看下官方PLEG示意图中红色的部分:

以node notready 这个场景为例:

Kubelet中的NodeStatus机制会定期检查集群节点状况,并把节点状况同步到API Server。而NodeStatus判断节点就绪状况的一个主要依据,就是PLEG。

PLEG定期检查节点上Pod运行情况,并且会把pod 的变化包装成Event发送给Kubelet的主同步机制syncLoop去处理。但是,在PLEG的Pod检查机制不能定期执行的时候,NodeStatus机制就会认为这个节点的状况是不对的,从而把这种状况同步到API Server,我们就会看到 not ready

PLEG有两个关键的时间参数,一个是检查的执行间隔,另外一个是检查的超时时间。以默认情况为准,PLEG检查会间隔一秒,换句话说,每一次检查过程执行之后,PLEG会等待一秒钟,然后进行下一次检查;而每一次检查的超时时间是三分钟,如果一次PLEG检查操作不能在三分钟内完成,那么这个状况,会被NodeStatus机制当做集群节点NotReady的凭据,同步给API Server。

如下图,上边一行表示正常情况下PLEG的执行流程,下边一行则表示有问题的情况。relist是检查的主函数。


PLEG Start就是启动一个协程,每个relistPeriod(1s)就调用一次relist,根据最新的PodStatus生成PodLiftCycleEvent。

relist是PLEG的核心,它从container runtime中查询属于kubelet管理的containers/sandboxes的信息,并与自身维护的 pods cache 信息进行对比,生成对应的 PodLifecycleEvent,然后输出到 eventChannel 中,通过 eventChannel 发送到 kubelet syncLoop 进行消费,然后由 kubelet syncPod 来触发 pod 同步处理过程,最终达到用户的期望状态。

not healthy 是如何发生的:

Healthy() 函数会以 “PLEG” 的形式添加到 runtimeState 中,Kubelet 在一个同步循环(SyncLoop() 函数)中会定期(默认是 10s)调用 Healthy() 函数。Healthy() 函数会检查 relist 进程(PLEG 的关键任务)是否在 3 分钟内完成。如果 relist 进程的完成时间超过了 3 分钟,就会报告 PLEG is not healthy。

代码

主要逻辑:

//// pkg/kubelet/pleg/generic.go - Healthy()

relistThreshold = 3 * time.Minute
:
func (g *GenericPLEG) Healthy() (bool, error) {
  relistTime := g.getRelistTime()
  elapsed := g.clock.Since(relistTime)
  if elapsed > relistThreshold {
    return false, fmt.Errorf("pleg was last seen active %v ago; threshold is %v", elapsed, relistThreshold)
  }
  return true, nil
}

//// pkg/kubelet/kubelet.go - NewMainKubelet()
func NewMainKubelet(kubeCfg *kubeletconfiginternal.KubeletConfiguration, ...
:
  klet.runtimeState.addHealthCheck("PLEG", klet.pleg.Healthy)

//// pkg/kubelet/kubelet.go - syncLoop()
func (kl *Kubelet) syncLoop(updates <-chan kubetypes.PodUpdate, handler SyncHandler) {
:
// The resyncTicker wakes up kubelet to checks if there are any pod workers
// that need to be sync'd. A one-second period is sufficient because the
// sync interval is defaulted to 10s.
:
  const (
    base   = 100 * time.Millisecond
    max    = 5 * time.Second
    factor = 2
  )
  duration := base
  for {
      if rs := kl.runtimeState.runtimeErrors(); len(rs) != 0 {
          glog.Infof("skipping pod synchronization - %v", rs)
          // exponential backoff
          time.Sleep(duration)
          duration = time.Duration(math.Min(float64(max), factor*float64(duration)))
          continue
      }
    :
  }
:
}

//// pkg/kubelet/runtime.go - runtimeErrors()
func (s *runtimeState) runtimeErrors() []string {
:
    for _, hc := range s.healthChecks {
        if ok, err := hc.fn(); !ok {
            ret = append(ret, fmt.Sprintf("%s is not healthy: %v", hc.name, err))
        }
    }
:
}

relist 处理

即使将relist设置为每1秒调用一次,如果容器运行时响应缓慢,或者一个周期中发生许多容器更改时,也可能需要花费1秒以上的时间才能完成。因此,下一个relist将在上一个完成后调用。

例如,如果relist需要5秒才能完成,则下一次relist时间为6秒(1秒+ 5秒)

因此如果容器数量很大或者进程有问题,会出现延时,3 分钟内没有响应,就会出现not healthy ,因此监控relist的延迟是有必要的,kubelet 暴露了和 pleg 相关的指标,可以通过这些指标查看当前的状态

relist 指标监控:

//// pkg/kubelet/pleg/generic.go - relist()
  :
  // get a current timestamp
  timestamp := g.clock.Now()

  // kubelet_pleg_relist_latency_microseconds for prometheus metrics
    defer func() {
        metrics.PLEGRelistLatency.Observe(metrics.SinceInMicroseconds(timestamp))
    }()
 
  // Get all the pods.
    podList, err := g.runtime.GetPods(true)
  :

最后,updateCache()将检查每个Pod,并在一个循环中一个接一个地更新它,因此,如果在同一relist期间更改了许多Pod ,则此过程可能会成为瓶颈。然后新Pod生命周期事件将发送到eventChannel。

 for pid, events := range eventsByPodID {
    pod := g.podRecords.getCurrent(pid)
    if g.cacheEnabled() {
      // updateCache() will inspect the pod and update the cache. If an
      // error occurs during the inspection, we want PLEG to retry again
      // in the next relist. To achieve this, we do not update the
      // associated podRecord of the pod, so that the change will be
      // detect again in the next relist.
      // TODO: If many pods changed during the same relist period,
      // inspecting the pod and getting the PodStatus to update the cache
      // serially may take a while. We should be aware of this and
      // parallelize if needed.
      if err := g.updateCache(pod, pid); err != nil {
        glog.Errorf("PLEG: Ignoring events for pod %s/%s: %v", pod.Name, pod.Namespace, err)
        :
      }
      :
    }
    // Update the internal storage and send out the events.
    g.podRecords.update(pid)
    for i := range events {
      // Filter out events that are not reliable and no other components use yet.
      if events[i].Type == ContainerChanged {
           continue
      }
      g.eventChannel <- events[i]
     }
  }

排查

出现 pleg not healthy,一般有以下几种可能:

  • 容器运行时无响应或响应超时,如 docker进程响应超时(比较常见)
  • 该节点上容器数量过多,导致 relist 的过程无法在 3 分钟内完成
  • relist 出现了死锁,该 bug 已在 Kubernetes 1.14 中修复
  • 网络

docker进程hang 死导致 pleg遇到过很多次,一般是由于 docker 版本过旧,或者机器负载过高导致,可以把 kubelet和 docker 的日志等级调到最高,对比日志定位后进行处理

参考

查看原文

赞 0 收藏 0 评论 0

徐亚松 发布了文章 · 2020-03-27

kubelet 原理解析六: 垃圾回收

概述

在k8s中节点会通过docker pull机制获取外部的镜像,那么什么时候清除镜像呢?k8s运行的容器又是什么时候清除呢?

  • api-server: 运行在master,无状态组件,go自动内存垃圾回收
  • controller-manager: 运行在master,无状态组件,go自动内存垃圾回收,owner机制提供resource垃圾回收
  • scheduler: 运行在master,无状态组件,go自动内存垃圾回收
  • kube-proxy:运行在node,无状态组件,无垃圾收集需要
  • kubelet:运行在node,无状态组件,需要管理宿主机的image和container

Kubelet会定期进行垃圾回收(Garbage Collection),以清理节点上的无用镜像和容器。

  • 每隔 1 分钟进行一次容器清理
  • 每隔 5 分钟进行一次镜像清理

截止到 v1.15 版本,垃圾回收间隔时间还都是在源码中固化的,不可自定义配置,如果节点上已经运行了 Kubelet,不建议再额外运行其它的垃圾回收工具,因为这些工具可能错误地清理掉 Kubelet 认为本应保留的镜像或容器,从而可能造成不可预知的问题。

容器的回收

docker容器的本质是宿主机上的一个进程,为了将容器做差异化的封装,docker借助于类似AUFS之类的文件系统做了很多事情。容器停止执行后,这些文件系统并不会自动清除,通过docker ps -a也能够看到这些资源(这是为了下次可以快速启动)。kubelet有一套container gc的方案,专门用于清理宿主机上的非所需容器。

另外,容器镜像较耗存储资源,但是每一台k8s node的存储空间都是有限的,kubelet上运行的pod生命周期可能很短,但是每个pod可能都使用不同的镜像,这就会导致宿主机上会留下很多不再需要的容器镜像,为了将有限的空间腾出来高效利用,kubelet设计了一套image gc的方案。

GC机制即将被eviction替代,在kubelet参数中已经有对应的提示信息。

容器GC的业务逻辑主要在(m *kubeGenericRuntimeManager)GarbageCollect中,主要是三个参数:

  • MinAge:容器可以被垃圾回收的最小年龄,默认0分钟,命令行参数为minimum-container-ttl-duration
  • MaxPerPodContainer: 每个pod中保留的最大的停止容器数量,默认为1,命令行参数为maximum-dead-containers-per-container
  • MaxContainers: 整个节点保留的最大的停止容器数量,默认为-1,标示没有限制,命令行参数为maximum-dead-containers

容器回收过程如下:

  1. 顶层函数会每分钟被调用,触发container gc操作;
  2. 该操作会以container的结束时间是否超过gcPolicy.MinAge为依据,查询出那些满足条件的容器,并组织成为按照pod为key,container列表为值的字典;这一步并没有做实际删除,但是其操作结果为后两部奠定了数据依据;
  3. 对字典中的每个pod的container做处理,找出该pod超过gcPolicy.MaxPerPodContainer的容器,然后对它们按照结束时间排序,执行删除,保障每个pod下已结束的container数满足配置参数;
  4. 经过上一部的删除后,针对node来讲,如果节点上待删除的容器数依然大于gcPolicy.MaxContainers, 就执行反向的运算。把node允许保留的最大容器数平分给每个pod,再按照该标准对每个pod执行一轮删除;
  5. 如果依然还不满足要求的数量,就不再按照pod做key,直接将所有的container拍扁平,按照时间顺序先删除最旧的容器,直到满足总数小于gcPolicy.MaxContainers。

镜像的回收

镜像回收主要参数:

  • minimum-image-ttl-duration:最少这么久镜像都未被使用,才允许清理;比如:’300ms’, ‘10s’ or ‘2h45m’.”
  • image-gc-high-threshold:imageFS磁盘使用率的上限,当达到该值时触发镜像清理。默认值为 90%
  • image-gc-low-threshold: imageFS磁盘使用率的下限,每次清理直到使用率低于这个值或者没有可以清理的镜像了才会停止。默认值为 80%

具体流程比较简单;

  1. 与容器GC比较起来,镜像GC顶层函数被触发的周期更长,为5分钟触发一次。
  2. 通过cadvisor获取到节点上imageFS的详情,得到capacity、avaiable,据此推算磁盘使用率等信息;
  3. 当磁盘使用率大于image-gc-high-threshold参数中指定的值时,触发镜像的GC操作;
  4. 找出当前未被使用的镜像列表并按时间排序,过滤掉那些小于minimum-image-ttl-duration的镜像;
  5. 正式从节点上删除镜像;每次都比较是否删除的镜像数以满足所需释放的bytesToFree,若满足就停止删除。
  • 那些用户手动 run 起来的容器,对于 Kubelet 垃圾回收来说就是不可见的,也就不能阻止对相关镜像的垃圾回收
  • 当镜像存放目录磁盘使用率(df -h) 大于 HighThresholdPercent后,开始删除节点中未使用的docker镜像
  • 当磁盘使用率降低至LowThresholdPercent时,停止镜像的垃圾回收。
  • 在节点中存在一定的image是必要的,因为可以减少docker拉取镜像的速度减少带宽压力,加速容器启动.

参考

查看原文

赞 0 收藏 0 评论 0

徐亚松 发布了文章 · 2020-03-27

kubelet 原理解析三:runtime

本文转自:https://feisky.xyz/posts/kube...

架构

Kubelet 架构图

  • Generic Runtime Manager:这是容器运行时的管理者,负责于 CRI 交互,完成容器和镜像的管理
  • 在 CRI 之下,包括两种容器运行时的实现
* 一个是内置的 dockershim,实现了 docker 容器引擎的支持以及 CNI 网络插件(包括 kubenet)的支持
* 另一个就是外部的容器运行时,用来支持 runc、containerd、gvisor 等外部容器运行时

Kubelet 通过 CRI 接口跟外部容器运行时交互,它包括

  • CRI Server: 这是 CRI gRPC server,监听在 unix socket 上面
  • Streaming Server: 提供 streaming API,包括 Exec、Attach、Port Forward
  • 容器和镜像的管理:比如拉取镜像、创建和启动容器等
  • CNI 网络插件的支持: 用于给容器配置网络
  • 容器引擎的管理: 比如支持 runc 、containerd 或者支持多个容器引擎

这样,Kubernetes 中的容器运行时按照不同的功能就可以分为三个部分:

  • 第一个是 Kubelet 中容器运行时的管理,它通过 CRI 管理容器和镜像
  • 第二个是容器运行时接口,是 Kubelet 与外部容器运行时的通信接口
  • 第三个是具体的容器运行时实现,包括 Kubelet 内置的 dockershim 以及外部的容器运行时(如 cri-o、cri-containerd、frakti等)

这样的话,我们就可以基于这三个不同部分来看一看容器运行时的演进过程

演进过程


容器运行时的演进可以分为三个阶段:

  • 第一阶段: 在 Kubernetes v1.5 之前,Kubelet 内置了 Docker 和 rkt 的支持,并且通过 CNI 网络插件给它们配置容器网络。这个阶段的用户如果需要自定义运行时的功能是比较痛苦的,需要修改 Kubelet 的代码,并且很有可能这些修改无法推到上游社区。这样,还需要维护一个自己的 fork 分支,维护和升级都非常麻烦。
  • 第二阶段: 不同用户实现的容器运行时各有所长,许多用户都希望Kubernetes支持更多的运行时。于是,从v1.5 开始增加了 CRI 接口,通过容器运行时的抽象层消除了这些障碍,使得无需修改 Kubelet 就可以支持运行多种容器运行时。CRI 接口包括了一组 Protocol Buffer、gRPC API 、用于 streaming 接口的库以及用于调试和验证的一系列工具等。在此阶段,内置的 Docker 实现也逐步迁移到了 CRI 的接口之下。但此时 rkt 还未完全迁移,这是因为 rkt 迁移 CRI 的过程将在独立的 repository 完成,方便其维护和管理。
  • 第三阶段: 从 v1.11 开始,Kubelet 内置的 rkt 代码删除,CNI 的实现迁移到 dockershim 之内。这样,除了 docker 之外,其他的容器运行时都通过 CRI 接入。外部的容器运行时一般称为 CRI Shim,它除了实现 CRI 接口外,也要负责为容器配置网络。一般推荐使用 CNI,因为这样可以支持社区内的众多网络插件,不过这不是必需的,网络插件只需要满足 Kubernetes 网络的基本假设即可,即 IP-per-Pod、所有 Pod 和 Node 都可以直接通过 IP 相互访问。

容器运行时接口(CRI)

容器运行时接口(CRI)是一个用来扩展容器运行时的接口,它基于 gPRC,用户不需要关心内部通信逻辑,而只需要实现定义的接口就可以,包括 RuntimeService 和 ImageService。

  • RuntimeService负责管理Pod和容器的生命周期
  • ImageService负责镜像的生命周期管理

除了 gRPC API,CRI 还包括用于实现 streaming server 的库(用于 Exec、Attach、PortForward 等接口)和 CRI Tools。

基于 CRI 接口的容器运行时通常称为 CRI shim, 这是一个 gRPC Server,监听在本地的unix socket上;而kubelet作为gRPC的客户端来调用CRI接口。另外,外部容器运行时需要自己负责管理容器的网络,推荐使用CNI,这样跟Kubernetes的网络模型保持一致。

CRI 的推出为容器社区带来了新的繁荣,cri-o、frakti、cri-containerd 等一些列的容器运行时为不同场景而生:

  • cri-containerd——基于 containerd 的容器运行时
  • cri-o——基于 OCI 的容器运行时
  • frakti——基于虚拟化的容器运行时

而基于这些容器运行时,还可以轻易联结新型的容器引擎,比如可以通过 clear container、gVisor 等新的容器引擎配合 cri-o 或 cri-containerd 等轻易接入 Kubernetes,将 Kubernetes 的应用场景扩展到了传统 IaaS 才能实现的强隔离和多租户场景。

当使用CRI运行时,需要配置kubelet的--container-runtime参数为remote,并设置--container-runtime-endpoint为监听的unix socket位置(Windows上面为 tcp 端口)。

CRI 接口

CRI 接口包括 RuntimeService 和 ImageService 两个服务,这两个服务可以在一个 gRPC server 里面实现,当然也可以分开成两个独立服务。目前社区的很多运行时都是将其在一个 gRPC server 里面实现。

分类

管理镜像的 ImageService 提供了 5 个接口,分别是查询镜像列表、拉取镜像到本地、查询镜像状态、删除本地镜像以及查询镜像占用空间等。这些都很容易映射到 docker API 或者 CLI 上面。

而 RuntimeService 则提供了更多的接口,按照功能可以划分为四组:

  • PodSandbox 的管理接口:PodSandbox 是对 Kubernete Pod 的抽象,用来给容器提供一个隔离的环境(比如挂载到相同的 cgroup 下面),并提供网络等共享的命名空间。PodSandbox 通常对应到一个 Pause 容器或者一台虚拟机。
  • Container 的管理接口:在指定的 PodSandbox 中创建、启动、停止和删除容器。
  • Streaming API 接口:包括 Exec、Attach 和 PortForward 等三个和容器进行数据交互的接口,这三个接口返回的是运行时 Streaming Server 的 URL,而不是直接跟容器交互。
  • 状态接口,包括查询 API 版本和查询运行时状态。

Streaming API

Streaming API 用于客户端与容器进行交互,包括Exec、PortForward 和 Attach 等三个接口。Kubelet 内置的 docker 通过 nsenter、socat 等方法来支持这些特性,但它们不一定适用于其他的运行时,也不支持 Linux 之外的其他平台。因而,CRI 也显式定义了这些 API,并且要求容器运行时返回一个 streaming server 的 URL 以便 Kubelet 重定向 API Server 发送过来的流式请求。

这是因为所有容器的流式请求都会经过 Kubelet,这有可能会给节点的网络流量带来瓶颈,因而 CRI 要求容器运行时启动一个对应请求的单独的流服务器,将地址返回给Kubelet。Kubelet然后将这个信息再返回给Kubernetes API Server,它会打开直接与运行时提供的服务器相连的流连接,并通过它跟客户端连通。

这样一个完整的 Exec 流程就如上图所示,分为多个阶段:

  • 客户端 kubectl exec -i -t ...
  • kube-apiserver 向 Kubelet 发送流式请求 /exec/
  • Kubelet 通过 CRI 接口向 CRI Shim 请求 Exec 的 URL
  • CRI Shim 向 Kubelet 返回 Exec URL
  • Kubelet 向 kube-apiserver 返回重定向的响应
  • kube-apiserver 重定向流式请求到 Exec URL,接着就是 CRI Shim 内部的 Streaming Server 跟 kube-apiserver 进行数据交互,完成 Exec 的请求和响应

在 v1.10 及更早版本中,容器运行时必需返回一个 API Server 可直接访问的 URL(通常跟 Kubelet 使用相同的监听地址);而从 v1.11 开始,Kubelet 新增了--redirect-container-streaming选项(默认为 false),支持不转发而是代理 Streaming 请求,这样运行时可以返回一个 localhost 的 URL(当然也不再需要配置 TLS)。

运行时分类

以下是几个常见容器运行时的例子,它们各有所长,并且也支持不同的容器引擎:

隔离性

在多租户场景下,强隔离(特别是虚拟化级别的隔离)是一个最基本的需求。

以前使用 Kubernetes 时,由于只支持Docker 容器,而它只提供了内核命名空间(namespace)的隔离,虽然也支持 SELinux、AppArmor 等基本的安全控制,但还是无法满足多租户的需求。所以曾经社区有人提出节点独占的方式实现租户隔离,即每个容器或租户独占一台虚拟机,资源的浪费是很明显的。

有了 CRI 之后,就可以接入 Kata Container、Clear Container 等基于虚拟化的容器引擎。这样通过虚拟化实现了容器的强隔离,不同租户的容器也可以运行在相同的 Node 上,大大提高了资源的利用率。

当然了,多租户不仅需要容器自身的强隔离,还需要众多其他的功能一起配合,比如

网络隔离,比如可以使用 CNI 构建新的网络插件,把不同租户的 Pod 接入到相互隔离的虚拟网络中。
资源管理,比如基于 CRD 构建租户 API 和租户控制器,管理租户和租户的资源。
认证、授权、配额管理等等也都可以在 Kubernetes API 之上构建。

工具

CRI Tools 是社区 Node 组针对 CRI 接口开发的辅助工具,它包括两个工具:crictl 和 critest。

crictl 是一个容器运行时命令行接口,它对系统和应用的排错来说是个很有用的工具。当使用 Docker 运行时,调试系统信息的时候我们可能使用 docker ps 和 docker inspect 等命令检查应用的进程情况。但是对于其他基于 CRI 的容器运行时来说,它们可能没有自己的命令行工具;或者即便有,它们的操作界面也不一定与 Kubernetes 中的概念一致。更不用说,很有很多的命令对 Kubernetes 没什么用,甚至会损害系统(比如 docker rename)。因而,我们推荐使用 crictl 作为 Docker CLI 的继任者,用于 Kubernetes 节点上 pod、容器以及镜像的除错工具。

crictl 提供了类似 Docker CLI 的使用体验, 并且支持所有 CRI 兼容的容器运行时。并且,crictl 提供了一个对 Kubernetes 来说更加友好的容器视角:它就是为 Kubernetes 而设计的,有不同的命令分别与Pod 和容器进行交互。例如 crictl pods 会列出 Pod 信息,而 crictl ps 只会列出应用容器的信息。

而 critest 则是一个容器运行时的验证测试工具,用于验证容器运行时是否符合 Kubelet CRI 的要求。除了验证测试,critest 还提供了 CRI 接口的性能测试,比如 critest -benchmark。

推荐将 critest 集成到容器运行时开发的 Devops 流程中,保证每个变更都不会破坏 CRI 的基本功能。另外,还可以选择将 critest 的测试结果与 Kubernetes Node E2E 的结果提交到 Sig-node 的 TestGrid,向社区和用户展示。

多容器运行时

多容器运行时用于不同的目的,比如使用虚拟化容器引擎式运行不可信应用和多租户应用,而使用 Docker 运行系统组件或者无法虚拟化的容器(比如需要 HostNetwork 的容器)。比如典型的用例为:

  • Kata Containers/gVisor + runc
  • Windows Process isolation + Hyper-V isolation containers

以前,多容器运行时通常以注解(Annotation)的形式支持,比如 cri-o、frakti 等都是这么支持了多容器运行时。但这一点也不优雅,并且也无法实现基于容器运行时来调度容器。因而,Kubernetes 在 v1.12 中将开始增加 RuntimeClass 这个新的 API 对象,用来支持多容器运行时。

GPU 支持

Nvidia提供了k8s pod使用GPU的一整套解决方案。运行时方面,nvidia提供了特定的运行时,主要的功能是为了让container访问从node节点上分配的GPU资源。

如上图所示,libnvidia-container被整合进docker的runc中。通过在runc的prestart hook 中调用nvidia-container-runtime-hook来控制GPU。启动容器时,prestart hook会校验环境变量GPU-enabled来校验该容器是否需要启用GPU,一旦确认需要启用,就调用nvidia定制的运行时来启动容器,从而为容器分配limit指定个数的GPU。

在k8s的调度方面,nvidia基于k8s的device plugin来实现了kubelet层GPU资源的上报,通过在pod的spec中指定对应的limit来声明对GPU个数的申请情况。在spec中必须指定limit的值(且必须为整数),reqire的值要么不设置,要么等于limit的值。

NVIDIA_VISIBLE_DEVICES : controls which GPUs will be accessible inside the container. By default, all GPUs are accessible to the container.
NVIDIA_DRIVER_CAPABILITIES : controls which driver features (e.g. compute, graphics) are exposed to the container.
NVIDIA_REQUIRE_* : a logical expression to define the constraints (e.g. minimum CUDA, driver or compute capability) on the configurations supported by the container.

serverless

无服务器(Serverless)现在是一个很热门的方向,各个云平台也提供很多种类的无服务器计算服务,比如 Azure Container Instance、AWS Farget 等。它们的好处是用户不需要去管理容器底层的基础设施,而只需要管理容器即可,并且容器通常按实际的运行时间收费。这样,对用户来说,不仅省去了管理基础设施的繁琐步骤,还更节省成本。

那么 CRI 在这里有什么应用场景呢?假如你是一个云平台的管理者,想要构建一个无服务器容器服务,那么使用 CRI 配合多容器运行时就是一个很好的思路。这样的话,

  • Kubernetes 可以用来给整个平台提供调度和编排
  • 基于 Kubernetes API 可以搭建租户管理功能
  • 基于 CRI 可以实现多租户容器运行的强隔离
  • 基于 CNI 可以实现多租户的网络强隔离

那么,对云平台的用户呢?这些无服务器容器服务提供的功能通常都比较简单,并不具备编排的功能。但可以借助 Virtual Kubelet 项目,使用 Kubernetes 为这些平台的容器提供编排功能。

Virtual Kubelet 是针对 Serverless 容器平台设计的虚拟 Kubernetes 节点,它模拟了 Kubelet 的功能,并将 Serverless 容器平台抽象为一个虚拟的无限资源的 Node。这样就可以通过 Kubernetes API 来管理其上的容器。

目前 Virtual Kubelet 已经支持了众多的云平台,包括

  • Azure Container Instance
  • AWS Farget
  • Service Fabric
  • Hyper.sh
  • IoT Edge
查看原文

赞 0 收藏 0 评论 0

徐亚松 发布了文章 · 2020-03-27

kubelet 原理解析四:probeManager

概述

在Kubernetes 中,系统和应用程序的健康检查任务是由 kubelet 来完成的,本文主要讨论kubelet中 probemanager 相关的实现原理。

如果你对k8s的各种probe如何使用还不了解,可以看下我之前写的这篇K8S 中的健康检查机制,是从实践的角度介绍的。

statusManager

在 kubelet 初始化的时候,会创建 statusManager 和 probeManager,这两个都是和 pod 状态相关的逻辑,在kubelet 原理解析一:pod管理文章中有提到,statusManager 负责维护状态信息,并把Pod状态及时更新到Api-Server,

但是它并不负责监控 pod 状态的变化,而是提供对应的接口供其他组件调用,比如 probeManager。probeManager 会定时去监控 pod 中容器的健康状况,一旦发现状态发生变化,就调用 statusManager 提供的方法更新 pod 的状态。

klet.statusManager = status.NewManager(kubeClient, klet.podManager)
klet.probeManager = prober.NewManager(
        klet.statusManager,
        klet.livenessManager,
        klet.runner,
        containerRefManager,
        kubeDeps.Recorder)

statusManager代码位于:pkg/kubelet/status/status_manager.go

type PodStatusProvider interface {
    GetPodStatus(uid types.UID) (api.PodStatus, bool)
}

type Manager interface {
    PodStatusProvider
    Start()
    SetPodStatus(pod *api.Pod, status api.PodStatus)
    SetContainerReadiness(podUID types.UID, containerID kubecontainer.ContainerID, ready bool)
    TerminatePod(pod *api.Pod)
    RemoveOrphanedStatuses(podUIDs map[types.UID]bool)
}

SetPodStatus:如果 pod 的状态发生了变化,会调用这个方法,把新状态更新到 apiserver,一般在 kubelet 维护 pod 生命周期的时候会调用

SetContainerReadiness:如果健康检查发现 pod 中容器的健康状态发生变化,会调用这个方法,修改 pod 的健康状态

TerminatePod:kubelet 在删除 pod 的时候,会调用这个方法,把 pod 中所有的容器设置为 terminated 状态

RemoveOrphanedStatuses:删除孤儿 pod,直接把对应的状态数据从缓存中删除即可

Start() 方法是在 kubelet 运行的时候调用的,它会启动一个 goroutine 执行更新操作:

const syncPeriod = 10 * time.Second

func (m *manager) Start() {
    ......
    glog.Info("Starting to sync pod status with apiserver")
    syncTicker := time.Tick(syncPeriod)
    // syncPod and syncBatch share the same go routine to avoid sync races.
    go wait.Forever(func() {
        select {
        case syncRequest := <-m.podStatusChannel:
            m.syncPod(syncRequest.podUID, syncRequest.status)
        case <-syncTicker:
            m.syncBatch()
        }
    }, 0)
}

这个 goroutine 就能不断地从两个 channel 监听数据进行处理:syncTicker 是个定时器,也就是说它会定时保证 apiserver 和自己缓存的最新 pod 状态保持一致;podStatusChannel 是所有 pod 状态更新发送到的地方,调用方不会直接操作这个 channel,而是通过调用上面提到的修改状态的各种方法,这些方法内部会往这个 channel 写数据。

m.syncPod 根据参数中的 pod 和它的状态信息对 apiserver 中的数据进行更新,如果发现 pod 已经被删除也会把它从内部数据结构中删除。

probeManager

probeManager负责 检测 pod 中容器的健康状态,目前有三种 probe:

  • liveness: 让Kubernetes知道你的应用程序是否健康,如果你的应用程序不健康,Kubernetes将删除Pod并启动一个新的替换它(与RestartPolicy有关)。Liveness 探测可以告诉 Kubernetes 什么时候通过重启容器实现自愈。
  • readiness: readiness与liveness原理相同,不过Readiness探针是告诉 Kubernetes 什么时候可以将容器加入到 Service 负载均衡中,对外提供服务。
  • startupProbe:1.16开始支持的新特性,检测慢启动容器的状态,具体参考startup-probes

并不是所有的 pod 中的容器都有健康检查的探针,如果没有,则不对容器进行检测,默认认为容器是正常的。在每次创建新 pod 的时候,kubelet 都会调用 probeManager.AddPod(pod) 方法,它对应的实现在 pkg/kubelet/prober/prober_manager.go 文件中:

func (m *manager) AddPod(pod *v1.Pod) {
    m.workerLock.Lock()
    defer m.workerLock.Unlock()

    key := probeKey{podUID: pod.UID}
    for _, c := range pod.Spec.Containers {
        key.containerName = c.Name

        if c.ReadinessProbe != nil {
            key.probeType = readiness
            if _, ok := m.workers[key]; ok {
                klog.Errorf("Readiness probe already exists! %v - %v",
                    format.Pod(pod), c.Name)
                return
            }
            w := newWorker(m, readiness, pod, c)
            m.workers[key] = w
            go w.run()
        }

        if c.LivenessProbe != nil {
            key.probeType = liveness
            if _, ok := m.workers[key]; ok {
                klog.Errorf("Liveness probe already exists! %v - %v",
                    format.Pod(pod), c.Name)
                return
            }
            w := newWorker(m, liveness, pod, c)
            m.workers[key] = w
            go w.run()
        }
    }
}

在这个方法里,kubelet 会遍历pod 中所有的 container,如果配置了 probe,就创建一个 worker,并异步处理这次探测

// Creates and starts a new probe worker.
func newWorker(
    m *manager,
    probeType probeType,
    pod *v1.Pod,
    container v1.Container) *worker {

    w := &worker{
        stopCh:       make(chan struct{}, 1), // Buffer so stop() can be non-blocking.
        pod:          pod,
        container:    container,
        probeType:    probeType,
        probeManager: m,
    }

    switch probeType {
    case readiness:
        w.spec = container.ReadinessProbe
        w.resultsManager = m.readinessManager
        w.initialValue = results.Failure
    case liveness:
        w.spec = container.LivenessProbe
        w.resultsManager = m.livenessManager
        w.initialValue = results.Success
    }

    w.proberResultsMetricLabels = prometheus.Labels{
        "probe_type":     w.probeType.String(),
        "container_name": w.container.Name,
        "pod_name":       w.pod.Name,
        "namespace":      w.pod.Namespace,
        "pod_uid":        string(w.pod.UID),
    }

    return w
}

worker 开始run之后,会调用doProbe方法

func (w *worker) doProbe() (keepGoing bool) {
    defer func() { recover() }() 
    defer runtime.HandleCrash(func(_ interface{}) { keepGoing = true })

    // pod 没有被创建,或者已经被删除了,直接跳过检测,但是会继续检测
    status, ok := w.probeManager.statusManager.GetPodStatus(w.pod.UID)
    if !ok {
        glog.V(3).Infof("No status for pod: %v", format.Pod(w.pod))
        return true
    }

    // pod 已经退出(不管是成功还是失败),直接返回,并终止 worker
    if status.Phase == api.PodFailed || status.Phase == api.PodSucceeded {
        glog.V(3).Infof("Pod %v %v, exiting probe worker",
            format.Pod(w.pod), status.Phase)
        return false
    }

    // 容器没有创建,或者已经删除了,直接返回,并继续检测,等待更多的信息
    c, ok := api.GetContainerStatus(status.ContainerStatuses, w.container.Name)
    if !ok || len(c.ContainerID) == 0 {
        glog.V(3).Infof("Probe target container not found: %v - %v",
            format.Pod(w.pod), w.container.Name)
        return true 
    }

    // pod 更新了容器,使用最新的容器信息
    if w.containerID.String() != c.ContainerID {
        if !w.containerID.IsEmpty() {
            w.resultsManager.Remove(w.containerID)
        }
        w.containerID = kubecontainer.ParseContainerID(c.ContainerID)
        w.resultsManager.Set(w.containerID, w.initialValue, w.pod)
        w.onHold = false
    }

    if w.onHold {
        return true
    }

    if c.State.Running == nil {
        glog.V(3).Infof("Non-running container probed: %v - %v",
            format.Pod(w.pod), w.container.Name)
        if !w.containerID.IsEmpty() {
            w.resultsManager.Set(w.containerID, results.Failure, w.pod)
        }
        // 容器失败退出,并且不会再重启,终止 worker
        return c.State.Terminated == nil ||
            w.pod.Spec.RestartPolicy != api.RestartPolicyNever
    }

    // 容器启动时间太短,没有超过配置的初始化等待时间 InitialDelaySeconds
    if int32(time.Since(c.State.Running.StartedAt.Time).Seconds()) < w.spec.InitialDelaySeconds {
        return true
    }

    // 调用 prober 进行检测容器的状态
    result, err := w.probeManager.prober.probe(w.probeType, w.pod, status, w.container, w.containerID)
    if err != nil {
        return true
    }

    if w.lastResult == result {
        w.resultRun++
    } else {
        w.lastResult = result
        w.resultRun = 1
    }

    // 如果容器退出,并且没有超过最大的失败次数,则继续检测
    if (result == results.Failure && w.resultRun < int(w.spec.FailureThreshold)) ||
        (result == results.Success && w.resultRun < int(w.spec.SuccessThreshold)) {
        return true
    }

    // 保存最新的检测结果
    w.resultsManager.Set(w.containerID, result, w.pod)

    if w.probeType == liveness && result == results.Failure {
        // 容器 liveness 检测失败,需要删除容器并重新创建,在新容器成功创建出来之前,暂停检测
        w.onHold = true
    }

    return true
}

liveness检测结果会存放在resultsManager,它把结果保存在缓存中,并发送到 m.updates 管道。而管道消费者是 kubelet 中的主循环syncLoopIteration。

case update := <-kl.livenessManager.Updates():
        if update.Result == proberesults.Failure {
            // The liveness manager detected a failure; sync the pod.
            pod, ok := kl.podManager.GetPodByUID(update.PodUID)
            if !ok {
                // If the pod no longer exists, ignore the update.
                glog.V(4).Infof("SyncLoop (container unhealthy): ignore irrelevant update: %#v", update)
                break
            }
            glog.V(1).Infof("SyncLoop (container unhealthy): %q", format.Pod(pod))
            handler.HandlePodSyncs([]*api.Pod{pod})
        }

liveness检测如果不通过,pod就会重启,由 kubelet 的 sync 循环处理即可。但 readness检测失败不能重启 pod,因此readness的逻辑是:

func (m *manager) updateReadiness() {
    update := <-m.readinessManager.Updates()

    ready := update.Result == results.Success
    m.statusManager.SetContainerReadiness(update.PodUID, update.ContainerID, ready)
}

proberManager 启动的时候,会运行一个 goroutine 定时读取 readinessManager 管道中的数据,并根据数据调用 statusManager 去更新 apiserver 中 pod 的状态信息。

负责 Service 逻辑的组件获取到了这个状态,就能根据不同的值来决定是否需要更新 endpoints 的内容,也就是 service 的请求是否发送到这个 pod。

Probe 方法

上面是 probemanager 的主要逻辑,我们接下来看下真正执行探测任务的 probe方法

// probe probes the container.
func (pb *prober) probe(probeType probeType, pod *v1.Pod, status v1.PodStatus, container v1.Container, containerID kubecontainer.ContainerID) (results.Result, error) {
    var probeSpec *v1.Probe
    switch probeType {
    case readiness:
        probeSpec = container.ReadinessProbe
    case liveness:
        probeSpec = container.LivenessProbe
    default:
        return results.Failure, fmt.Errorf("Unknown probe type: %q", probeType)
    }
    ...
    result, output, err := pb.runProbeWithRetries(probeType, probeSpec, pod, status, container, containerID, maxProbeRetries)
    ...

probe主方法调用pb.runProbeWithRetries 方法,传入containerid、类型、重试次数等。

exec 方法

调用runtimeService的ExecSync方法进入容器执行命令,回收结果,如果退出码为 0 ,就认为探测成功。

command := kubecontainer.ExpandContainerCommandOnlyStatic(p.Exec.Command, container.Env)
        return pb.exec.Probe(pb.newExecInContainer(container, containerID, command, timeout))
    
....
    
func (pb *prober) newExecInContainer(container v1.Container, containerID kubecontainer.ContainerID, cmd []string, timeout time.Duration) exec.Cmd {
    return execInContainer{func() ([]byte, error) {
        return pb.runner.RunInContainer(containerID, cmd, timeout)
    }}
}

...

func (m *kubeGenericRuntimeManager) RunInContainer(id kubecontainer.ContainerID, cmd []string, timeout time.Duration) ([]byte, error) {
    stdout, stderr, err := m.runtimeService.ExecSync(id.ID, cmd, timeout)
    return append(stdout, stderr...), err
}

func (pr execProber) Probe(e exec.Cmd) (probe.Result, string, error) {
    data, err := e.CombinedOutput()
    klog.V(4).Infof("Exec probe response: %q", string(data))
    if err != nil {
        exit, ok := err.(exec.ExitError)
        if ok {
            if exit.ExitStatus() == 0 {
                return probe.Success, string(data), nil
            }
            return probe.Failure, string(data), nil
        }
        return probe.Unknown, "", err
    }
    return probe.Success, string(data), nil
}

HTTP 方法

标准的 http 探测模板,如果400 > code >= 200,则认为成功。不支持 https

func DoHTTPProbe(url *url.URL, headers http.Header, client GetHTTPInterface) (probe.Result, string, error) {
    req, err := http.NewRequest("GET", url.String(), nil)
    if err != nil {
        // Convert errors into failures to catch timeouts.
        return probe.Failure, err.Error(), nil
    }
    if _, ok := headers["User-Agent"]; !ok {
        if headers == nil {
            headers = http.Header{}
        }
        // explicitly set User-Agent so it's not set to default Go value
        v := version.Get()
        headers.Set("User-Agent", fmt.Sprintf("kube-probe/%s.%s", v.Major, v.Minor))
    }
    req.Header = headers
    if headers.Get("Host") != "" {
        req.Host = headers.Get("Host")
    }
    res, err := client.Do(req)
    if err != nil {
        // Convert errors into failures to catch timeouts.
        return probe.Failure, err.Error(), nil
    }
    defer res.Body.Close()
    b, err := ioutil.ReadAll(res.Body)
    if err != nil {
        return probe.Failure, "", err
    }
    body := string(b)
    if res.StatusCode >= http.StatusOK && res.StatusCode < http.StatusBadRequest {
        klog.V(4).Infof("Probe succeeded for %s, Response: %v", url.String(), *res)
        return probe.Success, body, nil
    }
    klog.V(4).Infof("Probe failed for %s with request headers %v, response body: %v", url.String(), headers, body)
    return probe.Failure, fmt.Sprintf("HTTP probe failed with statuscode: %d", res.StatusCode), nil
}

TCP 方法

gRPC或FTP服务一般会使用 TCP 探测,尝试在指定端口上建立TCP连接。

如果socket连接能成功,则返回成功。

func DoTCPProbe(addr string, timeout time.Duration) (probe.Result, string, error) {
    conn, err := net.DialTimeout("tcp", addr, timeout)
    if err != nil {
        // Convert errors to failures to handle timeouts.
        return probe.Failure, err.Error(), nil
    }
    err = conn.Close()
    if err != nil {
        klog.Errorf("Unexpected error closing TCP probe socket: %v (%#v)", err, err)
    }
    return probe.Success, "", nil
}

参考

查看原文

赞 0 收藏 0 评论 0

徐亚松 发布了文章 · 2020-03-27

高可用prometheus:常见问题

监控系统的历史悠久,是一个很成熟的方向,而Prometheus作为新生代的开源监控系统,慢慢成为了云原生体系的事实标准,也证明了其设计很受欢迎。本文主要分享在prometheus实践中遇到的一些问题和思考

几点原则

  • 监控是基础设施,目的是为了解决问题,不要只朝着大而全去做,尤其是不必要的指标采集,浪费人力和存储资源(To B商业产品例外)
  • 需要处理的告警才发出来,发出来的告警必须得到处理
  • 简单的架构就是最好的架构,业务系统都挂了,监控也不能挂,Google SRE里面也说避免使用magic系统,例如机器学习报警阈值、自动修复之类。这一点见仁见智吧,感觉很多公司都在搞智能AI运维

prometheus 的局限

  • prometheus是基于metric的监控,不适用于日志(logs)、事件(event)、调用链(tracing)
  • prometheus默认是pull模型,合理规划你的网络,尽量不用pushgateway转发
  • 对于集群化、水平扩展,官方和社区都没有银弹,合理选择 federate、cortex、thanos
  • 监控系统一般 可用性>一致性,这个后面说 thanos 的时候会提到

合理选择黄金指标

我们应该关注哪些指标?Google在“SRE Handbook”中提出了“四个黄金信号”:延迟、流量、错误数、饱和度。实际操作中可以使用USE或RED 方法作为指导,USE用于资源,RED用于服务

  • USE 方法:Utilization、Saturation、Errors
  • RED 方法:Rate、Errors、Duration

对USE和RED的阐述可以参考容器监控实践—K8S常用指标分析这篇文章

采集组件all in one

prometheus 体系中 exporter 都是独立的,每个组件各司其职,如机器资源用 node-exporter,gpu 有NVIDIA exporter等等,但是 exporter 越多,运维压力越大,尤其是对 agent做资源控制、版本升级。我们尝试对一些exporter进行组合,方案有二:

  1. 通过主进程拉起n个exporter 进程,仍然可以跟着社区版本更新
  2. 用telegraf来支持各种类型的 input,n 合 1

另外,node-exporter 不支持进程监控,可以加一个process-exporter,也可以用上边提到的telegraf。

k8s 1.16中cadvisor的指标兼容问题

在 k8s 1.16版本,cadvisor的指标去掉了pod_name 和 container_name的label,替换为了pod 和 container。如果你之前用这两个 label 做查询或者 grafana 绘图,得更改下 sql 了。因为我们一直支持多个 k8s 版本,就通过 relabel配置继续保留了原来的**_name

metric_relabel_configs:
- source_labels: [container]
  regex: (.+)
  target_label: container_name
  replacement: $1
  action: replace
- source_labels: [pod]
  regex: (.+)
  target_label: pod_name
  replacement: $1
  action: replace

注意要用metric_relabel_configs,不是relabel_configs,采集后做的replace。

prometheus集群内与集群外部署

prometheus 如果部署在k8s集群内采集是很方便的,用官方给的yaml就可以,但我们因为权限和网络需要部署在集群外,二进制运行,专门划了几台高配服务器运行监控组件。

以 pod 方式运行在集群内是不需要证书的(in-cluster 模式),但集群外需要声明 token之类的证书,并替换__address__。例如:

kubernetes_sd_configs:
- api_server: https://xx:6443
  role: node
  bearer_token_file: token/xx.token
  tls_config:
    insecure_skip_verify: true
relabel_configs:
- separator: ;
  regex: __meta_kubernetes_node_label_(.+)
  replacement: $1
  action: labelmap
- separator: ;
  regex: (.*)
  target_label: __address__
  replacement: xx:6443
  action: replace
- source_labels: [__meta_kubernetes_node_name]
  separator: ;
  regex: (.+)
  target_label: __metrics_path__
  replacement: /api/v1/nodes/${1}/proxy/metrics/cadvisor
  action: replace

上面是通过默认配置中通过apiserver proxy 到 let,如果网络能通,其实也可以直接把kubelet的10255作为 target,规模大的时候还减轻了 apiserver 的压力,不过这种方式就要写服务发现来更新 node列表了。

gpu 指标的获取

nvidia-smi可以查看机器上的gpu资源,而cadvisor 其实暴露了metric来表示 容器使用 gpu 情况,

container_accelerator_duty_cycle
container_accelerator_memory_total_bytes
container_accelerator_memory_used_bytes

如果要更详细的gpu 数据,可以安装dcgm exporter,不过k8s 1.13 才能支持

更改 prometheus的显示时区

prometheus为避免时区混乱,在所有组件中专门使用Unix time和UTC进行显示。不支持在配置文件中设置时区,也不能读取本机/etc/timezone时区。

其实这个限制是不影响使用的:

  • 如果做可视化,grafana是可以做时区转换的
  • 如果是调接口,拿到了数据中的时间戳,你想怎么处理都可以
  • 如果因为prometheus 自带的 ui不是本地时间,看着不舒服, 2.16 版本的新版 webui已经引入了local timezone 的选项。区别见下图
  • 如果你仍然想改prometheus 代码来适应自己的时区,可以参考这篇文章

关于 timezone 的讨论,可以看这个issue

如何采集 lb 后面的rs 的 metric

假如你有一个负载均衡lb,但网络上prometheus 只能访问到 lb本身,访问不到后面的rs,应该如何采集 rs 暴露的 metric?

  • rs 的服务加 sidecar proxy,或者本机增加 proxy 组件,保证prometheus能访问到
  • lb 增加/ backend1和/ backend2请求转发到两个单独的后端,再由prometheus访问 lb 采集

版本

prometheus当前最新版本为 2.16,prometheus还在不断迭代,因此尽量用最新版,1.x版本就不用考虑了。

2.16 版本上有一套实验 UI,可以查看TSDB的状态,包括top 10的label、metric

prometheus 大内存问题

随着规模变大,prometheus需要的cpu和内存都会升高,内存一般先达到瓶颈,这个时候要么加内存,要么集群分片减少单机指标。这里我们先讨论单机版prometheus的内存问题

原因:

  • prometheus 的内存消耗主要是因为每隔2小时做一个 block 数据落盘,落盘之前所有数据都在内存里面,因此和采集量有关。
  • 加载历史数据时,是从磁盘到内存的,查询范围越大,内存越大。这里面有一定的优化空间
  • 一些不合理的查询条件也会加大内存,如 group、大范围rate

我的指标需要多少内存:

以我们的一个 promserver为例,本地只保留 2 小时数据,95 万 series,大概占用的内存如下:

有什么优化方案:

  • sample 数量超过了 200 万,就不要单实例了,做下分片,然后通过victoriametrics,thanos,trickster等方案合并数据
  • 评估哪些metric 和 label占用较多,去掉没用的指标。2.14 以上可以看 tsdb 状态
  • 查询时尽量避免大范围查询,注意时间范围和 step 的比例,慎用 group
  • 如果需要关联查询,先想想能不能通过 relabel 的方式给原始数据多加个 label,一条sql 能查出来的何必用join,时序数据库不是关系数据库。

prometheus 内存占用分析:

相关 issue:

prometheus 容量规划

容量规划除了上边说的内存,还有磁盘存储规划,这和你的 prometheus 的架构方案有关

  • 如果是单机prometheus,计算本地磁盘使用量
  • 如果是 remote-write,和已有的 tsdb 共用即可。
  • 如果是 thanos 方案,本地磁盘可以忽略(2h),计算对象存储的大小就行。

Prometheus每2小时将已缓冲在内存中的数据压缩到磁盘上的块中。包括chunks, indexes, tombstones 和metadata,这些占用了一部分存储空间。一般情况下,Prometheus中存储的每一个样本大概占用1-2字节大小(1.7byte)。可以通过promql来查看每个样本平均占用多少空间:

rate(prometheus_tsdb_compaction_chunk_size_bytes_sum[1h])
/ 
 rate(prometheus_tsdb_compaction_chunk_samples_sum[1h])
 
{instance="0.0.0.0:8890", job="prometheus"}  1.252747585939941

如果大致估算本地磁盘大小,可以通过以下公式:

磁盘大小 = 保留时间 * 每秒获取样本数 * 样本大小

保留时间(retention_time_seconds)和样本大小(bytes_per_sample)不变的情况下,如果想减少本地磁盘的容量需求,只能通过减少每秒获取样本数(ingested_samples_per_second)的方式。

查看当前每秒获取的样本数:

rate(prometheus_tsdb_head_samples_appended_total[1h])

有两种手段,一是减少时间序列的数量,二是增加采集样本的时间间隔。考虑到Prometheus会对时间序列进行压缩,因此减少时间序列的数量效果更明显.

举例说明:

  • 采集频率 30s,机器数量1000,metric种类6000,1000600026024 约 200 亿,30G左右磁盘
  • 只采集需要的指标,如 match[], 或者统计下最常使用的指标,性能最差的指标

以上磁盘容量并没有把 WAL 文件算进去,WAL 文件(raw data)Prometheus 官方文档中说明至少会保存3个 write-ahead log files,每一个最大为128M(实际运行发现数量会更多)

因为我们使用了 thanos 的方案,所以本地磁盘只保留2h 热数据。WAL 每2小时生成一份block文件,block文件每2小时上传对象存储,本地磁盘基本没有压力。

关于prometheus存储机制,可以看这篇

对 apiserver的 性能影响

如果你的 prometheus 使用了kubernetes_sd_config做服务发现,请求一般会经过集群的 apiserver,随着规模的变大,需要评估下对 apiserver性能的影响,尤其是proxy失败的时候,会导致cpu 升高。当然了,如果单k8s集群规模太大,一般都是拆分集群,不过随时监测下 apiserver 的进程变化还是有必要的。

在监控cadvisor、docker、kube-proxy 的 metric 时,我们一开始选择从 apiserver proxy 到节点的对应端口,统一设置比较方便,但后来还是改为了直接拉取节点,apiserver 仅做服务发现。

rate 的计算逻辑

prometheus 中的counter类型主要是为了 rate 而存在的,即计算速率,单纯的counter计数意义不大,因为counter一旦重置,总计数就没有意义了。

rate会自动处理counter重置的问题,counter一般都是一直变大的,例如一个exporter启动,然后崩溃了。本来以每秒大约10的速率递增,但仅运行了半个小时,则速率(x_total [1h])将返回大约每秒5的结果。另外,counter的任何减少也会被视为counter重置。例如,如果时间序列的值为[5,10,4,6],则将其视为[5,10,14,16]。

rate值很少是精确的。由于针对不同目标的抓取发生在不同的时间,因此随着时间的流逝会发生抖动,query_range计算时很少会与抓取时间完美匹配,并且抓取有可能失败。面对这样的挑战,rate的设计必须是健壮的。

rate并非想要捕获每个增量,因为有时候增量会丢失,例如实例在抓取间隔中挂掉。如果counter的变化速度很慢,例如每小时仅增加几次,则可能会导致【假象】。比如出现一个counter时间序列,值为100,rate就不知道这些增量是现在的值,还是目标已经运行了好几年并且才刚刚开始返回。

建议将rate计算的范围向量的时间至少设为抓取间隔的四倍。这将确保即使抓取速度缓慢,且发生了一次抓取故障,您也始终可以使用两个样本。此类问题在实践中经常出现,因此保持这种弹性非常重要。例如,对于1分钟的抓取间隔,您可以使用4分钟的rate 计算,但是通常将其四舍五入为5分钟。

如果 rate 的时间区间内有数据缺失,他会基于趋势进行推测,比如:

详细的内容可以看下这个视频

反直觉的 p95统计

histogram_quantile 是 Prometheus 常用的一个函数,比如经常把某个服务的 P95 响应时间来衡量服务质量。不过它到底是什么意思很难解释得清,特别是面向非技术的同学,会遇到很多“灵魂拷问”。

我们常说 P95(p99,p90都可以) 响应延迟是 100ms,实际上是指对于收集到的所有响应延迟,有 5% 的请求大于 100ms,95% 的请求小于 100ms。Prometheus 里面的 histogram_quantile 函数接收的是 0-1 之间的小数,将这个小数乘以 100 就能很容易得到对应的百分位数,比如 0.95 就对应着 P95,而且还可以高于百分位数的精度,比如 0.9999。

当你用histogram_quantile画出响应时间的趋势图时,可能会被问:为什么p95大于或小于我的平均值?

正如中位数可能比平均数大也可能比平均数小,P99 比平均值小也是完全有可能的。通常情况下 P99 几乎总是比平均值要大的,但是如果数据分布比较极端,最大的 1% 可能大得离谱从而拉高了平均值。一种可能的例子:

1, 1, ... 1, 901 // 共 100 条数据,平均值=10,P99=1

服务 X 由顺序的 A,B 两个步骤完成,其中 X 的 P99 耗时 100ms,A 过程 P99 耗时 50ms,那么推测 B 过程的 P99 耗时情况是?

直觉上来看,因为有 X=A+B,所以答案可能是 50ms,或者至少应该要小于 50ms。实际上 B 是可以大于 50ms 的,只要 A 和 B 最大的 1% 不恰好遇到,B 完全可以有很大的 P99:

A = 1, 1, ... 1,  1,  1,  50,  50 // 共 100 条数据,P99=50
B = 1, 1, ... 1,  1,  1,  99,  99 // 共 100 条数据,P99=99
X = 2, 2, ... 1, 51, 51, 100, 100 // 共 100 条数据,P99=100
如果让 A 过程最大的 1% 接近 100ms,我们也能构造出 P99 很小的 B:
A = 50, 50, ... 50,  50,  99 // 共 100 条数据,P99=50
B =  1,  1, ...  1,   1,  50 // 共 100 条数据,P99=1
X = 51, 51, ... 51, 100, 100 // 共 100 条数据,P99=100

所以我们从题目唯一能确定的只有 B 的 P99 应该不能超过 100ms,A 的 P99 耗时 50ms 这个条件其实没啥用。

类似的疑问很多,因此对于histogram_quantile函数,可能会产生反直觉的一些结果,最好的处理办法是不断试验调整你的 bucket 的值,保证更多的请求时间落在更细致的区间内,这样的请求时间才有统计意义。

慢查询问题

  • promql 的基础知识看这篇文章

prometheus 提供了自定义的promql作为查询语句,在 graph上调试的时候,会告诉你这条 sql 的返回时间,如果太慢你就要注意了,可能是你的用法出现了问题。

评估 prometheus 的整体响应时间,可以用这个默认指标:

prometheus_engine_query_duration_seconds{}

一般情况下响应过慢都是promql 使用不当导致,或者指标规划有问题,如:

  • 大量使用 join 来组合指标或者增加 label,如将 kube-state-metric 中的一些 meta label和 node-exporter 中的节点属性 label加入到 cadvisor容器 数据里,像统计 pod 内存使用率并按照所属节点的机器类型分类,或按照所属rs 归类。
  • 范围查询时,大的时间范围,step 值却很小,导致查询到的数量量过大。
  • rate会自动处理counter重置的问题,最好由 promql 完成,不要自己拿出来全部元数据在程序中自己做rate计算。
  • 在使用 rate 时,range duration要大于等于step,否则会丢失部分数据
  • prometheus 是有基本预测功能的,如derivpredict_linear(更准确)可以根据已有数据预测未来趋势
  • 如果比较复杂且耗时的sql,可以使用record rule减少指标数量,并使查询效率更高,但不要什么指标都加record,一半以上的 metric 其实不太会查询到。同时 label 中的值不要加到record rule 的 name 中。

高基数问题Cardinality

高基数是数据库避不开的一个话题,对于mysql这种db来讲,基数是指特定列或字段中包含的唯一值的数量。基数越低,列中重复的元素越多。对于时序数据库而言,就是tags、label 这种标签值的数量多少。

比如 prometheus 中如果有一个指标 http_request_count{method="get",path="/abc",originIP="1.1.1.1"}表示访问量,method 表示请求方法,originIP是客户端 IP,method的枚举值是有限的,但originIP却是无限的,加上其他 label 的排列组合就无穷大了,也没有任何关联特征,因此这种高基数不适合作为metric 的 label,真要的提取originIP,应该用日志的方式,而不是 metric 监控

时序数据库会为这些 label建立索引,以提高查询性能,以便您可以快速找到与所有指定标签匹配的值。如果值的数量过多,索引是没有意义的,尤其是做p95 等计算的时候,要扫描大量 series 数据

官方文档中对于label 的建议

CAUTION: Remember that every unique combination of key-value label pairs represents a new time series, which can dramatically increase the amount of data stored. Do not use labels to store dimensions with high cardinality (many different label values), such as user IDs, email addresses, or other unbounded sets of values.

如何查看当前的label 分布情况呢,可以使用 prometheus提供的tsdb工具。可以使用命令行查看,也可以在 2.16 版本以上的 prometheus graph 查看

[work@xxx bin]$ ./tsdb analyze ../data/prometheus/
Block ID: 01E41588AJNGM31SPGHYA3XSXG
Duration: 2h0m0s
Series: 955372
Label names: 301
Postings (unique label pairs): 30757
Postings entries (total label pairs): 10842822
....

top10 高基数的 metric

Highest cardinality metric names:
87176 apiserver_request_latencies_bucket
59968 apiserver_response_sizes_bucket
39862 apiserver_request_duration_seconds_bucket
37555 container_tasks_state
....

高基数的 label

Highest cardinality labels:
4271 resource_version
3670 id
3414 name
1857 container_id
1824 __name__
1297 uid
1276 pod
...

找到最大的 metric 或 job

top10的 metric 数量: 按 metric 名字分

topk(10, count by (__name__)({__name__=~".+"}))

apiserver_request_latencies_bucket{}  62544
apiserver_response_sizes_bucket{}   44600

top10的 metric 数量: 按 job 名字分

topk(10, count by (__name__, job)({__name__=~".+"}))

{job="master-scrape"}    525667
{job="xxx-kubernetes-cadvisor"}  50817
{job="yyy-kubernetes-cadvisor"}   44261

k8s组件性能指标

除了基础的cadvisor资源监控,还应该对核心组件的 metric 进行采集,包括:

  • 10250:kubelet监听端口,包括/stats/summary、metrics、metrics/cadvisor。10250为认证端口,非认证端口用10255
  • 10251:kube-scheduler的 metric 端口,本地 127 访问不需要认证,如调度延迟,
  • 10252:kube-controller的metric 端口,本地 127 访问不需要认证
  • 6443: apiserver,需要证书认证,直接 curl命令为curl --cacert /etc/kubernetes/pki/ca.pem --cert /etc/kubernetes/pki/admin.pem --key /etc/kubernetes/pki/admin-key.pem https://ip:6443/metrics -k
  • 2379: etcd的 metric 端口,直接 curl命令为:curl --cacert /etc/etcd/ssl/ca.pem --cert /etc/etcd/ssl/etcd.pem --key /etc/etcd/ssl/etcd-key.pem https://localhost:2379/metrics -k

docker 指标暴露:

如果要开放 docker进程指标,需要开启实验特性,文件/etc/docker/daemon.json

{
  "metrics-addr" : "127.0.0.1:9323",
  "experimental" : true
}

kube-proxy 指标:

端口为10249,默认 127开放,可以修改为 hostname 开放,--metrics-bind-address=机器 ip

示例图:

prometheus 重启慢

prometheus重启的时候需要把 wal 中的内容 load 到内存里,保留时间越久、wal 文件越大,重启的实际越长,这个是prometheus的机制,没得办法,因此能 reload 的,就不要重启,重启一定会导致短时间的不可用,而这个时候prometheus高可用就很重要了。

但prometheus 也曾经对启动时间做过优化,在 2.6 版本中对于WAL的 load速度就做过速度的优化,希望重启的时间不超过 1 分钟

你的应用应该暴露多少指标

当你开发自己的服务的时候,你可能会把一些数据暴露 metric出去,比如特定请求数、goroutine 数等,指标数量多少合适呢?

虽然指标数量和你的应用规模相关,但也有一些建议(Brian Brazil),

比如简单的服务如缓存等,类似 pushgateway,大约 120 个指标,prometheus 本身暴露了 700 左右的指标,如果你的应用很大,也尽量不要超过 10000 个指标,需要合理控制你的 label。

relabel_configs 与metric_relabel_configs

relabel_config发生在采集之前,metric_relabel_configs发生在采集之后,合理搭配可以满足场景的配置

metric_relabel_configs:
  - separator: ;
    regex: instance
    replacement: $1
    action: labeldrop
- source_labels: [__meta_kubernetes_namespace, __meta_kubernetes_endpoints_name,
      __meta_kubernetes_service_annotation_prometheus_io_port]
    separator: ;
    regex: (.+);(.+);(.*)
    target_label: __metrics_path__
    replacement: /api/v1/namespaces/${1}/services/${2}:${3}/proxy/metrics
    action: replace

Prometheus 的预测能力

场景1:你的磁盘剩余空间一直在减少,并且降低的速度比较均匀,你希望知道大概多久之后达到阈值,并希望在某一个时刻报警出来。

场景2:你的pod内存使用率一直升高,你希望知道大概多久之后会到达 limit 值,并在一定时刻报警出来,在被杀掉之前上去排查。

prometheus的deriv和predict_linear方法可以满足这类需求, promtheus 提供了基础的预测能力,基于当前的变化速度,推测一段时间后的值。

以mem_free 为例,最近一小时的 free值一直在下降。

mem_free仅为举例,实际内存可用以mem_available为准

deriv函数可以显示指标在一段时间的变化速度

predict_linear方法是预测基于这种速度,最后可以达到的值

predict_linear(mem_free{instanceIP="100.75.155.55"}[1h], 2*3600)/1024/1024

你可以基于设置合理的报警规则,如小于 10 时报警

rule: predict_linear(mem_free{instanceIP="100.75.155.55"}[1h], 2*3600)/1024/1024 <10 

predict_linear与deriv的关系,含义上约等于,predict_linear稍微准确一些。

  deriv(mem_free{instanceIP="100.75.155.55"}[1h]) * 2 * 3600
+
  mem_free{instanceIP="100.75.155.55"}[1h]

如果你要基于 metric做模型预测,可以参考下forecast-prometheus

错误的高可用设计

有些人提出过这种类型的方案,想提高其扩展性和可用性。

应用程序将metric 推到到消息队列如 kafaka,然后经过 exposer消费中转,再被 prometheus 拉取。产生这种方案的原因一般是有历史包袱、复用现有组件、想通过 mq 来提高扩展性。

这种方案有几个问题:

  1. 增加了 queue 组件,多了一层依赖,如果 app与 queue 之间连接失败,难道要在app本地缓存监控数据?
  2. 抓取时间可能会不同步,延迟的数据将会被标记为陈旧数据,当然你可以通过添加时间戳来标识,但就失去了对陈旧数据的处理逻辑
  3. 扩展性问题:prometheus 适合大量小目标,而不是一个大目标,如果你把所有数据都放在了 exposer 中,那么 prometheus 的单个 job拉取就会成为cpu 瓶颈。这个和 pushgateway 有些类似,没有特别必要的场景,都不是官方建议的方式。
  4. 缺少了服务发现和拉取控制,prom只知道一个exposer,不知道具体是哪些 target,不知道他们的 up 时间,无法使用scrape_*等指标做查询,也无法用scrape_limit做限制。

如果你的架构和 prometheus 的设计理念相悖,可能要重新设计一下方案了,否则扩展性和可靠性反而会降低。

高可用方案

prometheus 高可用有几种方案:

  1. 基本 HA:即两套 prometheus 采集完全一样的数据,外边挂负载均衡
  2. HA + 远程存储:除了基础的多副本prometheus,还通过Remote write 写入到远程存储,解决存储持久化问题
  3. 联邦集群:即federation,按照功能进行分区,不同的 shard 采集不同的数据,由Global节点来统一存放,解决监控数据规模的问题。
  4. 使用thanos 或者victoriametrics,来解决全局查询、多副本数据 join 问题。

就算使用官方建议的多副本 + 联邦,仍然会遇到一些问题:

官方建议数据做Shard,然后通过federation来实现高可用,
但是边缘节点和Global节点依然是单点,需要自行决定是否每一层都要使用双节点重复采集进行保活。
也就是仍然会有单机瓶颈。

另外部分敏感报警尽量不要通过global节点触发,毕竟从Shard节点到Global节点传输链路的稳定性会影响数据到达的效率,进而导致报警实效降低。

例如服务updown状态,API请求异常这类报警我们都放在shard节点进行报警。

本质原因是,prometheus的本地存储没有数据同步能力,要在保证可用性的前提下,再保持数据一致性是比较困难的,基础的 HA proxy 满足不了要求,比如:

  • 集群的后端有 A 和 B 两个实例,A 和 B 之间没有数据同步。A 宕机一段时间,丢失了一部分数据,如果负载均衡正常轮询,请求打到A 上时,数据就会异常。
  • 如果 A 和 B 的启动时间不同,时钟不同,那么采集同样的数据时间戳也不同,就不是多副本同样数据的概念了
  • 就算用了远程存储,A 和 B 不能推送到同一个 tsdb,如果每人推送自己的 tsdb,数据查询走哪边就是问题了。

因此解决方案是在存储、查询两个角度上保证数据的一致:

  • 存储角度:如果使用 remote write 远程存储, A 和 B后面可以都加一个 adapter,adapter做选主逻辑,只有一份数据能推送到 tsdb,这样可以保证一个异常,另一个也能推送成功,数据不丢,同时远程存储只有一份,是共享数据。方案可以参考这篇文章
  • 查询角度:上边的方案实现很复杂且有一定风险,因此现在的大多数方案在查询层面做文章,比如thanos 或者victoriametrics,仍然是两份数据,但是查询时做数据去重和join。只是 thanos是通过 sidecar 把数据放在对象存储,victoriametrics是把数据remote write 到自己的 server 实例,但查询层 thanos-query 和victor的 promxy的逻辑基本一致。

我们采用了thanos来支持多地域监控数据,具体方案可以看这篇文章

关于日志

k8s 中的日志一般指是容器标准输出 + 容器内日志,方案基本是采用 fluentd/fluent-bit/filebeat等采集推送到 es,但还有一种是日志转 metric,如解析特定字符串出现次数,nginx 日志得到qps指标 等,这里可以采用 grok或者 mtail,以 exporter的形式提供 metric 给 prometheus

参考

本文为容器监控实践系列文章,完整内容见:container-monitor-book

查看原文

赞 1 收藏 1 评论 0

徐亚松 发布了文章 · 2020-03-27

高可用prometheus:thanos 实践

背景

prometheus 使用心得文章中有简单提到prometheus 的高可用方案,尝试了联邦、remote write 之后,我们最终选择了 thanos 作为监控配套组件,利用其全局视图来管理我们的多地域/上百个集群的监控数据。本文主要介绍 thanos 的一些组件使用和心得体会。

prometheus官方的高可用有几种方案:

  1. HA:即两套 prometheus 采集完全一样的数据,外边挂负载均衡
  2. HA + 远程存储:除了基础的多副本prometheus,还通过Remote write 写入到远程存储,解决存储持久化问题
  3. 联邦集群:即federation,按照功能进行分区,不同的 shard 采集不同的数据,由Global节点来统一存放,解决监控数据规模的问题。

使用官方建议的多副本 + 联邦仍然会遇到一些问题,本质原因是prometheus的本地存储没有数据同步能力,要在保证可用性的前提下再保持数据一致性是比较困难的,基本的多副本 proxy 满足不了要求,比如:

  • prometheus集群的后端有 A 和 B 两个实例,A 和 B 之间没有数据同步。A 宕机一段时间,丢失了一部分数据,如果负载均衡正常轮询,请求打到A 上时,数据就会异常。
  • 如果 A 和 B 的启动时间不同,时钟不同,那么采集同样的数据时间戳也不同,就多副本的数据不相同
  • 就算用了远程存储,A 和 B 不能推送到同一个 tsdb,如果每人推送自己的 tsdb,数据查询走哪边就是问题
  • 官方建议数据做Shard,然后通过federation来实现高可用,但是边缘节点和Global节点依然是单点,需要自行决定是否每一层都要使用双节点重复采集进行保活。也就是仍然会有单机瓶颈。
  • 另外部分敏感报警尽量不要通过global节点触发,毕竟从Shard节点到Global节点传输链路的稳定性会影响数据到达的效率,进而导致报警实效降低。

目前大多数的 prometheus 的集群方案是在存储、查询两个角度上保证数据的一致:

  • 存储角度:如果使用 remote write 远程存储, A 和 B后面可以都加一个 adapter,adapter做选主逻辑,只有一份数据能推送到 tsdb,这样可以保证一个异常,另一个也能推送成功,数据不丢,同时远程存储只有一份,是共享数据。方案可以参考这篇文章
  • 存储角度:仍然使用 remote write 远程存储,但是 A 和 B 分别写入 tsdb1 和 tsdb2 两个时序数据库,利用sync的方式在 tsdb1 和2 之前做数据同步,保证数据是全量的。
  • 查询角度:上边的方案需要自己实现,有侵入性且有一定风险,因此大多数开源方案是在查询层面做文章,比如thanos 或者victoriametrics,仍然是两份数据,但是查询时做数据去重和join。只是 thanos是通过 sidecar 把数据放在对象存储,victoriametrics是把数据remote write 到自己的 server 实例,但查询层 thanos-query 和victor的 promxy的逻辑基本一致,都是为全局视图服务

实际需求

随着我们的集群规模越来越大,监控数据的种类和数量也越来越多:如master/node 机器监控、进程监控、4 大核心组件的性能监控,pod 资源监控、kube-stats-metrics、k8s events监控、插件监控等等。除了解决上面的高可用问题,我们还希望基于 prometheus 构建全局视图,主要需求有:

  • 长期存储:1 个月左右的数据存储,每天可能新增几十G,希望存储的维护成本足够小,有容灾和迁移。考虑过使用 influxdb,但influxdb没有现成的集群方案,且需要人力维护。最好是存放在云上的 tsdb 或者对象存储、文件存储上。
  • 无限拓展:我们有300+集群,几千节点,上万个服务,单机prometheus无法满足,且为了隔离性,最好按功能做 shard,如 master 组件性能监控与 pod 资源等业务监控分开、主机监控与日志监控也分开。或者按租户、业务类型分开(实时业务、离线业务)。
  • 全局视图:按类型分开之后,虽然数据分散了,但监控视图需要整合在一起,一个 grafana 里 n个面板就可以看到所有地域+集群+pod 的监控数据,操作更方便,不用多个 grafana 切来切去,或者 grafana中多个 datasource 切来切去。
  • 无侵入性:不要对已有的 prometheus 做过多的修改,因为 prometheus 是开源项目,版本也在快速迭代,我们最早使用过 1.x,可1.x 和 2.x的版本升级也就不到一年时间,2.x 的存储结构查询速度等都有了明显提升,1.x 已经没人使用了。因此我们需要跟着社区走,及时迭代新版本。因此不能对 prometheus 本身代码做修改,最好做封装,对最上层用户透明。

在调研了大量的开源方案(cortex/thanos/victoria/..)和商业产品之后,我们选择了 thanos,准确的说,thanos只是监控套件,与 原生prometheus 结合,满足了长期存储+ 无限拓展 + 全局视图 + 无侵入性的需求。

thanos 架构

thanos 的默认模式:sidecar 方式


除了 这个sidecar 方式,thanos还有一种不太常用的reviver 模式,后面会提到。

Thanos是一组组件,在官网上可以看到包括:

  • Bucket
  • Check
  • Compactor
  • Query
  • Rule
  • Sidecar
  • Store

除了官方提到的这些,其实还有:

  • receive
  • downsample

看起来组件很多,但其实部署时二进制只有一个,非常方便。只是搭配不同的参数实现不同的功能,如 query 组件就是 ./thanos query,sidecar 组件就是./thanos sidecar,组件all in one,也就是代码也只有一份,体积很小。

其实核心的sidecar+query就已经可以运行,其他的组件只是为了实现更多的功能

最新版 thanos 在 这里下载release,对于 thanos这种仍然在修bug、迭代功能的软件,有新版本就不要用旧的。

组件与配置

下面会介绍如何组合thanos组件,来快速实现你的 prometheus 高可用,因为是快速介绍,和官方的 quick start有一部分雷同,且本文截止2020.1 月的版本,不知道以后会thanos 会迭代成什么样子

第 1 步:确认已有的 prometheus

thanos 是无侵入的,只是上层套件,因此你还是需要部署你的 prometheus,这里不再赘述,默认你已经有一个单机的 prometheus在运行,可以是 pod 也可以是主机部署,取决于你的运行环境,我们是在 k8s 集群外,因此是主机部署。prometheus采集的是地域A的监控数据。你的 prometheus配置可以是:

启动配置:

"./prometheus
--config.file=prometheus.yml \
--log.level=info \
--storage.tsdb.path=data/prometheus \
--web.listen-address='0.0.0.0:9090' \
--storage.tsdb.max-block-duration=2h \
--storage.tsdb.min-block-duration=2h \
--storage.tsdb.wal-compression \
--storage.tsdb.retention.time=2h \
--web.enable-lifecycle"

web.enable-lifecycle一定要开,用于热加载reload你的配置,retention保留 2 小时,prometheus 默认 2 小时会生成一个 block,thanos 会把这个 block 上传到对象存储。

采集配置:prometheus.yml

global:
  scrape_interval:     60s
  evaluation_interval: 60s
  external_labels:
     region: 'A'
     replica: 0

rule_files:
scrape_configs:
  - job_name: 'prometheus'
    static_configs:
      - targets: ['0.0.0.0:9090']

  - job_name: 'demo-scrape'
    metrics_path: '/metrics'
    params:
    ...

这里需要声明external_labels,标注你的地域。如果你是多副本运行,需要声明你的副本标识,如 0号,1,2 三个副本采集一模一样的数据,另外2个 prometheus就可以同时运行,只是replica值不同而已。这里的配置和官方的 federation差不多。

对 prometheus 的要求:

  • 2.2.1版本以上
  • 声明你的external_labels
  • 启用--web.enable-admin-api
  • 启用--web.enable-lifecycle

第 2 步:部署 sidecar 组件

关键的步骤来了,最核心莫过于 sidecar组件。sidecar是 k8s 中的一种模式

Sidecar 组件作为 Prometheus server pod 的 sidecar 容器,与 Prometheus server 部署于同一个 pod 中。 他有两个作用:

  1. 它使用Prometheus的remote read API,实现了Thanos的Store API。这使后面要介绍的Query 组件可以将Prometheus服务器视为时间序列数据的另一个来源,而无需直接与Prometheus API交互(这就是 sidecar 的拦截作用)
  2. 可选配置:在Prometheus每2小时生成一次TSDB块时,Sidecar将TSDB块上载到对象存储桶中。这使得Prometheus服务器可以以较低的保留时间运行,同时使历史数据持久且可通过对象存储查询。

当然,这不意味着Prometheus可以是完全无状态的,因为如果它崩溃并重新启动,您将丢失2个小时的指标,不过如果你的 prometheus 也是多副本,可以减少这2h 数据的风险。

sidecar配置:

./thanos sidecar \
--prometheus.url="http://localhost:8090" \
--objstore.config-file=./conf/bos.yaml \
--tsdb.path=/home/work/opdir/monitor/prometheus/data/prometheus/
"

配置很简单,只需要声明prometheus.url和数据地址即可。objstore.config-file是可选项。如果你要把数据存放在对象存储(这也是推荐做法),就配置下对象存储的账号信息。

thanos 默认支持谷歌云/AWS等,以 谷歌云为例,配置如下:

type: GCS
config:
  bucket: ""
  service_account: ""

因为thanos默认还不支持我们的云存储,因此我们在 thanos代码中加入了相应的实现,并向官方提交了 pr。

需要注意的是:别忘了为你的另外两个副本 1号 和 2号prometheus都搭配一个 sidecar。如果是 pod运行可以加一个 container,127 访问,如果是主机部署,指定prometheus端口就行。

另外 sidecar是无状态的,也可以多副本,多个 sidecar 可以访问一份 prometheus 数据,保证 sidecar本身的拓展性,不过如果是 pod 运行也就没有这个必要了,sidecar和prometheus 同生共死就行了。

sidecar 会读取prometheus 每个 block 中的 meta.json信息,然后扩展这个 json 文件,加入了 Thanos所特有的 metadata 信息。而后上传到块存储上。上传后写入thanos.shipper.json 中

第 3 步:部署 query 组件

sidecar 部署完成了,也有了 3 个一样的数据副本,这个时候如果想直接展示数据,可以安装 query 组件

Query组件(也称为“查询”)实现了Prometheus 的HTTP v1 API,可以像 prometheus 的 graph一样,通过PromQL查询Thanos集群中的数据。

简而言之,sidecar暴露了StoreAPI,Query从多个StoreAPI中收集数据,查询并返回结果。Query是完全无状态的,可以水平扩展。

配置:

"
./thanos query \
--http-address="0.0.0.0:8090" \
--store=relica0:10901 \
--store=relica1:10901 \
--store=relica2:10901 \
--store=127.0.0.1:19914 \
"

store 参数代表的就是刚刚启动的 sidecar 组件,启动了 3 份,就可以配置三个relica0、relica1、relica2,10901 是 sidecar 的默认端口。

http-address 代表 query 组件本身的端口,因为他是个 web 服务,启动后,页面是这样的:

和 prometheus 几乎一样对吧,有了这个页面你就不需要关心最初的 prometheus 了,可以放在这里查询。

点击 store,可以看到对接了哪些 sidecar。

query 页面有两个勾选框,含义是:

  • deduplication:是否去重。默认勾选代表去重,同样的数据只会出现一条,否则 replica0 和 1、2 完全相同的数据会查出来 3 条。
  • partial response:是否允许部分响应,默认允许,这里有一致性的折中,比如 0、1、2 三副本有一个挂掉或者超时了,查询时就会有一个没有响应,如果允许返回用户剩下的 2 份,数据就没有很强的一致性,但因为一个超时就完全不返回,就丢掉了可用性,因此默认允许部分响应。

第 4 步:部署 store gateway 组件

你可能注意到了,在第 3 步里,./thanos query有一条--store是 xxx:19914,并不是一直提到的 3 副本,这个 19914 就是接下来要说的store gateway组件。

在第 2 步的 sidecar 配置中,如果你配置了对象存储objstore.config-file,你的数据就会定时上传到bucket 中,本地只留 2 小时,那么要想查询 2 小时前的数据怎么办呢?数据不被 prometheus 控制了,应该如何从 bucket 中拿回来,并提供一模一样的查询呢?

Store gateway 组件:Store gateway 主要与对象存储交互,从对象存储获取已经持久化的数据。与sidecar一样,Store gateway也实现了store api,query 组可以从 store gateway 查询历史数据。

配置如下:

./thanos store \
--data-dir=./thanos-store-gateway/tmp/store \
--objstore.config-file=./thanos-store-gateway/conf/bos.yaml \
--http-address=0.0.0.0:19904 \
--grpc-address=0.0.0.0:19914 \
--index-cache-size=250MB \
--sync-block-duration=5m \
--min-time=-2w \
--max-time=-1h \

grpc-address就是store api暴露的端口,也就是query 中--store是 xxx:19914的配置。

因为Store gateway需要从网络上拉取大量历史数据加载到内存,因此会大量消耗 cpu 和内存,这个组件也是 thanos 面世时被质疑过的组件,不过当前的性能还算可以,遇到的一些问题后面会提到。

Store gateway也可以无限拓展,拉取同一份 bucket 数据。

放个示意图,一个 thanos 副本,挂了多个地域的 store 组件

到这里,thanos 的基本使用就结束了,至于 compact 压缩和 bucket 校验,不是核心功能,compact我们只是简单部署了一下,rule组件我们没有使用,就不做介绍了。

5.查看数据

有了多地域多副本的数据,就可以结合 grafana 做全局视图了,比如:

按地域和集群查看 etcd 的性能指标:

按地域、集群、机器查看核心组件监控,如多副本 master 机器上的各种性能

数据聚合在一起之后,可以将所有视图都集中展示,比如还有这些面板:

  • 机器监控:node-exporter、process-exporter
  • pod 资源使用: cadvisor
  • docker、kube-proxy、kubelet 监控
  • scheduler、controller-manager、etcd、apiserver 监控
  • kube-state-metrics 元信息
  • k8s events
  • mtail 等日志监控

Receive 模式

前面提到的所有组件都是基于 sidecar 模式配置的,但thanos还有一种Receive模式,不太常用,只是在Proposals中出现

因为一些网络限制,我们之前尝试过Receive方案,这里可以描述下Receive的使用场景:

  1. sidecar 模式有一个缺点:就是2 小时内的数据仍然需要通过 sidecar->prometheus来获取,也就是仍然依赖 prometheus,并不是完全的数据在外部存储。如果你的网络只允许你查询特定的存储数据,无法达到集群内的prometheus,那这 2 小时的数据就丢失了,而 Receive模式采用了remote write 就没有所谓的 2 小时 block 的问题了。
  2. sidecar 模式对网络连通性是有要求的,如果你是多租户环境或者是云厂商,对象存储(历史数据)query 组件一般在控制面,方便做权限校验和接口服务封装,而 sidecar 和 prometheus却在集群内,也就是用户侧。控制面和用户侧的网络有时候会有限制,是不通的,这个时候会有一些限制导致你无法使用 sidecar
  3. 租户和控制面隔离,和第2 条类似,希望数据完全存在控制面,我一直觉得Receive就是为了云厂商服务的。。

不过Receive毕竟不是默认方案,如果不是特别需要,还是用默认的 sidecar 为好

一些问题

prometheus 压缩

压缩:官方文档有提到,使用sidecar时,需要将 prometheus 的--storage.tsdb.min-block-duration 和 --storage.tsdb.max-block-duration,这两个值设置为2h,两个参数相等才能保证prometheus关闭了本地压缩,其实这两个参数在 prometheus -help 中并没有体现,prometheus 作者也说明这只是为了开发测试才用的参数,不建议用户修改。而 thanos 要求关闭压缩是因为 prometheus 默认会以2,25,25*5的周期进行压缩,如果不关闭,可能会导致 thanos 刚要上传一个 block,这个 block 却被压缩中,导致上传失败。

不过你也不必担心,因为在 sidecar 启动时,会坚持这两个参数,如果不合适,sidecar会启动失败
43a131c689d9fedba5a7844363876ee7

store-gateway

store-gateway: store 组件资源消耗是最大的,毕竟他要拉取远程数据,并加载到本地供查询,如果你想控制历史数据和缓存周期,可以修改相应的配置,如

--index-cache-size=250MB \
--sync-block-duration=5m \ 
--min-time=-2w \ 最大查询 1 周
--max-time=-1h \

store-gateway 默认支持索引缓存,来加快tsdb 块的查找速度,但有时候启动会占用了大量的内存,在 0.11.0之后的版本做了修复,可以查看这个issue

Prometheus 2.0 已经对存储层进行了优化。例如按照时间和指标名字,连续的尽量放在一起。而 store gateway可以获取存储文件的结构,因此可以很好的将指标存储的请求翻译为最少的 object storage 请求。对于那种大查询,一次可以拿成百上千个 chunks 数据。

二在 store 的本地,只有 index 数据是放入 cache的,chunk 数据虽然也可以,但是就要大几个数量级了。目前,从对象存储获取 chunk 数据只有很小的延时,因此也没什么动力去将 chunk 数据给 cache起来,毕竟这个对资源的需求很大。

store-gateway中的数据:

每个文件夹中其实是一个个的索引文件index.cache.json

compactor组件

prometheus数据越来越多,查询一定会越来越慢,thanos提供了一个compactor组件来处理,他有两个功能,

  • 一个是做压缩,就是把旧的数据不断的合并。
  • 另外一个是降采样,他会把存储的数据,按照一定的时间,算出最大,最小等值,会根据查询的间隔,进行控制,返回采样的数据,而不是真实的点,在查询特别长的时间的数据的时候,看的主要是趋势,精度是可以选择下降的。
  • 注意的是compactor并不会减少磁盘占用,反而会增加磁盘占用(做了更高维度的聚合)。

通过以上的方式,有效了优化查询,但是并不是万能的。因为业务数据总在增长,这时候可能要考虑业务拆分了,我们需要对业务有一定的估算,例如不同的业务存储在不同bucket里(需要改造或者多部署几个 sidecar)。例如有5个bucket,再准备5个store gateway进行代理查询。减少单个 store 数据过大的问题。

第二个方案是时间切片,也就是就是上面提到的store gateway可以选择查询多长时间的数据。支持两种表达,一种是基于相对时间的,例如--max-time 3d前到5d前的。一种是基于绝对时间的,19年3月1号到19年5月1号。例如想查询3个月的数据,一个store代理一个月的数据,那么就需要3个store来合作。

query 的去重

query组件启动时,默认会根据query.replica-label字段做重复数据的去重,你也可以在页面上勾选deduplication 来决定。query 的结果会根据你的query.replica-label的 label选择副本中的一个进行展示。可如果 0,1,2 三个副本都返回了数据,且值不同,query 会选择哪一个展示呢?

thanos会基于打分机制,选择更为稳定的 replica 数据, 具体逻辑在:https://github.com/thanos-io/...

参考

本文为容器监控实践系列文章,完整内容见:container-monitor-book

查看原文

赞 3 收藏 3 评论 0

徐亚松 发布了文章 · 2020-01-17

基于 etcd 实现分布式锁

概述

在传统单体应用单机部署的情况下,可以使用Java并发处理相关的API(如ReentrantLock或Synchronized)进行互斥控制。在单机环境中,Java中提供了很多并发处理相关的API。但是,随着业务发展的需要,原单体单机部署的系统被演化成分布式集群系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这将使原单机部署情况下的并发控制锁策略失效,单纯的Java API并不能提供分布式锁的能力。为了解决这个问题就需要一种跨JVM的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题!

锁是在执行多线程时用于强行限制资源访问的同步机制,在单机系统上,可以使用Java并发处理相关的API(如ReentrantLock或Synchronized)进行互斥控制。而在分布式系统场景下,实例会运行在多台机器上,为了使多进程(多实例上)对共享资源的读写同步,保证数据的最终一致性,引入了分布式锁。

分布式锁应具备以下特点:

  • 互斥性:在任意时刻,只有一个客户端(进程)能持有锁
  • 安全性:避免死锁情况,当一个客户端在持有锁期间内,由于意外崩溃而导致锁未能主动解锁,其持有的锁也能够被正确释放,并保证后续其它客户端也能加锁
  • 可用性:分布式锁需要有一定的高可用能力,当提供锁的服务节点故障(宕机)时不影响服务运行,避免单点风险,如Redis的集群模式、哨兵模式,ETCD/zookeeper的集群选主能力等保证HA,保证自身持有的数据与故障节点一致。
  • 对称性:对同一个锁,加锁和解锁必须是同一个进程,即不能把其他进程持有的锁给释放了,这又称为锁的可重入性。

分布式锁常见实现方式:

  1. 通过数据库方式实现:采用乐观锁、悲观锁或者基于主键唯一约束实现
  2. 基于分布式缓存实现的锁服务: Redis 和基于 Redis 的 RedLock(Redisson提供了参考实现)
  3. 基于分布式一致性算法实现的锁服务:ZooKeeper、Chubby(google闭源实现)和 Etcd

网上常见的是基于Redis和ZooKeeper的实现,基于数据库的因为实现繁琐且性能较差,不想维护第三方中间件的可以考虑。本文主要描述基于 ETCD 的实现,etcd3 的client也给出了新的 api,使用上更为简单

基于 Redis 的实现

既然是锁,核心操作无外乎加锁、解锁。

Redis的加锁操作:

SET lock_name my_random_value NX PX 30000
  • lock_name,锁的名称,对于 Redis 而言,lock_name 就是 Key-Value 中的 Key,具有唯一性。
  • random_value,由客户端生成的一个随机字符串,它要保证在足够长的一段时间内,且在所有客户端的所有获取锁的请求中都是唯一的,用于唯一标识锁的持有者。
  • NX 只有当 lock_name(key) 不存在的时候才能 SET 成功,从而保证只有一个客户端能获得锁,而其它客户端在锁被释放之前都无法获得锁。
  • PX 30000 表示这个锁节点有一个 30 秒的自动过期时间(目的是为了防止持有锁的客户端故障后,无法主动释放锁而导致死锁,因此要求锁的持有者必须在过期时间之内执行完相关操作并释放锁)。

Redis的解锁操作:

del lock_name
  • 在加锁时为锁设置过期时间,当过期时间到达,Redis 会自动删除对应的 Key-Value,从而避免死锁。注意,这个过期时间需要结合具体业务综合评估设置,以保证锁的持有者能够在过期时间之内执行完相关操作并释放锁。
  • 正常执行完毕,未到达锁过期时间,通过del lock_name主动释放锁。

基于 ETCD的分布式锁

机制

etcd 支持以下功能,正是依赖这些功能来实现分布式锁的:

  • Lease 机制:即租约机制(TTL,Time To Live),Etcd 可以为存储的 KV 对设置租约,当租约到期,KV 将失效删除;同时也支持续约,即 KeepAlive。
  • Revision 机制:每个 key 带有一个 Revision 属性值,etcd 每进行一次事务对应的全局 Revision 值都会加一,因此每个 key 对应的 Revision 属性值都是全局唯一的。通过比较 Revision 的大小就可以知道进行写操作的顺序。
  • 在实现分布式锁时,多个程序同时抢锁,根据 Revision 值大小依次获得锁,可以避免 “羊群效应” (也称 “惊群效应”),实现公平锁。
  • Prefix 机制:即前缀机制,也称目录机制。可以根据前缀(目录)获取该目录下所有的 key 及对应的属性(包括 key, value 以及 revision 等)。
  • Watch 机制:即监听机制,Watch 机制支持 Watch 某个固定的 key,也支持 Watch 一个目录(前缀机制),当被 Watch 的 key 或目录发生变化,客户端将收到通知。

过程

实现过程:

  • 步骤 1: 准备

客户端连接 Etcd,以 /lock/mylock 为前缀创建全局唯一的 key,假设第一个客户端对应的 key="/lock/mylock/UUID1",第二个为 key="/lock/mylock/UUID2";客户端分别为自己的 key 创建租约 - Lease,租约的长度根据业务耗时确定,假设为 15s;

  • 步骤 2: 创建定时任务作为租约的“心跳”

当一个客户端持有锁期间,其它客户端只能等待,为了避免等待期间租约失效,客户端需创建一个定时任务作为“心跳”进行续约。此外,如果持有锁期间客户端崩溃,心跳停止,key 将因租约到期而被删除,从而锁释放,避免死锁。

  • 步骤 3: 客户端将自己全局唯一的 key 写入 Etcd

进行 put 操作,将步骤 1 中创建的 key 绑定租约写入 Etcd,根据 Etcd 的 Revision 机制,假设两个客户端 put 操作返回的 Revision 分别为 1、2,客户端需记录 Revision 用以接下来判断自己是否获得锁。

  • 步骤 4: 客户端判断是否获得锁

客户端以前缀 /lock/mylock 读取 keyValue 列表(keyValue 中带有 key 对应的 Revision),判断自己 key 的 Revision 是否为当前列表中最小的,如果是则认为获得锁;否则监听列表中前一个 Revision 比自己小的 key 的删除事件,一旦监听到删除事件或者因租约失效而删除的事件,则自己获得锁。

  • 步骤 5: 执行业务

获得锁后,操作共享资源,执行业务代码。

  • 步骤 6: 释放锁

完成业务流程后,删除对应的key释放锁。

实现

自带的 etcdctl 可以模拟锁的使用:


// 第一个终端
$ ./etcdctl lock mutex1
mutex1/326963a02758b52d
​
// 第二终端
$ ./etcdctl lock mutex1
​
// 当第一个终端结束了,第二个终端会显示
mutex1/326963a02758b531

在etcd的clientv3包中,实现了分布式锁。使用起来和mutex是类似的,为了了解其中的工作机制,这里简要的做一下总结。

etcd分布式锁的实现在go.etcd.io/etcd/clientv3/concurrency包中,主要提供了以下几个方法:

* func NewMutex(s *Session, pfx string) *Mutex, 用来新建一个mutex
* func (m *Mutex) Lock(ctx context.Context) error,它会阻塞直到拿到了锁,并且支持通过context来取消获取锁。
* func (m *Mutex) Unlock(ctx context.Context) error,解锁

因此在使用etcd提供的分布式锁式非常简单,通常就是实例化一个mutex,然后尝试抢占锁,之后进行业务处理,最后解锁即可。

demo:

package main

import (  
    "context"
    "fmt"
    "github.com/coreos/etcd/clientv3"
    "github.com/coreos/etcd/clientv3/concurrency"
    "log"
    "os"
    "os/signal"
    "time"
)

func main() {  
    c := make(chan os.Signal)
    signal.Notify(c)

    cli, err := clientv3.New(clientv3.Config{
        Endpoints:   []string{"localhost:2379"},
        DialTimeout: 5 * time.Second,
    })
    if err != nil {
        log.Fatal(err)
    }
    defer cli.Close()

    lockKey := "/lock"

    go func () {
        session, err := concurrency.NewSession(cli)
        if err != nil {
            log.Fatal(err)
        }
        m := concurrency.NewMutex(session, lockKey)
        if err := m.Lock(context.TODO()); err != nil {
            log.Fatal("go1 get mutex failed " + err.Error())
        }
        fmt.Printf("go1 get mutex sucess\n")
        fmt.Println(m)
        time.Sleep(time.Duration(10) * time.Second)
        m.Unlock(context.TODO())
        fmt.Printf("go1 release lock\n")
    }()

    go func() {
        time.Sleep(time.Duration(2) * time.Second)
        session, err := concurrency.NewSession(cli)
        if err != nil {
            log.Fatal(err)
        }
        m := concurrency.NewMutex(session, lockKey)
        if err := m.Lock(context.TODO()); err != nil {
            log.Fatal("go2 get mutex failed " + err.Error())
        }
        fmt.Printf("go2 get mutex sucess\n")
        fmt.Println(m)
        time.Sleep(time.Duration(2) * time.Second)
        m.Unlock(context.TODO())
        fmt.Printf("go2 release lock\n")
    }()

    <-c
}

原理

Lock()函数的实现很简单:

// Lock locks the mutex with a cancelable context. If the context is canceled
// while trying to acquire the lock, the mutex tries to clean its stale lock entry.
func (m *Mutex) Lock(ctx context.Context) error {
    s := m.s
    client := m.s.Client()

    m.myKey = fmt.Sprintf("%s%x", m.pfx, s.Lease())
    cmp := v3.Compare(v3.CreateRevision(m.myKey), "=", 0)
    // put self in lock waiters via myKey; oldest waiter holds lock
    put := v3.OpPut(m.myKey, "", v3.WithLease(s.Lease()))
    // reuse key in case this session already holds the lock
    get := v3.OpGet(m.myKey)
    // fetch current holder to complete uncontended path with only one RPC
    getOwner := v3.OpGet(m.pfx, v3.WithFirstCreate()...)
    resp, err := client.Txn(ctx).If(cmp).Then(put, getOwner).Else(get, getOwner).Commit()
    if err != nil {
        return err
    }
    m.myRev = resp.Header.Revision
    if !resp.Succeeded {
        m.myRev = resp.Responses[0].GetResponseRange().Kvs[0].CreateRevision
    }
    // if no key on prefix / the minimum rev is key, already hold the lock
    ownerKey := resp.Responses[1].GetResponseRange().Kvs
    if len(ownerKey) == 0 || ownerKey[0].CreateRevision == m.myRev {
        m.hdr = resp.Header
        return nil
    }

    // wait for deletion revisions prior to myKey
    hdr, werr := waitDeletes(ctx, client, m.pfx, m.myRev-1)
    // release lock key if wait failed
    if werr != nil {
        m.Unlock(client.Ctx())
    } else {
        m.hdr = hdr
    }
    return werr
}

首先通过一个事务来尝试加锁,这个事务主要包含了4个操作: cmp、put、get、getOwner。需要注意的是,key是由pfx和Lease()组成的。

  • cmp: 比较加锁的key的修订版本是否是0。如果是0就代表这个锁不存在。
  • put: 向加锁的key中存储一个空值,这个操作就是一个加锁的操作,但是这把锁是有超时时间的,超时的时间是session的默认时长。超时是为了防止锁没有被正常释放导致死锁。
  • get: get就是通过key来查询
  • getOwner: 注意这里是用m.pfx来查询的,并且带了查询参数WithFirstCreate()。使用pfx来查询是因为其他的session也会用同样的pfx来尝试加锁,并且因为每个LeaseID都不同,所以第一次肯定会put成功。但是只有最早使用这个pfx的session才是持有锁的,所以这个getOwner的含义就是这样的。

接下来才是通过判断来检查是否持有锁

m.myRev = resp.Header.Revision
if !resp.Succeeded {
    m.myRev = resp.Responses[0].GetResponseRange().Kvs[0].CreateRevision
}
// if no key on prefix / the minimum rev is key, already hold the lock
ownerKey := resp.Responses[1].GetResponseRange().Kvs
if len(ownerKey) == 0 || ownerKey[0].CreateRevision == m.myRev {
    m.hdr = resp.Header
    return nil
}

m.myRev是当前的版本号,resp.Succeeded是cmp为true时值为true,否则是false。这里的判断表明当同一个session非第一次尝试加锁,当前的版本号应该取这个key的最新的版本号。

下面是取得锁的持有者的key。如果当前没有人持有这把锁,那么默认当前会话获得了锁。或者锁持有者的版本号和当前的版本号一致, 那么当前的会话就是锁的持有者。

// wait for deletion revisions prior to myKey
hdr, werr := waitDeletes(ctx, client, m.pfx, m.myRev-1)
// release lock key if wait failed
if werr != nil {
    m.Unlock(client.Ctx())
} else {
    m.hdr = hdr
}

上面这段代码就很好理解了,因为走到这里说明没有获取到锁,那么这里等待锁的删除。

waitDeletes方法的实现也很简单,但是需要注意的是,这里的getOpts只会获取比当前会话版本号更低的key,然后去监控最新的key的删除。等这个key删除了,自己也就拿到锁了。

这种分布式锁的实现和我一开始的预想是不同的。它不存在锁的竞争,不存在重复的尝试加锁的操作。而是通过使用统一的前缀pfx来put,然后根据各自的版本号来排队获取锁。效率非常的高。避免了惊群效应


如图所示,共有4个session来加锁,那么根据revision来排队,获取锁的顺序为session2 -> session3 -> session1 -> session4。

这里面需要注意一个惊群效应,每一个client在锁住/lock这个path的时候,实际都已经插入了自己的数据,类似/lock/LEASE_ID,并且返回了各自的index(就是raft算法里面的日志索引),而只有最小的才算是拿到了锁,其他的client需要watch等待。例如client1拿到了锁,client2和client3在等待,而client2拿到的index比client3的更小,那么对于client1删除锁之后,client3其实并不关心,并不需要去watch。所以综上,等待的节点只需要watch比自己index小并且差距最小的节点删除事件即可。

基于 ETCD的选主

机制

etcd有多种使用场景,Master选举是其中一种。说起Master选举,过去常常使用zookeeper,通过创建EPHEMERAL_SEQUENTIAL节点(临时有序节点),我们选择序号最小的节点作为Master,逻辑直观,实现简单是其优势,但是要实现一个高健壮性的选举并不简单,同时zookeeper繁杂的扩缩容机制也是沉重的负担。

master 选举根本上也是抢锁,与zookeeper直观选举逻辑相比,etcd的选举则需要在我们熟悉它的一系列基本概念后,调动我们充分的想象力:

  • 1、MVCC,key存在版本属性,没被创建时版本号为0;
  • 2、CAS操作,结合MVCC,可以实现竞选逻辑,if(version == 0) set(key,value),通过原子操作,确保只有一台机器能set成功;
  • 3、Lease租约,可以对key绑定一个租约,租约到期时没预约,这个key就会被回收;
  • 4、Watch监听,监听key的变化事件,如果key被删除,则重新发起竞选。

    至此,etcd选举的逻辑大体清晰了,但这一系列操作与zookeeper相比复杂很多,有没有已经封装好的库可以直接拿来用?etcd clientv3 concurrency中有对选举及分布式锁的封装。后面进一步发现,etcdctl v3里已经有master选举的实现了,下面针对这部分代码进行简单注释,在最后参考这部分代码实现自己的选举逻辑。

实现

官方示例:https://github.com/etcd-io/et...

如crontab 示例:

package main

import (
    "context"
    "fmt"
    "go.etcd.io/etcd/clientv3"
    "go.etcd.io/etcd/clientv3/concurrency"
    "log"
    "time"
)

const prefix = "/election-demo"
const prop = "local"

func main() {
    endpoints := []string{"szth-cce-devops00.szth.baidu.com:8379"}
    cli, err := clientv3.New(clientv3.Config{Endpoints: endpoints})
    if err != nil {
        log.Fatal(err)
    }
    defer cli.Close()

    campaign(cli, prefix, prop)

}

func campaign(c *clientv3.Client, election string, prop string) {
    for {
        // 租约到期时间:5s
        s, err := concurrency.NewSession(c, concurrency.WithTTL(5))
        if err != nil {
            fmt.Println(err)
            continue
        }
        e := concurrency.NewElection(s, election)
        ctx := context.TODO()

        log.Println("开始竞选")

        err = e.Campaign(ctx, prop)
        if err != nil {
            log.Println("竞选 leader失败,继续")
            switch {
            case err == context.Canceled:
                return
            default:
                continue
            }
        }

        log.Println("获得leader")
        if err := doCrontab(); err != nil {
            log.Println("调用主方法失败,辞去leader,重新竞选")
            _ = e.Resign(ctx)
            continue
        }
        return
    }
}

func doCrontab() error {
    for {
        fmt.Println("doCrontab")
        time.Sleep(time.Second * 4)
        //return fmt.Errorf("sss")
    }
}

原理

/*
 * 发起竞选
 * 未当选leader前,会一直阻塞在Campaign调用
 * 当选leader后,等待SIGINT、SIGTERM或session过期而退出
 * https://github.com/etcd-io/etcd/blob/master/etcdctl/ctlv3/command/elect_command.go
 */
 
func campaign(c *clientv3.Client, election string, prop string) error {
        //NewSession函数中创建了一个lease,默认是60s TTL,并会调用KeepAlive,永久为这个lease自动续约(2/3生命周期的时候执行续约操作)
    s, err := concurrency.NewSession(c)
    if err != nil {
        return err
    }
    e := concurrency.NewElection(s, election)
    ctx, cancel := context.WithCancel(context.TODO())

    donec := make(chan struct{})
    sigc := make(chan os.Signal, 1)
    signal.Notify(sigc, syscall.SIGINT, syscall.SIGTERM)
    go func() {
        <-sigc
        cancel()
        close(donec)
    }()

    //竞选逻辑,将展开分析
    if err = e.Campaign(ctx, prop); err != nil {
        return err
    }

    // print key since elected
    resp, err := c.Get(ctx, e.Key())
    if err != nil {
        return err
    }
    display.Get(*resp)

    select {
    case <-donec:
    case <-s.Done():
        return errors.New("elect: session expired")
    }

    return e.Resign(context.TODO())
}

/*
 * 类似于zookeeper的临时有序节点,etcd的选举也是在相应的prefix path下面创建key,该key绑定了lease并根据lease id进行命名,
 * key创建后就有revision号,这样使得在prefix path下的key也都是按revision有序
 * https://github.com/etcd-io/etcd/blob/master/clientv3/concurrency/election.go
 */
 
func (e *Election) Campaign(ctx context.Context, val string) error {
    s := e.session
    client := e.session.Client()
    
    //真正创建的key名为:prefix + lease id
    k := fmt.Sprintf("%s%x", e.keyPrefix, s.Lease())
    //Txn:transaction,依靠Txn进行创建key的CAS操作,当key不存在时才会成功创建
    txn := client.Txn(ctx).If(v3.Compare(v3.CreateRevision(k), "=", 0))
    txn = txn.Then(v3.OpPut(k, val, v3.WithLease(s.Lease())))
    txn = txn.Else(v3.OpGet(k))
    resp, err := txn.Commit()
    if err != nil {
        return err
    }
    e.leaderKey, e.leaderRev, e.leaderSession = k, resp.Header.Revision, s
    //如果key已存在,则创建失败;
        //当key的value与当前value不等时,如果自己为leader,则不用重新执行选举直接设置value;
        //否则报错。
    if !resp.Succeeded {
        kv := resp.Responses[0].GetResponseRange().Kvs[0]
        e.leaderRev = kv.CreateRevision
        if string(kv.Value) != val {
            if err = e.Proclaim(ctx, val); err != nil {
                e.Resign(ctx)
                return err
            }
        }
    }
    
    //一直阻塞,直到确认自己的create revision为当前path中最小,从而确认自己当选为leader
    _, err = waitDeletes(ctx, client, e.keyPrefix, e.leaderRev-1)
    if err != nil {
        // clean up in case of context cancel
        select {
        case <-ctx.Done():
            e.Resign(client.Ctx())
        default:
            e.leaderSession = nil
        }
        return err
    }
    e.hdr = resp.Header

    return nil
}

锁基础:

https://tech.meituan.com/2018...

Reference

查看原文

赞 7 收藏 3 评论 0

徐亚松 发布了文章 · 2020-01-17

kubelet 原理解析:先导片

[TOC]

一. 概述

本文是kubelet源码阅读的先导片,先了解kubelet的主要配置和功能以及一些注意事项,后面走读源码的时候才会更加顺畅,不然一堆 config 的初始化和chan处理,不知道支持哪些新特性,啥场景会用到,看了也没啥意思。

二. 配置方式

2.1 flag 模式

k8s 迭代速度很快,几个月一个大版本,kubelet 的启动参数也在不断变化,一切配置以官方文档为准,或者拿二进制直接--help 看,不然忙活一圈才发现某个特性在当前版本不支持。下面是一份基础可用的kubelet配置,版本 1.8

./kubelet \
--address=192.168.5.228 \
--allow-privileged=true \
--client-ca-file=/etc/kubernetes/pki/ca.pem \
--cloud-config=/etc/kubernetes/cloud.config \
--cloud-provider=external \
--cluster-dns=172.16.0.10 \
--cluster-domain=cluster.local \
--docker-root=/data/docker \
--fail-swap-on=false \
--feature-gates=VolumeSnapshotDataSource=true,CSINodeInfo=true,CSIDriverRegistry=true \
--hostname-override=192.168.5.228 \
--kubeconfig=/etc/kubernetes/kubelet.conf \
--logtostderr=true \
--network-plugin=kubenet \
--max-pods=256 \
--non-masquerade-cidr=172.26.0.0/16 \
--pod-infra-container-image=hub.docker.com/public/pause:2.0 \
--pod-manifest-path=/etc/kubernetes/manifests \
--root-dir=/data/kubelet \
--tls-cipher-suites=TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,TLS_RSA_WITH_AES_128_CBC_SHA,TLS_RSA_WITH_AES_256_CBC_SHA,TLS_RSA_WITH_AES_128_GCM_SHA256,TLS_RSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA \
--anonymous-auth=false \
--v=5 \
--enforce-node-allocatable=pods,kube-reserved,system-reserved \
--kube-reserved-cgroup=/system.slice/kubelet.service \
--system-reserved-cgroup=/system.slice \
--kube-reserved=cpu=50m \
--system-reserved=cpu=50m \
--eviction-hard=memory.available<5%,nodefs.available<10%,imagefs.available<10%% \
--eviction-soft=memory.available<10%,nodefs.available<15%,imagefs.available<15%% \
--eviction-soft-grace-period=memory.available=2m,nodefs.available=2m,imagefs.available=2m \
--eviction-max-pod-grace-period=30 \
--eviction-minimum-reclaim=memory.available=0Mi,nodefs.available=500Mi,imagefs.available=500Mi

2.2 config 模式

上面的配置文件是老版本 kubelet(1.10 以前),启动参数都是用 flag 来声明的,简单粗暴,在 1.10 以后,kubelet 支持了KubeletConfiguration的方式来声明参数,如 kubeadm 部署的集群配置如下:

# Note: This dropin only works with kubeadm and kubelet v1.11+
[Service]
Environment="KUBELET_KUBECONFIG_ARGS=--bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubelet.conf --kubeconfig=/etc/kubernetes/kubelet.conf"
Environment="KUBELET_CONFIG_ARGS=--config=/var/lib/kubelet/config.yaml"
# This is a file that "kubeadm init" and "kubeadm join" generates at runtime, populating the KUBELET_KUBEADM_ARGS variable dynamically
EnvironmentFile=-/var/lib/kubelet/kubeadm-flags.env
# This is a file that the user can use for overrides of the kubelet args as a last resort. Preferably, the user should use
# the .NodeRegistration.KubeletExtraArgs object in the configuration files instead. KUBELET_EXTRA_ARGS should be sourced from this file.
EnvironmentFile=-/etc/sysconfig/kubelet
ExecStart=
ExecStart=/usr/bin/kubelet $KUBELET_KUBECONFIG_ARGS $KUBELET_CONFIG_ARGS $KUBELET_KUBEADM_ARGS $KUBELET_EXTRA_ARGS
~

kubelet 的--config配置采用了本地文件KubeletConfiguration资源做参数

本地文件/var/lib/kubelet/config.yaml内容为:

apiVersion: kubelet.config.k8s.io/v1beta1
authentication:
  anonymous:
    enabled: false
  webhook:
    cacheTTL: 0s
    enabled: true
  x509:
    clientCAFile: /etc/kubernetes/pki/ca.crt
authorization:
  mode: Webhook
  webhook:
    cacheAuthorizedTTL: 0s
    cacheUnauthorizedTTL: 0s
clusterDNS:
- 10.96.0.10
clusterDomain: cluster.local
cpuManagerReconcilePeriod: 0s
evictionPressureTransitionPeriod: 0s
fileCheckFrequency: 0s
healthzBindAddress: 127.0.0.1
healthzPort: 10248
httpCheckFrequency: 0s
imageMinimumGCAge: 0s
kind: KubeletConfiguration
nodeStatusReportFrequency: 0s
nodeStatusUpdateFrequency: 0s
rotateCertificates: true
runtimeRequestTimeout: 0s
staticPodPath: /etc/kubernetes/manifests
streamingConnectionIdleTimeout: 0s
syncFrequency: 0s
volumeStatsAggPeriod: 0s

为什么要这么改呢?

--max-pods int32                                                                                            Number of Pods that can run on this Kubelet. (default 110) (DEPRECATED: This parameter should be set via the config file specified by the Kubelet's --config flag. See https://kubernetes.io/docs/tasks/administer-cluster/kubelet-config-file/ for more information.)

以上面的max-pods配置为例,你运行kubelet --help你会发现,kubelet的绝大多数命令行flag参数都被DEPRECATED了,后面一句就是官方推荐我们使用--config文件来指定这些配置,具体内容可以查看这里Set Kubelet parameters via a config file,一来是区分出什么是机器特有配置,什么是机器可以共享的配置,二来是为了支持之后更高级的动态Kubelet配置Dynamic Kubelet Configuration,即把可以共享的配置做成一种资源,节点共享一份配置

2.3 动态 config 模式

kubelet 的支持动态 kubelet 配置,即dynamic-config,在1.11版本开始支持,原理如下:

参考:https://kubernetes.io/docs/ta...

config 文件的概念出来之后,kubelet 的配置就划分为了两部分:

  • KubeletFlag: 指那些不允许在kubelet运行时进行修改的配置集,或者不能在集群中各个Nodes之间共享的配置集。直接以 flag 的形式添加,如nodeip
  • KubeletConfiguration: 指可以在集群中各个Nodes之间共享的配置集。


动态配置在 1.17仍然处于 beta 状态,其中有几个关键点还没解决:

  • 没有提供原生的集群灰度能力,需要用户自己实现自动化灰度节点配置。如果所有Node引用同一个Kubelet ConfigMap,当该ConfigMap发生错误变更后,可能会导致集群短时间不可用。

    • 分批灰度所有Nodes的能力
    • 或者是滚动灰度所有Nodes的能力
  • 哪些集群配置可以通过Kubelet Dynamic Config安全可靠的动态变更,还没有一个完全明确的集合。通常情况下,我们可以参考staging/src/k8s.io/kubelet/config/v1beta1/types.go:62中对KubeletConfiguration的定义注解了解那些Dynamic Config,但是还是建议在测试集群中测试过后,再通过Dynamic Config灰度到生产环境。

三.参数含义

无论是flag 参数还是 config 参数,都是kubelet 运行的必备条件,这里对一些关键指标进行解释,以下内容大量引用官方文档

全部 config:https://kubernetes.io/zh/docs...

config 文件中的参数列表为:https://github.com/kubernetes...

剩下的就是 flag参数,参数的示例值见上文。

3.1 flag 参数

  • cloud-config: 云服务商的配置文件路径,cloud-provider开启时使用
  • cloud-provider: 云服务商,为空表示没有云服务商,用于确定节点名称。
  • hostname-override: 如果为非空,将使用此字符串而不是实际的主机名作为节点标识
  • config: 用于声明 config 文件的路径
  • container-runtime: 容器运行时,默认为 docker,还支持remote、rkt(已弃用)
  • docker-root: docker 根目录的路径,默认值:/var/lib/docker
  • kubeconfig: kubeconfig 配置文件的路径,指定如何连接到 API 服务器。提供 --kubeconfig 将启用 API 服务器模式,而省略 --kubeconfig 将启用独立模式。
  • logtostderr: 日志输出到 stderr 而不是文件(默认值为 true)
  • network-plugin: 仅当容器运行环境设置为 docker 时生效,如 kubenet
  • non-masquerade-cidr: kubelet 向该 IP 段之外的 IP 地址发送的流量将使用 IP 伪装技术,该参数将在未来版本中删除 ?
  • pod-infra-container-image: 仅当容器运行环境设置为 docker 时生效,指定pause镜像
  • root-dir: 设置用于管理 kubelet 文件的根目录(例如挂载卷的相关文件),默认/var/lib/kubelet
  • tls-cipher-suites: 服务器端加密算法列表,以逗号分隔,如果不设置,则使用 Go 语言加密包的默认算法列表
  • v : 设置 kubelet 日志级别详细程度的数值

3.2 config 参数

  • address: kubelet绑定的主机IP地址,默认为0.0.0.0表示绑定全部网络接口
  • allow-privileged: 是否允许以特权模式启动容器。当前默认值为false,已废弃
  • client-ca-file: 基础 ca证书,即 ca.pem
  • cluster-dns: 集群内DNS服务的IP地址,仅当 Pod 设置了 “dnsPolicy=ClusterFirst” 属性时可用
  • cluster-domain: 集群的域名
  • fail-swap-on: 设置为 true 表示如果主机启用了交换分区,kubelet 将无法使用。(默认值为 true)
  • max-pods: kubelet 能运行的 Pod 最大数量。(默认值为 110)
  • feature-gates: 用于 alpha 实验性质的特性开关组,每个开关以 key=value 形式表示
  • pod-manifest-path: 设置包含要运行的static Pod 的文件的路径,或单个静态 Pod 文件的路径
  • anonymous-auth: 设置为 true 表示 kubelet 服务器可以接受匿名请求。
  • enforce-node-allocatable: 包含由 kubelet 强制执行的节点可分配资源级别。可选配置为:‘none’、‘pods’、‘system-reserved’ 和 ‘kube-reserved’
  • kube-reserved-cgroup: 顶层kube cgroup 的名称
  • system-reserved-cgroup: 顶层system cgroup 的名称
  • kube-reserved: kube 组件资源预留
  • system-reserved: 系统组件组件预留
  • eviction-hard: 硬驱逐策略
  • eviction-soft: 软驱逐策略
  • eviction-soft-grace-period: 软驱逐宽限期
  • eviction-max-pod-grace-period: 响应满足软驱逐阈值(soft eviction threshold)而终止 Pod 时使用的最长宽限期(以秒为单位)
  • eviction-minimum-reclaim: 当本节点压力过大时,kubelet 执行软性驱逐操作。此参数设置软性驱逐操作需要回收的资源的最小数量(例如:imagefs.available=2Gi)。

四. 最佳实践

4.1 合理配置驱逐与预留值

详细内容参考:k8s节点资源预留与 pod 驱逐

驱逐:通过--eviction-hard标志预留一些内存后,当节点上的可用内存降至保留值以下时,kubelet 将会对pod进行驱逐。驱逐有软硬两种,而且软驱逐可以细化到持续多久才触发。

预留:可以给系统核心进程、k8s 核心进程配置资源预留,总数-预留数剩下的才是给 pod 分配的量,资源预留建议使用阶梯式,机器配置越高,预留越多

如,对于内存资源:

  • 内存少于1GB,则设置255 MiB
  • 内存大于4G,设置前4GB内存的25%
  • 接下来4GB内存的20%(最多8GB)
  • 接下来8GB内存的10%(最多16GB)
  • 接下来112GB内存的6%(最高128GB)
  • 超过128GB的任何内存的2%
  • 在1.12.0之前的版本中,内存小于1GB的节点不需要保留内存

4.2 减少心跳上报频率

设计文档:node-heartbeat

目的:

  • 在 Kubernetes 集群中,影响其扩展到更大规模的一个核心问题是如何有效的处理节点的心跳。在一个典型的生产环境中 (non-trival),kubelet 每 10s 汇报一次心跳,每次心跳请求的内容达到 15kb(包含节点上数十计的镜像,和若干的卷信息),这会带来两大问题:
  • 心跳请求触发 etcd 中 node 对象的更新,在 10k nodes 的集群中,这些更新将产生近 1GB/min 的 transaction logs(etcd 会记录变更历史);

API Server 很高的 CPU 消耗,node 节点非常庞大,序列化/反序列化开销很大,处理心跳请求的 CPU 开销超过 API Server CPU 时间占用的 80%。

方法:

  • 为了解决这个问题,Kubernetes 引入了一个新的 build-in Lease API ,将与心跳密切相关的信息从 node 对象中剥离出来,也就是上图中的 Lease 。原本 kubelet 每 10s 更新一次 node 对象升级为:每 10s 更新一次 Lease 对象,表明该节点的存活状态,Node Controller 根据该 Lease 对象的状态来判断节点是否存活;
  • 处于兼容性的考虑,降低为每 60s 更新一次 node 对象,使得 Eviction_ _Manager 等可以继续按照原有的逻辑工作。
  • 因为 Lease 对象非常小,因此其更新的代价远小于更新 node 对象。kubernetes 通过这个机制,显著的降低了 API Server 的 CPU 开销,同时也大幅减小了 etcd 中大量的 transaction logs,成功将其规模从 1000 扩展到了几千个节点的规模,该功能在社区 Kubernetes-1.14 中已经默认启用。

具体参考:https://kubernetes.io/docs/co...

4.3 bookmark

设计文档:watch-bookmark

目的:

  • watch client 重启后会对所有的资源进行重新 watch,apiserver负载会剧增,可以减少不必要的 watch 事件
  • 如果重启前为 resourceVersion v1的资源,重启后发现资源变成了v2,客户端并不会知道,仍然使用 v1,apiserver会将 v2 转到 v1,这里的处理其实是没有必要的

方法:

kubernetes 从v1.15开始 支持 bookmark 机制,bookmark 主要作用是只将特定的事件发送给客户端,从而避免增加 apiserver 的负载。bookmark 的核心思想概括起来就是在 client 与 server 之间保持一个“心跳”,即使队列中无 client 需要感知的更新,reflector 内部的版本号也需要及时的更新。

比如:每个节点上的 kubelet 仅关注 和自己节点相关的 pods,pod storage 队列是有限的(FIFO),当 pods 的队列更新时,旧的变更就会从队列中淘汰,当队列中的更新与某个 kubelet client 无关时,kubelet client watch 的 resourceVersion 仍然保持不变,若此时 kubelet client 重连 apiserver 后,这时候 apiserver 无法判断当前队列的最小值与 kubelet client 之间是否存在需要感知的变更,因此返回 client too old version err 触发 kubelet client 重新 list 所有的数据。

EventType多了一种枚举值:Bookmark

  Added    EventType = "ADDED"
  Modified EventType = "MODIFIED"
  Deleted  EventType = "DELETED"
  Error    EventType = "ERROR"
  Bookmark EventType = "BOOKMARK"

4.4 hugepages

设计文档: hugepages

目的:

HugePages是Linux内核的一个特性,使用hugepage可以用更大的内存页来取代传统的4K页面。可以提高内存的性能,降低CPU负载,作用详情参考hugepage的优势与使用

对于大内存工作集或者对内存访问延迟很敏感的应用来说,开启hugepages的效果比较显著,如 mysql、java 程序、dpdk等,默认情况下 kubelet启动的 pod 是没有开启hugepages的。

方法:

从 k8s1.8 开始就开始支持hugepage,kubelet中通过--feature-gates=HugePages=true来开启,hugepage 建议谨慎使用,由管理员来分配,因为预分配的大页面会减少节点上可分配的内存量。该节点将像对待其他系统保留一样对待预分配的大页面,可分配 mem 的公式就变成了:

[Allocatable] = [Node Capacity] - 
 [Kube-Reserved] - 
 [System-Reserved] - 
 [Pre-Allocated-HugePages * HugePageSize] -
 [Hard-Eviction-Threshold]

如开了 hugepage 的 node 状态为:

apiVersion: v1
kind: Node
metadata:
  name: node1
...
status:
  capacity:
    memory: 10Gi
    hugepages-2Mi: 1Gi
  allocatable:
    memory: 9Gi
    hugepages-2Mi: 1Gi

在创建 pod 时可以声明使用这些页内存,

apiVersion: v1
kind: Pod
metadata:
  name: example
spec:
  containers:
...
    volumeMounts:
    - mountPath: /hugepages-2Mi
      name: hugepage-2Mi
    - mountPath: /hugepages-1Gi
      name: hugepage-1Gi
    resources:
      requests:
        hugepages-2Mi: 1Gi
        hugepages-1Gi: 2Gi
      limits:
        hugepages-2Mi: 1Gi
        hugepages-1Gi: 2Gi
  volumes:
  - name: hugepage-2Mi
    emptyDir:
      medium: HugePages-2Mi
  - name: hugepage-1Gi
    emptyDir:
      medium: HugePages-1Gi

4.5 新的健康检查机制

设计文档:[https://github.com/kubernetes...]

目的:

健康检查的基础内容参考:[K8S 中的健康检查机制
](https://www.jianshu.com/p/5de...

kubelet 中有 health check 相关的逻辑来判断 pod 启动后状态是否正常,如果检查不通过会杀死 pod 重启,直到检查通过,但对于慢启动容器 来说现有的健康检查机制不太好用

慢启动容器:指需要大量时间(一到几分钟)启动的容器。启动缓慢的原因可能有多种:

  • 长时间的数据初始化:只有第一次启动会花费很多时间
  • 负载很高:每次启动都花费很多时间
  • 节点资源不足/过载:即容器启动时间取决于外部因素

这种容器的主要问题在于,在livenessProbe失败之前,应该给它们足够的时间来启动它们。对于这种问题,现有的机制的处理方式为:

  • 方法一:livenessProbe中把延迟初始时间initialDelaySeconds设置的很长,以允许容器启动(即initialDelaySeconds大于平均启动时间)。虽然这样可以确保livenessProbe不会检测失败,但是不知道initialDelaySeconds应该配置为多少,启动时间不是一个固定值。另外,因为livenessProbe在启动过程还没运行,因此pod 得不到反馈,events 看不到内容,如果你initialDelaySeconds是 10 分钟,那这 10 分钟内你不知道在发生什么。
  • 方法二:增加livenessProbe的失败次数。即failureThreshold*periodSeconds的乘积足够大,简单粗暴,同时容器在初次成功启动后,就算死锁或以其他方式挂起,livenessProbe也会不断探测

方法二可以解决这个问题,但不够优雅。

因为livenessProbe的设计是为了在 pod 启动成功后进行健康探测,最好前提是 pod 已经启动成功,否则启动阶段的多次失败是没有意义的,因此官方提出了一种新的探针:即startupProbe,startupProbe并不是一种新的数据结构,他完全复用了livenessProbe,只是名字改了下,多了一种概念,关于这个 probe 的提议讨论可以参考issue

使用方式:startup-probes

ports:
- name: liveness-port
  containerPort: 8080
  hostPort: 8080

livenessProbe:
  httpGet:
    path: /healthz
    port: liveness-port
  failureThreshold: 1
  periodSeconds: 10

startupProbe:
  httpGet:
    path: /healthz
    port: liveness-port
  failureThreshold: 30
  periodSeconds: 10

这个配置的含义是:

startupProbe首先检测,该应用程序最多有5分钟(30 * 10 = 300s)完成启动。一旦startupProbe成功一次,livenessProbe将接管,以对后续运行过程中容器死锁提供快速响应。如果startupProbe从未成功,则容器将在300秒后被杀死。

k8s 1.16 才开始支持startupProbe这个特性

Reference

查看原文

赞 0 收藏 0 评论 0

认证与成就

  • 获得 59 次点赞
  • 获得 2 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 2 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2017-05-09
个人主页被 1.7k 人浏览