前言#

之前在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/Shoothzj/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对应一个键值对

参考#

之前就在环境上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打通的模式吧。

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

前言#

华为云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产品部的部署、运维策略和所遇到的挑战,包括容器化部署、监控指标和配置问题。

背景#

我们的业务有些时候总是在升级期间rpc业务有一些呼损,想总结一下让rpc调用零呼损的两种方式:重试和优雅启停。我先介绍这两种方式,再描述一下这两种方式的优缺点

rpc-lossless

A是一个微服务

B也是一个微服务

蓝色的是常见的注册中心,有zookeepereureka等实现。

重试#

重试,在发生可重试错误的时候,重试一次。什么是可重试错误呢?就是重试一次,可能会成功。比如400 BadRequest,那出现这种错误,基本上重试也没有用,就不要浪费我们宝贵的服务器资源了。常见的如servicecomb框架就有重试几次、重试间隔这样的参数。值得一提的是,如果你指望通过重试让升级零呼损,那么你的重试次数,要比你的并行升级实例数大才行。

这也很容易理解,比如A服务调用B服务,B服务有5个实例,B1~B5。这个时候,同时升级B1和B2,A第一次调用了B1,接下来重试,如果运气不好,恰好重试到了B2节点,那么业务还是会失败的。如果防异常故障,就得重试三次才行。

如果是防止单数据中心宕机,重试次数大于同时宕机节点数,这个规则可能就没那么靠谱了。现在,企业部署十几个乃至二十几个微服务实例,已经不是什么新闻了,假设分3数据中心部署,总不能重试接近10次吧,这种时候,最好重试策略和数据中心相关,重试的时候,选择另一个az的实例。目前servicecomb还不支持这种功能。

优雅启停#

优雅停止#

优雅停止,就是说当微服务快要宕机的时候,先从注册中心进行去注册,然后把发送给微服务的消息,处理完毕后,再彻底关闭。这个方式,可以有效地防止升级期间,发送到老节点的呼损。

优雅启动#

优雅启动,当微服务实例,能够处理rpc请求的时候,再将实例自己注册到注册中心。避免请求发进来,实例却无法处理。

这里有一个要求,就是调用方发现被调用方(即A发现B)的注册中心,要和B注册、去注册的注册中心是一个注册中心。有案例是,发现采用k8s发现,注册、去注册却使用微服务引擎,导致呼损。

优劣对比#

可预知节点升级的场景#

重试相对于优雅启停,在预知节点升级的场景没那么优雅,重试次数可能还要和并行升级的节点挂钩,非常的不优雅,且难以维护

不可预知节点升级的场景#

优雅启停无法对不可预知节点升级的场景生效。只有重试能在这个场景发挥作用

其他场景#

重试可以很好地处理网络闪断、长链接中断等场景

总结#

想要实现rpc调用零呼损,重试和优雅启停都不可或缺,都需要实现。

两个超时的注释#

首先看一下一下ipvsadm -h对这两个参数的注释

persistent timeout#

1
2
--persistent  -p [timeout]     persistent service
Specify that a virtual service is persistent. If this option is specified, multiple requests from a client are redirected to the same real server selected for the first request. Optionally, the timeout of persistent sessions may be specified given in seconds, otherwise the default of 300 seconds will be used. This option may be used in conjunction with protocols such as SSL or FTP where it is important that clients consistently connect with the same real server.

说明这个VS是否是持久的。如果配置了这个选项,来自同一个客户端的链接(这里注意:这里的同一个客户端指的是同一个IP)会转发向相同的服务器。注释中特意提到了FTP协议。我查阅了一下资料,可能像FTP协议这种,客户端通过21端口打开控制连接,再通过20端口打开数据连接,这种协议,要求来自同一个客户端ip,不同端口的请求也送向同一个服务器,估计是这个参数存在的核心原因。如果是现在的系统,比如k8s使用ipvs,这个参数是完全没必要配置的

connection timeout#

1
2
--set tcp tcpfin udp
Change the timeout values used for IPVS connections. This command always takes 3 parameters, representing the timeout values (in seconds) for TCP sessions, TCP sessions after receiving a FIN packet, and UDP packets, respectively. A timeout value 0 means that the current timeout value of the corresponding entry is preserved.

