对象存储与生态

说起对象存储,不得不提Amazon的AWS S3(Simple Storage Service).

进入到21世纪,数据急剧增长,当时已经是电商巨头的Amazon需要一种海量的、可扩展的、支持非结构化数据且对开发友好的网络存储。2006年,S3云服务应运而生。

从那之后,各个云厂商也随之跟进,发布了自己的对象存储服务。

S3也不仅仅代表了一种云服务,也成为了对象存储业界的一种协议。

当前,很多云产品也都构建在S3之上,围绕对象存储基础设施,构建了庞大的生态。除了云商巨头,像Snowflake、Databricks这些明星data infra公司,更是离不开对象存储。

国内近年来也有基于对象存储创业的公司,如兼容S3协议的JuiceFS,新式云仓Databend。

从最几年的趋势来看,S3或者说对象存储俨然已经成为云上数据湖的基础。

使用JuiceFS让CITA-Cloud跑在对象存储上

对象存储普遍用于存放图片、音视频等静态文件。

那么,既然作为存储的一种,CITA-Cloud能否直接运行在对象存储之上,而不占用本地空间呢?答案是肯定的,下面我们演示下怎么使用JuiceFS让CITA-Cloud跑在对象存储上。

JuiceFS简介与使用

JuiceFS是为云环境设计,兼容 POSIX、HDFS 和 S3 协议的分布式文件系统,具体介绍可以查看官网

在我看来,JuiceFS能让对象存储上的一个Bucket变成一块大的云盘,挂载在本地,让应用能像读取本地文件一样来操作对象存储上的对象。

JuiceFS主要分为三部分:JuiceFS客户端、数据存储(公有云对象存储/MinIO等)、元数据引擎(Redis/SQLite等)。

这里我们使用Redis作为元数据引擎。JuiceFS提供了针对Kubernetes环境的CSI,并提供了Helm的安装方式。

JuiceFS安装时需要一个存储来保存对应文件系统的元数据,这里我们使用Redis;后端对象存储使用MinIO。

Helm安装时的配置如下:

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
storageClasses:
- # -- `StorageClass` Name. It is important.
name: juicefs-sc
# -- Default is `true` will create a new `StorageClass`. It will create `Secret` and `StorageClass` used by CSI Driver.
enabled: true
# -- Either `Delete` or `Retain`. Refer to [this document](https://juicefs.com/docs/csi/guide/resource-optimization#reclaim-policy) for more information.
reclaimPolicy: Delete
# -- Additional annotations for this `StorageClass`, e.g. make it default.
# annotations:
# storageclass.kubernetes.io/is-default-class: "true"
backend:
# -- The JuiceFS file system name
name: "juicefs"
# -- Connection URL for metadata engine (e.g. Redis), for community edition use only. Refer to [this document](https://juicefs.com/docs/community/databases_for_metadata) for more information.
metaurl: "redis://:123456@redis-service.default:6379/1"
# -- Object storage type, such as `s3`, `gs`, `oss`, for community edition use only. Refer to [this document](https://juicefs.com/docs/community/how_to_setup_object_storage) for the full supported list.
storage: "minio"
# -- Bucket URL, for community edition use only. Refer to [this document](https://juicefs.com/docs/community/how_to_setup_object_storage) to learn how to setup different object storage.
bucket: "http://minio.zhujq:9000/juicefs"
# -- JuiceFS managed token, for cloud service use only. Refer to [this document](https://juicefs.com/docs/cloud/acl) for more details.
token: ""
# -- Access key for object storage
accessKey: "minio"
# -- Secret key for object storage
secretKey: "minio123"

安装完成之后,可以看到多了一个名为juicefs-sc的StorageClass,在我的集群中如下所示:

创建一条链

有了相应的StorageClass,我们可以用Cloud-Config来创建一条链,创建时指定StorageClass参数为juicefs-sc。

可以看到一条4个节点的链已经运行起来了,并且能正常出块:

同时创建了对应的PVC:

在MinIO界面上,我们能看到对应的名为juice的bucket被创建,链节点的数据文件被分割为多个chunk进行存储:

结语

对象存储作为一种相对廉价的海量存储,在强调降本增效的今天来看,越来越能体现它的重要性。

当然,上面的例子只是一个实验,想要在生产环境使用,需要做完善的测试和评估,相信将来会有更多能与对象存储一起结合的场景。

开头

容器的出现和k8s的普及已经改变了我们传统的运维方式,这些技术给我们带来了更多的资源利用率和更强的容错能力。

这其中控制器模式必然占据了举足轻重的作用。这种设计模式是开发和扩展Kubernetes的核心,在Kubernetes的代码仓库中能到处看到它的身影。

声明式or命令式

当然,当提到控制器模式时候,你也一定会看到声明式API这种东西,这两种一定是同时搭配使用的。

再之,提到“声明式”,你也必然会在大量的文章中看到“命令式”与“声明式”的比较。这些文章一般会说:

  1. 与命令性API相比,声明性API的使用更加简洁,并且提供了更好的抽象性。
  2. “命令式”强调的是“how”,你必须step-by-step告诉计算机如何完成一项工作(类似自己做菜)。“声明式”只需要告诉计算机你想要什么,声明你的”what”,计算机会为你完成具体的工作(类似于去饭店点菜吃饭)

还有的教程上会说:

“声明式 API”允许有多个 API 写端,以 PATCH 的方式对 API 对象进行修改,而无需关心本地原始 YAML 文件的内容。

在我看来,声明式能够提供所谓的更简洁的API,且有Patch的能力,这些并不是我们常用的命令式API所不能做到的

如果你觉得API不够简洁抽象,那你应该去考虑你设计上是否存在问题,或者没按照规范来?

想要命令式API具有Patch的能力,那可以把资源的属性全部传过去,让你的接口具有类似compare and update的能力不就行了?

所以,不管声明式还是命令式,我觉得核心还是在背后处理API的方式上的不同,这种处理方式通常叫做”控制器模式”。

云平台的困境

在之前的工作中,我们基于IaaS平台来实现业务。与web服务不同的是,IaaS平台存在更多的耗时请求,且请求过程中出现报错、超时等错误更是家常便饭。

不管是自研IaaS还是二次开发,针对一个多阶段的耗时请求,为了保证发生异常时不存在垃圾资源,通常会实现与正向操作相反的方法,我们称之为rollback方法。

就拿创建虚拟机实例的例子来说,一个createVmInstance操作通常会有很多阶段(正向):

[AllocateVolume -> AllocateNic -> CreateOnHypervisor…]

这里的每一步都会报错,那么为了报错之后保持环境干净,你必须提供相应的回滚操作(反向):

[DeleteOnHypervisor -> DeleteNic -> DeleteVolume]

这里通常会设计成一个Stack,当执行一个正向操作时,将相应的反向操作压栈;当发生错误时对栈进行Pop,依次执行回滚操作。

有点像Saga的分布式事务

但是这里会有个几个问题:

  1. 我在执行回滚函数时又发生了报错该怎么办?这时候可能就需要定时GC相关的逻辑来处理垃圾资源。然后,用户通过重试请求createVmInstance的方式再次创建,循环往复。

  2. 假如vm创建成功,其下一个子资源被人误删(虽然很多时候会报device is busy删除失败),那这个vm只能一直处于错误状态了。

控制器模式

控制器模式可以很好地解决上面解决上面这两个问题。

下面以k8s中的控制器来说明。

Kubernetes控制器会追踪一个或多个资源,并且将资源描述成如下结构:

1
2
3
4
5
6
type Object struct {
metav1.TypeMeta
metav1.ObjectMeta
Spec ObjectSpec
Status ObjectStatus
}

除了一些必要的元数据外,还有两个字段: Spec(期望)和Status(当前状态)。控制器不断监控该资源,比较Spec的定义和当前的实际环境,在一个调谐循环中尝试将当前实际环境变换成期望的状态。伪代码如下:

1
2
3
4
5
for {
desired := getDesiredState()
current := getCurrentState()
makeChanges(desired, current)
}

这也就是我们常说的最终一致性,当整个系统恢复正常时,总能给你一个你想要的状态的资源,哪怕你中间误删了一些子资源。

在Kubernetes中,主要通过Informer机制来实现,它作为客户端被使用在每一个Kubernetes的控制器逻辑中。
其中,为了保证事件不丢失,实现了list-watch机制, cache机制减少了对apiserver的请求压力,还有限速队列等保护机制等,这里不再展开叙述。

现实中的例子

控制器模式让我想起了我大学时的自动化专业,在自动控制领域中,闭环的负反馈控制系统如下所示:

可以看到有类似的地方:通过不断地比较输入信号和反馈信号,再经过相应的算法(如经典的PID算法),以达到输出信号和输入信号趋于一致的状态。

这些控制器的例子在生活中比比皆是:空调温湿度调节,电机转速等。

结语

在分布式环境下,错误永远不能避免,如果你想让你的软件达到Always On的效果,可能就需要引入控制器模式。当然,基于Kubernetes之上运行的大多数软件,已经不需要再考虑这些问题了。

问题

CITA-Cloud采用了微服务架构,微服务之间以及对应用暴露的接口都采用了gRPC

gRPC调用时可能会返回错误,需要对错误进行处理。经过讨论之后,我们采用的方案是将错误码分成两层。应用层面的错误用单独的status_code来表示;gRPC本身的错误会用响应中的Status来表示。

前段时间碰到了可能是网络抖动造成的客户端返回UNAVAILABLE的现象。因为这个是gRPC本身的错误,应用层没办法处理,只能靠客户端重试来解决。

gRPC重试

针对这个需求,可以使用gRPC本身就提供的拦截器功能。通过注入一个拦截器,检查每次调用的结果,如果返回错误(并不是每种错误都可以重试的,具体参见官方关于错误码的描述),则再次发起调用。

golang这样的亲儿子上,甚至已经有现成的库可以非常方便的做到这样的事情。

gRPC重试 in Rust

因为CITA-Cloud主要使用Rust,结果搜索了一圈,震惊的发现竟然没有现成的库。在Rust这样造轮子情绪高涨的社区里,这是一个很不寻常的情况。

所幸一番搜索之后,发现了相应的原因,还是跟Rust的所有权特性有关系。

因为要在失败后重试,就要复制一份调用的请求参数。这个在其他语言里面根本不是个事,但是在Rust里就麻烦了。

tonic(一个纯RustgRPC实现)中一个接口的客户端函数原型为:

1
2
3
4
pub async fn store(
&mut self,
request: impl tonic::IntoRequest<super::Content>,
) -> Result<tonic::Response<super::super::common::StatusCode>, tonic::Status>

请求的类型是tonic::IntoRequest<T>(其中的T为请求中的应用层数据结构),这个类型是没有实现Clone的。

至于为什么不实现,开发者的解释是要考虑到gRPCstream模式,stream中的请求是没法Clone的。

那非stream模式可以实现吗?答案也是不行,因为gRPC是基于Http2的,Http2总是stream的,因此单次调用模式其实就是只包含一个请求的stream

解决方案

请求的类型tonic::IntoRequest<T>无法Clone,但是里面的T通常都是可以Clone的。

因此在Rust中像golang一样通过拦截器来非常优雅的实现重试是做不到了,但是用复杂一点的方法还是可以实现的。

其实说白了就是在应用层,按照最直接的方式来实现重试。在应用层多封装一层函数,其参数是应用层的请求类型T。调用接口之后,判断结果,如果是可重试的错误,则将类型T复制一份,重新发起调用。

当然这样实现的问题是重复的模式化的代码会非常多,所以具体实现还是用了一些技巧尽量让重复的代码少一点。

方案参考了temporalio/sdk-core,具体实现参见代码

