前言

之前在InfoQ的《华为云物联网四年配置中心实践》文章中分享了业务配置中心。

本文讲述业务配置中心(下文简述为配置中心)的关键技术和实现方式。华为云物联网平台按照本文的实现方式实现了一个业务配置中心,该配置中心2020年1月上线,平稳运行至今。

概念

运维配置

和用户无关,通常为集群界级别的配置,程序只会进行读取,如数据库配置、邮箱服务器配置、网卡配置、子网地址配置等。

业务配置

作为SaaS 服务,每个用户在上面都有一些业务配置。如用户的证书配置、用户服务器的流控配置等,这些业务配置相对运维配置来说更加复杂,且可能会有唯一性限制,如按用户 id 唯一。这部分配置数据一般由用户操作触发,代码动态写入,并且通知到各个微服务实例。通常,我们希望这些配置能在界面展示,且支持人为修改。上述逻辑如果由各微服务自己实现,会存在大量重复代码,并且质量无法保证。我们希望由一个公共组件来统一实现这个能力。开源或体量较小的项目就不会选择依赖一个配置中心,而是直接通过连接数据库或etcd来解决问题

env

代表一个部署环境。

cluster

代表环境下的集群。常见于单环境下蓝绿发布,蓝集群、绿集群、金丝雀集群等。

配置

配置名称,如用户证书配置、用户流控配置等。

Key

配置的唯一键,如用户id。

Value

配置唯一键对应的值。

配置中心设计梗概

业务配置特点

  • 虽然业务配置写入可能存在并发,但并发量不大,频率较低。
  • 业务配置常常以用户为id,单集群用户量有限,一般不超过5万。

配置中心要解决的问题

business-config-center-impl1

设计要点

  • 单配置要求有配置id,每个id上通过version的乐观并发控制来解决多版本冲突问题
  • 通知不追求可靠,应用程序和配置中心断链无法接收通知的场景下,通过定期同步数据来保证数据的可靠
  • 支持Schema的变更,因Schema变更不频繁,也采用version的乐观并发控制来解决多版本冲突问题

通知是否包含消息内容

我认为应该只通知Key,具体的数值让应用程序再去配置中心查询。仅通知Key实现简洁易懂。同时通知Key&Value需要多考虑定期同步和通知两条通道并发,可能引起的竞态冲突。

配置中心业务流程

本小节描述业务配置中心的所有业务流程,并试图从交互中抽象出与具体实现无关的接口

配置的增删改查

business-config-center-impl2

配置值的增删改查

business-config-center-impl3

定期同步

分布式场景下,通知有可能无法送达,如程序陷入网络中断(或长gc),通知消息送达超时,待程序恢复后,数据不再准确。因此需要对数据做定期同步,提高可靠性。

business-config-center-impl4

同步过程中,仅仅请求交互id和version,避免传输大量数据。应用程序接收到需要同步的数据后:

  • 删除操作,触发删除通知,从本地缓存中移除数据。
  • 添加、修改操作,向配置中心查询最新数据,触发通知并写入本地缓存。

服务启动

服务启动也可看做是一个同步的流程,只是需要同步大量的数据添加。为了避免向配置中心频繁大量的请求,引入批量操作来减轻压力

business-config-center-impl5

限制

该配置中心设计思路依赖客户端可把数据全量放入到内存中,如用户量太大,则不适合采用这种模式。

注:一个节省内存的思路是,内存中只放置全量的id和version,数据只有当用到的时候再去查询。这个思路要求配置中心持久化一些老旧数据以供以下场景的查询使用

  • 业务流程中,需要使用该配置值的。

  • 回调业务程序修改的时候,需要提供旧值的。

除此之外没有任何区别。

业务配置抽象实现

从上述描述的业务场景,我们抽象出业务配置中心的交互接口和抽象实现。接口的Swagger Yaml已上传到Github:https://gist.github.com/hezhangjian/68c9c2ecae72cc2a125184e95b0a741e

配置相关接口

  • 提供env、cluster、配置名称、配置Schema、配置版本号添加配置
  • 提供env、cluster、配置名称删除配置
  • 提供env、cluster、配置名称、新Schema、新Version来修改配置
  • 提供env、cluster、配置名称来查询配置

配置值相关接口

  • 提供env、cluster、配置名称、Key、Value来添加配置值
  • 提供env、cluster、Key、ValueVersion(可选)来删除配置值
  • 提供env、cluster、Key、Value、ValueVersion(可选)修改配置值
  • 提供env、cluster、Key查询配置值
  • 根据env、cluster、应用程序当前的配置数据来做定期同步
  • 根据Key列表批量查询配置值

通知相关接口

  • 通知某env某cluster下,配置项中的一个Key发生变化,新增、修改或是删除。可选方式有HTTP长链接(Inspired by Apollo)、Mqtt、WebSocket等。

配置中心存储层抽象实现

配置中心存储层需要存储配置配置值数据,支持UpdateByVersion,且需要捕捉数据的变化,用来通知到应用程序

服务发现抽象实现

为了使应用程序连接到配置中心,需要一个发现机制可以让应用程序感知到配置中心的地址。高可用的方式很多,如K8s发现、ZooKeeper、Etcd、ServiceComb、业务环境变量注入ELB地址(ELB后端挂载配置中心的地址)等。

抽象总结

business-config-center-impl6

根据这个抽象,我们可以进行关键技术点选型,来实现业务配置中心。

配置中心实现

华为云物联网配置中心实现

business-config-center-impl7

  • env+cluster+config组成数据表的名称
  • 一个key、value对应一行数据

另一种实现方式

只要实现上述接口和抽象能力,都可以实现业务配置中心,也可以这么实现

business-config-center-impl8

  • env+cluster+config+key 组合成etcd的key
  • 一个key、value对应一个键值对

又一种实现方式

当然也可以

business-config-center-impl9

  • env+cluster+config+key 组合成RocksDB的key
  • 一个key、value对应一个键值对

一句话结论,可以在拷贝镜像文件的时候,通过如下命令指定user来压缩dockerfile的体积,避免把指定的文件在dockerfile中计算两次。

为什么要指定User?

  • 往往,我们会因为安全的要求,不允许使用root用户运行程序。
  • 像ElasticSearch这个开源组件要求不能用root用户运行,其实也是出于安全的原因
1
COPY --chown=sh:sh source /opt/sh

效果展示

先使用dd命令创建1GB的测试文件

1
dd if=/dev/zero of=testfile bs=1024 count=1048576

测试基础镜像ttbb/base:latest,大小439MB

1
2
docker images|grep 'ttbb/base'|grep latest
ttbb/base latest bacdb9e7b5f4 2 weeks ago 439MB

优化前DockerFile

1
2
3
4
5
FROM ttbb/base

COPY testfile /opt/sh/testfile

RUN chown -R sh:sh /opt/sh/testfile

大小

1
1280d315e09d        31 seconds ago      2.59GB

可以看到testfile计算了两次,大小达到了2G多。

优化后DockerFile

1
2
3
FROM ttbb/base

COPY --chown=sh:sh testfile /opt/sh/testfile

大小

1
115b68bc4db8        21 seconds ago       1.51GB

testfile仅计算一次,仅使用1.5G。

Raft主要使用了重叠的大多数技术来保证算法的安全

Raft首要追求的是可理解性

Raft使用数个技术来提升可理解性。包括

  • 问题分解:主备选举、日志复制、安全性
  • 尽量减少状态空间(相比Paxos,Raft减少了不确定性)

Raft新颖的特性

强leader

日志文件只单向传输,简化状态

leader选举

Raft使用随机定时器来选举leader。只添加了很小的机制,却能简单、快速解决冲突

Membership变更

Raft的Membership变更机制使用joint consensus方法,在变更过程中,两个不同配置的大多数 重叠。这使得在集群成员变更时,也能正常处理请求

