前言

最近因为业务的需求,在学习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

用了几个小时读完了Gorilla这篇经典的 时序数据库论文
,prometheus的时序数据库在很多地方都参考了这篇论文。以此文总结一下读后感,非论文翻译。截图基本都出自于论文。本论文可以解答如下的普罗问题

为什么普罗不支持字符串类型,只支持double作为监控值

为了压缩数据,普罗使用了高效的用于double的压缩算法。

为什么普罗的默认的落盘间隔是2个小时

根据这篇论文,2个小时或以上的block的压缩比更小

普罗data盘里的文件都是用来干啥的?

有索引文件、数据文件、恢复日志等

顺带一提,Gorilla是大猩猩的意思,也是银魂中近藤勋的绰号。

Facebook因为从HBase读取时间序列太慢,再加上扩展性已经无法满足需求。Facebook对时延迟要求如此之低,Facebook否决了所有依赖磁盘做数据存储查询的方案,希望数据查询从内存返回。最终从论文看来,查询比HBase快了300多倍。

数据的编码方式

Facebook想要把数据都放到内存中,prometheus号称单机可处理数百万序列,如果按照业务代码的模式书写,三百万序列在1个小时内要占用多少内存呢?时间戳long值4byte,字符串名称加维度20byte,值算double类型,8byte,总共是32byte,两个小时,假设1分钟一个点,共有120个点。共1.2G内存。仅仅纯数据就占用了1.2G内存。Facebook基于如下两个监控数据的特点,对数据进行了高效压缩,缩小12倍原数据的大小

  • 大多数监控数据往往相差固定的时间间隔,而其他监控数据,虽然不是严格固定间隔,也基本接近固定的时间间隔。这个比例在Facebook的监控数据是24:1,即百分之96的数据都是固定间隔上报的
  • 大多数监控数据监控的值变化缓慢

基于这两点假设,它们对时间戳和值提出了两种压缩算法

时间戳

对时间戳的差值的差值进行存储,压缩空间大小。

image-20210313174810933

先存储这一块的起始时间2015 02:00:00,对于第一个数值2015 02:01:02记录差值62,对下一个值2015 02:02:02,其的差值是60,差值的差值是
60-62=-2,只存储-2即可,这样子大大节约了存储时间戳的空间

监控值

监控值采用异或的手法进行压缩,会得到0很多的二进制串,再通过合理的编码,降低总字节数。根据论文,有超过一半的数据相较上一个值没什么变化,使用一个字节即可存储

image-20210313175320885

那么这样一个block里面应该存储多久的数据呢?这是一个权衡,如果存储很久的数据,则每次查询都需要查询出很large的值,才能获得结果,如果存储数据较短,则难以达到很高的压缩比。最终他们选择了两小时。

block时长和压缩比的关系

对于普罗里的指标名称及维度,在普罗里,一个指标名称加上一组维度称为一个时间序列,Gorilla论文并没有提及维度的概念,仅仅使用名称。这块普罗实际和Gorilla都通过码表的方式,通过将字符串映射为一个longId,来大大降低存储的字节数。

对于普罗的查询流程,是 指标名称+维度=》一组时间序列,然后分别查询其中的值。Gorilla自身没有包装这一个流程,需要客户端组网自己想要查询的时间序列列表。

基于时序监控系统里的,新数据比旧数据关键,Gorilla也会将旧数据落盘。

Gorilla的高可用

单机可靠性

Facebook调查了自己之前的监控系统,发现百分之85以上请求都只查询了26个小时以内的数据。在Gorilla的第一版中,他们决定只支持26个小时数据的查询,将2小时的数据放在内存中。超过2小时的数据,会存储在高可用磁盘上,如GlusterFs、HDFS等。2小时以内的数据,有一个log用来做重启时恢复数据来保证可靠性。这样就保证了单机重启的可靠性
注:这个日志不保证能恢复所有的数据,允许异常场景下有数据丢失

Region宕机的可靠性

对于每一个Gorilla,都会主备部署一个对等的位于不同Region的实例,这两个实例都会存储数据,但数据并不完全一致。对于用户来说,他们接入距离他们最近的Gorilla实例。一旦其中一个Gorilla宕机,另一个Gorilla将会接管它的工作。为了保证数据的准确,待其恢复后26小时(拥有了它该拥有的全量数据),才可以接受业务请求。

Gorilla的扩展

Gorilla选择了水平扩展的方式,根据指标名做分区,分区到不同的主备Gorilla

论文还提到了一些其他有用的信息

监控时序数据的特点

写请求占大多数

读请求很少,人工读取,或者一些自动化告警系统

注重状态的变化

内存突然上升,乃至于一个指标值的导数突然上升等