为了复用retry的逻辑,单独抽象出了retry模块。首先定义了RetryClient

1
2
3
4
pub struct RetryClient<SG> {
client: SG,
retry_config: RetryConfig,
}

其中client是原始的gRPC clientretry_config是重试相关的选项,比如最多重试多少次等。

重试的逻辑在其成员方法call_with_retry中,里面主要用到了FutureRetry,即把整个调用封装成一个Future闭包,退避策略则使用了ExponentialBackoff

当然最根本的还是前面提到的,要封装一层,使闭包的参数是可以Clone的。这部分都是一些模式化的代码,因此使用了一个宏来自动生成相关代码:

1
2
3
4
5
6
7
8
macro_rules! retry_call {
($myself:ident, $call_name:ident) => { retry_call!($myself, $call_name,) };
($myself:ident, $call_name:ident, $($args:expr),*) => {{
let call_name_str = stringify!($call_name);
let fact = || { async { $myself.get_client_clone().$call_name($($args,)*).await.map(|ret| ret.into_inner()) }};
$myself.call_with_retry(fact, call_name_str).await
}}
}

为了让RetryClient能够用于不同的Service,这里会把每个Service的客户端函数定义成一个Trait。比如:

1
2
3
4
5
6
7
8
#[async_trait::async_trait]
pub trait StorageClientTrait {
async fn store(&self, content: storage::Content) -> Result<common::StatusCode, tonic::Status>;

async fn load(&self, key: storage::ExtKey) -> Result<storage::Value, tonic::Status>;

async fn delete(&self, key: storage::ExtKey) -> Result<common::StatusCode, tonic::Status>;
}

注意这里的函数原型是封装之后的。

然后为RetryClient相对应的特化类型实现这个Trait

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#[async_trait::async_trait]
impl StorageClientTrait for RetryClient<StorageServiceClient<InterceptedSvc>> {
async fn store(&self, content: storage::Content) -> Result<common::StatusCode, tonic::Status> {
retry_call!(self, store, content.clone())
}

async fn load(&self, key: storage::ExtKey) -> Result<storage::Value, tonic::Status> {
retry_call!(self, load, key.clone())
}

async fn delete(&self, key: storage::ExtKey) -> Result<common::StatusCode, tonic::Status> {
retry_call!(self, delete, key.clone())
}
}

内容是完全使用前面的宏来实现的。第一个参数是RetryClientself,第二个参数是gRPC接口的名称,后面是接口的参数。

这样就实现了一个尽量通用的RetryClient,然后以尽量少的重复代码来为多个Service都实现了重试的功能。

用法可以参见里面的测试代码。

1
2
3
let mock_client = TestClient::new(code);
let retry_client = RetryClient::new(mock_client, Default::default());
let result = retry_client.test(1).await;

首先按照原有的方法获取底层的Client;然后将其和RetryConfig一起放入RetryClient,得到带重试功能的客户端;用这个客户端调用前述Trait中封装的方法就会自带重试功能了。

什么是负载均衡

负载均衡是高可用架构中的一个关键组件,他可以将请求平摊到后端服务上。

在单副本应用时代,你的服务负载可能并不高,这个时候负载均衡并没有多大用处。当你的应用火爆,用户请求数量多时,你需要将你的服务从单副本转变为多副本,那这个时候,是很有必要引入负载均衡的。

特别是当前微服务盛行的时代,一个应用多副本的高可用形态随处可见,负载均衡已经是个不可或缺的组件了。

Kubernetes中的Service介绍

我们知道,Kubernetes中的Pod由于各种原因随时有可能被销毁和新建,且一般应用均以多副本的形式存在。

如果你想要访问一组Pod(或称之为微服务)时,必须有一种抽象资源,能够跟踪到其下所有的Pod,这个抽象便是Service。

Service主要有如下作用:

  • 服务发现:动态地将具有相同selector标志的后端Pod绑定起来
  • 负载均衡:通过iptables或ipvs的负载均衡算法实现

这里我们主要来讲下Service的负载均衡。

原理

官网 可知,Service有三种代理模式:

  • userspace代理模式
  • iptables代理模式
  • IPVS代理模式

以iptables模式为例:

可以从图上看到,有一个重要的组件————kube-proxy,它以DaemonSet的形式存在,通过访问apiserver并watch相应资源(Service对象和Endpoint对象)来动态生成各自节点上的iptables规则。

当用户想要访问Pod服务时,iptables会通过NAT(网络地址转化)等方式随机请求到任意一个Pod。

在iptables模式下,kube-proxy通过在目标Node节点上的iptables配置中的NAT表的PREROUTIN和POSTROUTING链中创建一系列的自定义链(这些自定义链主要是”KUBE-SERVICE”链, “KUBE-POSTROUTING”链,每个服务对应的”KUBE-SVC-XXXXXX”链和”KUBE-SEP-XXXX”链),然后通过这些自定义链对流经到该Node的数据包做DNAT和SNAT操作从而实现路由,负载均衡和地址转化。

iptables

iptables是Linux平台下的包过滤防火墙,相关的四表五链知识可以去网上学习了解。

当设置了iptables规则后,每个数据包都要通过iptables的过滤,不同流向的数据包会通过不同的链:

  • 到本机某进程的报文:PREROUTING –> INPUT
  • 由本机转发的报文:PREROUTING –> FORWARD –> POSTROUTING
  • 由本机的某进程发出报文:OUTPUT –> POSTROUTING

每个链上会有一些规则去过滤数据包进行操作,这些规则在大体上又可以分为4类,分别存在4张table中:

  • filter表:负责过滤功能,防火墙;内核模块:iptables_filter
  • nat表:network address translation,网络地址转换功能;内核模块:iptable_nat
  • mangle表:拆解报文,做出修改,并重新封装 的功能;内核模块:iptable_mangle
  • raw表:关闭nat表上启用的连接追踪机制;内核模块:iptable_raw

实验

下面我们来做个实验,具体看下kube-proxy生成的iptables规则是怎样请求到其中一个Pod里的

1. 准备

在集群中应用如下创建一个名为nginx-service的Service和副本数为3的nginx Deployment:

1
2
3
4
# 创建Deployment
kubectl create deployment nginx --image=nginx --replicas=3
# 创建Service
kubectl expose deployment nginx --port=80 --target-port=80

我们可以看到3个Pod的ip分别为:

  • 10.244.1.14
  • 10.244.1.16
  • 10.244.1.17

给Service分配的ip为: 10.97.54.248

1
2
3
4
5
6
7
8
9
10
11
12
13
$ kubectl get pod -owide

NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
busybox 1/1 Running 0 24s 10.244.1.20 node1 <none> <none>
nginx-6799fc88d8-8jnxd 1/1 Running 0 39h 10.244.1.14 node1 <none> <none>
nginx-6799fc88d8-g4d5x 1/1 Running 0 39h 10.244.1.16 node1 <none> <none>
nginx-6799fc88d8-q45nc 1/1 Running 0 44m 10.244.1.17 node1 <none> <none>

$ kubectl get svc

NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 175d
nginx-svc ClusterIP 10.97.54.248 <none> 80/TCP 3m1s

我们可以看到nginx Service其下已有一组Endpoint暴露出来,对应的便是3个Pod的ip地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ kubectl describe svc nginx-svc

Name: nginx-svc
Namespace: default
Labels: app=nginx
Annotations: <none>
Selector: app=nginx
Type: ClusterIP
IP Family Policy: SingleStack
IP Families: IPv4
IP: 10.97.54.248
IPs: 10.97.54.248
Port: <unset> 80/TCP
TargetPort: 80/TCP
Endpoints: 10.244.1.14:80,10.244.1.16:80,10.244.1.17:80
Session Affinity: None
Events: <none>

2. ClusterIP

此外,我还建了一个busybox的镜像作为请求的发起方,他的地址是:10.244.1.20

所以当前的请求路径是:10.244.1.20(busybox) –> 10.97.54.248:80(nginx-svc)

根据前文知识(由本机的某进程发出报文),我们先来看主机上的OUTPUT链上的nat表

1
2
3
4
5
6
root@master:~# iptables -t nat -nvL OUTPUT

Chain OUTPUT (policy ACCEPT 878 packets, 52888 bytes)
pkts bytes target prot opt in out source destination
37M 2597M KUBE-SERVICES all -- * * 0.0.0.0/0 0.0.0.0/0 /* kubernetes service portals */
11M 673M DOCKER all -- * * 0.0.0.0/0 !127.0.0.0/8 ADDRTYPE match dst-type LOCAL

可以看到流量指向了KUBE-SERVICES的链

1
2
3
4
5
6
7
8
9
root@master:~# iptables -t nat -nvL KUBE-SERVICES
Chain KUBE-SERVICES (2 references)
pkts bytes target prot opt in out source destination
0 0 KUBE-SVC-HL5LMXD5JFHQZ6LN tcp -- * * 0.0.0.0/0 10.97.54.248 /* default/nginx-svc cluster IP */ tcp dpt:80
0 0 KUBE-SVC-NPX46M4PTMTKRN6Y tcp -- * * 0.0.0.0/0 10.96.0.1 /* default/kubernetes:https cluster IP */ tcp dpt:443
0 0 KUBE-SVC-TCOU7JCQXEZGVUNU udp -- * * 0.0.0.0/0 10.96.0.10 /* kube-system/kube-dns:dns cluster IP */ udp dpt:53
0 0 KUBE-SVC-ERIFXISQEP7F7OF4 tcp -- * * 0.0.0.0/0 10.96.0.10 /* kube-system/kube-dns:dns-tcp cluster IP */ tcp dpt:53
0 0 KUBE-SVC-JD5MR3NA4I4DYORP tcp -- * * 0.0.0.0/0 10.96.0.10 /* kube-system/kube-dns:metrics cluster IP */ tcp dpt:9153
1756 106K KUBE-NODEPORTS all -- * * 0.0.0.0/0 0.0.0.0/0 /* kubernetes service nodeports; NOTE: this must be the last rule in this chain */ ADDRTYPE match dst-type LOCAL

由于目标是Service的ip(10.97.54.248),所以这边又匹配到了KUBE-SVC-HL5LMXD5JFHQZ6LN这条链。

也可以从后面的注释中看到下面需要走哪条链

1
2
3
4
5
6
7
root@master:~# iptables -t nat -nvL KUBE-SVC-HL5LMXD5JFHQZ6LN
Chain KUBE-SVC-HL5LMXD5JFHQZ6LN (1 references)
pkts bytes target prot opt in out source destination
0 0 KUBE-MARK-MASQ tcp -- * * !10.244.0.0/16 10.97.54.248 /* default/nginx-svc cluster IP */ tcp dpt:80
0 0 KUBE-SEP-U46YXJIMXXUGWXXH all -- * * 0.0.0.0/0 0.0.0.0/0 /* default/nginx-svc */ statistic mode random probability 0.33333333349
0 0 KUBE-SEP-DUL3TOEKR4Q7XNNH all -- * * 0.0.0.0/0 0.0.0.0/0 /* default/nginx-svc */ statistic mode random probability 0.50000000000
0 0 KUBE-SEP-OJQRYVIILJUTFXOB all -- * * 0.0.0.0/0 0.0.0.0/0 /* default/nginx-svc */

到这里,若源端不是10.244.0.0/16的话,会被打上标记;由于我们的busybox是该网段的,这条规则略过。

然后会随机匹配KUBE-SEP-U46YXJIMXXUGWXXH,KUBE-SEP-DUL3TOEKR4Q7XNNH,KUBE-SEP-OJQRYVIILJUTFXOB这三条链的其中一条。

