不想当数据库的 API Server,不是好平台:Kubernetes CRD 的另类用法

2014 年,Kubernetes(简称 K8s)作为 Google 内部 Borg 编排系统的开源版本推出,到目前已是最成功和发展最快的 IT 基础架构项目之一。K8s 的成功,在于它架构的简单、功能的强大和使用的便利。但还有一个非常重要的因素,也是本文重点要介绍的,就是 K8s 灵活的扩展能力。
K8s 就像我们日常使用的语言一样,虽然自带了一些原生词汇(例如 Pod、Service、ConfigMap 等),但也具备很强的造词能力,也就是所谓的 CRD(Custom Resource Definition)。CRD 在用法上和 Pod 这样的原生资源基本相同,都支持通过 kubectl 命令行或者 K8s API 进行访问和操作。正是 CRD 的灵活和强大,促成了 K8s 周边生态的蓬勃发展。
CRD 经常和对应的 Operator 配合出现,来完成一些复杂操作的封装。比如 Prometheus Operator会处理 Prometheus CRD,来部署和运维对应的 Prometheus 实例。但是今天,我打算介绍一个不太常见的 CRD 用法,即把 CRD 当成一个 DB 来使用。

举个例子:2018 世界杯比赛数据后台

为了言之有物,这里先举一个足球迷的例子。我打算做一个简单的后台,用来对外提供 2018 世界杯的比赛数据。简单起见,它仅包含一个 API,即获取所有比赛场次的信息和结果。

常规的基于数据库的实现方式

常规的实现方式,就是使用一个 MySQL 或者 MongoDB 作为后台的数据库。我的后台程序在接收到用户请求后,向数据库查询数据,然后返回结果为用户。在这个实现方式下,我需要自行编写 RESTful 的接口以及数据库的存取过程。虽然谈不上复杂,却也是不少的代码工作。

基于 CRD 的实现方式

基于 CRD 的实现方式,就是把 kube-apiserver 当作资源对象的存储来用,类似数据库的作用。对于基于数据库的实现方式,有几个好处。
首先是 kube-apiserver 的分布式方案足够简单易懂。kube-apiserver 的底层存储是 etcd,一个基于 Raft 协议的强一致性分布式 Key-value 存储。etcd 的分布式配置非常简单,而 Raft 协议也以易读易懂而出名。
其次是大部分代码可以自动生成。K8s 的生态确实非常强大。通过像 Kubebuilder 这样的工具和框架,可以只写非常少的代码就完成 API 后台的开发。
最后是能通过 kubectl 和 YAML 来直接操作资源对象。虽然 MySQL 以及 MongoDB 也有不少优秀的后台工具,但总是有学习门槛。而 kubectl 和 YAML 作为 K8s 的标配,可以认为是已有技能的复用。
好了,talk is cheap,下面来实际感受下这种另类的玩法。

准备工作:安装 Kubebuilder

Kubebuilder 是用来协助编写 K8s CRD 和 Operator 的代码生成工具和框架。在它的帮助下,我可以只写几行基本的资源定义,由它来完成大部分的代码工作。

安装 Kubebuilder:

  1. curl -LO https://github.com/kubernetes-sigs/kubebuilder/releases/download/v2.0.0-beta.0/kubebuilder_2.0.0-beta.0_linux_amd64.tar.gztar xvf kubebuilder_2.0.0-beta.0_linux_amd64.tar.gzsudo mv kubebuilder_2.0.0-beta.0_linux_amd64 /usr/local/kubebuildercat <<EOF >> ~/.bashrcexport PATH=$PATH:/usr/local/kubebuilder/binEOF

请根据需要到 Kubebuilder releases 检查是否有新版本。

Kubebuilder 需要用到 kustomize,也把它安装上:

  1. curl -LO https://github.com/kubernetes-sigs/kustomize/releases/download/v3.1.0/kustomize_3.1.0_linux_amd64chmod +x kustomize_3.1.0_linux_amd64sudo mv kustomize_3.1.0_linux_amd64 /usr/local/bin/kustomize