监控系统的目标

高可用

出现问题的时候,监控系统和业务系统同时宕机是什么体验?是一把黑的体验。

低延迟

可容错

容忍单点故障灯

可扩展

随着业务系统的扩展,监控系统也需要扩展

RUM定理的背景

现在的基础设备复杂,多种多样。数据的保存和查询也多种多样,很多时候会为了很小的差异重新设计数据结构。这篇论文主要是指出了无论如何设计数据结构,都不可能在Read(读取)、Update(更新)、Memory(存储)三个方向上都做到最优,也希望指导接下来其他数据结构的设计,更希望能有一种自适应的系统,可以根据数据的查询、数据的写入、配置的硬件、人工的配置在Read、Update、Memory之间权衡。

RUM开销介绍

我们将存储在数据中心的数据称作基础数据。那些用来辅助写入,辅助查询的数据成为辅助数据

读开销 RO

也称作读放大,通过读取到的辅助数据加上基础数据除以基础数据来计算。举个例子,在mysql中查询数据,中间经过的B树层级就是读放大

更新开销 UO

也叫做写放大,实例物理上写入磁盘的大小除以逻辑上需要更新的大小。

内存开销 MO

也叫做空间放大,全部的基础数据加上全部的辅助数据除以基础数据。

RUM不可能达成举例

我们选择一个有代表性的基础数据:一个整数数组。我们将这个数据集合组织到N个块中的固定大小的元素,每一个持有一个数值。每一个块可以用一个单调递增的ID来指示。工作负载使用数据的方式有 点查询、点更新、插入和删除。

最小化RO

那我们就把bk的Id当做我们数据结构数组的下标,举例子,{1, 17}是两个元素的id,我们就开辟大小为17的数组array,然后通过array[i]来得到i的数据。现在已经达成了RO最小,但我们的索引非常稀疏,理论上我们的数组是无限大的。更新需要操作两次,将旧的数组元素置空,然后将新的数据存放在新的block中。

RO: 1 UO: 2 MO: 无穷大

最小化UO

为了最小化UO,我们将每次更新的数据直接插入到日志的最尾端,就算更新完成。查询需要遍历原来的数据和整个log文件。

UO: 1 RO: 无穷大 MO: 无穷大

最小化MO

最小化MO时,不存储辅助数据,而将基础数据密集地存储起来。 读取需要进行全表扫描,如果任意更新,也需要进行全表扫描

MO: 1 RO: N UO: 1

数据结构的RUM

image-20210314164113082

参数 N m B P T MEM
含义 数据集大小 查询结果大小 块大小 分区大小 LSM级别比例 内存
单位 元组 元组
批量创建 索引大小 点查询 范围查询(大小m) 查询/更新/删除
读取方式
B+树
完美哈希索引
ZoneMaps
层级LSM树
排序列
未排序列

image-20210314164933209

论文作者对将来系统的设想

作者认为,将来的系统应该是RUM可调的,来满足大多数场景的需要。通过一套可以轻松适应不同优化目标的访问方法来展望未来的数据系统。例如:

• 具有动态调整参数(包括树高,节点大小和拆分条件)的B +树,以便在运行时调整树大小,读取成本和更新成本。

• Approximate (tree) indexing that supports updates with low read performance overhead, by absorbing them in updatable probabilistic data structures (like quotient filters).

• Morphingaccessmethods,combiningmultipleshapesatonce. Adding structure to data gradually with incoming queries, and building supporting index structures when further data reorganization becomes infeasible.

• Update-friendly bitmap indexes, where updates are absorbed using additional, highly compressible, bitvectors which are gradually merged.

• Accessmethodswithiterativelogsenhancedbyprobabilistic data structures that allows for more efficient reads and up- dates by avoiding accessing unnecessary data at the expense of additional space.

论文中其他有意思的点

  • 现代数据系统,通常在压缩的数据上运行,并尽可能晚地解压缩

前言

iptables和ipvs都是常见的转发工具,可以进行报文的转发,比如从 IPa,Port1的消息,经过转发机器发向IPb,Port2,不管是在iptables,抑或是ipvs,进行过一次转发之后,就会留下一条转发记录,iptables是在nf_conntrack,ipvs是在ipvs自己的会话管理里,后面的来自IPa,Port1的消息,就会直接发向IPb,Port2。这个时候,假如强行有一个报文,试图把IPc,Port1的消息,转发到IPb,Port2该怎么办?在四元组冲突的情况下,该转发给谁?返程的报文是什么走向。是这篇文章试图分析的问题

本文基于Linux内核版本5.4.0

出场网元介绍

image-20210311221120239

  • S: 缩写服务的意思

  • G: 缩写Gateway的意思