更改用于ipvs连接的超时值。此命令始终使用3个参数,分别表示tcp会话,接收到FIN包的TCP会话和UDP包的超时值。单位为秒。设置为0并不代表将超时值设置为0,而是保持原有不变。顺便来说,timeout的默认值是900、120、300.

区别#

一个以客户端ip为维度,一个以客户端ip+port为维度

联系:#

  • persistent值大于等于set时,persistent timeout以persistent的设置为准。
  • persistent值小于set时,当set超时,但persistent超时后,会将persistent再次设置为60。只到set超时为止。所以这个时候,真实生效的persistent timeout是(s/60)*60 + p%60 + 60

Entry Log File#

背景#

测试环境上出现了一些entryLog解析异常的问题,想分析一下磁盘上.log文件的格式,分析分析我们的文件是否有问题

解析代码地址#

https://github.com/protocol-laboratory/bookkeeper-codec-java/blob/main/src/main/java/com/github/protocol/EntryLogReader.java

正文#

我们采用的配置是singleEntryLog模式,就是说很多ledger的信息都会放在一个log文件内部。

插一句话:这种log文件,其实和LSM相似,属于不可变的数据结构,这种数据结构,得益于不可变,所以内容可以安排的非常紧凑,不像B树结构,需要预留一定空间给原地更新,随机插入等。

bookkeeper-entry-log-format

如上图所示,接下来,我们沿着解析的流程,解读每个部分的详细格式

解析头部#

首先,我们解析文件的头部字段,bookkeeper的设计中,文件头部预留了1024字节,目前只使用了20个字节
前四个字节是BKLO的文件魔数
然后紧跟着的4个字节是bk文件的版本号,这里我们仅分析版本号1
然后8字节的long类型代表ledgersMap的开始位置,称为ledgersMapOffset
然后4字节的int类型代表ledgersMap的总长度。

解析ledgerMap部分#

最前面四个字节,代表这部分的大小

然后开始的ledgerId和entryId分别为-1,-2,随后是一个ledger的count大小,后面的ledgerId和size才是有效值

随后的部分非常紧凑,由一个个ledgerId,size组成

读取完ledgerMap,可以知道,这个文件包含了多少ledger,总大小是多少?

注:size代表这一段ledger占用的磁盘空间大小

解析body内容#

body内容也非常紧凑.
最前面4个字节,代表这个entry的大小。
然后8个字节,ledgerId
然后8个字节,entryId
剩下的内容,就是pulsar写数据的编码,不再属于bookkeeper的格式范畴了

Txn Log File#

解析代码地址#

https://github.com/protocol-laboratory/bookkeeper-codec-java/blob/main/src/main/java/com/github/protocol/TxnLogReader.java

简述#

bookkeeper中的journal log,和大部分基于LSM的数据结构一样,是用来保证文件一定被写入的。会在数据写入的时候,写入journal log,崩溃恢复的时候从journal log里面恢复。

bookkeeper-txn-log-format

解析头部#

首先,我们解析文件的头部字段
前四个字节是BKLG的文件魔数
然后紧跟着的4个字节是bk文件的版本号

1
2
3
4
5
6
7
8
9
private TxnHeader readHeader(FileChannel fileChannel) throws Exception {
final ByteBuf headers = Unpooled.buffer(HEADER_SIZE);
final int read = fileChannel.read(headers.internalNioBuffer( index: 0, HEADER_SIZE));
headers.writerIndex(read);
final byte[] bklgByte = new byte[4];
headers.readBytes(bklgByte, dstIndex: 0, length: 4);
final int headerVersion = headers.readInt();
return new TxnHeader(headerVersion);
}

解析内容#

内容非常紧凑,由ledgerId,entryId和内容组成。ledgerId一定大于0,entryId在小于0的情况下代表特殊的数据。如

  • -0x1000即4096 代表ledger的masterKey
  • -0x2000即8192 代表ledger是否被fence
  • -0x4000即16384 代表ledger的force
  • -0x8000即32768 代表ledger的显示LAC

回放流程#

当bookkeeper启动的时候,他会从data路径下取得lastMark文件,该文件一定为16个字节,前八个字节代表落盘的最新journal log文件,后八个字节代表文件的位置。会从这个位置开始回放
值得一提的是,lastId文件,代表下一个dataLog该使用什么文件名