复制状态机

补图

保证复制状态机的一致,也就保证了数据的一致

一致性算法拥有如下的典型属性

  • 在非拜占庭场景下,保证了正确性。包括 网络延迟、分区、丢包、乱序等
  • 当大多数节点在线的时候,功能可用
  • 不依赖时间来保证日志的一致性。错误的时钟和极大地消息延时,在最差的场景下,可能会导致一致性问题
  • 在最常见的场景下,当一轮大多数节点反悔的时候,就能完成一个命令。小部分节点响应缓慢并不影响系统的整体性能。

Raft协议

简述

Leader选举

新的leader必须在已存在的leader宕机后选出

日志复制

leader必须从客户端哪里接收日志请求,复制到整个集群,迫使其他人达成一致

安全

Raft的安全属性关键。如果任何服务器将一个entry log复制到状态机中,那么其他任意服务器都不能在相同的log index上放置不同的命令。

安全的详细内容

Election Safety 选举安全

每一个任期内,至多只会有一个leader

Leader仅追加

leader不会覆写或者删除已存在的entry,只会追加新的entry(todo 待确认,是写入的,还是commit的,从leader可能是一个老的节点来说,这里应该说commit的更为恰当)

Log Matching 日志匹配

如果两个日志具有相同的任期值和相同的index,那么直到这个index之前的日志都是一样的

Leader Completeness Leader完整性

如果一个日志在一个给定任期内提交了,那么这个日志会一直存在,存在在任何高任期的Leader之中

State Machine Safety 状态机安全

如果服务器已在其状态机上将给定索引的日志条目应用于其状态机,则其他服务器将永远不会对同一索引应用不同的日志条目

Raft基础

Raft将时间切分成任期时长间隔的任期。Raft保证一个任期内至多只有一个Leader。任期可以称为是Raft中的逻辑时钟。每个服务器之间都会互相传播任期值。

Leader选举

Raft使用随机的选举时间来保证分裂投票场景少见并快速解决。将选举超时设定为一个范围。

Raft的作者们考虑过使用不同的Rank值,当分裂投票的时候,Rank值高的优先成为主节点,但在可用性方面有细微的问题。Rank值低的节点需要超时才能成为新的leader,这个时间间隔如果太短,会破坏已有的选举,集群太过敏感)

最终认为随机的措施更明显、更易懂

日志复制

Leader来决定何时将日志提交到状态机是安全的,叫做committed提交。Raft保证提交过的entry都是持久化的,然后最终会被所有的状态机执行。

只有当前的Leader在任期内,然后将其复制到大多数节点,才算做committed!(这里有和仅仅复制到大多数节点有着重要的区别)然后这里会将之前的日志提交。

Leader每次发送AppendEntries RPC请求时,确认在这之前的日志和从节点完全相同。

Raft可以accept、replicate、应用新的日志记录。在正常场景下,经过一轮大多数RPC调用,就可以复制完成。

安全

假如,当leader提交数个日志的时候,follower不可用,然后他当选了leader之后,提交的日志把之前提交的日志覆盖了怎么办?

这里在选举当选leader的上面加了个限制,保证了之后的leader包含了之前所有已提交的entry。

选举限制

Raft使用投票阶段来防止一个没有之前提交过日志的候选者当选leader。候选者必须联络大多数节点才能当选,这就意味着提交过的entry一定在其中的一个服务器中。

提交之前任期的entry

leader不能立刻得出结论:之前任期的日志复制到大多数节点就已经算commit了。

image-20210325211802223

  • a S1是leader,然后部分复制了日志2
  • b S1宕机,S5接受了S3和S4的投票当选了任期3的leader,在index2接受了不同的entry
  • c S5宕机,S1重启,当选了leader,继续复制
  • d S1宕机,S5重启,然后用任期3的日志覆盖了其他节点
  • e 然而如果S1在宕机前,把日志覆盖到大多数节点,那么S5就不能当选leader了

为了避免上图的问题,Raft绝不将复制的数量当作commit 日志的依据。只有当前任期下的entry log通过复制数量来计算。一旦当前任期的entry被提交,那么之前所有的entry都被间接commit了。

安全性保证

我们用反证法证明一旦Leader Completeness Property没有满足,我们就会推断出一个矛盾。假设任期T的Leader提交了一个log entry在任期T,但是这个log entry没有被将来一些任期的Leader拥有。假设有一个没有包含这个entry的最小的任期U的Leader,Leader U没有存储这个entry

    1. 在选举的时候,提交的entry必须不在leader U的日志中(leader从不删除或复写日志)
    1. Leader将这个entry复制到了集群中的大多数节点,并且leader U接收到了集群中大多数节点的投票。至少有一个服务器,即从leader T哪里接受了entry,并且给U投票。这是达成矛盾的关键
    2. voter 必须在接收leaderT的entry之前给U投票。否则它就要拒绝T的写入请求
    3. 当voter给U投票的时候,它始终持久化着这个日志,因为每个中间的leader都包含这个entry,leader不会删除这个entry,除非冲突,follower也不会删除这个entry
    4. voter给U投票,所以U的日志必须至少和voter的一样新,这就达成了第一个冲突
    5. 首先假设,如果voter和U都有同样的上一次log的任期,U的日志至少和vote一样。矛盾,因为最初假设U没有这个log,而voter有。
    6. 否则,leader U的上次log任期比voter的大。此外,它比T大,因为选民的上一个log term至少为T(其中包含来自T的提交entry)。 创建leaderU的最后一个log term的较早的领导者必须在其日志中包含已提交的条目(通过假设)。 然后,通过Log Matching Property,leaderU的日志还必须包含已提交的条目,这是矛盾的
    7. 这就完成了矛盾的证明。比T任期大的leader一定包含了任期T内提交的entry

时间和可用性

Raft可以选举并维持一个稳定的leader,只要系统满足如下的时间限制条件

1
broadcastTime << electionTimeout << MTBF

broadcastTime是进行一个并行rpc到所有服务器来回的平均时间。MTBF是单个服务器故障的平均时间。

广播时间必须比选举时间小一个量级,所以leader放心的发送心跳消息,维护自己的follower。加上随机的选举时延,这个不等式也让选票分裂变得不可能。 如果广播时间和选举时间差不多,选举leader不稳定。

选举超时应该比MTBF小几个数量级,要不然选举的leader就不稳定。
broadcast的时间差不多在0.5ms到20ms
选举超时应该在100ms到500ms。
典型的服务器MTBF时间应该在数月或以上

集群成员变更

成员变更的时候,中途必须没有两个相同任期的leader。不幸的是,任何将服务器们直接从老配置变换到新配置都是不安全的。不可能一次性地原子性地把所有服务器的配置变更,所以中集群在中途可能会分裂为两个多数派。
为了保证安全性,集群成员变更必须使用两阶段的方式。有很多种方式实现两阶段提交。例如,一些系统使用第一次提交来禁用旧的配置,使得旧的配置无法接受客户端的请求,然后第二次操作启动新的配置。在Raft中,集群首先切换到一个过度的配置,叫做joint consensus。一旦joint consensus被提交,系统接下来过渡到新的配置。joint consensus结合了新老配置

  • Log entry在两种配置下都会复制。即新机器和老机器都会复制entry
  • 不管是老配置还是新配置,都有可能当选leader
  • Agreement(协议,包括选举和entry提交)需要老配置和新配置多数派都确认
    补图 Figure11
    集群配置通过复制日志中的特殊entry来进行存储、通信。