请根据需要到 kustomize releases 检查是否有新版本。

初始化项目

因为要用到 go mod,所以到一个 GOPATH 外的目录,创建项目:

  1. mkdir fifa2018 && cd fifa2018go mod init github.com/fengye87/fifa2018

请根据需要替换 github.com/fengye87/fifa2018 为合适的项目路径。

用 Kubebuilder 生成初始项目代码:

  1. kubebuilder init --domain fengye87.me

请根据需要替换 fengye87.me 为合适的值。

先跑起来看看

Kubebuilder 生成的初始项目代码是可以直接跑起来的,前提是你已经有一个 K8s。它会尝试用你环境中默认的 K8s 客户端配置(例如 ~/.kube/config)去连接 K8s。如果不存在这样的配置,或者连接不上 K8s,那么它就无法正常运行。
可以尝试运行 kubectl api-resources 来初步判断下是否有一个默认的可用的 K8s 环境,否则可以使用 kubeadm 来新部署一个 K8s。如果 K8s 正常可用,那么尝试 make run 把项目跑起来:
  1. [root@localhost fifa2018]# make run/root/go/bin/controller-gen object:headerFile=./hack/boilerplate.go.txt paths=./api/...go fmt ./...go vet ./...go run ./main.go2019-08-13T17:48:31.438+0800    INFO    controller-runtime.metrics      metrics server is starting to listen    {"addr": ":8080"}2019-08-13T17:48:31.439+0800    INFO    setup   starting manager2019-08-13T17:48:31.440+0800    INFO    controller-runtime.manager      starting metrics server {"path": "/metrics"}

到这里,我们的项目已经连上了 K8s,但目前基本上还是个空壳子,处于无所事事的状态。接下来,我们来给它填充点内容。

创建 Match CRD

通过 Kubebuilder 创建 Match CRD(仅创建 Resource 即可,不用创建 controller):

  1. kubebuilder create api --group demo --version v1 --namespaced false --kind Match

请根据需要替换 demo 为合适的值。

在 api/v1/match_types.go 中,我们能找到 Match 资源的具体定义:

  1. // api/v1/match_types.gotype Match struct {    metav1.TypeMeta   `json:",inline"`    metav1.ObjectMeta `json:"metadata,omitempty"`
    
        Spec   MatchSpec   `json:"spec,omitempty"`    Status MatchStatus `json:"status,omitempty"`}

可以看到,它和 K8s 的原生资源像是一个模子刻出来的,所以理解它应该不费什么脑筋。我们要修改的是其中的 MatchSpec 定义:

  1. // api/v1/match_types.gotype MatchSpec struct {    Datetime      string `json:"datetime"`    HomeTeam      string `json:"homeTeam"`    HomeTeamGoals int    `json:"homeTeamGoals"`    AwayTeam      string `json:"awayTeam"`    AwayTeamGoals int    `json:"awayTeamGoals"`}

这就定义了 Match 资源的一些属性。理论上你也可以把这些属性直接定义在 Match 类型上,不过我个人更倾向把它们放到 MatchSpec 类型中。

定义完 CRD 后,需要把 CRD 安装到 K8s 上。直接 make install,没有报错就表示安装成功了,也可以进一步通过 kubectl api-resources|grep matches 的输出来确认下。

无保护地暴露 K8s API

到这里,我们已经能像 Pod、Service 等原生 K8s 资源一样来对待 Match 资源了。我们不仅能通过 kubectl 从命令行操作 Match 资源,还可以通过 K8s 的 RESTful API 进行访问和操作。