其意思是:会有1/3的概率命中KUBE-SEP-U46YXJIMXXUGWXXH这条链,如果没命中的话,会有2/3 * 1/2 = 1/3 的概率命中第二条链KUBE-SEP-DUL3TOEKR4Q7XNNH,最后还有1/3的概率命中最后一条链KUBE-SEP-OJQRYVIILJUTFXOB。

可以看出,这边是在做负载均衡。

我们选择其中一条链KUBE-SEP-U46YXJIMXXUGWXXH继续走下去

1
2
3
4
5
root@master:~# iptables -t nat -nvL KUBE-SEP-U46YXJIMXXUGWXXH
Chain KUBE-SEP-U46YXJIMXXUGWXXH (1 references)
pkts bytes target prot opt in out source destination
0 0 KUBE-MARK-MASQ all -- * * 10.244.1.14 0.0.0.0/0 /* default/nginx-svc */
0 0 DNAT tcp -- * * 0.0.0.0/0 0.0.0.0/0 /* default/nginx-svc */ tcp to:10.244.1.14:80

KUBE-MARK-MASQ自己Pod内访问,打上标记,可以先不看。

看DNAT那条链,可以看到这里做了目标地址转化,最终我们的请求从:
10.244.1.20(busybox) –> 10.97.54.248:80(nginx-svc)
变成了
10.244.1.20(busybox) –> 10.244.1.14:80(nginx-6799fc88d8-8jnxd)

OUTPUT链走完之后还会经过POSTROUTING链:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
root@master:~# iptables -t nat -nvL POSTROUTING
Chain POSTROUTING (policy ACCEPT 5321 packets, 321K bytes)
pkts bytes target prot opt in out source destination
41M 2939M KUBE-POSTROUTING all -- * * 0.0.0.0/0 0.0.0.0/0 /* kubernetes postrouting rules */
0 0 MASQUERADE all -- * !docker0 172.17.0.0/16 0.0.0.0/0
6994K 525M RETURN all -- * * 10.244.0.0/16 10.244.0.0/16
758K 67M MASQUERADE all -- * * 10.244.0.0/16 !224.0.0.0/4 random-fully
0 0 RETURN all -- * * !10.244.0.0/16 10.244.0.0/24
0 0 MASQUERADE all -- * * !10.244.0.0/16 10.244.0.0/16 random-fully
root@master:~# iptables -t nat -nvL KUBE-POSTROUTING
Chain KUBE-POSTROUTING (1 references)
pkts bytes target prot opt in out source destination
5396 325K RETURN all -- * * 0.0.0.0/0 0.0.0.0/0 mark match ! 0x4000/0x4000
0 0 MARK all -- * * 0.0.0.0/0 0.0.0.0/0 MARK xor 0x4000
0 0 MASQUERADE all -- * * 0.0.0.0/0 0.0.0.0/0 /* kubernetes service traffic requiring SNAT */ random-fully

KUBE-POSTROUTING会对数据包进行判断,如果发现它有0x4000/0x4000标记,就会跳到MASQUERADE规则,由于我们并没有被打上标记,直接RETURN。

3. NodePort

NodePort是集群外访问集群内服务的一种方式,从iptables规则来看,NodePort是ClusterIP的超集,额外比ClusterIP多了一些规则。

现在我把原来的ClusterIP删了之后创建了一个名为nginx-svc-nodeport的NodePort类型的service。

1
2
3
4
5
zjq@master:~$ kubectl get svc

NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 175d
nginx-svc-nodeport NodePort 10.109.235.134 <none> 80:31082/TCP 10s

该Service对集群外暴露的端口是31082,这个端口是由每个节点上的kube-proxy打开的,可以用如下命令查看:

1
2
3
4
5
root@master:~# netstat -anp | grep 31082
tcp 0 0 0.0.0.0:31082 0.0.0.0:* LISTEN 1231199/kube-proxy

root@node1:~# netstat -anp | grep 31082
tcp 0 0 0.0.0.0:31082 0.0.0.0:* LISTEN 1986768/kube-proxy

这样,你便能通过任意节点+port的方式访问到微服务了。

现在我们来看下NodePort类型的iptables。

1
2
3
4
5
root@master:~# iptables -t nat -nvL PREROUTING
Chain PREROUTING (policy ACCEPT 20 packets, 980 bytes)
pkts bytes target prot opt in out source destination
6621K 791M KUBE-SERVICES all -- * * 0.0.0.0/0 0.0.0.0/0 /* kubernetes service portals */
2918K 426M DOCKER all -- * * 0.0.0.0/0 0.0.0.0/0 ADDRTYPE match dst-type LOCAL
1
2
3
4
5
6
7
8
9
root@master:~# iptables -t nat -nvL KUBE-SERVICES
Chain KUBE-SERVICES (2 references)
pkts bytes target prot opt in out source destination
0 0 KUBE-SVC-XKWTKZBTDCMU3FHC tcp -- * * 0.0.0.0/0 10.109.235.134 /* default/nginx-svc-nodeport cluster IP */ tcp dpt:80
0 0 KUBE-SVC-NPX46M4PTMTKRN6Y tcp -- * * 0.0.0.0/0 10.96.0.1 /* default/kubernetes:https cluster IP */ tcp dpt:443
0 0 KUBE-SVC-TCOU7JCQXEZGVUNU udp -- * * 0.0.0.0/0 10.96.0.10 /* kube-system/kube-dns:dns cluster IP */ udp dpt:53
0 0 KUBE-SVC-ERIFXISQEP7F7OF4 tcp -- * * 0.0.0.0/0 10.96.0.10 /* kube-system/kube-dns:dns-tcp cluster IP */ tcp dpt:53
0 0 KUBE-SVC-JD5MR3NA4I4DYORP tcp -- * * 0.0.0.0/0 10.96.0.10 /* kube-system/kube-dns:metrics cluster IP */ tcp dpt:9153
1978 119K KUBE-NODEPORTS all -- * * 0.0.0.0/0 0.0.0.0/0 /* kubernetes service nodeports; NOTE: this must be the last rule in this chain */ ADDRTYPE match dst-type LOCAL

从上述看到,会先跳转掉KUBE-NODEPORTS这条链,来看下KUBE-NODEPORTS这条链之后的路径

1
2
3
4
root@master:~# iptables -t nat -nvL KUBE-NODEPORTS
Chain KUBE-NODEPORTS (1 references)
pkts bytes target prot opt in out source destination
0 0 KUBE-SVC-XKWTKZBTDCMU3FHC tcp -- * * 0.0.0.0/0 0.0.0.0/0 /* default/nginx-svc-nodeport */ tcp dpt:31082
1
2
3
4
5
6
7
8
root@master:~# iptables -t nat -nvL KUBE-SVC-XKWTKZBTDCMU3FHC
Chain KUBE-SVC-XKWTKZBTDCMU3FHC (2 references)
pkts bytes target prot opt in out source destination
0 0 KUBE-MARK-MASQ tcp -- * * !10.244.0.0/16 10.109.235.134 /* default/nginx-svc-nodeport cluster IP */ tcp dpt:80
0 0 KUBE-MARK-MASQ tcp -- * * 0.0.0.0/0 0.0.0.0/0 /* default/nginx-svc-nodeport */ tcp dpt:31082
0 0 KUBE-SEP-VLHANZGCXJXNRTPY all -- * * 0.0.0.0/0 0.0.0.0/0 /* default/nginx-svc-nodeport */ statistic mode random probability 0.33333333349
0 0 KUBE-SEP-L66MBC5WQIY6TV6O all -- * * 0.0.0.0/0 0.0.0.0/0 /* default/nginx-svc-nodeport */ statistic mode random probability 0.50000000000
0 0 KUBE-SEP-EAEOYSPXP66WOJLO all -- * * 0.0.0.0/0 0.0.0.0/0 /* default/nginx-svc-nodeport */

先会匹配到第二个KUBE-MARK-MASQ链,用于打上标记(之后有用)。

再往下,通过随机负载均衡和ClusterIP的逻辑一致。

这里需要注意的一点是,这时候执行KUBE-POSTROUTING链时,由于匹配到之前做的标记0x4000,会做一个SNAT操作。

为什么要做SNAT转化呢?这边假设一个场景,如下图:

当一个外部的client通过node2的地址访问一个Service的时候,node2上的负载均衡规则,就可能把这个IP包转发给一个在node1上的Pod。这里没有任何问题。

而当node1上的这个Pod处理完请求之后,它就会按照这个IP包的源地址发出回复。

可是,如果没有做SNAT操作的话,这时候,被转发来的IP包的源地址就是client的IP地址。所以此时,Pod就会直接将回复发给client。对于client来说,它的请求明明发给了node2,收到的回复却来自node1,这个client很可能会报错。

4. LoadBalance

LoadBalance是NodePort的超集,这边不再分析。

原理分析

通过分析kube-proxy的实现能够更好地理解Service的实现。

新建proxyServer对象newProxyServer方法,会根据不同的模式来初始化proxier对象。

如果你的节点未开启ipvs,则自动降级为iptables模式。

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
func newProxyServer(
config *proxyconfigapi.KubeProxyConfiguration,
cleanupAndExit bool,
master string) (*ProxyServer, error) {
...
if proxyMode == proxyModeIPTables {
klog.V(0).InfoS("Using iptables Proxier")
...
if dualStack {
...
// TODO this has side effects that should only happen when Run() is invoked.
proxier, err = iptables.NewDualStackProxier(
ipt,
utilsysctl.New(),
execer,
config.IPTables.SyncPeriod.Duration,
config.IPTables.MinSyncPeriod.Duration,
config.IPTables.MasqueradeAll,
int(*config.IPTables.MasqueradeBit),
localDetectors,
hostname,
nodeIPTuple(config.BindAddress),
recorder,
healthzServer,
config.NodePortAddresses,
)
} else {
...
// TODO this has side effects that should only happen when Run() is invoked.
proxier, err = iptables.NewProxier(
iptInterface,
utilsysctl.New(),
execer,
config.IPTables.SyncPeriod.Duration,
config.IPTables.MinSyncPeriod.Duration,
config.IPTables.MasqueradeAll,
int(*config.IPTables.MasqueradeBit),
localDetector,
hostname,
nodeIP,
recorder,
healthzServer,
config.NodePortAddresses,
)
}

if err != nil {
return nil, fmt.Errorf("unable to create proxier: %v", err)
}
proxymetrics.RegisterMetrics()
} else if proxyMode == proxyModeIPVS {
...
klog.V(0).InfoS("Using ipvs Proxier")
} else {
...
klog.V(0).InfoS("Using userspace Proxier")
}
}

ProxyServer结构体最主要的是Run()方法:

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
func (s *ProxyServer) Run() error {
...
// 暴露/healthz接口
// Start up a healthz server if requested
serveHealthz(s.HealthzServer, errCh)

// 暴露指标信息
// Start up a metrics server if requested
serveMetrics(s.MetricsBindAddress, s.ProxyMode, s.EnableProfiling, errCh)

...
// 新建informerFactory
// Make informers that filter out objects that want a non-default service proxy.
informerFactory := informers.NewSharedInformerFactoryWithOptions(s.Client, s.ConfigSyncPeriod,
informers.WithTweakListOptions(func(options *metav1.ListOptions) {
options.LabelSelector = labelSelector.String()
}))

// kube-proxy主要watch了service和endpoint(或endpointSlices)资源的变动,
// 当它们有变动时,对应节点上的iptables规则也会相相应地变动
// Create configs (i.e. Watches for Services and Endpoints or EndpointSlices)
// Note: RegisterHandler() calls need to happen before creation of Sources because sources
// only notify on changes, and the initial update (on process start) may be lost if no handlers
// are registered yet.
serviceConfig := config.NewServiceConfig(informerFactory.Core().V1().Services(), s.ConfigSyncPeriod)
serviceConfig.RegisterEventHandler(s.Proxier)
go serviceConfig.Run(wait.NeverStop)

if endpointsHandler, ok := s.Proxier.(config.EndpointsHandler); ok && !s.UseEndpointSlices {
endpointsConfig := config.NewEndpointsConfig(informerFactory.Core().V1().Endpoints(), s.ConfigSyncPeriod)
endpointsConfig.RegisterEventHandler(endpointsHandler)
go endpointsConfig.Run(wait.NeverStop)
} else {
endpointSliceConfig := config.NewEndpointSliceConfig(informerFactory.Discovery().V1().EndpointSlices(), s.ConfigSyncPeriod)
endpointSliceConfig.RegisterEventHandler(s.Proxier)
go endpointSliceConfig.Run(wait.NeverStop)
}

// 启动informer
// This has to start after the calls to NewServiceConfig and NewEndpointsConfig because those
// functions must configure their shared informer event handlers first.
informerFactory.Start(wait.NeverStop)

...
// Birth Cry after the birth is successful
s.birthCry()

// 进入定时循环
go s.Proxier.SyncLoop()

return <-errCh
}