上述流程有三个问题
第一个问题是,新的服务器可能初始没有存储任何log entry。如果现在添加到集群中,会花费一些时间来跟上集群的数据,这中间有可能无法commit新的log entry。为了避免可用性的gap。Raft在配置变更之前引入了一个额外的阶段,新的服务器首先
第二个问题是,cluster的leader可能不是新配置中的服务器。这个场景,leader的变化发生在新配置提交的时候。
第三个问题是,移除的服务器可以打乱整个集群。这些服务器接收不到心跳,这些服务器会超时然后启动新的选举。他们将使用新的任期发送RequestVote RPC,会导致当前的leader变为follower。新的leader最终会被选举,但是移除的服务器将会再次超时,重复整个过程,最终导致集群较差的可用性。
为了解决这个问题,Server当认为有leader存在的时候,会忽略RequestVote请求。如果服务器在选举超时前接收到RequestVote RPC请求,它并不会更新它的任期或是给予它的投票。这并不影响正常的选举(每个服务器在选举之前等待最小超时时间)。并且,这有助于避免移除的server破坏选举:如果一个leader可以发送心跳到他负责的集群中的大多数节点,他将不会被更高任期的节点罢免。

成员变更过程中如果发生Failover,老Leader宕机, Cold,new中任意一个节点都可能成为新Leader,如果新 Cold,newLeader上没有 日志,则继续使用Cold ,Follower上如果有 Cold,new 日志会被新Leader截断,回退到 Cold,成员变更失败;如果新Leader上有 Cold,new日志,则继续将未完成的成员变更流程走完。

新成员先加入再同步数据,成员变更可以立即完成,并且因为只要大多数成员同意即可加入,甚至可以加入还不存在的成员,加入后再慢慢同步数据。但在数据同步完成之前新成员无法服务,但新成员的加入可能让多数派集合增大,而新成员暂时又无法服务,此时如果有成员发生Failover,很可能导致无法满足多数成员存活的条件,让服务不可用。因此新成员先加入再同步数据,简化了成员变更,但可能降低服务的可用性。

新成员先同步数据再加入,成员变更需要后台异步进行,先将新成员作为Learner角色加入,只能同步数据,不具有投票权,不会增加多数派集合,等数据同步完成后再让新成员正式加入,正式加入后可立即开始工作,不影响服务可用性。因此新成员先同步数据再加入,不影响服务的可用性,但成员变更流程复杂,并且因为要先给新成员同步数据,不能加入还不存在的成员。

日志压缩

每当有新的操作发生的时候,Raft的日志就会增长,然而在实际的系统中,日志并不能无边界地增长。
快照是最简单的压缩日志的方式。在快照中,整个系统的状态写入到持久化存储的快照中,然后在这之前的日志都可以丢弃。
todo 补图
其他方式,像日志清理或lsm树。在数据的一部分子集上面执行,它们均摊了压缩日志的消耗。

Leader创建snapshot,再分发给follower。有如下两个缺点
第一,Server必须选择何时进行快照,如果服务器快照进行地太频繁,将会浪费磁盘带宽和磁盘energy。如果快照太不频繁,会浪费磁盘的存储空间,然后增加了重放日志所需的时间。如果阈值设置地大,时间周期长的话,磁盘开销小。
第二,写快照会消耗较大的时间,我们不希望这个操作延迟了正常的操作。方案是使用Copy on write技术,这样子在不影响snapshot写入的情况下,集群可以接受新的更新。

客户端的交互

Raft实现了线性化的语义。Linearizable semantics。像es那样使用version,是达不到线性化的语义的。
在读取数据的时候需要额外的措施来保证线性化的语义。首先,leader必须知道最新有那些entry已经提交。Leader Completeness Property 保证了leader有所有的committed entries,但是在任期的开头,可能并不知道那些entry已被提交。(为了确认,可以发空请求来commit数据)
通过向大多数节点来发送心跳,来保证读请求的返回的是最新的。这里就依赖了前面所说的时钟。依赖时钟来实现安全。

性能

todo 补图
随机杀死leader,重新选举最短时间刚好是leader选举超时的一半,因为心跳超时时间刚好是选举超时的一半。

实现

InstallSnapshot RPC接口

由leader调用,发送snapshot的一部分到从节点。Leader总是按顺序发送chunk

参数

  • term leader的任期
  • leaderId 使得follower可重定向client的请求
  • lastIncludedIndex snapshot最后包含的index号
  • lastIncludedTerm snapshot最后包含的最后一个任期号
  • offset chunk在整个snapshot中的offset
  • data[] 原始数据
  • done 如果是last chunk则为true

返回体

term 当前任期,使得leader可以根据这个结果判断是否做操作

接收方的实现

  • 如果接收到的任期小于当前任期,则立刻返回
  • 如果接收到的chunk offset为0,则开始创建快照文件
  • 在给定的offset处写入数据
  • 如果done为false,返回且等待接下来的数据
  • 保存snapshot文件,丢弃之前存在的所有快照文件
  • 如果现有日志条目的索引和术语与快照的最后一个包含的条目相同,请保留其后的日志条目并回复
  • 丢弃整个日志文件
  • 把状态机重设为快照的内容(也使用快照内的集群信息)

State 状态

所有服务器上的持久化状态

在响应RPC之前更新到持久化存储上

  • 当前任期 server见过的最大任期值(初始值是0,单调递增)
  • votedFor 在当前任期内获得投票的候选人ID(如果没有,则为null)
  • log[] log entry;每一个包含一个状态机的命令,包含从leader获取的任期(初始index为1)

所有服务器上的可变状态

  • commitIndex 已知要提交的最高日志条目的index(初始值是0,单调递增)
  • lastApplied 已知要应用到状态机上的最高index(初始值是0,单调递增)

leader上的可变状态

选举后重新初始化

  • nextIndex[] 对每个服务器,将要送给另一个服务器的下一个log entry的index(初始化为leader的lastLogIndex+1)
  • matchIndex[] 对每个服务器,知道的复制到该服务器的最大的log entry的索引。(初始化为0,单调递增)

AppendEntryies RPC

被leader触发,用来复制log entry;也用于心跳。

参数

  • term leader的任期
  • leaderId 使得follower可重定向client的请求
  • prevLogIndex 紧接新记录之前的日志条目索引
  • prevLogTerm 紧接新记录之前的日志条目索引的任期
  • entries[] 要存储的entries。心跳时为空,批量来提升性能
  • leaderCommit leader的commit index提交索引

返回结果

  • term 当前任期,使得leader可以根据这个结果判断是否做操作
  • success 如果follower包含了匹配的prevLogIndex和prevLogTerm,就返回true

接收方实现

  • 如果term < current term,返回false
  • 如果不包含匹配的prevLogIndex和prevLogTerm,就返回false
  • 如果有一个存在的entry和新的冲突(相同的index,不同的term),删除哪个entry和在其之后的所有entry
  • 添加没在log里面的所有entry
  • 如果leaderCommit > commitIndex, 设置commitIndex为leaderCommit、lastNewEntry的最小值

RequestVote RPC

由candidate调用来收集选票

参数

  • term 候选者的任期
  • candidateId 候选者的请求投票
  • lastLogIndex 候选者的最后log entry的索引
  • lastLogTerm 候选者的最后log entry的任期

返回结果

  • term 任期,可以让candidate根据任期做操作
  • voteGranted true代表候选者收到了选票

接收方实现

  • 如果term < currentTerm返回false
  • 如果votedFor或candidateId是null,并且候选者日志至少比接受者的新,给予自己的投票

Server遵守的规则 Rules for Servers

所有服务器

  • 如果commitIndex > lastApplied: 增加lastApplied,把log[lastAplied]应用到状态机上
  • 如果rpc请求或响应中,包含的任期T比当前的Term值大,则将当前的任期值设置为T,转换自己为follower

Follower

  • 向候选者和leader响应rpc
  • 在选举超时的时间间隔内,没有接收到AppendEntries RPC请求或者投票给其他人,那么切换为候选者

候选者

  • 一旦转化为候选者,开始选举
    • 增加任期号
    • 给自己投票
    • 重置选举定时器
    • 向其他服务器发送RequestVote RPC请求
  • 如果接收到了大部分节点的投票,成为leader
  • 如果AppendEntries RPC从新的leader处返回,变为follower
  • 如果选举超时,启动新的选举流程

