参考

之前就在环境上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产品部的部署、运维策略和所遇到的挑战,包括容器化部署、监控指标和配置问题。

前言

记一次代码检视中领悟到的知识,和大家一起交流

正文

提交上来的代码大概是这个样子的

1
2
3
4
5
Socket socket = new Socket(ip, port);
final DataOutputStream dataOutputStream = new DataOutputStream(socket.getOutputStream());
dataOutputStream.write("HelloWorld".getBytes(StandardCharsets.UTF_8));
socket.shutdownOutput();
dataOutputStream.flush();

这次主要是添加shutdownOutput的调用,及时关闭tcp会话,防止TW过多。

经过大家的讨论,主要的矛盾点在shutdownOutputflush的顺序。首先想到的是 flush方法放在了output后面,这样还能起作用吗?但是提交代码之前是经过测试的,这样子是可以正常工作的。然后的想法就是,傻逼了,想错了,shutdown应该自带flush效果,os都发fin了,之前的buffer肯定出去了。

我做个实验,来探究下是不是这样子的,我从python开启了一个http server来开启实验

1
python3 -m http.server

java测试类代码

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
package com.github.hezhangjian.demo.basic;

import lombok.extern.slf4j.Slf4j;
import org.junit.Test;

import java.io.DataOutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;

/**
* @author hezhangjian
*/
@Slf4j
public class DemoSocketSend {

String ip = "127.0.0.1";

int port = 8000;

@Test
public void testSocketSend() throws Exception {
Socket socket = new Socket(ip, port);
final DataOutputStream dataOutputStream = new DataOutputStream(socket.getOutputStream());
dataOutputStream.write("HelloWorld".getBytes(StandardCharsets.UTF_8));
socket.shutdownOutput();
dataOutputStream.flush();
}

}

随后我在25行和26行打了断点

当运行到25行的时候,python server并没有收到数据

image-20210402121759024

还没运行26行的时候,数据就已经发送到python服务器了

image-20210402121818000

总结

这个时候证明我们的推测是正确的,shutdownOutput方法自带了flush效果。
我也尝试了配置tcpNoDeplay参数,配不配置tcpNoDelay,都是一样的效果。看起来jvm都有缓冲
那么已经调用了shutdownOutput方法之后,flush方法还有没有必要调用呢,从clean code的角度,flush方法的调用已经是没有任何必要的了,建议删除。一般场景下可能不会有问题,但是如果极端场景,比如在25行到26行之间,程序陷入了长gc,这行就有可能抛出IOException,影响原来的逻辑。

背景

我们的业务有些时候总是在升级期间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调用零呼损,重试和优雅启停都不可或缺,都需要实现。

Dapper出现的背景

分布式系统不容易观测。有些问题靠日志和统计根本无法挖掘。
有些无法重现或极难重现的场景。

Dapper设计的原则

低时延

微不足道的性能影响,使应用程序团队愿意迁移。

应用透明

应用尽量少做侵入式修改

可扩展

随着应用程序的规模扩展

Dapper概念

概览

image-20210321221020253

image-20210321214734150

通过引入parent id和span id等来将调用链串起来

trace id

特定的模式是trace id

span id

span,包括日志,起始、终止时间,也包括key、value。还区分了网络和非网络时延。

annotation

应用程序根据自己的需要可以打上annotation,不仅仅是key、value,还可以有时间戳等,有助于分析方法级别的耗时

image-20210321221204695

Dapper原理

  • 每当一个线程处理一个采样的控制线路时。Dapper在其中放置一个trace context在thread-local中。trace
    context是一个小型、容易拷贝的对象,包含trace id和span id
  • 异步或者callback的时候,用统一的library封装传递
  • RPC自动继承
  • 不行,就通过api的方式接入

Dapper的流程

image-20210321215159560

写入流程分为三步。1、写入本地日志文件 2、被Dapper daemon获取 3、写入Bigtable

端到端的中位数时延在15秒。百分之75的数据的98时延在2分钟以内,但百分之25的数据的98时延可能会到几小时

为什么不在RPC接口中顺手收集信息

  • trace信息可能比rpc本身的报文要大
  • 对于异步流程,无法收集

Trace消耗的性能

在应用程序侧

创建root span耗时约204纳秒,非root span耗时176纳秒

日志文件的消耗,可并行批量

image-20210321215727742

Trace收集

每个trace数据约426byte

仅占用千分之一的Google生产带宽

自适应采样

测试环境或低量请求多采样。请求量大小采样

远端采样

远端采样,降低服务端风险。

Dapper索引选择

基于服务、机器、时间

Dapper的典型使用流程

  • 1用户选择服务、时间、期望看到的指标值(如时延)
  • 2展示出满足这个条件的所有pattern,(trace pattern)
  • 3展示这个pattern的调用链图,
  • 4右侧是全部的采样
  • 5展示出详细的时间戳分布

Dapper实际作用

  • 发现无意义的调用
  • 是否有必要访问主库,而不是从库
  • 理解黑盒系统的依赖
  • 测试。如果你不能观测,就不能优化
  • 推断依赖
  • 判断点到点网络故障,根据请求的大小,进一步推断,谁到谁引发了网络故障。
  • 在底层share系统之上,外围按用户或其他维度统计调用
  • Dapper daemon的实时信息,可用来救火

Dapper现在做不到的

  • 合并、批量处理,无法拆开识别
  • 针对批系统,能力有限
  • 根因分析,需要联合分析,比如在annotation中携带线程池队列大小,进行多个请求的联合分析等
  • 如何和Linux内核关联,如何将错误和内核联系起来

两个超时的注释

首先看一下一下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
0%