这里我们来看下serviceConfig中的Run()方法,这里面根据注册的eventHandlers动作,均会执行OnServiceSynced()方法,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Run waits for cache synced and invokes handlers after syncing.
func (c *ServiceConfig) Run(stopCh <-chan struct{}) {
klog.InfoS("Starting service config controller")

if !cache.WaitForNamedCacheSync("service config", stopCh, c.listerSynced) {
return
}

for i := range c.eventHandlers {
klog.V(3).InfoS("Calling handler.OnServiceSynced()")
// 注册的事件动作执行相应的OnServiceSynced()
c.eventHandlers[i].OnServiceSynced()
}
}

在OnServiceSynced中,我们可以看到核心方法syncProxyRules()

在十分冗长的syncProxyRules方法中(大约800行),里面就会应用iptables rule到当前的节点。

1
2
3
4
5
6
7
8
9
10
11
12
// OnServiceSynced is called once all the initial event handlers were
// called and the state is fully propagated to local cache.
func (proxier *Proxier) OnServiceSynced() {
proxier.mu.Lock()
proxier.servicesSynced = true
proxier.setInitialized(proxier.endpointSlicesSynced)
proxier.mu.Unlock()

// Sync unconditionally - this is called once per lifetime.
// 应用iptables rule到节点上
proxier.syncProxyRules()
}

结论

本文简单介绍了负载均衡的作用和Kubernetes中负载均衡的原理,并通过一些实验例子来展示请求是如何被转发到Pod中的,最后通过分析核心源码的方式来了解具体实现。

但是,iptables也存在许多问题:

  1. iptables规则多了之后性能下降。按照 官方说法:

    尽管 Kubernetes 在版本1.6中已经支持5000个节点,但是使用 iptables 的 kube-proxy 实际上是将集群扩展到5000个节点的一个瓶颈。一个例子是在一个包含5000个节点的集群中使用 NodePort Service,如果我们有2000个服务,每个服务有10个 pods,这将导致每个工作节点上至少有20000个 iptable 记录,这会使内核非常繁忙。

  2. iptables使用的负载均衡算法简单,不支持复杂场景。相反,ipvs能够支持更多的负载均衡算法,且性能更好

另外,基于eBPF技术实现的CNI插件cilium可以完全替换kube-proxy,感兴趣的同学可以试一下。

问题描述

在实际的应用场景中,我们的应用程序运行在集群中(以 Pod 的形式存在),并且该应用程序将在集群中进行创建资源、修改资源、删除资源等等操作。

在 Pod 中,访问 Kubernetes API 的方法有很多,而通过 Client Libraries(程序类库)是官方推荐的做法,也是我们接下来将要学习的方法。

该笔记将记录:在 Pod 中,通过 Client Libraries 访问 Kubernetes API(管理 Kubernetes 集群)的方法,以及相关问题的解决办法。

解决方案

接下来我们将介绍例如,如果创建 ServiceAccount 对应用程序进行访问控制,只允许其查看 Pod 资源(即查看 Pod 列表和详细信息)

第 1 步、创建 ServiceAccount 资源

而这其中最大的问题是,如何进行合理的授权,即对于既定的用户或应用程序,如何允许或拒绝特定的操作?—— 通过 ServiceAccount 实现。

# kubectl create serviceaccount myappsa

第 2 步、引用 ServiceAccount 资源

定义一个 Pod,使用为 myappsa 的 ServiceAccount 资源:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
kubectl apply -f - <<EOF
kind: Pod
apiVersion: v1
metadata:
name: myapp
spec:
serviceAccountName: myappsa
containers:
- name: main
image: bitnami/kubectl:latest
command:
- "sleep"
- "infinity"
EOF

ServiceAccount 是种身份,而在 Pod 中引用 ServiceAccount 则是将这种身份赋予 Pod 实例。而接下来的任务是给 ServiceAccount 这种身份赋予各种权限 —— 具体的做法便是将各种角色(Role)绑定(RoleBinding)到这个身份(ServiceAccount)上。

第 3 步、创建 Role 资源

定义名为 podreader 的 Role 资源,并定义其能够进行的访问操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
kubectl apply -f - <<EOF
kind: Role
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: podreader
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
EOF

# 或,直接使用命令创建
# kubectl create role podreader --verb=get --verb=list --resource=pods -n default

第 4 步、将 Role 与 ServiceAccount 绑定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kubectl apply -f - <<EOF
kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: podreaderbinding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: podreader
subjects:
- kind: ServiceAccount
name: myappsa
namespace: default
EOF

# 或,从从命令行直接创建:
# kubectl create rolebinding podreaderbinding --role=default:podreader --serviceaccount=default:myappsa --namesepace default -n default

第 5 步、访问 Kubernetes API 测试

通过 Service Account 相关信息来访问资源:

1
2
3
4
5
6
7
8
9
10
11
12
# 通过我们运行的 kubectl 容器访问
kubectl get pods # 这里是为了体现 kubectl 并未通过 kubeconfig 的信息来访问集群,
# 而是通过 /var/run/secrets/kubernetes.io/serviceaccount 来访问集群
# kubectl 手册介绍该命令是如何查找访问信息的;

# 通过 curl API 访问
APISERVER=https://kubernetes.default.svc
SERVICEACCOUNT=/var/run/secrets/kubernetes.io/serviceaccount
NAMESPACE=$(cat ${SERVICEACCOUNT}/namespace)
TOKEN=$(cat ${SERVICEACCOUNT}/token)
CACERT=${SERVICEACCOUNT}/ca.crt
curl --cacert ${CACERT} --header "Authorization: Bearer ${TOKEN}" -X GET ${APISERVER}/api/v1/namespaces/default/pods

第 6 步、通过客户端类库访问集群

以 Java 客户端为例:

如果需要在集群内部访问集群:
1)按照如上示例,定义 ServiceAccount 资源,
2)并参照 InClusterClientExample.java 示例代码

如果需要在集群外部访问集群:
1)按照如上示例,在远端集群定义 ServiceAccount 资源,
2)并参照 java/KubeConfigFileClientExample.java 示例代码

补充说明

访问集群级别的资源

上面是命名空间内的访问控制设置,因为上述使用的是 Role 和 RoleBinding 资源,而对于集群范围的访问控制应该使用 ClusterRole 和ClusterRoleBinding 命令。

替换 kind 为 ClusterRole 及 ClusterRoleBinding 即可,其他部分与 Role、RoleBinding 类似,这里不再赘述。

通过 ServiceAccount 生成 Kubeconfig 的方法

如果需要使用 kubeconfig 文件,可通过 ServiceAccount 资源来创建,参考 How to create a kubectl config file for serviceaccount 讨论。

简而言之,kubeconfig 的 user 部分为 token,而非 client-certificate-data 与 client-key-data 参数。

参考文献

Accessing the Kubernetes API from a Pod | Kubernetes
dashboard - How to bind roles with service accounts - Kubernetes - Stack Overflow
A Quick Intro to the Kubernetes Java Client | Baeldung

背景

曾经有用户在论坛里反馈过区块链系统启动过程比较复杂。

首先区块系统是一个对等网络,而传统系统一般都是Client/Server或者Master/Slave形态。

所以在区块链设计中就有一个常见的模式,把非对等形态的软件变为对等形态。比较简单的做法就是所有节点都是Server,同时又是其他节点的Client,可以认为是把常见的网络库提供的Server/Client形态的功能转换为对等网络。

这个方案麻烦的是生成配置文件,以及后续增加删除节点时修改配置文件。

需要提供已知的所有节点的网络信息。遍历所有的节点,为每个节点生成一个相关配置。找到本节点的网络信息,确定本节点的监听端口,用于启动Server;使用除自己之外的其他节点的信息,用于本节点作为Client去连接其他节点。

相当于N * (N - 1)Client/Server的配置,且需要所有节点信息才能生成对应的配置文件,所以需要集中统一生成。

同样,增加删除节点的时候,也涉及到所有配置文件的修改,需要集中修改,生成新的配置文件,然后下发到所有的节点。

其次它是一个去中心化的系统,实际生产部署的时候是有多个参与方的,每个参与方负责一个节点,相互之间的协调和配合工作量比较大。且需要考虑参与方的机密信息不能泄露,因此需要分成多个步骤,将机密信息生成和协作产生区块链配置分开,进一步增加了操作的复杂程度。

问题

  1. 参与方之间交互比较多,需要传递信息也比较多。不管是通过发邮件,还是通过其他方式传递,管理都比较困难。
    比如,需要认为回信息的方式来确认对方已经收到,如果没有及时反馈,发送方重复发送,且信息与之前的不一致,如何处理?如果有人冒充参与方发送了假冒的信息,如何甄别?
  2. 因为区块链系统多方协作的特点,上线运营之后可能还会持续有参与方加入和退出,导致节点的配置不断变化。
    有新增节点,有节点退出,节点的ip地址或者端口可能发生变动。如何应用这些变更?如何同步到其他节点?如何记录历史上的变更?手工操作进行配置升级容易出错,且消耗时间比较长。如果不能做到配置变更时,及时完成节点配置的更新部署,导致节点配置落后或者错误,可能会让整个区块链系统存在安全风险。比如一个参与方已经退出了,但是其他参与方没有及时更新这个信息,导致已经退出的参与方仍然能够访问系统的信息。

GitOps

Git是一个开源的分布式版本控制系统,分布式相比集中式的最大区别是Git没有中央版本库,每一位开发者都可以通过克隆远程代码库,在本地机器上初始化一个完整的代码版本,开发者可以把代码的修改提交到本地代码库,也可以把本地的代码库同步到远程的代码库。

GitOps是一种持续交付的方式。它的核心思想是将应用系统的配置和部署以声明性的方式存放在Git版本库中。将Git作为交付流水线的核心,开发人员只需要将修改提到至Git,使用Git来加速和简化应用程序部署和运维任务。通过GitOps,当使用Git提交应用系统的配置更改时,自动化的交付流水线会将这些更改应用到实际系统中。

GitOps方法应用在持续交付流水线上,有诸多优势和特点:

  • 自动保证实际的应用系统和Git仓库中的配置是一致的。
  • 更快的部署时间和恢复时间。
  • 稳定且可重现的回滚。Git中保存有历史的配置信息,有问题可以随时切回历史版本。

解决方案

通过Git来管理系统配置,使用GitOps实现持续交付,在传统系统中已经非常流行。

但是区块链系统比传统系统更加适合这种配置管理方式。因为其配置的产生是一个协作的过程,更能发挥Git作为团队协作工具的特点;参与方本地拥有完整配置,但是只把部分非机密信息共享给其他参与方也符合Git在分布式上的特点。