Leader

  • 选举完成后:发送空的AppendEntries RPC请求到每个服务器(心跳),在空闲期间重复此操作避免选举超时
  • 从客户端哪里接收到命令;添加entry到本地的日志中,待把这个entry复制到状态机后响应
  • 如果对一个follower有,last log index >= nextIndex,从nextIndex开始发送AppendEntries RPC请求
    • 如果成功:更新follower的nextIndex和matchIndex
    • 如果因为日志不一致的原因失败:降低nextIndex然后重试
  • 如果存在N,且N>commitIndex,大多数节点的matchIndex[i]>=N,并且log[N].term == cuurentTerm,设置commitIndex=N

为什么要采样追踪对接SkyWalking

为了提升Pulsar的可维护性,我们希望能深入pulsarbookkeeper的底层,做性能剖析,识别中间延时高的环节,然后很方便地进行定位分析。

为什么Pulsar原生的普罗监控无法满足

  • 部门基础设施方面,所有环境都集成了ELK,大部分环境都没有普罗,且部门正在引入SkyWalking。
  • 普罗的监控无法和用户的一条消息对应,较难处理单个客户保障。

幸运的是,这两个问题都可以通过采样跟踪来解决。并且在合理的采样跟踪配置下,测试环境可以达成百分百采样追踪,对于我们定位测试环境问题非常方便。

我们就想到了使用经典的采样追踪模式来对数据进行采样,将数据输出到SkyWalking进行下一步的分析、告警。并且在没有对接SkyWalking的环境,通过日志输出来进行下一步的分析、告警

Sample Tracing Basic

image-20210424091405442

  • 通过traceIdspanId结合,识别一条链路。
  • 每个spanId中间计算耗时
  • 为了性能,不会每个spanIdtraceId都收集分析,会进行采样(收集部分消息、收集时延较大的消息)

注:SkyWalking采用STAM来进行拓扑分析,并且引入了Segment等概念来表达进程内、进程外等含义,但大致原理相同。

SkyWalking Architecture

Image

Trace Internal In Pulsar

目前,我们将追踪的头部消息放在BrokerMetadata处,以下为追踪数据流向

message

image-20210506200048088

batch message

image-20210506200035405

Two ways output

Logging

可在ELK上展示,搜索时延大的消息。(注:如搜索4位数的消息)。

SkyWalking

可依赖SkyWalking进行分析,告警。

对比

Logging SkyWalking
采样方式 SkyWalking Way SkyWalking Way
输出格式 Logging SkyWalking protocol through kafka
追踪数据传播方式 protobuf、Broker Metadata protobuf、Broker Metadata

Beyond SkyWalking

在SkyWalking的采样算法之上,支持对时延大的消息再进行采样,输出到日志,进行分析、告警。

采样规则

  • 全局一秒最多xx条,单topic一秒最多xx条
  • 采样时延大于xx的消息

Follow-up

  • 将Pulsar和Bookkeeper的metric信息上报到SkyWalking,使得在SkyWalking分析、告警更多信息。
  • 使用agent方式实现,避免和SkyWalking协议过度耦合

参考

之前就在环境上ps -ef看到过xxxxxx的密码,一直没搞明白怎么回事,今天整理了一下,核心内容均来自于上述连接,作了一些额外的测试和查阅资料。

测试

运行Mysql实例

1
2
# 自己做的Mysql8的镜像
docker run ttbb/mysql:stand-alone

使用密码连接Mysql服务器

1
mysql -u hzj -p Mysql@123 -e "select 1"

ps -ef查看

1
2
3
4
5
6
7
8
9
10
[root@91bcbd15a82e mysql]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 07:34 ? 00:00:00 /usr/local/bin/dumb-init bash -vx /opt/sh/mysql/hzj/scripts/start.sh
root 8 1 0 07:34 ? 00:00:00 bash -vx /opt/sh/mysql/hzj/scripts/start.sh
root 17 1 0 07:34 ? 00:00:00 mysqld --daemonize --user=root
root 62 8 0 07:34 ? 00:00:00 tail -f /dev/null
root 63 0 0 07:34 pts/0 00:00:00 bash
root 98 63 0 07:37 pts/0 00:00:00 mysql -h 127.0.0.1 -u hzj -px xxxxxxx
root 99 0 1 07:37 pts/1 00:00:00 bash
root 122 99 0 07:37 pts/1 00:00:00 ps -ef

Mysql隐藏密码原理

改写了args系统参数,demo如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//
// Created by 张俭 on 2021/4/26.
//
#include <stdio.h>
#include <unistd.h>
#include <string.h>

int main(int argc, char *argv[]) {
int i = 0;
pid_t mypid = getpid();
if (argc == 1)
return 1;
printf("argc = %d and arguments are:\n", argc);
for (i; i < argc; i++) {
printf("%d = %s\n", i, argv[i]);
}
fflush(stdout);
sleep(30);
printf("Replacing first argument with x:es... Now open another terminal and run: ps p %d\n", (int)mypid);
memset(argv[1], 'x', strlen(argv[1]));
getc(stdin);
return 0;
}

编译并运行

1
2
3
4
5
6
7
gcc password_hide.c
[root@c77dc365cd1a sh]# ./a.out abcd
argc = 2 and arguments are:
0 = ./a.out
1 = abcd
Replacing first argument with x:es... Now open another terminal and run: ps p 55

观测结果,开始看的确有明文密码

1
2
3
4
5
6
[root@c77dc365cd1a sh]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 07:49 pts/0 00:00:00 bash
root 32 0 0 07:51 pts/1 00:00:00 bash
root 64 1 0 07:56 pts/0 00:00:00 ./a.out abcd
root 66 32 0 07:56 pts/1 00:00:00 ps -ef

经过30秒后,已经被复写

1
2
3
[root@c77dc365cd1a sh]# ps p 55
PID TTY STAT TIME COMMAND
55 pts/0 S+ 0:00 ./a.out xxxx

Mysql源码地址

mysql-server/client/mysql.cc line 2054

1
2
3
4
5
6
7
8
9
if (argument) {
char *start = argument;
my_free(opt_password);
opt_password = my_strdup(PSI_NOT_INSTRUMENTED, argument, MYF(MY_FAE));
while (*argument) *argument++ = 'x'; // Destroy argument
if (*start) start[1] = 0;
tty_password = false;
} else
tty_password = true;

PS: 后面,我还在OSX上用go程序尝试修改参数,估摸go程序的args传入是值拷贝,修改完成之后args没有生效,看来这个黑科技只有c程序能使用呀。

需要了解的概念

  • VPC:用户的私有网段
  • peering:多个VPC之间打通的方式,可跨用户

前言

今天微信推送Pulsar社区有个Hackathon比赛, 开始想的idea就是,实现pulsar在华为云上提供服务。因为是社区的比赛,是以一个三方系统的方式在华为云上提供服务,而非是以华为云的名义提供服务。分析了下可行性和能达到的效果,对比了StreamNative的官网上提供的pulsar服务在阿里云托管的能力,能提供的能力差不多,最多只不过是实现了在华为云托管的能力,没有从0到1的突破。

现在,在公有云上买rediskafka这类组件已经变得非常普遍,由公有云供应商提供的中间件往往能给你带来良好的体验,相比三方厂家在云上进行托管,我个人认为云厂商的优势主要在以下三点

网络打通容易

下文说一下不是公有云的供应商能以什么样的方式暴露自己的服务。云厂商可以把中间件的ip地址申请在你的vpc内,对任何应用程序来说,连接都是最方便的。无论是容器化部署、虚拟机部署、和其他vpc peering打通的场景,都可以通信。

低廉的成本

不考虑人力成本,云厂商自运营的价格要低于三方厂家。

