用 Karmada 和 Pipy 搭建野生多集群

2021-11-02 14:49:45 浏览数 (1)

最近加入 CNCF 成为沙箱项目的 Karmada 项目是一个开放的多 Kubernetes 管理工具,从 Kubernetes Federation 1/2 继承的大量经验,让这个项目有成为多集群管理首选产品的潜力。Karmada 具有开箱即用的跨集群调度能力,简单的使用 PropagationPolicy 就能将 DeploymentService 这样的原生 Kubernetes 对象在多个集群之间进行灵活的调度。

工作负载的分配结束之后,还有个重要的问题就是流量分配了——多集群管理加上流量分配,会有很多有意思的事情可以完成,例如蓝绿灰度金丝雀,集群维护升级等等。实际包括各种网格、API 网关方案在内的很多产品,都有提供跨集群流量管理方案可用。这个例子里我选择了 Pipy 来搭档完成任务,Flomesh 出品的这个东西可塑性很强,除了轻量、快速之外,更重要的是能够用高级语言对流量进行编程,最新版本还为插件模型加入了图形交互界面。

整个原型大致架构如下:

  1. 创建 Karmada 管理集群,并部署 Karmada。
  2. 纳管两个集群,分别命名为 member1member2
  3. 部署 Deployment,并分布到两个集群
  4. 部署 Service,用 NodePort 方式开放服务,分发到集群
  5. 将暴露的服务端口同步给 Pipy,完成流量分发

部署 Karmada 集群

Karmada 的远程部署需要有一个运行的 Kubernetes 集群,然后克隆代码库,运行其中的 hack/remote-up-karmada.sh 脚本即可,命令行 ./remote-up-karmada.sh ~/.kube/config admin@v19 表示在 ~/.kube/config 配置文件中 admin@v19 上下文所指代的集群中部署 Karmada 控制平面。

部署之前,有两个需要注意的点:

如果要在 arm64 平台上运行,可以将脚本中的镜像改为 dustise/****:v0.0009-29-gc2030ca6

如果像我一样偏偏使用 NodePort 方式暴露 Karmada API Server,在 deploy-karmada.sh 的 203 行 installCRDs 语句之前加入一个 read -p "Review your kubeconfig, then press [Enter] key to continue..",脚本运行至此会暂停工作,修改 kubeconfig 文件中新出现的 karmada-apiserver 上下文中的服务器即可,例如:

安装器会在集群中生成 kamada-system 命名空间,在其中运行一个自己的 API Server,用于提供集群管理服务,并拉取认证信息到指定 Kubeconfig

纳管集群

完成部署之后,可以在 Karmada 项目的 Release 页面上下载 karmada 用客户端 karmadactl 来加入集群了,例如:

代码语言:javascript复制
$  kubectl karmada join member2 
--cluster-kubeconfig=./total.yaml 
--cluster-context=admin@karmada2

为了方便使用,上面的命令把 karmada 客户端设置成为了 krew 插件,实际上直接解压使用二进制也是等效的。

和部署控制平面的命令类似,Karmada CLI 也是使用指定 kubeconfig 和上下文的方式,获取集群操作权限,把集群加入 Karmada。

Karmada 加入新集群之后,会在它的管理面 API Server 中注册一个 Cluster 对象,下面是加入了两个集群之后的样子:

代码语言:javascript复制
$ kubectl get clusters
NAME      VERSION    MODE   READY   AGE
member1   v1.19.15   Push   True    13h
member2   v1.19.15   Push   True    13h

操作成员集群

既然是对象,就可以看看他葫芦里卖的是什么 YAML 了:

代码语言:javascript复制
apiVersion: cluster.karmada.io/v1alpha1
kind: Cluster
metadata:
  creationTimestamp: "2021-10-14T11:51:54Z"
  finalizers:
  - karmada.io/cluster-controller
  generation: 1
  name: member1
...
spec:
  apiEndpoint: https://10.211.55.61:6443
  secretRef:
    name: member1
    namespace: karmada-cluster
  syncMode: Push
status:
  apiEnablements:
  - groupVersion: v1
    resources:
    - kind: Binding
      name: bindings
    - kind: ComponentStatus
      name: componentstatuses
    - kind: ConfigMap
      name: configmaps
    - kind: Endpoints
      name: endpoints
    ...
  conditions:
  - lastTransitionTime: "2021-10-14T13:52:31Z"
    message: cluster is reachable and health endpoint responded with ok
    reason: ClusterReady
    status: "True"
    type: Ready
  kubernetesVersion: v1.19.15
  nodeSummary:
    readyNum: 1
    totalNum: 1
  resourceSummary:
    allocatable:
      cpu: "2"
      ephemeral-storage: "59200992363"
      hugepages-1Gi: "0"
      hugepages-2Mi: "0"
      hugepages-32Mi: "0"
      hugepages-64Ki: "0"
      memory: 1927288Ki
      pods: "110"
    allocated:
      cpu: "1"
      ephemeral-storage: "0"
      memory: 140Mi
      pods: "10"
    allocating:
      cpu: "0"
      ephemeral-storage: "0"
      memory: "0"
      pods: "0"

会发现这里对集群的描述很像节点,状态字段中包含了:

  1. 资源情况
  2. 可接受的对象类型
  3. 运行状况

spec字段中的内容则包含了集群的访问端点和一个对 Secret 对象的引用,查看一下其中的内容:

代码语言:javascript复制
$  kubectl view-secret -n karmada-cluster member1
Multiple sub keys found. Specify another argument, one of:
-> caBundle
-> token
$ kubectl view-secret -n karmada-cluster member1 caBundle
-----BEGIN CERTIFICATE-----
MIIC5zCCAc gAwIBAgIBADANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDEwprdWJl
...
-----END CERTIFICATE-----
$ kubectl view-secret -n karmada-cluster member1 token
eyJhbGciOiJSUzI1NiIsImtpZCI6ImRqZTY2OGVua0ltSHA2UGJ3LUZHQ0V
...

看起来这是个访问 Kubernetes 使用的凭据,我们可以导出 caBundle 测试一下:

代码语言:javascript复制
$ kubectl --certificate-authority=member1/caBundle.pem 
  --server=https://10.211.55.61:6443 --token=eyJhb...
  get nodes
NAME       STATUS   ROLES    AGE   VERSION
karmada1   Ready    master   37h   v1.19.15

果然返回了集群信息。

Karmada v0.9.0 中的 Cluster 对象属于 cluster.karmada.io/v1alpha1,因此可以用如下代码获取集群信息:

代码语言:javascript复制
crd = client.CustomObjectsApi(karmada_config)
member_list = crd.list_cluster_custom_object(group="cluster.karmada.io",
                                             version="v1alpha1", plural="clusters")

而根据前面的尝试,用这个结果连接集群也是很方便的:

代码语言:javascript复制
secret_name = member_obj["spec"]["secretRef"]["name"]
secret_ns = member_obj["spec"]["secretRef"]["namespace"]
secret_client = client.CoreV1Api(karmada_config)
secret_obj = secret_client.read_namespaced_secret(secret_name, secret_ns)
server_token = secret_obj.data["token"]
server = member_obj["spec"]["apiEndpoint"]
cfg = config.kube_config.Configuration()
cfg.host = server
cfg.api_key = {'authorization': 'Bearer '   base64.b64decode(server_token.encode("ascii")).decode("ascii")}
cfg.verify_ssl = Falseapi_client = client.api_client.ApiClient(cfg)

部署应用

创建一个 Deployment:

代码语言:javascript复制
$ kubectl create deploy flask --image=dustise/flaskapp:v0.2.7
...
deployment.apps/flask scaled
$ kubectl get deploy
NAME    READY   UP-TO-DATE   AVAILABLE   AGE
flask   0/4     0            0           13h

这里我们创建一个 Deployment,并设置为四副本运行,在控制面看来,这个 Deployment 无法运行,也没有生成 Replicaset,接下来我们为它设置一个传播策略:

代码语言:javascript复制
apiVersion: policy.karmada.io/v1alpha1
kind: PropagationPolicy
metadata:
  name: flask
spec:
  resourceSelectors:
    - apiVersion: apps/v1
      kind: Deployment
      name: flask
  placement:
    clusterAffinity:
      clusterNames:
        - member1
        - member2
    replicaScheduling:
      replicaDivisionPreference: Weighted
      replicaSchedulingType: Divided
      weightPreference:
        staticWeightList:
          - targetCluster:
              clusterNames:
                - member1
            weight: 1
          - targetCluster:
              clusterNames:
                - member2
            weight: 1

提交之后,可以看到成员集群按照我们设置的权重来创建 Pod:

代码语言:javascript复制
$ k1 get po ; k2 get po
NAME                     READY   STATUS    RESTARTS   AGE
flask-6d75654674-54c7p   1/1     Running   0          34s
flask-6d75654674-qgbjg   1/1     Running   0          34s
NAME                     READY   STATUS    RESTARTS   AGE
flask-6d75654674-7d5vl   1/1     Running   0          3s
flask-6d75654674-9ns6n   1/1     Running   0          112s
~~~

缩容到 2 实例:

代码语言:javascript复制
k1 get po ; k2 get po
NAME                     READY   STATUS        RESTARTS   AGE
flask-6d75654674-54c7p   0/1     Terminating   0          2m14s
flask-6d75654674-qgbjg   1/1     Running       0          2m14s
NAME                     READY   STATUS    RESTARTS   AGE
flask-6d75654674-9ns6n   1/1     Running   0          3m32s

Pod 运行起来之后,我们来部署一个 Service:

代码语言:javascript复制
apiVersion: v1
kind: Service
metadata:
  labels:
    app: flask
  name: flask
spec:
  ports:
  - name: http
    port: 80
  selector:
    app: flask
  type: NodePort

创建之后,会发现 Karmada 为新服务分配了端口,Endpoint 是没有的:

代码语言:javascript复制
$ kubectl get svc
NAME         TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)        AGE
flask        NodePort    10.110.144.229   <none>        80:31193/TCP   13h
kubernetes   ClusterIP   10.96.0.1        <none>        443/TCP        41h
$ kubectl get ep
NAME         ENDPOINTS           AGE
kubernetes   10.211.55.58:5443   41h

同样创建一个 pp,把 Service 分配给集群。完成分发后就可以访问各个集群的服务了。

有时候有人跟你说 PP 其实不是耍流氓,是在讨论 Karmada。

用 Pipy 做负载均衡

Pipy 的部署很方便,在 Release 页面下载二进制即可。源码中的 tutorial/08-load-balancing-improved 就是一个负载均衡的例子,这里我们对其配置做个简化:

这个例子中的 router 对象和 Kong Gateway 的概念类似,我们修改一下 config/router.json,仅包含一个对 /env/* 路径的转发:

代码语言:javascript复制
{
  "routes": {
    "/env/*": "flask"
  }}

config/balancer.js 中则包含了对负载均衡池的定义,原文内容:

代码语言:javascript复制
{
  "services": {
    "service-hi"      : ["127.0.0.1:8080", "127.0.0.1:8082"],
    "service-echo"    : ["127.0.0.1:8081"],
    "service-tell-ip" : ["127.0.0.1:8082"]  }}

清理一下,只留下:

代码语言:javascript复制
{
  "services": {
    "flask"      : [<每个集群每个节点的 Flask 服务 NodePort>] 
  }}

启动 Pipy:pipy --admin-port=8889 proxy.js,在 8889 启用了控制台端口,浏览器打开会看到类似界面:

现在就可以在 config/proxy.json 中定义的代理端口中上访问我们部署在 Kubernetes 中的服务了。整个流程都非常清楚,这个服务发生任何变化,把新的开放端口写入配置,刷新 Pipy 即可,大致代码如下:

代码语言:javascript复制
# 列出 Karmada 集群for cluster in members["items"]:
    member_name = cluster["metadata"]["name"]
    logging.info("Services in cluster {}".format(member_name))
    cluster_client = karmada.get_member_client(cluster)    # 查找服务
    services = karmada.list_service(cluster_client, "default", {"app": "flask"})    # 每个集群的节点
    nodes = karmada.list_nodes(cluster_client)
    address_list = []
    port_list = []    for node in nodes.items:
        addresses = node.status.addresses        for address in addresses:            if address.type == "InternalIP":
                logging.info("Found a node in {} with IP {}".format(member_name, address.address))
                address_list.append(address.address)                break
    for service in services.items:
        port_list.append(service.spec.ports[0].node_port)
        logging.info("Found service named {} in cluster {}".format(service.metadata.name, member_name))    # 组装 URL
    for address in address_list:        for port in port_list:
            lb_config["services"]["flask"].append("{}:{}".format(address, port))

最终结果写入 Pipy 配置,管理页面重载配置就完成了刷新。此时访问 Pipy 在 proxy.js 中定义的端口,就能看到负载均衡的效果了。

其实没这么简单

首先,我知道该用 watch :);

其次,在管理界面刷新配置是个挺傻的事情,Pipy 提供了 Repo 功能,可以进行热加载。这个组件也是 Pipy 高可用和控制平面、GitOps 的命门所在。

另外,在节点比较多的集群中,往往不会把所有节点用于暴露 NodePort,这时可以考虑使用节点标签来限制负载均衡池的生成情况。

最后直接生成 balancer.js 是个非常粗糙的行为,这种做法里,Pipy 必须独占,否则一次刷新可能就覆盖了其它服务的定义,因此这里最好能够使用 Annotation 或者 CRD 等方式,给集群、服务、节点做出标识,从而精确完成刷新过程。

相关链接

  • Karmadahttps://github.com/karmada-io/karmada
  • Pipyhttps://github.com/flomesh-io/pipy

0 人点赞