链的配置涉及到多个参与方,以及相互之间的信息交互,并就此对链的配置进行相应的修改。所以这是一个多方协作的场景,使用Git管理链的配置可以很好的解决现有方案的问题。

  1. Git可以设置权限,只允许参与方查看和修改存放链配置的仓库。
  2. 通过push命令推送本地修改到远程,安全且有回馈。
  3. 可以通过PR等功能对修改进行审查,一个或者多个参与方都确认之后才能合并。
  4. 其他参与方可以通过git pull拉取最新的配置。
  5. Git本身可以记录修改的历史,何时何人做了什么修改都有记录。

注意:

  1. 因为配置里面有ip,端口等比较的敏感信息,可以通过建立私有仓库来避免信息泄露的风险。
  2. 可以通过Github/Gitlab/Gitee等提供的图形界面和扩展功能更加方便的对配置修改进行管理。

区块链声明式的配置方式

要实施GitOps,核心的一点是要将区块链的配置方式改造为声明式。

如果配置方式仍然是命令式的,比如,增加一个节点,删除一个节点等。因为每个节点实施的顺序不同,可能会得出不同的结果,导致不同节点的配置不一致。

因此我们对区块链系统的配置项进行梳理:

并据此制定一个声明式的配置数据结构:

配置变更时,不再下发配置变更动作,而是直接重新下发所有的配置信息,节点根据这个配置重新生成节点本地的配置文件。

链和节点Git仓库分离

理论上可以将一个区块链系统所有的配置信息都放在一个仓库中。

但是区块链去中心化的特性,链的配置需要公开,至少在参与方之间公开,以方便多个参与方都可以修改链的配置。但是节点配置中有很多机密信息,比如节点的私钥等,如果公开会造成安全方面的隐患。

因此,将链的配置信息和节点配置信息分开放在两个Git仓库中。

链的配置信息只包含可以公开的信息,比如账户地址等,可以放在一个公开的Git仓库中;而私钥等机密信息存在放节点配置中,放在参与方内部私有Git仓库中。

交付流水线

以增加一个节点为例。

  • 新的参与方首先申请公开的存放链的配置信息的仓库的访问权限。
  • 拉取最新的链的配置,并提交要增加的节点的公开信息,然后以PR的形式提交对链的配置的修改。
  • 增加节点信息的PR经过审批之后,合并进最新的链的配置。
  • 已有节点通过设置链的配置Git仓库的Webhook感知链的配置的变化。
  • 通过本地的配置工具更新本地节点的配置文件,并提交至存放节点配置的Git仓库。
  • 应用部署系统同样通过设置存放节点配置的Git仓库的Webhook感知节点配置的变化。
  • 停掉已经存在的节点应用,并拉取最新的节点配置,重启节点应用,完成整个配置变更。

演示

这里在gitee上创建链级配置的仓库

配置工具为cloud-config

在公司内部的Gitlab上创建节点级配置仓库。

使用Jekins,并在相应的Git仓库设置Webhook来自动触发流水线。

区块链系统运行环境为k8s,并且集群中安装ArgoCD,用于支持GitOps操作。

链级别配置

创建仓库

gitee上创建仓库gitops-test-chain,保存链级配置。设置master分支不能直接push,只能PR的方式合入,并需要多个人的审批才能合入。

参与方加入

各个参与方申请该仓库的权限,并进行相关的设置,比如设置SSH Key

链的发起方初始化链级配置

初始化链级配置并提交至gitops-test-chain

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ cloud-config init-chain --chain-name gitops-test-chain
$ cloud-config init-chain-config --chain-name gitops-test-chain --consensus_tag v6.4.0 --controller_tag v6.4.0 --executor_tag v6.4.0 --kms_tag v6.4.0 --network_tag v6.4.0 --storage_tag v6.4.0
$ cd gitops-test-chain/
$ git init
$ git status
On branch master

No commits yet

Untracked files:
(use "git add <file>..." to include in what will be committed)
.gitignore
accounts/
ca_cert/
certs/
chain_config.toml

nothing added to commit but untracked files present (use "git add" to track)
$ git add .
$ git commit -m "init chain config"
$ git remote add origin git@gitee.com:cita-cloud/gitops-test-chain.git
$ git push -u origin master

设置超级管理员

超级管理员拉取最新配置,生成自己的账号,并将地址设置为为链的admin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 拉取最新的链的配置
$ git clone git@gitee.com:cita-cloud/gitops-test-chain.git
// 切换到新的分支
$ cd gitops-test-chain
$ git checkout -b set-admin
$ cd ..

// 生成admin账户并设置到链级配置中
$ cloud-config new-account --chain-name gitops-test-chain
key_id:1, address:4dafebf719f2a0439387a231977e5209fabb0cca
$ cloud-config set-admin --chain-name gitops-test-chain --admin 4dafebf719f2a0439387a231977e5209fabb0cca

// 提交修改
$ cd gitops-test-chain
$ git add .
$ git commit -m "set admin"
$ git push --set-upstream origin set-admin

创建PR:

经过评审之后合并。

参与方1设置共识账户

参与方1拉取最新配置,生成自己的共识账号,并将地址添加为链的validator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 拉取最新的链的配置
$ git clone git@gitee.com:cita-cloud/gitops-test-chain.git

// 切换到新的分支
$ cd gitops-test-chain
$ git checkout -b add-validator1
$ cd ..

// 生成共识账户并设置到链级配置中
$ cloud-config new-account --chain-name gitops-test-chain
key_id:1, address:a746b04e30709203d3c8aadcca31bb024bbcf5df
$ cloud-config append-validator --chain-name gitops-test-chain --validator a746b04e30709203d3c8aadcca31bb024bbcf5df

// 提交修改
$ cd gitops-test-chain
$ git add .
$ git commit -m "append validator1"
$ git push --set-upstream origin add-validator1

创建PR:

经过评审之后合并。

参与方2设置共识账户

参与方2拉取最新配置,生成自己的共识账号,并将地址添加为链的validator

操作同参与方1,这里不再赘述。

链的发起方关闭链级配置

所有共识参与方都已经添加过共识账户。

链的参与方将链级配置的stage设置为finalize

此后将无法添加共识账户。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 拉取最新的链的配置,并创建新的分支
$ cd gitops-test-chain
$ git checkout master
$ git pull
$ git checkout -b set-stage-finalize
$ cd ..

// 将链级配置的stage设置为finalize
$ cloud-config set-stage --chain-name gitops-test-chain

// 提交修改
$ cd gitops-test-chain
$ git add .
$ git commit -m "set stage finalize"
$ git push --set-upstream origin set-stage-finalize

创建PR:

经过评审之后合并。

参与方1添加节点网络信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 拉取最新的链的配置,并创建新的分支
$ cd gitops-test-chain
$ git checkout master
$ git pull
$ git checkout -b set-node0
$ cd ..

// 添加节点0的网络信息
$ cloud-config append-node --chain-name gitops-test-chain --node www.node0.com:40000:node0:k8s

// 提交修改
$ cd gitops-test-chain
$ git add .
$ git commit -m "set node0"
$ git push --set-upstream origin set-node0

创建PR:

经过评审之后合并。

参与方2添加节点网络信息

操作同参与方1,这里不再赘述。

超级管理员创建CA证书

因为网络采用network_tls,因此需要创建链的CA证书,并为每个节点创建证书。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 拉取最新的链的配置,并创建新的分支
$ cd gitops-test-chain
$ git checkout master
$ git pull
$ git checkout -b create-ca
$ cd ..

// 创建CA证书
$ cloud-config create-ca --chain-name gitops-test-chain

// 提交修改
$ cd gitops-test-chain
$ git add .
$ git commit -m "create ca"
$ git push --set-upstream origin create-ca

创建PR:

经过评审之后合并。

参与方1创建CSR

为了不暴露参与方证书的私钥信息,这里采用Certificate Signing Request的方式申请证书。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 拉取最新的链的配置,并创建新的分支
$ cd gitops-test-chain
$ git checkout master
$ git pull
$ git checkout -b create-csr-node0
$ cd ..

// 创建CA证书
$ cloud-config create-csr --chain-name gitops-test-chain --domain node0

// 提交修改
$ cd gitops-test-chain
$ git add .
$ git commit -m "create csr for node0"
$ git push --set-upstream origin create-csr-node0

创建PR:

评审后合并。

参与方2创建CSR

操作同参与方1,这里不再赘述。

超级管理员处理CSR

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 拉取最新的链的配置,并创建新的分支
$ cd gitops-test-chain
$ git checkout master
$ git pull
$ git checkout -b sign-csr
$ cd ..

// 处理CSR,签名
$ cloud-config sign-csr --chain-name gitops-test-chain --domain node0
$ cloud-config sign-csr --chain-name gitops-test-chain --domain node1

// 提交修改
$ cd gitops-test-chain
$ git add .
$ git commit -m "sign csr"
$ git push --set-upstream origin sign-csr

创建PR:

评审后合并。

节点配置

参与方1初始化节点

在内部的GitLab上创建节点配置仓库gitops-test-chain-node0

创建read_write_access token,记录token和同时创建的bot账户,方便后续在流水线中拉取和提交代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 拉取最新的链的配置
$ cd gitops-test-chain
$ git checkout master
$ git pull
$ cd ..

// 初始化node0节点配置
$ cloud-config init-node --chain-name gitops-test-chain --domain node0 --account a746b04e30709203d3c8aadcca31bb024bbcf5df
// 生成node0节点配置文件
$ cloud-config update-node --chain-name gitops-test-chain --domain node0
// 生成node0资源清单
$ cloud-config update-yaml --chain-name gitops-test-chain --domain node0 --storage-class nfs-client

// 提交节点初始化配置
$ cd gitops-test-chain-node0
$ git init
$ git add .
$ git commit -m "init node config"
$ git remote add origin https://project_xxx_bot:xxxxxxxx@git.XXX.com/xxx/gitops-test-chain-node0.git
$ git push -u origin master

参与方1设置流水线

Jekins中创建新的流水线,设置源码为链级配置的仓库: https://gitee.com/cita-cloud/gitops-test-chain.gitmaster分支,并设置检出到子目录gitops-test-chain

设置WebHooktoken并设置过滤条件,只在master分支有更新的时候才触发流水线。

执行脚本类似前面的初始化节点配置:

1
2
3
4
5
6
7
8
9
10
set +e
rm -rf gitops-test-chain-node0
git clone https://project_xxx_bot:xxxxxxxx@git.XXX.com/xxx/gitops-test-chain-node0.git
docker run -i --rm -v `pwd`:`pwd` -w `pwd` citacloud/cloud-config:v6.4.0 cloud-config init-node --chain-name gitops-test-chain --domain node0 --account 6b9ac59d83e9d0744f6231d453e2b883b1819358
docker run -i --rm -v `pwd`:`pwd` -w `pwd` citacloud/cloud-config:v6.4.0 cloud-config update-node --chain-name gitops-test-chain --domain node0
docker run -i --rm -v `pwd`:`pwd` -w `pwd` citacloud/cloud-config:v6.4.0 cloud-config update-yaml --chain-name gitops-test-chain --domain node0 --storage-class nfs-client
cd gitops-test-chain-node0
git add .
git commit -m "update node0 config"
git push -u origin master

在链级配置的仓库中设置WebHook:

参与方2初始化节点并设置流水线

操作同参与方1,这里不再赘述。

集群配置

节点配置仓库到k8s集群的同步使用ArgoCD

安装和使用方法参见文档

创建节点应用,并设置自动同步配置:

1
2
3
argocd app create gitops-test-chain-node0 --repo https://project_xxx_bot:xxxxxxxx@git.XXX.com/xxx/gitops-test-chain-node0.git --path yamls --dest-server https://xxx.xxx.xxx.xxx:6443 --dest-namespace default --sync-policy auto

argocd app create gitops-test-chain-node1 --repo https://project_yyy_bot:yyyyyyyy@git.YYY.com/yyy/gitops-test-chain-node1.git --path yamls --dest-server https://yyy.yyy.yyy.yyy:6443 --dest-namespace default --sync-policy auto

测试

参与方3添加节点网络信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 拉取最新的链的配置,并创建新的分支
$ git clone git@gitee.com:cita-cloud/gitops-test-chain.git
$ cd gitops-test-chain
$ git checkout -b set-node2
$ cd ..

// 添加节点0的网络信息
$ cloud-config append-node --chain-name gitops-test-chain --node www.node2.com:60000:node2:k8s

// 提交修改
$ cd gitops-test-chain
$ git add .
$ git commit -m "set node2"
$ git push --set-upstream origin set-node2

创建PR:

审核后合并。

此后node0node1会经由WebHook得知链的配置发生变更,并通过Jenkins流水线自动更新节点配置文件,再通过Argocd,自动将新配置应用到集群中。

因为我们并没有真正的运行node2,所以node0node1network微服务应该会报连接错误,如下:

 2022-04-25T12:59:31.881922Z  INFO network::peer: connecting.. peer=gitops-test-chain-node2 host=gitops-test-chain-node2-nodeport port=40000                                                                │
│ 2022-04-25T12:59:36.885651Z  INFO network::peer: connecting.. peer=gitops-test-chain-node2 host=gitops-test-chain-node2-nodeport port=40000

Service的分类

在Kubernetes中,经常用到的有这些类型的Service:ClusterIP,NodePort,LoadBalancer

ClusterIP

ClusterIP主要用来集群内应用的互相访问,比如你有个数据库服务暴露了一个名为db-service的Service ,那么你的应用可以使用如下的方式连接:

1
2
# <service>.<namespace>:<service-port>
spring.datasource.url=jdbc:mysql://db-service.default:3306/test?characterEncoding=utf8&useSSL=true

NodePort

NodePort为我们给集群外暴露服务提供了一种方式。NodePort顾名思义,这种Service在每个Kubernetes的节点暴露一个Port(默认为30000-32767)。

注意,这里是每个节点都会暴露一个Port。

所以,你可以在集群外所用任意节点的IP+Port连接到集群内的服务。

一般情况下,这种方式只会在测试阶段使用,所以不建议在生产环境中使用NodePort方式对外暴露服务,有如下缺点:

  • 端口范围有限制,只能是30000-32767
  • 直接对外暴露云主机的IP并不安全
  • 由于客户端固定使用其中一个云主机实例的IP连接,若该云主机故障,则整个多副本应用都不能对外提供服务,除非自己在客户端层面实现故障检测机制。
  • 一些厂商的云主机的公有IP会变,如AWS的EC2,关闭后再重启,可能导致IP不一致

LoadBalancer

为解决以上问题,传统云环境下,我们都会用云厂商的LoadBalancer来实现负载均衡和服务的高可用。如:AWS的ELB,阿里云的SLB等。顾名思义,LoadBalancer可以将流量导入后端多个实例,实现请求服务的负载均衡。

Kubernetes作为云原生时代的基础设施,必然会用到一些公有云资源,如LoadBalancer、云盘等,来适应云环境下的各种场景(当然私有云环境也会有与公有云相对应的资源或服务,这里不在讨论范围)。

Kubernetes中的LoadBalancer类型的Service为我们创建云上LoadBalancer的服务提供了便利,你可以使用如下配置:

1
2
3
4
5
6
7
8
9
10
11
12
apiVersion: v1
kind: Service
metadata:
name: nginx-service-lb
spec:
type: LoadBalancer
selector:
app: nginx-net
ports:
- protocol: TCP
port: 8080
targetPort: 80

如上,我在我自己的集群上使用了名为nginx-service-lb的LoadBalancer类型的Service来暴露后端的Nginx服务。不过会遇到如下错误状态:

可以看到,nginx-service-lb会一直处于pending状态。不过,仔细一想也会知道,我的自建集群并未告知Kubernetes相关云服务的账号信息,怎么会为我们创建对应的云资源呢。

Cloud Provider与Cloud Controller Manager

其实,在Kubernetes创建公有云资源,还需要告诉Kubernetes所处于的云环境,Kubernetes把创建云资源的事交给了叫做Cloud Provider或Cloud Controller Manager的组件来做。

以上提及的组件存在两种形式:

  • in-tree方式:Kubernetes v1.6之前的做法,也就是Cloud Provider。该方式各Providers的代码集成在Kubernetes主代码中,各核心组件通过–cloud-provider参数来启动对应的Provider(如AWS/Azure/GCE等),由于和Kubernetes代码耦合,当前这种方法已被官方弃用。
  • out-of-tree方式:需要在集群中安装一个独立组件,也叫Cloud Controller Manager,并通过设置–cloud-provider=external的方式来告诉各核心组件通过外部组件来完成云资源的创建和管理,如AWS的 cloud-provider-aws ,阿里云的 cloud-provider-alibaba-cloud ,它们均被托管在Kubernetes官方仓库下。这种方式独立于Kubernetes核心代码的开发、构建和发布,是官方推荐的做法。

In-tree方式的Cloud Provider初始化分析

虽说已弃用,但又不是不能用

现在,我在AWS的EC2实例上自建了Kubernetes集群,并在初始化集群的时候设置–cloud-provider=aws参数(in-tree方式,生产环境并不建议用,此处做实验用)来告诉Kubernetes初始化AWS的Provider:

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
# 你可以使用kubeadm init --config=如下配置来初始化集群
apiVersion: kubeadm.k8s.io/v1beta3
bootstrapTokens:
- groups:
- system:bootstrappers:kubeadm:default-node-token
token: abcdef.0123456789abcdef
ttl: 24h0m0s
usages:
- signing
- authentication
kind: InitConfiguration
localAPIEndpoint:
advertiseAddress: 10.0.0.216
bindPort: 6443
nodeRegistration:
criSocket: /var/run/dockershim.sock
imagePullPolicy: IfNotPresent
name: ip-10-0-0-216.ap-east-1.compute.internal
taints: null
kubeletExtraArgs:
cloud-provider: "aws"
---
apiServer:
timeoutForControlPlane: 4m0s
extraArgs:
cloud-provider: "aws"
apiVersion: kubeadm.k8s.io/v1beta3
certificatesDir: /etc/kubernetes/pki
clusterName: kubernetes
controllerManager:
extraArgs:
cloud-provider: "aws"
dns: {}
etcd:
local:
dataDir: /var/lib/etcd
imageRepository: k8s.gcr.io
kind: ClusterConfiguration
kubernetesVersion: 1.23.0
networking:
dnsDomain: cluster.local
serviceSubnet: 10.96.0.0/12
podSubnet: 10.244.0.0/16
scheduler: {}
---
kind: KubeletConfiguration
apiVersion: kubelet.config.k8s.io/v1beta1
cgroupDriver: cgroupfs

我们可以在Kubernetes的代码中(以v1.23.3为例)看到初始化Cloud Provider的相关逻辑:

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
func createCloudProvider(cloudProvider string, externalCloudVolumePlugin string, cloudConfigFile string,
allowUntaggedCloud bool, sharedInformers informers.SharedInformerFactory) (cloudprovider.Interface, ControllerLoopMode, error) {
var cloud cloudprovider.Interface
var loopMode ControllerLoopMode
var err error

if utilfeature.DefaultFeatureGate.Enabled(features.DisableCloudProviders) && cloudprovider.IsDeprecatedInternal(cloudProvider) {
cloudprovider.DisableWarningForProvider(cloudProvider)
return nil, ExternalLoops, fmt.Errorf(
"cloud provider %q was specified, but built-in cloud providers are disabled. Please set --cloud-provider=external and migrate to an external cloud provider",
cloudProvider)
}
// 判断是否是external参数
if cloudprovider.IsExternal(cloudProvider) {
loopMode = ExternalLoops
if externalCloudVolumePlugin == "" {
// externalCloudVolumePlugin is temporary until we split all cloud providers out.
// So we just tell the caller that we need to run ExternalLoops without any cloud provider.
return nil, loopMode, nil
}
cloud, err = cloudprovider.InitCloudProvider(externalCloudVolumePlugin, cloudConfigFile)
} else {
// 输出弃用信息
cloudprovider.DeprecationWarningForProvider(cloudProvider)

loopMode = IncludeCloudLoops
// 初始化相应的云厂商provider
cloud, err = cloudprovider.InitCloudProvider(cloudProvider, cloudConfigFile)
}
if err != nil {
return nil, loopMode, fmt.Errorf("cloud provider could not be initialized: %v", err)
}

if cloud != nil && !cloud.HasClusterID() {
if allowUntaggedCloud {
klog.Warning("detected a cluster without a ClusterID. A ClusterID will be required in the future. Please tag your cluster to avoid any future issues")
} else {
return nil, loopMode, fmt.Errorf("no ClusterID Found. A ClusterID is required for the cloud provider to function properly. This check can be bypassed by setting the allow-untagged-cloud option")
}
}
// 设置Informoer
if informerUserCloud, ok := cloud.(cloudprovider.InformerUser); ok {
informerUserCloud.SetInformers(sharedInformers)
}
return cloud, loopMode, err
}

创建LoadBalancer

现在我们可以在集群中创建Nginx的Deployment和Service:

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
kind: Service
apiVersion: v1
metadata:
name: hello
spec:
type: LoadBalancer
selector:
app: hello
ports:
- name: http
protocol: TCP
targetPort: 80
# ELB's port
port: 80
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: hello
spec:
replicas: 1
selector:
matchLabels:
app: hello
template:
metadata:
labels:
app: hello
spec:
containers:
- name: hello
image: nginx

你可以在的控制台上看到,一个LoadBalancer资源被自动创建了,且负载均衡的80端口对应的后端实例端口为31977。

我们再来看下刚才创建的LoadBalancer Service:

和NodePort的Service类似,LoadBalancer类型的Service也会有一个NodePort端口暴露,这块的逻辑对于这两种Service是一样的,也就是kube-proxy在每个节点开放了31977这个端口,这里不在赘述。

登录浏览器,你可以使用External-IP访问到Nginx主页面。

以上,我们通过创建LoadBalancer类型的Service来暴露我们集群内部的服务。创建LoadBalancer时Kubernetes主要为我们做了这些事情:

  • 在所有节点上开放一个相同的端口,并做流量的负载均衡(由kube-proxy完成)
  • 自动为我们创建云上LoadBalancer资源,并且完成对应的实例端口映射(由service-controller完成)

源码分析