监控系统对接

方便地和云厂商的告警、统计系统对接,接收告警通知和报表等。

其中网络打通成本尤为重要,三方厂家好好做监控统计系统,也能给用户较为良好的体验。

三方厂家能提供什么样的Pulsar接入

统一接入

三方厂家自己作为公有云上一个用户,无论这个Region上有多少个租户,都用这一个用户提供服务,这也就意味着无法与每个用户进行私网通信。如果在华为云,利用华为云推出的VPCEP服务(此处应有链接),倒是可以给每个用户提供私网通信,不过这个是做了DNAT地址转换的,跟做了DNAT转换的中间件连接,是非常麻烦的。(懂的自然懂。如果有人想详细了解,可以留言,我可以写一个文章介绍里面的坑)

如果使用公网,又想避免扩容的时候动态申请EIP,动态申请EIP并不复杂,问题是EIP是有配额限制的,这才是关键。那么就需要一个统一的接入点,就需要部署pulsar proxy。到这一步,是每个用户申请一个EIP的,如果还想继续节省EIP,那么可以统一域名接入,后端通过SNI的方式转发,个别流量大的客户,单独把域名指向单独的集群。

pulsar-third-vendor1

Peering打通

Peering打通可以给用户不错的私网体验,需要用户预留一个网段,网段不需要太大,能容纳pulsar所在的vm就行。采用peering打通一般绝不会选择容器化部署,想要两个容器化的集群互通,对网设的要求很高,暂且忽略Service的存在,这要求用户的vpc网段和pod网段和三方厂商的vpc网段和pod网段都不重叠!而且peering打通,给用户私有,再搭建一个k8s集群,对成本影响比较大。主要有如下两个问题

自动化

和客户peering打通,需要较大的权限,如何自动化,最大程度的减少需要的权限。

客户网段和其他网段又做了peering

pulsar-third-vendor2

这个问题其实还好,就是路由规则配置麻烦

总结

Peering打通对用户来说已经比较方便了,相信做到自动化也没有太大的技术难度,只是时间和人力投入的问题。统一接入因为网络打通的原因,不好使用kopmop这些高级特性,此外还有不小的公网带宽成本,羊毛出在羊身上,比较大量的用户也会倾向于Peering打通的模式吧。

前言

自17年入职华为之后,一直在使用配置中心,4年期间经历了自研配置中心到Apollo再到自研配置中心和Apollo并存的场景。总结了一下这几年的配置中心演进流程,想把我们在配置中心上的一些实践分享给大家,实现共同进步。Apollo是一款非常优秀的开源软件,是国人的骄傲。如果对Apollo存在理解错误,还望大家不吝赐教,谢谢。

使用到的配置分类

从场景分类

运维配置,即程序只读的配置

人工配置。通过人工在配置中心界面进行配置,而程序只进行读取,如数据库配置、邮箱服务器配置、网卡配置、子网地址配置等。这部分配置数据不要求代码动态写入。

业务配置,即程序可写的配置

我们是一个SaaS服务,每个用户在上面都有一些业务配置。如用户的证书配置、用户服务器的流控配置等,这些业务配置相对运维配置来说更加复杂,且可能会有唯一性限制,如按用户id唯一。这部分配置数据一般由用户操作触发,代码动态写入,并且通知到各个微服务实例。通常,我们希望这些配置能在界面展示,且支持人为修改。上述逻辑如果由各微服务自己实现,会存在大量重复代码,并且质量无法保证。我们希望由一个公共组件来统一实现这个能力。

从配置是否会有列表可分为单值配置或多值配置

单值配置

整个配置下只是多对key、value。value不是很复杂的格式,往往是整数或字符串。

image-20210330171658154

多值配置

多值配置更加复杂,往往是单值配置在不同的key下,有不同的值。比如下面的配置,用户一和用户二的线程池大小和队列不同

img

第一阶段 自研配置中心

在做云服务之前,我们的配置中心层级数较少。我们以软件的形式交付给客户,软件运行时分为管理面和业务面,配置中心管理着管理面和业务面的配置,最为复杂的场景是多套业务面,这个时候需要保证不同集群、不同微服务下的配置不冲突,配置层级为 集群、微服务、配置。

image-20210324204231586

此时的配置中心是完全自研的,不包含蓝绿、灰度配置这些功能,它独具特色的地方有以下两点:

单配置单表

  • 在存储模型上,每个配置对应一张数据表。
  • 对多值配置比较友好,尤其是复杂业务配置,可以支持各种主键约束。对单值配置,稍微重型了一些。
  • 配置的强Schema限制。这些限制包括类型、大小、长度、是否敏感等限制。这种限制既能为界面修改配置提供良好的体验(如:不同格式不同的输入框、敏感字段,前台输入明文,后台入库加密等),也能在通过接口写入配置时做充分的校验。

通过回调方式来确保配置的可靠

举个例子,添加一个配置的流程是这样的

image-20210324205828998

可能这里,有读者想要问了,这个流程能确保什么可靠呢。这个流程通过调用微服务接口来校验配置是否可靠,如IP地址是否合法、对端地址是否可达、配置数量是否超过规格等等,来保证配置基本可用。

总的来说,这个自研的配置中心在当时综合体验还是不错的。但是也有一些问题有待改进,比如单配置下配置项数量过多时,因为底层有部分接口单配置下所有数据都通过一个http请求来承载,会导致响应超时等问题。

第二阶段 Apollo

开始第二阶段实践的原因主要是,我们进行了组织切换,业务重心转向做云服务,同时团队进行DevOps转型。原先的老配置中心是由另一个团队维护的,组织切换完之后,如果还要使用,就要我们自己维护。所以我们需要在继续维护老配置中心和引入开源Apollo中间进行选择。除了上文中提到的运维配置和业务配置,这个时候我们的需求还有改变:

  • 配置的层级愈发丰富了
  • 要构建灰度发布微服务的能力

老配置中心一方面由于组织切换原因不提供维护了,另一方面不能支撑丰富的配置层级,也不具备灰度发布的能力。这个时候,Apollo的一些特性吸引了我们,这些特性正是老配置中心所缺乏的,例如(部分引用自Apollogithub主页)

  • 丰富的层级,从app_idcluster,namespace,key-value的层级能满足我们region、集群、微服务的层级诉求
  • 支持配置的灰度发布,比如点了发布后,只对部分应用实例生效,等观察一段时间没问题后再推给所有应用实例。
  • 所有的配置发布都有版本概念,从而可以方便的支持配置的回滚。
  • 应用和配置的管理都有完善的权限管理机制,对配置的管理还分为了编辑和发布两个环节,从而减少人为的错误。
  • 所有的操作都有审计日志,可以方便的追踪问题。

因此我们选型引入了Apollo,我和我的主管,还有一个其他同事参与了这项工作。我们在Apollo开源代码的基础上做了比较大的改动,主要原因有以下几点

  • 节约成本,将注册中心、数据库替换成我们当前正在使用的组件,因为这两个依赖不是Apollo的核心依赖
  • 继承老配置中心强Schema的优点。
  • 保留回调确认配置的流程,提前拦截错误的配置,降低代码处理异常配置的复杂度
  • 通过spi或环境变量的方式兼容存量老局点使用老配置中心的场景

结合上述原因,我们最终是这么实践的

  • 数据库切换为postgre数据库、注册中心切换到servicecomb

  • 在namespace上实现了Schema,每个namespace都可以注册对应的SchemaSchema要求数据必须是json格式,且json内对应的value必须满足Schema定义的规范(如ip地址、小数、整数等)

    Schema举例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[
{
"name":"name",
"type":"string"
},
{
"name":"age",
"type":"int",
"max":120
},
{
"name":"ip",
"type":"ipv4"
}
]

那么数据应该是这样的