但是,稍等一下。通常来说,K8s 的 API 是受到保护的:在缺乏身份认证信息的情况下,我们几乎无法调用什么 API。不妨试下直接访问你的 K8s 根 API(例如 https://127.0.0.1:6443),通常是一个 403 返回。

K8s API 的认证方式有很多种,这里为了简单起见,我决定使用认证代理的模式来简单粗暴的把 K8s API 无保护的暴露出来。认证代理模式实际上是在 K8s API 的前面立一个反向代理,通过该反向代理而来的请求都会被 K8s 认为是已经经过认证了。而 K8s 为了确保请求的来源确实是这个反向代理,需要反向代理在调用 K8s API 的时候出示一个经过 K8s 根证书签名的客户端证书。如果是通过 kubeadm 安装的 K8s,那么证书和私钥分别在 /etc/kubernetes/pki/front-proxy-client.crt 和 /etc/kubernetes/pki/front-proxy-client.key,把它们拷贝到项目的根目录下。此外,虽然不是必须,但建议把根证书 /etc/kubernetes/pki/ca.crt 也拷贝过来。

利用标准库中的 httputil.ReverseProxy 实现一个 K8s 的认证代理(需要在 main 函数中用 goroutine 运行这个函数):

  1. // main.gofunc serveProxy() error {    u, err := url.Parse("https://127.0.0.1:6443")    if err != nil {        return err    }
    
        rp := httputil.NewSingleHostReverseProxy(u)
    
        origDirector := rp.Director    rp.Director = func(req *http.Request) {        origDirector(req)        req.Header.Set("X-Remote-User", "kubernetes-admin")        req.Header.Set("X-Remote-Group", "system:masters")    }
    
        clientCert, err := tls.LoadX509KeyPair("front-proxy-client.crt",        "front-proxy-client.key")    if err != nil {        return err    }
    
        caCert, err := ioutil.ReadFile("ca.crt")    if err != nil {        return err    }    caCertPool := x509.NewCertPool()    caCertPool.AppendCertsFromPEM(caCert)
    
        rp.Transport = &http.Transport{        TLSClientConfig: &tls.Config{            Certificates: []tls.Certificate{clientCert},            RootCAs:      caCertPool,        },    }
    
        return http.ListenAndServe(":3000", rp)}

到这里,我们再次 make run 运行它,用浏览器打开 http://127.0.0.1:3000/apis/demo.fengye87.me/v1/matches,可以看到正常返回了一个空的 MatchList,这是期望中的,因为我们还没有创建过任何 Match 对象。

用 kubectl 创建 Match 对象

目前,Match 资源的 API 已经可以正常工作了,但是还没有数据。因为 Match 资源是 K8s 的 CRD,所以可以非常方便的通过 kubectl 在后台添加 Match 对象:

  1. # config/samples/demo_v1_match.yamlapiVersion: demo.fengye87.me/v1kind: Matchmetadata:  name: group-a-1spec:  datetime: '2018-06-14T18:00:00Z03:00'  homeTeam: Russia  homeTeamGoals: 5  awayTeam: Saudi Arabia  awayTeamGoals: 0

执行 kubectl apply-f config/samples/demo_v1_match.yaml 就可以把上面的比赛数据保存到 K8s 的 API server 中。此时,再通过浏览器访问 http://127.0.0.1:3000/apis/demo.fengye87.me/v1/matches,将得到包含一个 Match 对象的 MatchList:

  1. {   "apiVersion":"demo.fengye87.me/v1",   "items":[      {         "apiVersion":"demo.fengye87.me/v1",         "kind":"Match",         "metadata":{            "name":"group-a-1",            "namespace":"default",            ...         },         "spec":{            "awayTeamGoals":0,            "awayTeam":"Saudi Arabia",            "datetime":"2018-06-14T18:00:00Z03:00",            "homeTeamGoals":5,            "homeTeam":"Russia",         }      }   ],   "kind":"MatchList",   "metadata":{      "continue":"",      "resourceVersion":"1111955",      "selfLink":"/apis/demo.fengye87.me/v1/matches"   }}

解决索引问题