Iptables和Iptables冲突

准备工作

假设S1和S2都需要经过G使用33333端口发送消息到V的33333端口,我们先配置G的iptables规则:

1
2
sysctl -w net.ipv4.ip_forward=1
iptables -t nat -I POSTROUTING -p udp -s ${S的子网} -o eth0 --sport 33333 -j SNAT --to-source ${G的IP}:33333

接下来在两台S上,把发往V的报文的默认路由指向V

1
ip route add ${V的IP}/32 via ${G的IP}

实验前先执行如下命令,收集输出

1
2
~# conntrack -S
cpu=n found=0 invalid=0 ignore=0 insert=0 insert_failed=0 drop=0 early_drop=0 error=0 search_restart=0

这个命令的输出跟您的cpu相关,有几个cpu就会输出几行。接下来在V上开启抓包

1
tcpdump -port 33333 -ann

然后在两台S上执行命令发送报文,这里有个小技巧就是使用不一样长度的内容,这样tcpdump会打印报文的长度,一眼就可以看出来送达的报文是谁的

1
echo "Hello world"| nc -4u -p 33333 V的IP 33333

你会观察到,只有先发送的报文抵达了V。随后再执行conntrack -S,收集输出的时候,会发现insert_faileddrop都增加了1。

结论

iptables和iptables冲突的场景,先发送的先生效,后生效的没有发送(这一点你可以通过在G上抓包证实)。观察点就是命令conntrack -S的输出,insert_faileddrop都有所增加。

Iptables流程图

我在做这个实验的时候,顺手打开了iptables的trace功能,记录一下报文的流程

image-20210311222756033

发送失败的也会走完这个流程,然后在插入iptables规则的时候失败。

Iptables和Ipvs冲突

准备工作

因为ipvs不会和ipvs冲突,所以我们尝试构造一下ipvs和iptables冲突的场景,让我们添加上允许V经过G发送报文到S1,让我们在G上配置ipvs所需的转发规则

1
2
ipvsadm -A -u ${G的IP}:33333 -s rr
ipvsadm -a -u ${G的IP}:33333 -r ${S1的IP}:33333 -m

实验流程

从V发送报文转发到S1,然后S2通过SNAT发送报文到V,观察情况

实施前的准备观察项

  • ipvsadm -lnc观察会话

  • ipvsadm -ln –stats 观察报文统计

  • conntrack -L|grep 33333 观察会话

  • 当V发送报文到S1后,

Ipvsadm -ln -stats统计值增加了,只有ipvs的会话表里有内容,iptables会话表里没有内容。

  • 然后S2通过SNAT发送报文到V后,

ipvsadm -ln -stats统计值没有变化,iptables会话表出现内容。(这里我试过S1先返回报文做几次交互,但是是一样的结果)

但是在ipvs有效期间,通过S1不断发送报文,还是可以发送到V节点的。这个时候,会出现S1和S2同时都能发送报文到V。

  • 让我们看看报文返程(即V发送到G的报文)会发送给谁?是S1还是S2。答案是S2。

返程的报文优先匹配了会话表,发送给了S2。如果conntrack老化,那么才会发送给S1

V上来的流程

image-20210311224826893

参考

https://blog.sourcerer.io/writing-a-simple-linux-kernel-module-d9dc3762c234

编码

C文件书写

首先,先书写一个C文件,命名为kernel_first.c

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
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Robert W. Oliver II");
MODULE_DESCRIPTION("A simple example Linux module.");
MODULE_VERSION("0.01");
static int __init

/**
* Load时候触发的函数
*/
example_init(void) {
printk(KERN_INFO
"Hello, World!\n");
return 0;
}

static void __exit

/**
* Unload时候触发的函数
*/
example_exit(void) {
printk(KERN_INFO
"Goodbye, World!\n");
}

module_init(example_init);
module_exit(example_exit);
  • 请注意使用printk而不是printf。 另外,printk与printf共享的参数不同。 例如,KERN_INFO是一个标志,用于声明应为此行设置日志记录的优先级,并且不带逗号。
    内核在printk函数中对此进行了分类,以节省堆栈内存。
  • 在文件末尾,我们调用module_init和module_exit告诉内核哪些函数是在load时候执行,那些在unload的时候执行

Makefile书写

1
2
3
4
5
obj-m += kernel_first.o
all:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean

注 make前面应该是Tab键

测试

执行如下命令加载模块到内核sudo insmod kernel_first.ko执行dmesg|grep -i hello,将会看到Hello world的输出。接下来卸载内核模块
sudo rmmod kernel_first,接下来运行dmesg,你将会看到Goodbye world的输出

0%