1
2
3
4
5
{
"name":"hezhangjian",
"age":23,
"ip":"127.0.0.1"
}
  • 在添加或修改配置的时候,实现了回调功能,由回调业务服务确认配置能否添加或修改
  • 配置分层:云服务对应Apolloapp_id,把内部的环境对应到Apollo上的集群,然后将微服务名+配置名拼接成配置名称。

下图展示了业务概念和Apollo概念的对应关系,有些配置是单值配置,有些是多值配置,所以配置项这一层级是可选的。

配置分层示意图

在这段时间的实践中,我们也发现如下问题

并发问题

其中最致命的就是并发问题,首先Apollo所有配置都存在一张表中,其次由于Apollo设计之初主要考虑的是运维人员手动在界面上操作,代码无并发语义(或者说没给客户端并发语义),使得我们通过代码写入配置时难以解决并发问题。

性能问题

打开namespace列表页面,需要显示这个app_id下的所有namespace,因为我们单app_id会存放单个云服务的所有配置,这个量很大,且界面不支持分页,导致页面加载缓慢

体验问题

Apollonamespace界面未提供搜索功能(可能Apollo设计之初也没想支持这么多),想要从namespace中定位到我们想要查看或修改的namespace,只能借助浏览器的搜索能力。

第三阶段 Apollo与自研配置中心并存

除了上述几个问题,还有一些原因使得我们开始了第三阶段的实践

  • 原来自上而下的配置分层模型,微服务间配置没隔离,不仅不易进行权限管理,而且不适合DevOps 单微服务自治的发布理念。
  • 第二阶段对Apollo改动太多,组织结构变动,没有足够的人力维护
  • 随着集群越来越多,回调功能需要网络的双向打通,网络维护不太方便
  • 我们对Apollo界面以及接口基于业务做的改动较多,导致其他兄弟部门难以共用Apollo

当时大家对是否保留Schema回调检查代码写配置这三个功能点有较大的争议。我个人最希望保留Schema回调检查,因为它们优点显著,而且接口是兼容的,可以与其他部门共用,但是增加了Schema这个概念和回调检查这个流程,会增加学习成本。而代码写配置,由于要解决并发问题,代码改动量较大,我不建议保留。

大家经过激烈的讨论,最终还是废弃了Schema回调检查代码写配置这三个功能点,仅仅把运维配置放在Apollo

然后,我们把业务配置,放在了一个自研的强Schema的配置中心上,这个配置中心,仅负责单集群的配置,每个集群部署一套,满足了我们的业务需求。自研强Schema配置中心的核心要点有,单配置单表、通过注册中心回调来检测配置是否合法、借助mqtt协议来实现长链接推送,无单点瓶颈。

而我们的运维配置中心Apollo回归到了开源的版本,重整了配置的结构,

image-20210405224010878

对运维配置而言好处有

  • 配置模型适合单微服务发布
  • 配置按微服务组织,一个页面上的namespace不会很多

缺点

  • Schema缺失后,不会对操作人员在界面的配置进行校验,即使配置格式或者内容错误也能配置成功。界面上配置密码不支持明文(Apollo无法感知是否为敏感字段),必须提前使用其他工具将明文转换为密文,然后再进行配置。
  • 回调检查功能去掉后,有些配置,如网卡网段配错,操作人员不能即时得到响应

最佳实践

业务配置经过我们的实践,确实不适合使用开源的Apollo。运维配置使用原生的Apollo,但是现在还不具备回调检查Schema的功能,希望Apollo能在后续版本中支持Schema,或者弱化的json格式检查功能。下面是我们在如下场景下的最佳实践

SRE在界面上的运维配置

通过Apollo来实现功能,至于配置如何组织,根据大家的组织结构、技术架构来对应Apollo上的概念,可按照微服务->部署环境部署环境 -> 微服务的层级来组织配置

复杂的参数校验

建议在Apollo上面自建portal包裹一层,后端服务可先进行一层处理,这一层处理可以做比较复杂的格式化校验甚至回调检查,再调用Apollo OpenApi将配置写入Apollo

业务配置的技术选型

最大的挑战是业务配置由用户触发,请求的并发不易处理。思路有两个,一个是在Apollo原生代码的基础上,通过数据库分布式锁来解决并发问题。第二个是借鉴我们的思路,通过单配置单表、mqtt协议实现通知等核心技术点,自研业务配置中心。

业务配置的部署

需要根据业务配置的数量来考虑是否合设业务配置中心。单集群场景下,毫无疑问只需要一个业务配置中心,甚至如果使用Apollo实现,可以考虑和运维配置中心合设。多集群场景下,部署一个业务配置中心,还是多个业务配置中心,我们自己的实践中,一个集群往往要支撑数万用户,我们采取了每个业务集群部署一套业务配置中心的策略。

Kubernetes pod内调用API的流程总体分为以下步骤

  • 创建role
  • 创建serviceaccount
  • 绑定role到serviceaccount
  • 指定pod使用serviceaccount

我们以查pod为例,演示一下整个流程

创建role

1
2
3
4
5
6
7
8
9
10
# role.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: role-hzj
namespace: default
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get","list"]
1
kubectl apply -f role.yaml

创建serviceaccount

1
2
3
4
5
6
# serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: serviceaccount-hzj
namespace: default
1
kubectl apply -f serviceaccount.yaml

绑定role

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# rolebinding.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: rolebinding-hzj
namespace: default
subjects:
- kind: ServiceAccount
name: serviceaccount-hzj
namespace: default
roleRef:
kind: Role
name: role-hzj
apiGroup: rbac.authorization.k8s.io
1
kubectl apply -f rolebinding.yaml

部署pod进行测试

部署一个zookeeper进行测试

手上刚好有zookeeper的模板文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
apiVersion: apps/v1
kind: Deployment
metadata:
name: zookeeper
labels:
app: zookeeper
spec:
replicas: 1
selector:
matchLabels:
app: zookeeper
template:
metadata:
labels:
app: zookeeper
spec:
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
containers:
- name: zookeeper
image: ttbb/zookeeper:stand-alone
imagePullPolicy: IfNotPresent
resources:
limits:
memory: 2G
cpu: 1000m
requests:
memory: 2G
cpu: 1000m
env:
- name: NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: PS1
value: '[\u@zookeeper@\W]\$ '

调用API

1
2
3
4
5
6
7
8
9
10
11
12
13
# Point to the internal API server hostname
APISERVER=https://kubernetes.default.svc
# Path to ServiceAccount token
SERVICEACCOUNT=/var/run/secrets/kubernetes.io/serviceaccount
# Read this Pod's namespace
NAMESPACE=$(cat ${SERVICEACCOUNT}/namespace)
# Read the ServiceAccount bearer token
TOKEN=$(cat ${SERVICEACCOUNT}/token)
# Reference the internal certificate authority (CA)
CACERT=${SERVICEACCOUNT}/ca.crt
# Explore the API with TOKEN
curl --cacert ${CACERT} --header "Authorization: Bearer ${TOKEN}" -X GET ${APISERVER}/api
curl --cacert ${CACERT} --header "Authorization: Bearer ${TOKEN}" -X GET ${APISERVER}/api/v1/namespaces/default/pods

kubernetes-pod-api1

发现这里,调用后面的api,403错误。第一个api不报错,是因为该接口不需要鉴权。

修改pod对应的serviceaccount

让我们修改部署模板对应的ServiceAccountName,注入权限。在pod的spec下,设置serviceAccountName

kubernetes-pod-api2

修改部署模板重启后调用api正常

再次尝试上述命令,api结果返回正常

kubernetes-pod-api3

前言

最近因为业务的需求,在学习etcd,想了解一下kubernetes是如何使用etcd集群的,不如动手搭建一个kubernetes集群,也顺手体验一下友商UCloud。后面有时间仔细分析一下kubeadm搭建的时候都做了哪些事情

选择香港Region进行搭建,下载国外的镜像比较方便。