Cloud Provider中的service-controller的新建代码:

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
73
74
75
76
77
78
// New returns a new service controller to keep cloud provider service resources
// (like load balancers) in sync with the registry.
func New(
cloud cloudprovider.Interface,
kubeClient clientset.Interface,
serviceInformer coreinformers.ServiceInformer,
nodeInformer coreinformers.NodeInformer,
clusterName string,
featureGate featuregate.FeatureGate,
) (*Controller, error) {
broadcaster := record.NewBroadcaster()
broadcaster.StartStructuredLogging(0)
broadcaster.StartRecordingToSink(&v1core.EventSinkImpl{Interface: kubeClient.CoreV1().Events("")})
recorder := broadcaster.NewRecorder(scheme.Scheme, v1.EventSource{Component: "service-controller"})

if kubeClient != nil && kubeClient.CoreV1().RESTClient().GetRateLimiter() != nil {
if err := ratelimiter.RegisterMetricAndTrackRateLimiterUsage(subSystemName, kubeClient.CoreV1().RESTClient().GetRateLimiter()); err != nil {
return nil, err
}
}

registerMetrics()
s := &Controller{
cloud: cloud,
knownHosts: []*v1.Node{},
kubeClient: kubeClient,
clusterName: clusterName,
cache: &serviceCache{serviceMap: make(map[string]*cachedService)},
eventBroadcaster: broadcaster,
eventRecorder: recorder,
nodeLister: nodeInformer.Lister(),
nodeListerSynced: nodeInformer.Informer().HasSynced,
queue: workqueue.NewNamedRateLimitingQueue(workqueue.NewItemExponentialFailureRateLimiter(minRetryDelay, maxRetryDelay), "service"),
// nodeSyncCh has a size 1 buffer. Only one pending sync signal would be cached.
nodeSyncCh: make(chan interface{}, 1),
}

serviceInformer.Informer().AddEventHandlerWithResyncPeriod(
cache.ResourceEventHandlerFuncs{
// 添加事件回调函数
AddFunc: func(cur interface{}) {
svc, ok := cur.(*v1.Service)
// Check cleanup here can provide a remedy when controller failed to handle
// changes before it exiting (e.g. crashing, restart, etc.).
if ok && (wantsLoadBalancer(svc) || needsCleanup(svc)) {
s.enqueueService(cur)
}
},
// 更新事件回调函数
UpdateFunc: func(old, cur interface{}) {
oldSvc, ok1 := old.(*v1.Service)
curSvc, ok2 := cur.(*v1.Service)
if ok1 && ok2 && (s.needsUpdate(oldSvc, curSvc) || needsCleanup(curSvc)) {
s.enqueueService(cur)
}
},
// No need to handle deletion event because the deletion would be handled by
// the update path when the deletion timestamp is added.
},
serviceSyncPeriod,
)
s.serviceLister = serviceInformer.Lister()
s.serviceListerSynced = serviceInformer.Informer().HasSynced

...

if err := s.init(); err != nil {
return nil, err
}

return s, nil
}

// LoadBalance类型
func wantsLoadBalancer(service *v1.Service) bool {
// if LoadBalancerClass is set, the user does not want the default cloud-provider Load Balancer
return service.Spec.Type == v1.ServiceTypeLoadBalancer && service.Spec.LoadBalancerClass == nil
}

可以看到,在Cloud Provider中的serviceInformer只关注了LoadBalancer类型的Service的动作事件。当相对应的事件入队时,控制循环会执行相应的调谐逻辑。

客户端到我们的业务实例请求流程如下:

结语

本文简单介绍了暴露服务的主要几种方式,并着重介绍了Cloud Provider,并通过AWS的Cloud Provider创建了一个LoadBalancer。当然还有以下要注意的点:

  • LoadBalancer是Kubernetes在NodePort类型Service上的功能加成,他们的基础均是ClusterIP
  • 公有云上的容器服务一般都会使用自家的out-of-tree的cloud-controller-manager组件
  • 微服务架构下,每个服务使用都使用一个LoadBalancer反而带来了额外的开销和管理成本,可以搭配Ingress等7层LoadBalancer一起使用

问题描述

现在,在我们的工作环境中,以笔记本为办公平台,在其上完成例如 编码、远程、文档处理 等等工作。对于其他数据资源,则保存在远程文件托管服务(例如 NAS 或 云盘服务 等等)中,通过网络进行远程访问及数据备份。

但是,该方案的最大问题是我们无法保证网络总是可用或高速,在某些特殊工作环境中,或无法访问网络,或网络速度受限,或网络质量不稳定,导致我们无法访问远程文件托管服务中的资源。

所以,我们尝试将经常访问的__数据资源本地化__,即将常用访问的数据直接保存在笔记本电脑上,并在其上运行__备份服务__以将我们的数据备份到远程文件托管服务。鉴于此,即使网络资源成为瓶颈,也不会影响我们对数据资源的访问,而远程文件托管服务则作为 极低频访问资源的存储笔记本数据备份的后端存储 而存在。

但是,随着办公平台的扩大,我们平时不得不以 Linux 系统为主要办公环境,但是有的时候会用到 Windows 系统,尤其是需要运行仅支持 Windows 平台的软件(例如 企业微信、钉钉、微信 等等)。我们尝试更换 Macbook,但并不能解决问题;两个笔记本,携带也不方便;通过 Wine 方案,仅能解决部分软件的运行问题,仍旧存在部分软件无法通过 Wine 来运行。

所以,我们尝试在虚拟机中运行操作系统,将我们的办公环境迁移到虚拟机。我们在宿主机中运行 Linux 操作系统,并在其中部署桌面虚拟化(例如 VirtualBox 等等),并在虚拟机中运行 Windows 操作系统。同时,借助虚拟机的 Guest Additions 组件,实现宿主机与虚拟机之间互操作(例如 文件共享、复制粘贴 等等)

但是,很多时候两个操作系统(Windows/Linux)都要使用相同的服务。例如:或为了提供网络质量,两个操作系统都需要使用网络加速服务;或为了访问办公网络,两个操作系统都要接入企业 VPN 服务;

所以,我们开始思考,既然虚拟机的流量是通过 NAT 进行网络访问,那能不能在虚拟化中运行路由器操作系统,然后所有的虚拟机操作系统将数据包发往路由器操作系统,而路由器操作系统的其他接口负责将数据包发送到外部网络。这样的网络模型就更加贴近于现实环境的终端网络,事情也变得越来越疯狂。

关键词:可移动数据中心、边缘数据中心、便携式数据中心、虚拟数据中心

解决方案

根据我们的想法进行描绘,整个系统原型类似如下:

1
2
3
4
5
6
7
+-------------------------------------------------+
| OpenWrt | Windows VM | Linux VM | ... |
+-------------------------------------------------+
| LINUX + KVM + Storage |
+-------------------------------------------------+
| LAPTOP |
+-------------------------------------------------+

LAPTOP:最底层是笔记本物理硬件本身,所有的环境将运行在其上。这其中既有配电设施,又有冷却设施,运维管理中心则为本机。至于机柜和布线系统,鉴于整个系统的基于多种虚拟化技术实现,所以完全不需要机柜和布线系统。

LINUX + KVM + Storage:向上则是虚拟化环境,运行 Linux 操作系统,并在其中部署 KVM 虚拟化,最后将物理存储加入到 KVM 虚拟化的存储池中,以向上层虚拟机提供存储服务。

OpenWrt:作为虚拟机访问外部网络的网关,所有虚拟机流量将通过 OpenWrt 路由到外部网络。对于 KVM 环境,运行 OpenWrt in QEMU 路由,网络加速也在 OpenWrt 中实现,通过全局流量检查来同时实现Linux 和 Windows 的网络加速,如此便无需在两个系统中安装客户端。

Windows VM、Linux VM:作为实际我们办公的操作系统,且所有虚拟机网卡与 OpenWrt 网卡位于相同的二层网络,所有的虚拟机流量将发送到 OpenWrt,并由 OpenWrt 的另张网卡发送到外部网络。

虚拟机的数据共享:鉴于多个虚拟机间的很多数据、文件、程序是需要共享的,我们通过用 NFS 或 CIFS 来实现。但是存储还是落在主机上,所以需要使用 KVM 隔离网络来允许虚拟机访问宿主机,这导致但个虚拟机至少有两块网卡。

虚拟机的桌面访问:宿主机具备图形化桌面,我们使用 virt-manager 对 KVM 虚拟机进行管理及桌面访问,对于虚拟机间的复制粘贴,通过 SPICE Agent 能够解决。

如此,我们便能将数据中心装在包里,随身携带。硬件配置要求,取决于个人工作负载,我们平时运行 Linux 及相关的应用就要 16G 的内存,主要是应用程序开的多。

首个实现

我们已实现该技术方案的首个版本,但是多少有些出入(但差别并不大):

共 4 层(从上到下,L4、L3、L2、L1)

虚拟化选用 VirtalBox 的原因是:
1)鉴于是移动数据中心,所以涉及桌面环境事件响应(例如 休眠处理 等等),而桌面虚拟化软件处理的更好;
2)VritualBox 免费;

鉴于虚拟化(L2)及物理层(L1)暂无特殊配置,所以不再详细说明。该部分的后续内容将概述网络层(L3)与客户机层(L4)的实现,及相关问题处理。

第一步、创建 OpenWrt 实例

在 VirtualBox 中,部署 OpenWrt 服务:参考 [OpenWrt Wiki] OpenWrt on VirtualBox HowTo 文档,获取官方配置说明。

我们需要三张不同网络网络类型的网卡(尽量依序创建):
1)NAT:负责网络访问,否则 OpoenWrt 将无法上网;(相当于 WAN 接口)
2)Internal Network:实现 OpenWrt、Linux 的二层互联;(相当于 LAN 接口)
3)Host-only Adapter:能与主机通信,用于从主机连接和管理 OpenWrt 服务;

第二步、配置 OpenWrt 服务

我们需要配置 OpenWrt 服务,使其成为路由设备:
[OpenWrt Wiki] OpenWrt as router device

如果遇到问题,或许需要使用 TCPDump 抓包:
[OpenWrt Wiki] How to capture, filter and inspect packets using tcpdump or wireshark tools

第三步、配置 Linux Guest 实例

创建 Linux Guest 主机,并添加 Internal Network 类型网络,使其与 OpenWrt 二层互联;

然后,启动 Linux Guest 实例;

如果配置正确,Linux Guest 能够通过 OpenWrt 的 DHCP 获取 IP 地址;

后续的改进工作

首个版本已能够运行,但是还有很多改进工作:

1)继续使用 VirtualBox 桌面虚拟化:KVM 非桌面虚拟化,对桌面场景处理不是很好。比如 当 KVM 休眠恢复后,时间跳跃导致 Guest CPU Usage 飙升;

2)替换 Host 系统为 Linux 发行版:Window 10 + VirtualBox 的问题是,网卡插拔后(间隔久一点),Guest Bridage Network 无法就再也无法访问网络;Linux + VirtualBox 经过测试暂无问题。桥接网络是为了 OpenWrt 与 另个数据中心运行动态路由协议,如果用 NAT Network 就无法运行动态路由协议。另外,笔记本的有线网卡/无线网卡可以进行 主/备/负载。

3)引入存储(待定):该改进的目的是使得整个模型更加贴近于终端环境,并通过 FCoE 或 iSCSI 的方式提供块存储。但并无必要性,虚拟机的磁盘扩容也是在文件系统上扩容的,没必要引入独立的存储服务。后来(04/27/2022)我们觉得还是要引入存储服务,为多个 Guest 提供共享的网络文件系统存储,实现文件共享,同时使得整个模型更加贴近于数据中心。

改进工作的推进

我们确实对整个模型进行改进

1)替换 Host 系统为 Linux 发行版:Ubuntu 20.04 LTS + VirtualBox 6.1.32

2)引入存储:使用 OpenMediaVault 作为存储,目前主要是将其作为网络文件系统来使用:(1)在多个 Guest 间通过网络分享文件和数据;(2)将数据从 Linux Guest 脱离出来,数据备份工作转移到存储服务层面;

3)替换 OpenWrt 组件:我们引入 pfSense 防火墙,以利用其中的 FRR 模块与 L2TP 模块;

低廉的分布式数据中心