我们在很多场景下会碰到java包冲突的问题:

  • 代码由第三方开发,无法对包名或依赖做管控
  • 跑在同一个进程里的代码,更新步调不一致。比如底层sdk,jvm agent。这些组件更新频率较低

最出名的解决路数还是类加载机制,诸如flink,osgi都给我们提供了很多方案,这些方案都非常重型。在代码可信任的情况下,其中有一个很轻量级的解决方案就是maven-shade包。

举个例子,比方说我想在java agent中打印日志,但是又不希望和业务代码中的log4j等冲突,agent里依赖的pom文件是这样子的:

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
<dependencies>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>1.7.30</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>2.13.3</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.13.3</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j-impl</artifactId>
<version>2.13.3</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-jcl</artifactId>
<version>2.13.3</version>
</dependency>
</dependencies>

这里我们log4j,slf4j可能用的版本太高或者太低,我们就可以通过打shade包的方式修改log4j和slf4j的包名,避免和业务冲突

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
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.2.4</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<artifactSet>
<includes>
<include>org.slf4j:slf4j-api</include>
<include>org.apache.logging.log4j:log4j-api</include>
<include>org.apache.logging.log4j:log4j-core</include>
<include>org.apache.logging.log4j:log4j-slf4j-impl</include>
<include>org.apache.logging.log4j:log4j-jcl</include>
</includes>
</artifactSet>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
<relocations>
<relocation>
<pattern>org.slf4j</pattern>
<shadedPattern>com.github.shoothzj.org.slf4j</shadedPattern>
</relocation>
<relocation>
<pattern>org.apache.logging</pattern>
<shadedPattern>com.github.shoothzj.org.apache.logging</shadedPattern>
</relocation>
</relocations>
</configuration>
</execution>
</executions>
</plugin>

通过上面的配置,artifactSet选择要修改的pom依赖,通过relocation修改包名,达到不冲突的效果。mvn clean package 后查看效果

java-shade-package-result

可以发现,包名已经被修改完成,达到了避免冲突的目的。

网关建设#

今天给大家介绍三种常见的四层负载均衡、网络转发方案,可用于四层的网关建设。

利用ipvs实现(需要后端服务能连通外部网络)#

lb-4-ipvs

该方案需要后端服务器与前端client网络打通,GatewayIp可以采用主备的方式保证高可用

配置都在GatewayIp上,需要配置的如下:

1
2
3
4
ipvsadm -A -u $GatewayIp:$port -s rr -p 600
# -u表示为udp协议,-t表示为tcp协议
# rr 为均衡算法,roundroubin的意思,lc则代表最短连接数
ipvsadm -a -u $GatewayIp:$port -r $ServerIp:$port -m

Ipvs+Iptables实现#

如果您不希望后端Server与客户端面对面打通,那么您可能会喜欢这种方式,将GatewayIP设置为ServerIp的默认网关,再由Snat转换将报文转换出去,这样子Server就不需要与客户端面对面打通了,图示如下:

lb-4-ipvs-iptables

配置默认路由也很简单

1
ip route add 客户端IP网段 via GateWayIp dev eth0

配置iptables

1
iptables -t nat -A POSTROUTING -m iprange -p udp --dst-range $client_ip_range -o eth1  -j SNAT  --to-source $GateWayIp

Ipvs+Iptables+Iptunnel实现#

默认路由有一个限制,就是说Server与Gateway都在一个子网内,有过商用经验的大家都知道DMZ之类的说法,就是说应用服务器和网关服务器在诸如安全组,子网等等上需要隔离。假设你需要将应用服务器和网关放在不同的子网,上面的方案就搞不定啊,这个时候需要使用ip隧道的方式来跨子网,图示如下,仅仅后边红色路线的ip发生了变化,原来的报文被ip隧道Wrap:

lb-4-ipvs-iptables-iptunnel

配置ip 隧道倒也不难

1
ip tunnel add $tun_name mode ipip remote $remote_ip local $local_ip ttl 255

总结#

以上三种方案均没有单点问题,且都兼容tcp,udp协议。GateWay处的单点问题,通过zk选主、etcd选主,keepalive等 + 浮动IP迁移的方式均能解决。大家可以根据自己的网规网设自由选择

0%