购买三台ECS

基础配置

三台4U16G,基础镜像选择Centos8版本

ApiServer创建ELB

创建LB实例

image-20210413104514588

添加一个6443端口的Vserver

image-20210413104700767

这里Vserver和LVS上的Virtual Service的概念相同。

向6443端口添加Rs

把三台虚拟机的6643端口都添加到负载均衡上

现在6443端口显示异常不要紧,后面安装过程中,各个节点的6443端口才会逐渐可用,让各个节点访问。

初始化Master集群

虚拟机上安装必须组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
yum install -y yum-utils
yum remove -y runc
yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
yum install -y docker-ce docker-ce-cli containerd.io
systemctl start docker
cat <<EOF | sudo tee /etc/yum.repos.d/kubernetes.repo
[kubernetes]
name=Kubernetes
baseurl=https://packages.cloud.google.com/yum/repos/kubernetes-el7-\$basearch
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://packages.cloud.google.com/yum/doc/yum-key.gpg https://packages.cloud.google.com/yum/doc/rpm-package-key.gpg
exclude=kubelet kubeadm kubectl
EOF

# Set SELinux in permissive mode (effectively disabling it)
sudo setenforce 0
sudo sed -i 's/^SELINUX=enforcing$/SELINUX=permissive/' /etc/selinux/config

sudo yum install -y kubelet kubeadm kubectl --disableexcludes=kubernetes

sudo systemctl enable --now kubelet
kubeadm config images pull

初始化master-001节点

这里这个IP地址填负载均衡的地址,这样子才能搭建出高可用集群

1
kubeadm init --control-plane-endpoint "10.7.157.12:6443" --upload-certs

初始化成功,返回以下提示信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Your Kubernetes control-plane has initialized successfully!

To start using your cluster, you need to run the following as a regular user:

mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config

Alternatively, if you are the root user, you can run:

export KUBECONFIG=/etc/kubernetes/admin.conf

You should now deploy a pod network to the cluster.
Run "kubectl apply -f [podnetwork].yaml" with one of the options listed at:
https://kubernetes.io/docs/concepts/cluster-administration/addons/

You can now join any number of the control-plane node running the following command on each as root:

kubeadm join 10.7.157.12:6443 --token bj4vpt.o999hbp96p1bvw5q \
--discovery-token-ca-cert-hash sha256:8560fa9211dbfdb55609d22ef0f0b428c6cb73b6e85a70c7a9e13d88b0b8400c \
--control-plane --certificate-key 85c86678b3d54b6017ac3fab2f2a92337f332c7172dfaf4b5a18ee1da679cd7d

Please note that the certificate-key gives access to cluster sensitive data, keep it secret!
As a safeguard, uploaded-certs will be deleted in two hours; If necessary, you can use
"kubeadm init phase upload-certs --upload-certs" to reload certs afterward.

Then you can join any number of worker nodes by running the following on each as root:

kubeadm join 10.7.157.12:6443 --token bj4vpt.o999hbp96p1bvw5q \
--discovery-token-ca-cert-hash sha256:8560fa9211dbfdb55609d22ef0f0b428c6cb73b6e85a70c7a9e13d88b0b8400c

其他节点上执行kubeadm join

1
2
3
kubeadm join 10.7.157.12:6443 --token bj4vpt.o999hbp96p1bvw5q \
--discovery-token-ca-cert-hash sha256:8560fa9211dbfdb55609d22ef0f0b428c6cb73b6e85a70c7a9e13d88b0b8400c \
--control-plane --certificate-key 85c86678b3d54b6017ac3fab2f2a92337f332c7172dfaf4b5a18ee1da679cd7d

调整kubectl命令行

1
2
3
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config

安装cni插件

1
kubectl apply -f "https://cloud.weave.works/k8s/net?k8s-version=$(kubectl version | base64 | tr -d '\n')"

至此,kubernetes搭建完成。

image-20210413162451762

前言

华为云IoT服务产品部致力于提供极简接入、智能化、安全可信等全栈全场景服务和开发、集成、托管、运营等一站式工具服务,助力合作伙伴/客户轻松、快速地构建5G、AI万物互联的场景化物联网解决方案。

架构方面,华为云IoT服务产品部采用云原生微服务架构,ZooKeeper组件在华为云IoT服务产品部的架构中扮演着重要的角色,本文将介绍华为云IoT服务产品部在ZooKeeper的使用。

Apache ZooKeeper 简介

Apache ZooKeeper是一个分布式、开源的分布式协调服务,由Apache Hadoop的子项目发展而来。作为一个分布式原语的基石服务,几乎所有分布式功能都可以借助ZooKeeper来实现,例如:应用的主备选举,分布式锁,分布式任务分配,缓存通知,甚至是消息队列、配置中心等。

抛开应用场景,讨论某个组件是否适合,并没有绝对正确的答案。尽管Apache ZooKeeper作为消息队列、配置中心时,性能不用想就知道很差。但是,倘若系统里面只有ZooKeeper,应用场景性能要求又不高,那使用ZooKeeper不失为一个好的选择。但ZooKeeper 客户端的编码难度较高,对开发人员的技术水平要求较高,尽量使用一些成熟开源的ZooKeeper客户端、框架,如:Curator、Spring Cloud ZooKeeper等。

Apache ZooKeeper 核心概念

ZNode

ZNode是ZooKeeper的数据节点,ZooKeeper的数据模型是树形结构,每个ZNode都可以存储数据,同时可以有多个子节点,每个ZNode都有一个路径标识,类似于文件系统的路径,例如:/iot-service/iot-device/iot-device-1。

Apache ZooKeeper在华为云IoT服务产品部的使用

zookeeper-huaweicloud-usage

支撑系统内关键组件

很多开源组件都依赖ZooKeeper,如FlinkIgnitePulsar等,通过自建和优化ZooKeeper环境,我们能够为这些高级组件提供更加可靠和高效的服务支持,确保服务的平稳运行。

严格分布式锁

分布式锁是非常常见的需求,相比集群Redis、主备Mysql等,ZooKeeper更容易实现理论上的严格分布式锁。

分布式缓存通知

ZooKeeper的分布式缓存通知能够帮助我们实现分布式缓存的一致性,例如:我们可以在ZooKeeper上注册一个节点,然后在其他节点上监听这个节点,当这个节点发生变化时,其他节点就能够收到通知,然后更新本地缓存。

这种方式的缺点是,ZooKeeper的性能不高,不适合频繁变更的场景,但是,对于一些不经常变更的配置,这种方式是非常适合的。如果系统中存在消息队列,那么可以使用消息队列来实现分布式缓存通知,这种方式的性能会更好、扩展性更强。

分布式Id生成器

直接使用ZooKeeper的有序节点

应用程序可以直接使用ZooKeeper的有序节点来生成分布式Id,但是,这种方式的缺点是,ZooKeeper的性能不高,不适合频繁生成的场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import org.apache.zookeeper.CreateMode;
import org.apache.zookeeper.ZooDefs;
import org.apache.zookeeper.ZooKeeper;
import org.apache.zookeeper.data.Stat;

import java.util.Optional;