为了能从办公室访问家里的网络(我们没有出口路由的控制权),而且是任意访问(我们希望直接访问 80 端口,但是受限于网络环境,80 端口默认被运营商屏蔽),所以我们通过部署 L2TP VPN 解决。通过十分服务器部署 LNS 服务,家里的路由器和笔记都作为 LAC 拨号到 LNS,形成二层网络,我们便能通过内网地址直接访问家里的服务。但缺点也很明显,双端需要配置静态路由,否则网络无法互通。虽然需要静态路由,但也不影响使用。而在思考改进该缺点的时候,我们得到创建分布式的低廉数据中心的灵感。

我们的目标很简单:利用当前公网架构,将分散在各地理位置的资源集结到一起,形成大型逻辑网络。于是便产生如下拓扑(相关的技术细节不再赘述,该图已披露出关键的技术要点)

问题描述

我们使用 Certbot 工具向 Let’s Encrypt 免费申请并自动续期证书。

在 Kubernetes Cluster 中,我们使用 cert-manager 组件来实现。

该笔记将记录:在 Kubernetes Cluster 1.22 中,部署 cert-manager 1.7 组件,以及相关问题解决办法。

解决方案

环境信息

集群版本:Kubernetes Cluster v1.22.3-aliyun.1
组件版本:cert-manager v1.7(Supported Kubernetes versions: 1.18-1.23)

补充说明

1)作为系列部署资源,cert-manager 运行在 Kubernetes Cluster 中,并利用 CRD 来配置 CA 并请求证书;
2)部署方式:我们使用官方文档中推荐的 cmctl 命令,不再使用原始的 YAML 清单文件;
3)在部署 cert-manager 组件之后,需要创建代表 CA 的 Issuer 或 ClusterIssuer 资源;
4)在集群中部署多个 cert-manager 实例会出现意外行为(以前 v1.3 文档提到过,该版本不清楚是否存在该限制);

准备工作

安装命令:

1
2
3
OS=$(go env GOOS); ARCH=$(go env GOARCH); curl -sSL -o cmctl.tar.gz https://github.com/cert-manager/cert-manager/releases/download/v1.7.2/cmctl-$OS-$ARCH.tar.gz
tar xzf cmctl.tar.gz
sudo mv cmctl /usr/local/bin

第一步、部署组件

1
cmctl x install --dry-run

第二步、验证安装

1
2
3
4
5
6
7
8
# cmctl check api --wait=2m
The cert-manager API is ready

# kubectl get pods --namespace cert-manager
NAME READY STATUS RESTARTS AGE
cert-manager-75cf8df6b6-t2xns 1/1 Running 0 2m37s
cert-manager-cainjector-857f5bd88c-5xzd7 1/1 Running 0 2m37s
cert-manager-webhook-5cd99556d6-s6jf5 1/1 Running 0 2m37s

第三步、签发测试

验证方法:通过创建自签名证书,并检查证书是否能够自动签发(参考 Verifying the Installation 文档,以获取具体细节)

我们有使用手工方式来验证(我们通过文档中提到的社区工具来验证,但是失败):

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
# cat > test-resources.yaml  <<EOF
apiVersion: v1
kind: Namespace
metadata:
name: cert-manager-test
---
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: test-selfsigned
namespace: cert-manager-test
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: selfsigned-cert
namespace: cert-manager-test
spec:
dnsNames:
- example.com
secretName: selfsigned-cert-tls
issuerRef:
name: test-selfsigned
EOF

# kubectl apply -f test-resources.yaml
...
Status:
Conditions:
Last Transition Time: 2022-04-01T09:51:50Z
Message: Certificate is up to date and has not expired
Observed Generation: 1
Reason: Ready
Status: True
Type: Ready
Not After: 2022-06-30T09:51:50Z
Not Before: 2022-04-01T09:51:50Z
Renewal Time: 2022-05-31T09:51:50Z
Revision: 1
...

# kubectl delete -f test-resources.yaml
namespace "cert-manager-test" deleted
issuer.cert-manager.io "test-selfsigned" deleted
certificate.cert-manager.io "selfsigned-cert" deleted

至此,已完成 cert-manager 部署,接下来便是使用 cert-manager 来申请 Let’s Encrypt 证书(结合我们的需求)。

通过 cert-manager 申请 Let’s Encrypt 证书

问题描述

前面步骤演示如何部署 cert-manager 组件,并成功申请自签名证书,但这并非我们的实际应用场景。

我们希望通过 cert-manager 组件,在集群内完成 Let’s Encrypt 证书申请和管理:
1)我们使用 阿里云 DNS,并通过 DNS01 完成域名所有权认证(部分集群在内网,无法使用 HTTP01 认证);

解决方案

需要阅读如下文档,以了解相关内容:
cert-manager/Configuration/ACME
—- cert-manager/Configuration/ACME/DNS01
——– cert-manager/Configuration/ACME/DNS01/Webhook

我们这里使用 DEVmachine-fr/cert-manager-alidns-webhook 来完成证书申请。

安装 Webhook 部分:

1
2
3
4
5
6
7
8
9
# 网络原因,所以我们提前下载 helm chart 文件
wget https://github.com/DEVmachine-fr/cert-manager-alidns-webhook/releases/download/alidns-webhook-0.6.1/alidns-webhook-0.6.1.tgz

# 查看相关变量
helm show values ./alidns-webhook-0.6.1.tgz

# 大多数变量不需要修改,除了 groupName 参数
helm -n cert-manager install alidns-webhook ./alidns-webhook-0.6.1.tgz \
--set groupName=your-company.example.com

创建 ClusterIssuer 资源:

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
# 完成 DNS 质询需要访问阿里云接口来修改 DNS 记录,所以需要使用 KEY 与 TOKEN 来认证
kubectl create secret generic \
alidns-secrets \
--from-literal="access-token=your-token" \
--from-literal="secret-key=your-secret-key"

# 创建 ClusterIssuer 资源
kubectl apply -f <<EOF
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt
spec:
acme:
# 修改,邮箱地址
email: contact@example.com
# 修改,生产地址:https://acme-v02.api.letsencrypt.org/directory
server: https://acme-staging-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt
solvers:
- dns01:
webhook:
# 修改,应用我们刚才创建的 Secret 资源
config:
accessTokenSecretRef:
key: access-token
name: alidns-secrets
regionId: cn-beijing
secretKeySecretRef:
key: secret-key
name: alidns-secrets
# 修改,需要填写在安装时指定的 groupName 信息
groupName: example.com
solverName: alidns-solver
EOF

在 Ingress 中,使用 HTTPS 证书:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# https://cert-manager.io/docs/usage/ingress/
kubectl apply -f <<EOF
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
# 修改,暗示要使用的 issuer 资源,由管理员提供
cert-manager.io/cluster-issuer: nameOfClusterIssuer
name: myIngress
namespace: myIngress
spec:
rules:
...
tls:
- hosts:
# 修改,需要签发证书的域名
- example.com
# 修改,保存证书的 Secret 资源(cert-manger 负责创建)
secretName: myingress-cert
EOF

参考文献

GitHub - DEVmachine-fr/cert-manager-alidns-webhook
Installation | cert-manager
cmctl | cert-manager
Securing Ingress Resources | cert-manager

问题描述

在进行某些验证性操作时,我们需要创建测试数据,并在验证操作的过程中修改数据。但是如果验证操作失败,那么我们又需要重新创建测试数据。为了避免重新创建数据,我们常见的做法是备份测试数据,以在验证失败时能够从备份数据中快速进行恢复。

还有种场景是服务升级的时候:为了能够在升级失败时回滚,需要对服务数据进行备份,否则数据被破坏之后,服务回滚后也无法运行。但是由于服务数据较多,导致备份周期长,服务停机时间长。而且在升级过程中,并非所有的数据都需要备份,因为并非所有的数据都会被破坏。

该笔记将记录:在 LVM 中,使用 Snapshot 快照的方法(对数据进行快速的备份与恢复),以及常见问题的解决办法。

解决方案

当创建快照后,如果不小心删除任何文件,也不必担心,因为快照具有我们已删除的原始文件。

注意事项:
1)快照不能用于持久的备份策略 —— 备份是某些数据文件的主副本,而快照是块级别,所以不能使用快照作为备份选项;
2)不要更改快照卷,保持原样,而快照用于快速恢复。

环境概述

1
2
3
4
5
6
7
8
pvcreate /dev/sdb                                                               # 10G
vgcreate vgdt /dev/sdb
lvcreate -n source --size 3G vgdt

mkfs.ext4 /dev/vgdt/source
mount /dev/vgdt/source /mnt/
echo 123456 > /mnt/foo.txt
md5sum /mnt/foo.txt # f447b20a7fcbf53a5d5be013ea0b15af

第一步、创建快照

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# lvcreate --size  1G --snapshot --name backup4source /dev/vgdt/source
Logical volume "backup4source" created.

# lvextend --size +1G /dev/vgdt/backup4source # 再额外增加 1G 空间
Size of logical volume vgdt/backup4source changed from 1.00 GiB (256 extents) to 2.00 GiB (512 extents).
Logical volume vgdt/backup4source successfully resized.

# lvs /dev/vgdt/backup4source # 查看快照信息
LV VG Attr LSize Pool Origin Data% Meta% Move Log Cpy%Sync Convert
backup4source vgdt swi-a-s--- 2.00g source 0.01

# lvdisplay /dev/vgdt/backup4source
--- Logical volume ---
LV Path /dev/vgdt/backup4source
...
LV snapshot status active destination for source # 该快照所属的 LV
...

第二步、数据修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# lvs
LV VG Attr LSize Pool Origin Data% Meta% Move Log Cpy%Sync Convert
ubuntu-lv ubuntu-vg -wi-ao---- <9.00g
backup4source vgdt swi-a-s--- 2.00g source 0.01
source vgdt owi-aos--- 3.00g

# dd if=/dev/zero of=/mnt/foo.txt bs=1M count=1024 conv=fdatasync
1024+0 records in
1024+0 records out
1073741824 bytes (1.1 GB, 1.0 GiB) copied, 9.26228 s, 116 MB/s

# lvs
LV VG Attr LSize Pool Origin Data% Meta% Move Log Cpy%Sync Convert
ubuntu-lv ubuntu-vg -wi-ao---- <9.00g
backup4source vgdt swi-a-s--- 2.00g source 50.21 # 原始数据被写入快照分区,以占用 50.21%
source vgdt owi-aos--- 3.00g

关于 Snapshot 大小:
1)如果数据变更的总量超过 Snapshot 大小,则会产生 Input/output error 错误,进而导致 Snapshot 不可用(解释扩容也无法恢复);
2)如果要避免该问题,可以创建相同大小的 Snapshot,或者自动扩容 Snapshot 分区(这里不再展开详细说明);

第三步、恢复快照

1
2
3
4
5
6
7
8
9
10
11
12
# umount /mnt

# lvconvert --merge /dev/vgdt/backup4source
Merging of volume vgdt/backup4source started.
vgdt/source: Merged: 50.29%
...
vgdt/source: Merged: 100.00%

# mount /dev/vgdt/source /mnt/

# md5sum /mnt/foo.txt
f447b20a7fcbf53a5d5be013ea0b15af /mnt/foo.txt

补充说明:
1)当 merge 后,Snapshot 会被自动删除;

常用操作

删除快照

如果没有必要保留快照,则可以删除:

1
# lvremove /dev/vgdt/backup4source

自动扩容

该特性是为了让 Snapshot 自动扩容,而不需要分配足够的空间,且当空间不足时不需要人工介入:

1
2
3
4
5
# vim /etc/lvm/lvm.conf
...
snapshot_autoextend_threshold = 70 # 当用量超过 70% 时,
snapshot_autoextend_percent = 20 # 自动扩容 20%
...

参考文献

How to Take ‘Snapshot of Logical Volume and Restore’ in LVM - Part III
How-to guide: LVM snapshot - Kernel Talks

0%