OK,到目前为止,我没有写任何具体的 API 接口,也没有写任何具体的数据存取过程,仅仅是定义了一下 MatchSpec 中的各个字段,以及一个 40 行代码左右的 K8s 认证代理。但是,我已经得到了一个 Match 资源的 RESTful API,并且能将 Match 对象持久化保存起来,甚至还有一个后台的命令行工具(kubectl)。听起来很不错不是么?

但是有个问题,如果我想检索出东道主俄罗斯的所有比赛怎么办?在通常的数据库方案中,这个简直是小菜一碟,加一个索引就是了。但是在 K8s 的 CRD 中,需要一些操作。答案就是标签。

在 K8s 的原生资源中,我们通常会在 Pod 上打上一些标签,然后在 Service 上通过 selector 来筛选出服务所对应的 Pod。可见标签是一个比较合适的用来做筛选的方式。为 Match 对象加上合适的标签,同时加入更多的几场比赛数据以做测试:

  1. # config/samples/demo_v1_match.yamlapiVersion: demo.fengye87.me/v1kind: Matchmetadata:  name: group-a-1  labels:    homeTeamCode: RUS    awayTeamCode: SAUspec:  datetime: '2018-06-14T18:00:00Z03:00'  homeTeam: Russia  homeTeamGoals: 5  awayTeam: Saudi Arabia  awayTeamGoals: 0---apiVersion: demo.fengye87.me/v1kind: Matchmetadata:  name: group-a-2  labels:    homeTeamCode: EGY    awayTeamCode: URYspec:  datetime: '2018-06-15T17:00:00Z05:00'  homeTeam: Egypt  homeTeamGoals: 0  awayTeam: Uruguay  awayTeamGoals: 1---apiVersion: demo.fengye87.me/v1kind: Matchmetadata:  name: group-a-3  labels:    homeTeamCode: RUS    awayTeamCode: EGYspec:  datetime: '2018-06-19T21:00:00Z03:00'  homeTeam: Russia  homeTeamGoals: 3  awayTeam: Egypt  awayTeamGoals: 1---apiVersion: demo.fengye87.me/v1kind: Matchmetadata:  name: group-a-4  labels:    homeTeamCode: URY    awayTeamCode: SAUspec:  datetime: '2018-06-20T18:00:00Z03:00'  homeTeam: Uruguay  homeTeamGoals: 1  awayTeam: Saudi Arabia  awayTeamGoals: 0

现在,在请求中加上 labelSelector 参数: http://127.0.0.1:3000/apis/demo.fengye87.me/v1/matches?labelSelector=homeTeamCode=RUS,返回的就只有俄罗斯参加的两场比赛,而不是全部四场比赛。

不过,使用标签进行筛选有一个限制:无法做到“或”。例如如果这里希望获取乌拉圭参加的所有比赛,那么无法在一个请求中完成。因为乌拉圭的两场比赛中,有一场是主场,另一场是客场。由于标签无法实现“homeTeamCode=URY 或 awayTeamCode=URY”这样的或语句,所以无法一步筛选出来。

结论:Long Live Kubernetes

以上就是把 K8s API server 用作数据库的大体操作了,总的来说,一个字“爽”。想象一下,后台 API 中的一个个资源,在实现的时候竟然只需要定义资源的 spec,剩下的竟全是免费午餐。不过,我们也必须看到这种方式的局限性。当涉及一些超越 CRUD 的复杂逻辑时,我们还是必须去实际编写那些业务逻辑代码。但不管怎么说,我们至少看到了 K8s 平台强大的扩展性,所以,Long Live Kubernetes!

作者:SmartX 微服务平台团队。SmartX 是国内超融合基础架构领域的技术领导者,其微服务平台团队专注于为内部微服务支撑平台的研发,持续不断地为微服务应用提供强大易用的平台功能,加速技术和产品落地。

K8S中文社区微信公众号

评论 抢沙发

登录后评论

立即登录