public class ZkDirectIdGenerator {

private ZooKeeper zooKeeper;
private String path = "/zk-direct-id";
private static final String PATH_PREFIX = "/id-";

public ZkDirectIdGenerator(String connectionString, int sessionTimeout) throws Exception {
this.zooKeeper = new ZooKeeper(connectionString, sessionTimeout, event -> {});
initializePath();
}

private void initializePath() throws Exception {
Stat stat = zooKeeper.exists(path, false);
if (stat == null) {
zooKeeper.create(path, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
}
}

public Optional<String> generateId() {
try {
String fullPath = zooKeeper.create(path + PATH_PREFIX, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT_SEQUENTIAL);
return Optional.of(extractId(fullPath));
} catch (Exception e) {
log.error("create znode failed, exception is ", e);
return Optional.empty();
}
}

private String extractId(String fullPath) {
return fullPath.substring(fullPath.lastIndexOf(PATH_PREFIX) + PATH_PREFIX.length());
}
}

使用ZooKeeper生成机器号

应用程序可以使用ZooKeeper生成机器号,然后使用机器号+时间戳+序列号来生成分布式Id。来解决ZooKeeper有序节点性能不高的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import lombok.extern.slf4j.Slf4j;
import org.apache.zookeeper.CreateMode;
import org.apache.zookeeper.ZooDefs;
import org.apache.zookeeper.ZooKeeper;

import java.time.LocalDateTime;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

@Slf4j
public class ZkIdGenerator {

private final String path = "/zk-id";

private final AtomicInteger atomicInteger = new AtomicInteger();

private final AtomicReference<String> machinePrefix = new AtomicReference<>("");

private static final String[] AUX_ARRAY = {"", "0", "00", "000", "0000", "00000"};

/**
* 通过zk获取不一样的机器号,机器号取有序节点最后三位
* id格式:
* 机器号 + 日期 + 小时 + 分钟 + 秒 + 5位递增号码
* 一秒可分近10w个id
* 需要对齐可以在每一位补零
*
* @return
*/
public Optional<String> genId() {
if (machinePrefix.get().isEmpty()) {
acquireMachinePrefix();
}
if (machinePrefix.get().isEmpty()) {
// get id failed
return Optional.empty();
}
final LocalDateTime now = LocalDateTime.now();
int aux = atomicInteger.getAndAccumulate(1, ((left, right) -> {
int val = left + right;
return val > 99999 ? 1 : val;
}));
String time = conv2Str(now.getDayOfYear(), 3) + conv2Str(now.getHour(), 2) + conv2Str(now.getMinute(), 2) + conv2Str(now.getSecond(), 2);
String suffix = conv2Str(aux, 5);
return Optional.of(machinePrefix.get() + time + suffix);
}

private synchronized void acquireMachinePrefix() {
if (!machinePrefix.get().isEmpty()) {
return;
}
try {
ZooKeeper zooKeeper = new ZooKeeper(ZooKeeperConstant.SERVERS, 30_000, null);
final String s = zooKeeper.create(path, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT_SEQUENTIAL);
if (s.length() > 3) {
machinePrefix.compareAndSet("", s.substring(s.length() - 3));
}
} catch (Exception e) {
log.error("connect to zookeeper failed, exception is ", e);
}
}

private static String conv2Str(int value, int length) {
if (length > 5) {
throw new IllegalArgumentException("length should be less than 5");
}
String str = String.valueOf(value);
return AUX_ARRAY[length - str.length()] + str;
}

}

微服务注册中心

相比其他微服务引擎,如阿里云的MSENacos等,已有的Zookeeper集群作为微服务的注册中心,既能满足微服务数量较少时的功能需求,并且更加节约成本

数据库连接均衡

在此前的架构中,我们采用了一种随机策略来分配微服务与数据库的连接地址。下图展示了这种随机分配可能导致的场景。考虑两个微服务:微服务B和微服务C。尽管微服务C的实例较多,但其对数据库的操作相对较少。相比之下,微服务B在运行期间对数据库的操作更为频繁。这种连接方式可能导致数据库Data2节点的连接数和CPU使用率持续居高,从而成为系统的瓶颈。

zookeeper-database-before.png

启发于Kafka中的partition分配算法,我们提出了一种新的连接策略。例如,如果微服务B1连接到了Data1和Data2节点,那么微服务B2将连接到Data3和Data4节点。如果存在B3实例,它将再次连接到Data1和Data2节点。对于微服务C1,其连接将从Data1和Data2节点开始。然而,由于微服务的数量与数据库实例数量的两倍(每个微服务建立两个连接)并非总是能整除,这可能导致Data1和Data2节点的负载不均衡。

为了解决这一问题,我们进一步优化了策略:第一个微服务实例在选择数据库节点时,将从一个随机起点开始。这种方法旨在确保Data1和Data2节点的负载均衡。具体的分配策略如下图所示。

zookeeper-database-after.png

Apache ZooKeeper在华为云IoT产品部的部署/运维

服务端部署方式

我们所有微服务和中间件均采用容器化部署,选择3节点(没有learner)规格。使用statefulsetPVC的模式部署。为什么使用statefulset进行部署?statefulset非常适合用于像Zookeeper这样有持久化存储需求的服务,每个Pod可以和对应的存储资源绑定,保证数据的持久化,同时也简化了部署,如果想使用deploy的部署模式,需要规划、固定每个pod的虚拟机部署。

Zookeeper本身对云硬盘的要求并不高,普通IO,几十G存储就已经能够支撑Zookeeper平稳运行了。Zookeeper本身运行的资源,使用量不是很大,在我们的场景,规格主要取决于Pulsar的topic数量,如果Pulsar的topic不多,那么0.5核、2G内存已经能保证Zookeeper平稳运行了。

客户端连接方式

借助coredns,客户端使用域名的方式连接Zookeeper,这样可以避免Zookeeper的IP地址变更导致客户端连接失败的问题,如zookeeper-0.zookeeper:2181,zookeeper-1.zookeeper:2181,zookeeper-2.zookeeper:2181

重要监控指标

  • readlantency、updatelantency

    zk的读写延迟

  • approximate_data_size

    zk中数据的平均大小估计

  • outstanding_requests

    等待Zookeeper处理的请求数

  • znode_count

    Zookeeper当前的znode总数

  • num_alive_connections

    Zookeeper当前活跃的连接数

Apache ZooKeeper在华为云IoT产品部的问题

readiness合理设置

这是碰到的最有趣的问题,readiness接口是k8s判断pod是否正常的依据,那么对于Zookeeper集群来说,最合理的就是,当这个Zookeeper节点加入集群,获得了属于自己的LeaderFollower状态,就算pod正常。可是,当初次部署的时候,只有一个节点可用,该节点一个实例无法完成选举流程,导致无法部署。

综上,我们把readiness的策略修改为:

zookeeper-readiness-strategy.png

PS:为了让readiness检查不通过时,Zookeeper集群也能选主成功,需要配置publishNotReadyAddresses为true,示例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
apiVersion: v1
kind: Service
metadata:
name: zookeeper
spec:
selector:
app: zookeeper
clusterIP: None
sessionAffinity: None
publishNotReadyAddresses: true
ports:
- protocol: TCP
port: 2181
name: client
- protocol: TCP
port: 2888
name: peer
- protocol: TCP
port: 3888
name: leader

jute.maxbuffer超过上限

jute.maxbuffer,这个是znode中存储数据大小的上限,在客户端和服务端都需要配置,根据自己在znode上存储的数据合理配置

zookeeper的Prometheus全0监听

不满足网络监听最小可见原则。修改策略,添加一个可配置参数来配置监听的IP metricsProvider.httpHost,PR已合入,见 https://github.com/apache/zookeeper/pull/1574/files

客户端版本号过低,域名无法及时刷新

客户端使用域名进行连接,但在客户端版本号过低的情况下,客户端并不会刷新新的ip,还是会用旧的ip尝试连接。升级客户端版本号到curator-4.3.0以上、zookeeper-3.6.2以上版本后解决。

总结

本文详细介绍了华为云IoT服务产品部如何使用Apache ZooKeeper来优化其云原生微服务架构。ZooKeeper作为分布式协调服务,在华为云IoT服务中发挥了重要作用,用于主备选举、分布式锁、任务分配和缓存通知等。文中还讨论了ZooKeeper在分布式ID生成、微服务注册中心、数据库连接均衡等方面的应用。此外,文章还覆盖了ZooKeeper在华为云IoT产品部的部署、运维策略和所遇到的挑战,包括容器化部署、监控指标和配置问题。

0%