manshu

manshu 查看完整档案

合肥编辑安徽大学  |  计算机应用 编辑软件  |  云原生 编辑填写个人主网站
编辑

clear is better than clever

个人动态

manshu 发布了文章 · 6月1日

k8s job简介和访问

job 描述

job是k8s提供的一种任务类型,负责批量处理短暂的一次性任务 (short lived one-off tasks),即仅执行一次的任务,它保证批处理任务的一个或多个Pod成功结束

job的三种使用场景

  • 非并行任务:只启一个pod,pod成功,job正常结束
  • 并行任务同时指定成功个数:.spec.completions为指定成功个数,可以指定也可以不指定.spec.parallelism(指定>1,会有多个任务并行运行)。当成功个数达到.spec.completions,任务结束。
  • 有工作队列的并行任务:.spec.completions默认为1,.spec.parallelism为大于0的整数。此时并行启动多个pod,只要有一个成功,任务结束,所有pod结束

参数

completions

指定job启动的任务(如:pod)成功运行completions次,job才算成功结束

parallelism

指定job同时运行的任务(如:pod)个数,Parallelism默认为1,如果设置为0,则job会暂定

backoffLimit

job建议指定pod的重启策略为never,如:.spec.template.spec.restartPolicy = "Never",然后通过job的backoffLimit来指定失败重试次数

在达到backoffLimit指定的次数后,job状态设置为failed(默认为6次),重试时间采用指数规避(10s, 20s, 40s …),并限制在6分钟内

activeDeadlineSeconds

通过指定job存活时间,来结束一个job。当job运行时间达到activeDeadlineSeconds指定的时间后,job会停止由它启动的所有任务(如:pod),并设置job的状态为failed, reason: DeadlineExceeded

activeDeadlineSeconds的优先级高于backoffLimit

apiVersion: batch/v1
kind: Job
metadata:
  name: pi-with-timeout
spec:
  backoffLimit: 5
  activeDeadlineSeconds: 100
  template:
    spec:
      containers:
      - name: pi
        image: perl
        command: ["perl",  "-Mbignum=bpi", "-wle", "print bpi(2000)"]
      restartPolicy: Never

ttlSecondsAfterFinished

默认情况下,job异常或者成功结束后,包括job启动的任务(pod),都不会被清理掉,因为你可以依据保存的job和pod,查看状态、日志,以及调试等。这些用户可以手动删除,用户手动删除job,job controller会级联删除对应的pod

除了手动删除,通过指定参数ttlSecondsAfterFinished也可以实现自动删除job,以及级联的资源,如:pod。如果设置为0,job会被立即删除。如果不指定,job则不会被删除

apiVersion: batch/v1
kind: Job
metadata:
  name: pi-with-ttl
spec:
  ttlSecondsAfterFinished: 100
  template:
    spec:
      containers:
      - name: pi
        image: perl
        command: ["perl",  "-Mbignum=bpi", "-wle", "print bpi(2000)"]
      restartPolicy: Never

域名访问

通过job的service或者pod域名来访问job的任务

yaml

需要指定hostname,以及subdomain,subdomain和svc name保持一致。

apiVersion: v1
kind: Service
metadata:
  name: pi-sub
spec:
  selector:
    job-name: pi
  ports:
    - protocol: TCP
      port: 80
      targetPort: 9376
---
apiVersion: batch/v1
kind: Job
metadata:
  name: pi
spec:
  template:
    spec:
      hostname: pi
      subdomain: pi-sub
      containers:
      - name: pi
        image: perl
        command: ["perl",  "-Mbignum=bpi", "-wle", "print bpi(10000)"]
      restartPolicy: Never
  backoffLimit: 4

访问

[root@master01 job]# nslookup pi-sub.default.svc.cluster.local 10.96.0.10
Server:        10.96.0.10
Address:    10.96.0.10#53

Name:    pi-sub.default.svc.cluster.local
Address: 10.109.132.186

[root@master01 job]# 
[root@master01 job]# 
[root@master01 job]# nslookup pi.pi-sub.default.svc.cluster.local 10.96.0.10
Server:        10.96.0.10
Address:    10.96.0.10#53

Name:    pi.pi-sub.default.svc.cluster.local
Address: 100.64.0.80
查看原文

赞 0 收藏 0 评论 0

manshu 发布了文章 · 5月30日

k8s hpa简介

HPA

HPA全称Horizontal Pod Autoscaling ,可以实现pod的水平自动化扩缩容,比如当POD中业务负载上升的时候,可以创建新的POD来保证业务系统稳定运行,当POD中业务负载下降的时候,可以销毁POD来提高资源利用率。

HPA目前支持四种类型的指标,分别是Resource、Object、External、Pods。其中在稳定版本autoscaling/v1中只支持对CPU指标的动态伸缩,在测试版本autoscaling/v2beta2中支持memory和自定义指标的动态伸缩,并以annotation的方式工作在autoscaling/v1版本中。

refer: https://zhuanlan.zhihu.com/p/...

metrics-server

k8s api-server的支持

    - --proxy-client-cert-file=/etc/kubernetes/pki/front-proxy-client.crt
    - --proxy-client-key-file=/etc/kubernetes/pki/front-proxy-client.key
    - --requestheader-allowed-names=front-proxy-client
    - --requestheader-client-ca-file=/etc/kubernetes/pki/front-proxy-ca.crt
    - --requestheader-extra-headers-prefix=X-Remote-Extra-
    - --requestheader-group-headers=X-Remote-Group
    - --requestheader-username-headers=X-Remote-User
notes:如果你未在 master 节点上运行 kube-proxy,则必须确保 kube-apiserver 启动参数中包含--enable-aggregator-routing=true

部署

metrics-server.yaml 参见下文,部署时注意nodeSelector是否匹配

      nodeSelector:
        kubernetes.io/os: linux
        kubernetes.io/arch: "amd64"
kubectl create -f metrics-server.yaml

metrics api

手动获取指标

kubectl get --raw /apis/metrics.k8s.io/v1beta1/pods

or

[root@master01 hpa]# curl -H "Authorization: Bearer $toke" -k https://172.31.133.26:6443/apis/|grep metrics -C 5
    {
      "name": "metrics.k8s.io",
      "versions": [
        {
          "groupVersion": "metrics.k8s.io/v1beta1",
          "version": "v1beta1"
        }
      ],
      "preferredVersion": {
        "groupVersion": "metrics.k8s.io/v1beta1",
        "version": "v1beta1"
      }
    }
  ]
}
[root@master01 hpa]# curl -H "Authorization: Bearer $toke" -k https://172.31.133.26:6443/apis/metrics.k8s.io/v1beta1/pods |grep php -C 7
    {
      "metadata": {
        "name": "php-apache-7bf64757b9-sh4rl",
        "namespace": "default",
        "selfLink": "/apis/metrics.k8s.io/v1beta1/namespaces/default/pods/php-apache-7bf64757b9-sh4rl",
      },
      "containers": [
        {
          "name": "php-apache",
          "usage": {
            "cpu": "31395n",
            "memory": "11988Ki"
          }
        }
      ]
    },

CPU HPA

创建应用

kubectl run php-apache --image=k8s.gcr.io/hpa-example --requests=cpu=200m --expose --port=80

创建hpa

kubectl autoscale deployment php-apache --cpu-percent=50 --min=1 --max=10

压测

kubectl run -i --tty load-generator --image=busybox /bin/sh

Hit enter for command prompt

while true; do wget -q -O- http://php-apache; done

自动弹性

[root@master01 msxu]# kubectl get hpa -w
NAME         REFERENCE               TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
php-apache   Deployment/php-apache   0%/50%    1         10        1          52m
php-apache   Deployment/php-apache   493%/50%   1         10        1          52m
php-apache   Deployment/php-apache   493%/50%   1         10        4          52m
php-apache   Deployment/php-apache   493%/50%   1         10        8          52m
php-apache   Deployment/php-apache   493%/50%   1         10        10         53m
php-apache   Deployment/php-apache   54%/50%    1         10        10         53m
php-apache   Deployment/php-apache   49%/50%    1         10        10         54m
php-apache   Deployment/php-apache   44%/50%    1         10        10         56m
php-apache   Deployment/php-apache   0%/50%     1         10        10         57m
php-apache   Deployment/php-apache   0%/50%     1         10        10         61m
php-apache   Deployment/php-apache   0%/50%     1         10        9          61m
php-apache   Deployment/php-apache   0%/50%     1         10        9          62m
php-apache   Deployment/php-apache   0%/50%     1         10        1          62m

MEM HPA

创建HPA

apiVersion: autoscaling/v2beta1
kind: HorizontalPodAutoscaler
metadata:
  name: php-apache-hpa
  namespace: default
spec:
  scaleTargetRef:
    apiVersion: apps/v1beta1
    kind: Deployment
    name: php-apache
  minReplicas: 1
  maxReplicas: 3
  metrics:
  - type: Resource
    resource:
      name: memory
      targetAverageUtilization: 30

查看

[root@master01 hpa]# kubectl get hpa
NAME             REFERENCE               TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
php-apache       Deployment/php-apache   0%/50%    1         10        1          5d17h
php-apache-hpa   Deployment/php-apache   11%/30%   1         3         1          5d17h
[root@master01 hpa]# kubectl describe  hpa php-apache-hpa 
Name:                                                     php-apache-hpa
Namespace:                                                default
Labels:                                                   <none>
Annotations:                                              <none>
CreationTimestamp:                                        Thu, 30 Apr 2020 17:57:40 +0800
Reference:                                                Deployment/php-apache
Metrics:                                                  ( current / target )
  resource memory on pods  (as a percentage of request):  11% (11546624) / 30%
Min replicas:                                             1
Max replicas:                                             3
Deployment pods:                                          1 current / 1 desired
Conditions:
  Type            Status  Reason              Message
  ----            ------  ------              -------
  AbleToScale     True    ReadyForNewScale    recommended size matches current size
  ScalingActive   True    ValidMetricFound    the HPA was able to successfully calculate a replica count from memory resource utilization (percentage of request)
  ScalingLimited  False   DesiredWithinRange  the desired count is within the acceptable range
Events:           <none>

yaml

metrics-server.yaml 0.3.6

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: system:aggregated-metrics-reader
  labels:
    rbac.authorization.k8s.io/aggregate-to-view: "true"
    rbac.authorization.k8s.io/aggregate-to-edit: "true"
    rbac.authorization.k8s.io/aggregate-to-admin: "true"
rules:
- apiGroups: ["metrics.k8s.io"]
  resources: ["pods", "nodes"]
  verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: metrics-server:system:auth-delegator
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: system:auth-delegator
subjects:
- kind: ServiceAccount
  name: metrics-server
  namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: metrics-server-auth-reader
  namespace: kube-system
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: extension-apiserver-authentication-reader
subjects:
- kind: ServiceAccount
  name: metrics-server
  namespace: kube-system
---
apiVersion: apiregistration.k8s.io/v1beta1
kind: APIService
metadata:
  name: v1beta1.metrics.k8s.io
spec:
  service:
    name: metrics-server
    namespace: kube-system
  group: metrics.k8s.io
  version: v1beta1
  insecureSkipTLSVerify: true
  groupPriorityMinimum: 100
  versionPriority: 100
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: metrics-server
  namespace: kube-system
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: metrics-server
  namespace: kube-system
  labels:
    k8s-app: metrics-server
spec:
  selector:
    matchLabels:
      k8s-app: metrics-server
  template:
    metadata:
      name: metrics-server
      labels:
        k8s-app: metrics-server
    spec:
      serviceAccountName: metrics-server
      volumes:
      # mount in tmp so we can safely use from-scratch images and/or read-only containers
      - name: tmp-dir
        emptyDir: {}
      containers:
      - name: metrics-server
        image: k8s.gcr.io/metrics-server-amd64:v0.3.1
        imagePullPolicy: IfNotPresent
        args:
          - --cert-dir=/tmp
          - --secure-port=4443
          - --kubelet-preferred-address-types=InternalIP
          - --kubelet-insecure-tls
        ports:
        - name: main-port
          containerPort: 4443
          protocol: TCP
        securityContext:
          readOnlyRootFilesystem: true
          runAsNonRoot: true
          runAsUser: 1000
        volumeMounts:
        - name: tmp-dir
          mountPath: /tmp
      nodeSelector:
        kubernetes.io/os: linux
        kubernetes.io/arch: "amd64"
---
apiVersion: v1
kind: Service
metadata:
  name: metrics-server
  namespace: kube-system
  labels:
    kubernetes.io/name: "Metrics-server"
    kubernetes.io/cluster-service: "true"
spec:
  selector:
    k8s-app: metrics-server
  ports:
  - port: 443
    protocol: TCP
    targetPort: main-port
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: system:metrics-server
rules:
- apiGroups:
  - ""
  resources:
  - pods
  - nodes
  - nodes/stats
  - namespaces
  - configmaps
  verbs:
  - get
  - list
  - watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: system:metrics-server
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: system:metrics-server
subjects:
- kind: ServiceAccount
  name: metrics-server
  namespace: kube-system
查看原文

赞 0 收藏 0 评论 0

manshu 发布了文章 · 5月8日

helm v3 简介

介绍

Kubernetes作为一个应用部署和运行平台,但并没有抽象出应用这一层的概念,用户在部署应用时,需要考虑到多个k8s对象(Deployment、Service、ConfigMap、pvc等),有比较高的应用部署门槛。

helm在此基础上整合和屏蔽k8s复杂的应用对象,抽象出应用部署chart包的概念,再加管理chart包的repo仓库,用户可以像使用yum一样,在k8s平台上部署和管理应用。

和helm v2的区别

去除tiller

v2 版本的tiller需要拥有较高的权限,带来了一定的安全风险,并且tiller的使用,增加了局限性,如release的命名必须唯一。tiller的功能现在完全可以通过k8s apiserver来完成,没有必要增加tiller组件

改进的upgrade策略

v2版本指对比old manifest和new manifest,可能会导致用户手动对应用的更新(live state)被忽略,如:kubectl scale --replicas=0 deployment/myapp。新的更新策略会考虑old manifest,live state和new manifest,更符合用户的使用用途。

release name局限到namespaces内

v2版本,release name 存储在tiller的namespace中,导致release虽然在不通的ns中,release name 都不能重复。v3中,release name 存储在应用的ns中,因此 helm list只会展示当前ns中的release。

使用secret存储release info

v2版本采用configmap存储release info,v3 改用secret,有利于更好的对chart信息的保护

合并requirements.yaml 到 Chart.yaml

支持docker registry

XDG 基础目录

v2版本信息存储在~/.helm中,helm 3支持这几个环境定义配置、数据、环境的目录

  • $XDG_CACHE_HOME
  • $XDG_CONFIG_HOME
  • $XDG_DATA_HOME

下面这几个环境变量对plugin也适用

  • $HELM_PATH_CACHE for the cache path
  • $HELM_PATH_CONFIG for the config path
  • $HELM_PATH_DATA for the data path

install

[root@master01 msxu]# wget https://get.helm.sh/helm-v3.2.1-linux-amd64.tar.gz && tar xzvf helm-v3.2.1-linux-amd64.tar.gz
[root@master01 msxu]# cp helm-v3.2.1/helm /usr/local/bin

案例

repo

// 公共repo 
[root@master01 msxu]# helm repo add apphub https://apphub.aliyuncs.com
// harbor仓库,此处container为harbor仓库中的项目
[root@master01 msxu]# helm repo add container https://hub.personal.com/chartrepo/container
[root@master01 msxu]# helm repo list
NAME         URL                                        
stable       https://apphub.aliyuncs.com                
container    https://hub.personal.com/chartrepo/container

创建应用

[root@master01 .helm]# helm search guestbook
NAME                       CHART VERSION    APP VERSION    DESCRIPTION                                       
stable/guestbook           0.2.0                           A Helm chart to deploy Guestbook three tier web...
stable/guestbook-kruise    0.3.0                           A Helm chart to deploy Guestbook three tier web...
[root@master01 .helm]# 
[root@master01 .helm]# helm install guestbook stable/guestbook
NAME: guestbook
LAST DEPLOYED: 2020-05-07 16:25:39.643044595 +0800 CST m=+0.979151956
NAMESPACE: default
STATUS: deployed
[root@master01 .helm]# 
[root@master01 .helm]# helm list
NAME         NAMESPACE    REVISION    UPDATED                                    STATUS      CHART          
guestbook    default      1           2020-05-07 16:25:39.643044595 +0800 CST    deployed    guestbook-0.2.0
[root@master01 msxu]# helm uninstall guestbook
release "guestbook" uninstalled

更新

[root@master01 msxu]# helm upgrade guestbook stable/guestbook  --set service.type=NodePort
Release "guestbook" has been upgraded. Happy Helming!
NAME: guestbook
LAST DEPLOYED: 2020-05-07 17:15:38.261101388 +0800 CST
NAMESPACE: default
STATUS: deployed
[root@master01 msxu]# helm  history guestbook
REVISION    UPDATED                                    STATUS        CHART              APP VERSION    DESCRIPTION     
1           2020-05-07 16:25:39.643044595 +0800 CST    superseded    guestbook-0.2.0                   Install complete
2           2020-05-07 17:15:38.261101388 +0800 CST    deployed      guestbook-0.2.0                   Upgrade complete
# helm3中应用版本信息默认放在和应用相同的ns下,并记录在secret中
[root@master01 msxu]# kubectl get secret
NAME                              TYPE                                  DATA   AGE
sh.helm.release.v1.guestbook.v1   helm.sh/release.v1                    1      2d22h

回滚

[root@master01 msxu]# 
[root@master01 msxu]# helm rollback  guestbook  1
Rollback was a success! Happy Helming!
[root@master01 msxu]# helm list
NAME         NAMESPACE    REVISION    UPDATED                                    STATUS      CHART          
guestbook    default      3           2020-05-07 17:19:05.366400699 +0800 CST    deployed    guestbook-0.2.0
[root@master01 msxu]# helm history guestbook
REVISION    UPDATED                                    STATUS        CHART              APP VERSION    DESCRIPTION     
1           2020-05-07 16:25:39.643044595 +0800 CST    superseded    guestbook-0.2.0                   Install complete
2           2020-05-07 17:15:38.261101388 +0800 CST    superseded    guestbook-0.2.0                   Upgrade complete
3           2020-05-07 17:19:05.366400699 +0800 CST    deployed      guestbook-0.2.0                   Rollback to 1

下载chart包,本地部署

[root@master01 msxu]# helm pull  stable/guestbook 
[root@master01 msxu]# tar xzvf guestbook-0.2.0.tgz
[root@master01 msxu]# ls
Chart.yaml  README.md  templates  values.yaml
[root@master01 guestbook]# tree
.
├── Chart.yaml
├── README.md
├── templates
│   ├── guestbook-deployment.yaml
│   ├── guestbook-service.yaml
│   ├── _helpers.tpl
│   ├── NOTES.txt
│   ├── redis-master-deployment.yaml
│   ├── redis-master-service.yaml
│   ├── redis-slave-deployment.yaml
│   └── redis-slave-service.yaml
└── values.yaml

1 directory, 11 files
[root@master01 msxu]#  helm install guestbook guestbook    //本地包部署
NAME: guestbook
LAST DEPLOYED: 2020-05-07 21:33:19.896874783 +0800 CST m=+0.097981276
NAMESPACE: default
STATUS: deployed

chart包管理

# 本地创建一个 Chart
helm create <Chart 名称>

// 安装helm-push插件,可能会卡
[root@master01 msxu]# helm plugin install https://github.com/chartmuseum/helm-push.git

# 推送 Chart 目录
helm push <Chart 名称> <本地仓库名称>

# 或者推送 Chart 压缩包
helm push <Chart 名称>-<Chart 版本>.tgz <本地仓库名称>   

# 从线上 Chart 仓库更新本地 Chart 索引
helm repo update

# 拉取 Chart
helm fetch <本地仓库名称>/<Chart 名称> --version <Chart 版本>

# 或者直接安装 Chart
helm install -f values.yaml <本地仓库名称>/<Chart 名称> --version <Chart 版本>

helm镜像

docker.io/alpine/helm:3.2.0
// 查看、添加repo
[root@master01 ~]# docker run  -v /root/.kube/:/root/.kube/ docker.io/alpine/helm:3.2.0  --kubeconfig /root/.kube/config repo list
Error: no repositories to show
[root@master01 ~]# docker run  -v /root/.kube/:/root/.kube/ docker.io/alpine/helm:3.2.0  --kubeconfig /root/.kube/config repo add apphub https://apphub.aliyuncs.com
"apphub" has been added to your repositories
// 安装、查看应用
[root@master01 ~]# docker run  -v /root/.kube/:/root/.kube/  -v /home/msxu:/home/msxu docker.io/alpine/helm:3.2.0  --kubeconfig /root/.kube/config install guestbook  /home/msxu/guestbook-0.2.0.tgz
NAME: guestbook
LAST DEPLOYED: Fri May  8 09:25:56 2020
NAMESPACE: default
STATUS: deployed
REVISION: 1
[root@master01 ~]# docker run  -v /root/.kube/:/root/.kube/  -v /home/msxu:/home/msxu docker.io/alpine/helm:3.2.0  --kubeconfig /root/.kube/config list
NAME         NAMESPACE    REVISION    UPDATED                                    STATUS      CHART              APP VERSION
guestbook    default      1           2020-05-08 09:25:56.924441076 +0000 UTC    deployed    guestbook-0.2.0
参考: https://www.infoq.cn/article/...
查看原文

赞 1 收藏 1 评论 0

manshu 关注了专栏 · 3月23日

kubernetes solutions

专注k8s,serverless,service mesh,devops

关注 2225

manshu 赞了文章 · 3月21日

浅谈k8s cni 插件

目前不论是个人还是企业,在使用k8s时,都会采用CNI作为集群网络方案实现的规范。

在早先的k8s版本中,kubelet代码里提供了networkPlugin,networkPlugin是一组接口,实现了pod的网络配置、解除、获取,当时kubelet的代码中有个一个docker_manager,负责容器的创建和销毁,亦会负责容器网络的操作。而如今我们可以看到基本上kubelet的启动参数中,networkPlugin的值都会设置为cni。

cni插件的使用方式

使用CNI插件时,需要做三个配置:

  • kubelet启动参数中networkPlugin设置为cni
  • 在/etc/cni/net.d中增加cni的配置文件,配置文件中可以指定需要使用的cni组件及参数
  • 将需要用到的cni组件(二进制可执行文件)放到/opt/cni/bin目录下

所有的cni组件都支持两个命令:add和del。即配置网络和解除网络配置。

cni插件的配置文件是一个json文件,不同版本的接口、以及不同的cni组件,有着不同的配置内容结构,目前比较通用的接口版本是0.3.1的版本。

在配置文件中我们可以填入多个cni组件,当这些cni组件的配置以数组形式记录时,kubelet会对所有的组件进行按序链式调用,所有组件调用成功后,视为网络配置完成,过程中任何一步出现error,都会进行回滚的del操作。以保证操作流上的原子性。

几种基本的cni插件

cni插件按照代码中的存放目录可以分为三种:ipam、main、meta。

  • ipam cni用于管理ip和相关网络数据,配置网卡、ip、路由等。
  • main cni用于进行网络配置,比如创建网桥,vethpair、macvlan等。
  • meta cni有的是用于和第三方CNI插件进行适配,如flannel,也有的用于配置内核参数,如tuning

由于官方提供的cni组件就有很多,这里我们详细介绍一些使用率较高的组件。

ipam类CNI

ipam类型的cni插件,在执行add命令时会分配一个IP给调用者。执行del命令时会将调用者指定的ip放回ip池。社区开源的ipam有host-local、dhcp。

host-local

我们可以通过host-local的配置文件的数据结构来搞懂这个组件是如何管理ip的。

type IPAMConfig struct {
    *Range
    Name       string
    Type       string         `json:"type"`
    Routes     []*types.Route `json:"routes"`//交付的ip对应的路由
    DataDir    string         `json:"dataDir"`//本地ip池的数据库目录
    ResolvConf string         `json:"resolvConf"`//交付的ip对应的dns
    Ranges     []RangeSet     `json:"ranges"`//交付的ip所属的网段,网关信息
    IPArgs     []net.IP       `json:"-"` // Requested IPs from CNI_ARGS and args
}

#配置文件范例:
{
    "cniVersion": "0.3.1",
    "name": "mynet",
    "type": "ipvlan",
    "master": "foo0",
    "ipam": {
        "type": "host-local",
        "resolvConf": "/home/here.resolv",
        "dataDir": "/home/cni/network",
        "ranges": [
            [
                {
                    "subnet": "10.1.2.0/24",
                    "rangeStart": "10.1.2.9",
                    "rangeEnd": "10.1.2.20",
                    "gateway": "10.1.2.30"
                },
                {
                    "subnet": "10.1.4.0/24"
                }
            ],
            [{
                "subnet": "11.1.2.0/24",
                "rangeStart": "11.1.2.9",
                "rangeEnd": "11.1.2.20",
                "gateway": "11.1.2.30"
            }]
        ]
    }
}

从上面的配置我们可以清楚:

  • host-local组件通过在配置文件中指定的subnet进行网络划分
  • host-local在本地通过指定目录(默认为/var/lib/cni/networks)记录当前的ip pool数据
  • host-local将IP分配并告知调用者时,还可以告知dns、路由等配置信息。这些信息通过配置文件和对应的resolv文件记录。

host-local的应用范围比较广,kubenet、bridge、ptp、ipvlan等cni network插件都被用来和host-local配合进行ip管理。

dhcp

社区的cni组件中就包含了dhcp这个ipam,但并没有提供一个可以参考的案例,翻看了相关的源码,大致逻辑是:

  • 向dhcp申请ip时,dhcp会使用rpc访问本地的socket(/run/cni/dhcp.sock)申请一个ip的租约。然后将IP告知调用者。
  • 向dhcp删除IP时,dhcp同样通过rpc请求,解除该IP的租约。

main(network)类CNI

main类型的cni组件做的都是一些核心功能,比如配置网桥、配置各种虚拟化的网络接口(veth、macvlan、ipvlan等)。这里我们着重讲使用率较高的bridge和ptp。

bridge

brige模式,即网桥模式。在node上创建一个linux bridge,并通过vethpair的方式在容器中设置网卡和IP。只要为容器配置一个二层可达的网关:比如给网桥配置IP,并设置为容器ip的网关。容器的网络就能建立起来。

如下是bridge的配置项数据结构:

type NetConf struct {
    types.NetConf
    BrName       string `json:"bridge"` //网桥名
    IsGW         bool   `json:"isGateway"`  //是否将网桥配置为网关
    IsDefaultGW  bool   `json:"isDefaultGateway"` //
    ForceAddress bool   `json:"forceAddress"`//如果网桥已存在且已配置了其他IP,通过此参数决定是否将其他ip除去
    IPMasq       bool   `json:"ipMasq"`//如果true,配置私有网段到外部网段的masquerade规则
    MTU          int    `json:"mtu"`
    HairpinMode  bool   `json:"hairpinMode"`
    PromiscMode  bool   `json:"promiscMode"`
}

我们关注其中的一部分字段,结合代码可以大致整理出bridge组件的工作内容。首先是ADD命令:

  • 执行ADD命令时,brdige组件创建一个指定名字的网桥,如果网桥已经存在,就使用已有的网桥;
  • 创建vethpair,将node端的veth设备连接到网桥上;
  • 从ipam获取一个给容器使用的ip数据,并根据返回的数据计算出容器对应的网关;
  • 进入容器网络名字空间,修改容器中网卡名和网卡ip,以及配置路由,并进行arp广播(注意我们只为vethpair的容器端配置ip,node端是没有ip的);
  • 如果IsGW=true,将网桥配置为网关,具体方法是:将第三步计算得到的网关IP配置到网桥上,同时根据需要将网桥上其他ip删除。最后开启网桥的ip_forward内核参数;
  • 如果IPMasq=true,使用iptables增加容器私有网网段到外部网段的masquerade规则,这样容器内部访问外部网络时会进行snat,在很多情况下配置了这条路由后容器内部才能访问外网。(这里代码中会做exist检查,防止生成重复的iptables规则);
  • 配置结束,整理当前网桥的信息,并返回给调用者。

其次是DEL命令:

  • 根据命令执行的参数,确认要删除的容器ip,调用ipam的del命令,将IP还回IP pool;
  • 进入容器的网络名字空间,根据容器IP将对应的网卡删除;
  • 如果IPMasq=true,在node上删除创建网络时配置的几条iptables规则。

ptp

ptp其实是bridge的简化版。但是它做的网络配置其实看上去倒是更复杂了点。并且有一些配置在自测过程中发现并没有太大用处。它只创建vethpair,但是会同时给容器端和node端都配置一个ip。容器端配置的是容器IP,node端配置的是容器IP的网关(/32),同时,容器里做了一些特殊配置的路由,以满足让容器发出的arp请求能被vethpair的node端响应。实现内外的二层连通。

ptp的网络配置步骤如下:

  • 从ipam获取IP,根据ip类型(ipv4或ipv6)配置响应的内核ip_forward参数;
  • 创建一对vethpair;一端放到容器中;
  • 进入容器的网络namespace,配置容器端的网卡,修改网卡名,配置IP,并配置一些路由。假如容器ip是10.18.192.37/20,所属网段是10.18.192.0/20,网关是10.18.192.1,我们这里将进行这样的配置:

    • 配置IP后,内核会自动生成一条路由,形如:10.18.192.0/20 dev eth0 scope link,我们将它删掉:ip r d ****
    • 配置一条私有网到网关的真实路由:ip r a 10.18.192.0/20 via 10.18.192.1 dev eth0
    • 配置一条到网关的路由:10.18.192.1/32 dev eth0 scope link
  • 退出到容器外,将vethpair的node端配置一个IP(ip为容器ip的网关,mask=32);
  • 配置外部的路由:访问容器ip的请求都路由到vethpair的node端设备去。
  • 如果IPMasq=true,配置iptables
  • 获取完整的网卡信息(vethpair的两端),返回给调用者。

与bridge不同主要的不同是:ptp不使用网桥,而是直接使用vethpair+路由配置,这个地方其实有很多其他的路由配置可以选择,一样可以实现网络的连通性,ptp配置的方式只是其中之一。万变不离其宗的是:

只要容器内网卡发出的arp请求,能被node回复或被node转发并由更上层的设备回复,形成一个二层网络,容器里的数据报文就能被发往node上;然后通过node上的路由,进行三层转发,将数据报文发到正确的地方,就可以实现网络的互联。

bridge和ptp其实是用了不同方式实现了这个原则中的“二层网络”:

  • bridge组件给网桥配置了网关的IP,并给容器配置了到网关的路由。实现二层网络
  • ptp组件给vethpair的对端配置了网关的IP,并给容器配置了单独到网关IP的路由,实现二层网络

ptp模式的路由还存在一个问题:没有配置default路由,因此容器不能访问外部网络,要实现也很简单,以上面的例子,在容器里增加一条路由:default via 10.18.192.1 dev eth0

host-device

相比前面两种cni main组件,host-device显得十分简单因为他就只会做两件事情:

  • 收到ADD命令时,host-device根据命令参数,将网卡移入到指定的网络namespace(即容器中)。
  • 收到DEL命令时,host-device根据命令参数,将网卡从指定的网络namespace移出到root namespace。

细心的你肯定会注意到,在bridge和ptp组件中,就已经有“将vethpair的一端移入到容器的网络namespace”的操作。那这个host-device不是多此一举吗?

并不是。host-device组件有其特定的使用场景。假设集群中的每个node上有多个网卡,其中一个网卡配置了node的IP。而其他网卡都是属于一个网络的,可以用来做容器的网络,我们只需要使用host-device,将其他网卡中的某一个丢到容器里面就行。

host-device模式的使用场景并不多。它的好处是:bridge、ptp等方案中,node上所有容器的网络报文都是通过node上的一块网卡出入的,host-device方案中每个容器独占一个网卡,网络流量不会经过node的网络协议栈,隔离性更强。缺点是:在node上配置数十个网卡,可能并不好管理;另外由于不经过node上的协议栈,所以kube-proxy直接废掉。k8s集群内的负载均衡只能另寻他法了。

macvlan

有关macvlan的实践可以参考这篇文章。这里做一个简单的介绍:macvlan是linux kernal的特性,用于给一个物理网络接口(parent)配置虚拟化接口,虚拟化接口与parent网络接口拥有不同的mac地址,但parent接口上收到发给其对应的虚拟化接口的mac的包时,会分发给对应的虚拟化接口,有点像是将虚拟化接口和parent接口进行了'桥接'。给虚拟化网络接口配置了IP和路由后就能互相访问。

macvlan省去了linux bridge,但是配置macvlan后,容器不能访问parent接口的IP。

ipvlan

ipvlan与macvlan有点类似,但对于内核要求更高(3.19),ipvlan也会从一个网络接口创建出多个虚拟网络接口,但他们的mac地址是一样的, 只是IP不一样。通过路由可以实现不同虚拟网络接口之间的互联。

使用ipvlan也不需要linux bridge,但容器一样不能访问parent接口的IP。
关于ipvlan的内容可以参考这篇文章

关于macvlan和ipvlan,还可以参考这篇文章

meta 类CNI

meta组件通常进行一些额外的网络配置(tuning),或者二次调用(flannel)。

tuning

用于进行内核网络参数的配置。并将调用者的数据和配置后的内核参数返回给调用者。

有时候我们需要配置一些虚拟网络接口的内核参数,比如:网易云在早期经典网络方案中曾修改vethpair的proxy_arp参数(后面会介绍)。可以通过这个组件进行配置。
另外一些可能会改动的网络参数比如:

  • accept_redirects
  • send_redirects
  • proxy_delay
  • accept_local
  • arp_filter

可以在这里查看可配置的网络参数和释义。

portmap

用于在node上配置iptables规则,进行SNAT,DNAT和端口转发。

portmap组件通常在main组件执行完毕后执行,因为它的执行参数仰赖之前的组件提供

flannel

cni plugins中的flannel是开源网络方案flannel的“调用器”。这也是flannel网络方案适配CNI架构的一个产物。为了便于区分,以下我们称cni plugins中的flannel 为flanenl cni

我们知道flannel是一个容器的网络方案,通常使用flannel时,node上会运行一个daemon进程:flanneld,这个进程会返回该node上的flannel网络、subnet,MTU等信息。并保存到本地文件中。

如果对flannel网络方案有一定的了解,会知道他在做网络接口配置时,其实干的事情和bridge组件差不多。只不过flannel网络下的bridge会跟flannel0网卡互联,而flannel0网卡上的数据会被封包(udp、vxlan下)或直接转发(host-gw)。

flannel cni做的事情就是:

  • 执行ADD命令时,flannel cni会从本地文件中读取到flanneld的配置。然后根据命令的参数和文件的配置,生成一个新的cni配置文件(保存在本地,文件名包含容器id以作区分)。新的cni配置文件中会使用其他cni组件,并注入相关的配置信息。之后,flannel cni根据这个新的cni配置文件执行ADD命令。
  • 执行DEL命令时,flannel cni从本地根据容器id找到之前创建的cni配置文件,根据该配置文件执行DEL命令。

也就是说flannel cni此处是一个flannel网络模型的委托者,falnnel网络模型委托它去调用其他cni组件,进行网络配置。通常调用的是bridge和host-local。

几种常见的网络方案

上述所有的cni组件,能完成的事情就是建立容器到虚拟机上的网络。而要实现跨虚拟机的容器之间的网络,有几种可能的办法:

  • 容器的IP就是二层网络里分配的IP,这样容器相当于二层网络里的节点,那么就可以天然互访;
  • 容器的IP与node的IP不属于同一个网段,node上配置个到各个网段的路由(指向对应容器网段所部属的node IP),通过路由实现互访[flannel host-gw, calico bgp均是通过此方案实现];
  • 容器的IP与node的IP不属于同一个网段,node上有服务对容器发出的包进行封装,对发给容器的包进行解封。封装后的包通过node所在的网络进行传输。解封后的包通过网桥或路由直接发给容器,即overlay网络。[flannel udp/vxlan,calico ipip,openshift-sdn均通过此方案实现]

kubenet

了解常用的网络方案前,我们先了解一下kubenet,kubenet其实是k8s代码中内置的一个cni组件。如果我们要使用kubenet,就得在kubelet的启动参数中指定networkPlugin值为kubenet而不是cni

如果你阅读了kubernetes的源码,你就可以在一个名为kubenet_linux.go的文件中看到kubenet做了什么事情:

  • 身为一种networkPlugin,kubenet自然要实现networkPlugin的一些接口。比如SetUpPod,TearDownPod,GetPodNetworkStatus等等,kubelet通过这些接口进行容器网络的创建、解除、查询。
  • 身为一个代码中内置的cni,kubenet要主动生成一个cni配置文件(字节流数据),自己按照cni的规矩去读取配置文件,做类似ADD/DEL指令的工作。实现网络的创建、解除。

设计上其实挺蠢萌的。实际上是为了省事。我们可以看下自生成的配置文件:

{
  "cniVersion": "0.1.0",
  "name": "kubenet",
  "type": "bridge",
  "bridge": "%s", //通常这里默认是“cbr0”
  "mtu": %d,    //kubelet的启动参数中可以配置,默认使用机器上的最小mtu
  "addIf": "%s", //配置到容器中的网卡名字
  "isGateway": true,
  "ipMasq": false,
  "hairpinMode": %t, 
  "ipam": {
    "type": "host-local",
    "subnet": "%s", //node上容器ip所属子网,通常是kubelet的pod-cidr参数指定
    "gateway": "%s", //通过subnet可以确定gateway
    "routes": [
      { "dst": "0.0.0.0/0" }
    ]
  }
}

配置文件中明确了要使用的其他cni组件:bridge、host-local(这里代码中还会调用lo组件,通常lo组件会被k8s代码直接调用,所以不需要写到cni配置文件中)。之后的事情就是执行二进制而已。

为什么我们要学习kubenet?因为kubenet可以让用户以最简单的成本(配置networkPlugin和pod-cidr两个启动kubelet启动参数),配置出一个简单的、虚拟机本地的容器网络。结合上面提到的几种“跨虚拟机的容器之间的网络方案”,就是一个完整的k8s集群网络方案了。

通常kubenet不适合用于overlay网络方案,因为overlay网络方案定制化要求会比较高。

许多企业使用vpc网络时,使用自定义路由实现不同pod-cidr之间的路由,他们的网络方案里就会用到kubenet,比如azure AKS(基础网络)。

flannel

关于flannel,上面的文章也提到了一下。网上flannel的文章也是一搜一大把。这里简单介绍下flannel对k8s的支持,以及通用的几个flannel backend(后端网络配置方案)。

flannel for kubernetes

flannel在对kubernets进行支持时,flanneld启动参数中会增加--kube-subnet-mgr参数,flanneld会初始化一个kubernetes client,获取本地node的pod-cidr,这个pod-cidr将会作为flannel为node本地容器规划的ip网段。记录到/run/flannel/subnet.env。(flannel_cni组件会读取这个文件并写入到net-conf.json中,供cni使用)。

udp/vxlan

flannel的overlay方案。每个node节点上都有一个flanneld进程,和flannel0网桥,容器网络会与flannel0网桥互联,并经由flannel0发出,所以flanneld可以捕获到容器发出的报文,进行封装。udp方案下会给报文包装一个udp的头部,vxlan下会给报文包装一个vxlan协议的头部(配置了相同VNI的node,就能进行互联)。 目前flannel社区还提供了更多实验性的封装协议选择,比如ipip,但仍旧将vxlan作为默认的backend。

host-gw

flannel的三层路由方案。每个node节点上都会记录其他节点容器ip段的路由,通过路由,node A上的容器发给node B上的容器的数据,就能在node A上进行转发。

alloc

类似kubenet,只分配子网,不做其他任何事情。

支持云厂商的vpc

flannel支持了aliVPC、gce、aws等云厂商的vpc网络。原理都是一样的,就是当flanneld在某云厂商的机器上运行时,根据机器自身的vpc网络IP,和flanneld分配在该机器上的subnet,调用云厂商的api创建对应的自定义路由。

calico

calico是基于BGP路由实现的容器集群网络方案,对于使用者来说,基础的calico使用体验可能和flannel host-gw是基本一样的:node节点上做好对容器arp的响应。然后通过node上的路由将容器发出的包转发到对端容器所在node的IP。对端节点上再将包转发给对端容器。

ipip模式则如同flannel ipip模式。对报文封装一个ipip头部,头部中使用node ip。发送到对端容器所在node的IP,对端的网络组件再解包,并转发给容器。

不同之处在于flannel方案下路由都是通过代码逻辑进行配置。而calico则在每个节点建立bgp peer,bgp peer彼此之间会进行路由的共享和学习,所以自动生成并维护了路由。

一些大厂的容器服务网络方案

阿里云

通过上文flannel aliVPC模式可见一斑。阿里云中kubernetes服务里,k8s集群通常使用自定义路由的方案+flannel_cni组件,这个方案易于部署和管理,同时将容器IP和nodeIP区分,用户可以自定义集群网络范围。

(比较奇怪的是这里flanenl的backend配置成alloc而非aliVPC,在集群中另外部署了一个controller进行自定义路由的配置)

自定义路由是vpc网络中的一个常用功能,在vpc范围内可以自定义某个网络接口作为一个任意网段的网关。在flannel host-gw模式中,我们将这块的路由配置在node上,由内核执行,而自定义路由则是将类似的路由记录到vpc网络的数据库中,由vpc-router去执行。

azure

azure最近开放了kubernetes服务AKS,AKS支持两种网络方案:基础和高级。

基础网络方案与阿里云的自定义路由方案如出一辙。基础网络中k8s集群使用的网络组件是kubenet,简单的做了网络划分和本地的网络接口配置,自定义路由由其vpc实现。

高级网络方案中,node上的网络接口会创建并绑定多个(默认三十个)fixedIP,主FixedIP作为node IP,其余fixedIP则用于容器IP。
通过azure SDN的支持,不同node之间的容器网络变成一个大二层,他们可以直接互联。高级网络方案中,k8s集群使用azure开源的cni组件:azure-container-networking。这个cni组件包括了ipam和main两部分

azure cni的ipam负责将本地网络接口上绑定着的空闲的fixedIP配置给容器使用。一旦空闲的fixedIP耗尽,除非手动给网卡创建新的fixedIP,否则容器无法创建成功。

azure cni的main组件在node上创建了一个bridge,将node的网卡连接到网桥上,并将node网卡IP设置到网桥上,容器网卡均由vethpair实现,vethpair的node端也是连在网桥上。由此构成node的网络:网桥上的IP作为容器网络的网关,容器网络通过网桥与其他节点形成一个大二层的网络。

查看原文

赞 7 收藏 5 评论 0

manshu 收藏了文章 · 3月21日

浅谈k8s cni 插件

目前不论是个人还是企业,在使用k8s时,都会采用CNI作为集群网络方案实现的规范。

在早先的k8s版本中,kubelet代码里提供了networkPlugin,networkPlugin是一组接口,实现了pod的网络配置、解除、获取,当时kubelet的代码中有个一个docker_manager,负责容器的创建和销毁,亦会负责容器网络的操作。而如今我们可以看到基本上kubelet的启动参数中,networkPlugin的值都会设置为cni。

cni插件的使用方式

使用CNI插件时,需要做三个配置:

  • kubelet启动参数中networkPlugin设置为cni
  • 在/etc/cni/net.d中增加cni的配置文件,配置文件中可以指定需要使用的cni组件及参数
  • 将需要用到的cni组件(二进制可执行文件)放到/opt/cni/bin目录下

所有的cni组件都支持两个命令:add和del。即配置网络和解除网络配置。

cni插件的配置文件是一个json文件,不同版本的接口、以及不同的cni组件,有着不同的配置内容结构,目前比较通用的接口版本是0.3.1的版本。

在配置文件中我们可以填入多个cni组件,当这些cni组件的配置以数组形式记录时,kubelet会对所有的组件进行按序链式调用,所有组件调用成功后,视为网络配置完成,过程中任何一步出现error,都会进行回滚的del操作。以保证操作流上的原子性。

几种基本的cni插件

cni插件按照代码中的存放目录可以分为三种:ipam、main、meta。

  • ipam cni用于管理ip和相关网络数据,配置网卡、ip、路由等。
  • main cni用于进行网络配置,比如创建网桥,vethpair、macvlan等。
  • meta cni有的是用于和第三方CNI插件进行适配,如flannel,也有的用于配置内核参数,如tuning

由于官方提供的cni组件就有很多,这里我们详细介绍一些使用率较高的组件。

ipam类CNI

ipam类型的cni插件,在执行add命令时会分配一个IP给调用者。执行del命令时会将调用者指定的ip放回ip池。社区开源的ipam有host-local、dhcp。

host-local

我们可以通过host-local的配置文件的数据结构来搞懂这个组件是如何管理ip的。

type IPAMConfig struct {
    *Range
    Name       string
    Type       string         `json:"type"`
    Routes     []*types.Route `json:"routes"`//交付的ip对应的路由
    DataDir    string         `json:"dataDir"`//本地ip池的数据库目录
    ResolvConf string         `json:"resolvConf"`//交付的ip对应的dns
    Ranges     []RangeSet     `json:"ranges"`//交付的ip所属的网段,网关信息
    IPArgs     []net.IP       `json:"-"` // Requested IPs from CNI_ARGS and args
}

#配置文件范例:
{
    "cniVersion": "0.3.1",
    "name": "mynet",
    "type": "ipvlan",
    "master": "foo0",
    "ipam": {
        "type": "host-local",
        "resolvConf": "/home/here.resolv",
        "dataDir": "/home/cni/network",
        "ranges": [
            [
                {
                    "subnet": "10.1.2.0/24",
                    "rangeStart": "10.1.2.9",
                    "rangeEnd": "10.1.2.20",
                    "gateway": "10.1.2.30"
                },
                {
                    "subnet": "10.1.4.0/24"
                }
            ],
            [{
                "subnet": "11.1.2.0/24",
                "rangeStart": "11.1.2.9",
                "rangeEnd": "11.1.2.20",
                "gateway": "11.1.2.30"
            }]
        ]
    }
}

从上面的配置我们可以清楚:

  • host-local组件通过在配置文件中指定的subnet进行网络划分
  • host-local在本地通过指定目录(默认为/var/lib/cni/networks)记录当前的ip pool数据
  • host-local将IP分配并告知调用者时,还可以告知dns、路由等配置信息。这些信息通过配置文件和对应的resolv文件记录。

host-local的应用范围比较广,kubenet、bridge、ptp、ipvlan等cni network插件都被用来和host-local配合进行ip管理。

dhcp

社区的cni组件中就包含了dhcp这个ipam,但并没有提供一个可以参考的案例,翻看了相关的源码,大致逻辑是:

  • 向dhcp申请ip时,dhcp会使用rpc访问本地的socket(/run/cni/dhcp.sock)申请一个ip的租约。然后将IP告知调用者。
  • 向dhcp删除IP时,dhcp同样通过rpc请求,解除该IP的租约。

main(network)类CNI

main类型的cni组件做的都是一些核心功能,比如配置网桥、配置各种虚拟化的网络接口(veth、macvlan、ipvlan等)。这里我们着重讲使用率较高的bridge和ptp。

bridge

brige模式,即网桥模式。在node上创建一个linux bridge,并通过vethpair的方式在容器中设置网卡和IP。只要为容器配置一个二层可达的网关:比如给网桥配置IP,并设置为容器ip的网关。容器的网络就能建立起来。

如下是bridge的配置项数据结构:

type NetConf struct {
    types.NetConf
    BrName       string `json:"bridge"` //网桥名
    IsGW         bool   `json:"isGateway"`  //是否将网桥配置为网关
    IsDefaultGW  bool   `json:"isDefaultGateway"` //
    ForceAddress bool   `json:"forceAddress"`//如果网桥已存在且已配置了其他IP,通过此参数决定是否将其他ip除去
    IPMasq       bool   `json:"ipMasq"`//如果true,配置私有网段到外部网段的masquerade规则
    MTU          int    `json:"mtu"`
    HairpinMode  bool   `json:"hairpinMode"`
    PromiscMode  bool   `json:"promiscMode"`
}

我们关注其中的一部分字段,结合代码可以大致整理出bridge组件的工作内容。首先是ADD命令:

  • 执行ADD命令时,brdige组件创建一个指定名字的网桥,如果网桥已经存在,就使用已有的网桥;
  • 创建vethpair,将node端的veth设备连接到网桥上;
  • 从ipam获取一个给容器使用的ip数据,并根据返回的数据计算出容器对应的网关;
  • 进入容器网络名字空间,修改容器中网卡名和网卡ip,以及配置路由,并进行arp广播(注意我们只为vethpair的容器端配置ip,node端是没有ip的);
  • 如果IsGW=true,将网桥配置为网关,具体方法是:将第三步计算得到的网关IP配置到网桥上,同时根据需要将网桥上其他ip删除。最后开启网桥的ip_forward内核参数;
  • 如果IPMasq=true,使用iptables增加容器私有网网段到外部网段的masquerade规则,这样容器内部访问外部网络时会进行snat,在很多情况下配置了这条路由后容器内部才能访问外网。(这里代码中会做exist检查,防止生成重复的iptables规则);
  • 配置结束,整理当前网桥的信息,并返回给调用者。

其次是DEL命令:

  • 根据命令执行的参数,确认要删除的容器ip,调用ipam的del命令,将IP还回IP pool;
  • 进入容器的网络名字空间,根据容器IP将对应的网卡删除;
  • 如果IPMasq=true,在node上删除创建网络时配置的几条iptables规则。

ptp

ptp其实是bridge的简化版。但是它做的网络配置其实看上去倒是更复杂了点。并且有一些配置在自测过程中发现并没有太大用处。它只创建vethpair,但是会同时给容器端和node端都配置一个ip。容器端配置的是容器IP,node端配置的是容器IP的网关(/32),同时,容器里做了一些特殊配置的路由,以满足让容器发出的arp请求能被vethpair的node端响应。实现内外的二层连通。

ptp的网络配置步骤如下:

  • 从ipam获取IP,根据ip类型(ipv4或ipv6)配置响应的内核ip_forward参数;
  • 创建一对vethpair;一端放到容器中;
  • 进入容器的网络namespace,配置容器端的网卡,修改网卡名,配置IP,并配置一些路由。假如容器ip是10.18.192.37/20,所属网段是10.18.192.0/20,网关是10.18.192.1,我们这里将进行这样的配置:

    • 配置IP后,内核会自动生成一条路由,形如:10.18.192.0/20 dev eth0 scope link,我们将它删掉:ip r d ****
    • 配置一条私有网到网关的真实路由:ip r a 10.18.192.0/20 via 10.18.192.1 dev eth0
    • 配置一条到网关的路由:10.18.192.1/32 dev eth0 scope link
  • 退出到容器外,将vethpair的node端配置一个IP(ip为容器ip的网关,mask=32);
  • 配置外部的路由:访问容器ip的请求都路由到vethpair的node端设备去。
  • 如果IPMasq=true,配置iptables
  • 获取完整的网卡信息(vethpair的两端),返回给调用者。

与bridge不同主要的不同是:ptp不使用网桥,而是直接使用vethpair+路由配置,这个地方其实有很多其他的路由配置可以选择,一样可以实现网络的连通性,ptp配置的方式只是其中之一。万变不离其宗的是:

只要容器内网卡发出的arp请求,能被node回复或被node转发并由更上层的设备回复,形成一个二层网络,容器里的数据报文就能被发往node上;然后通过node上的路由,进行三层转发,将数据报文发到正确的地方,就可以实现网络的互联。

bridge和ptp其实是用了不同方式实现了这个原则中的“二层网络”:

  • bridge组件给网桥配置了网关的IP,并给容器配置了到网关的路由。实现二层网络
  • ptp组件给vethpair的对端配置了网关的IP,并给容器配置了单独到网关IP的路由,实现二层网络

ptp模式的路由还存在一个问题:没有配置default路由,因此容器不能访问外部网络,要实现也很简单,以上面的例子,在容器里增加一条路由:default via 10.18.192.1 dev eth0

host-device

相比前面两种cni main组件,host-device显得十分简单因为他就只会做两件事情:

  • 收到ADD命令时,host-device根据命令参数,将网卡移入到指定的网络namespace(即容器中)。
  • 收到DEL命令时,host-device根据命令参数,将网卡从指定的网络namespace移出到root namespace。

细心的你肯定会注意到,在bridge和ptp组件中,就已经有“将vethpair的一端移入到容器的网络namespace”的操作。那这个host-device不是多此一举吗?

并不是。host-device组件有其特定的使用场景。假设集群中的每个node上有多个网卡,其中一个网卡配置了node的IP。而其他网卡都是属于一个网络的,可以用来做容器的网络,我们只需要使用host-device,将其他网卡中的某一个丢到容器里面就行。

host-device模式的使用场景并不多。它的好处是:bridge、ptp等方案中,node上所有容器的网络报文都是通过node上的一块网卡出入的,host-device方案中每个容器独占一个网卡,网络流量不会经过node的网络协议栈,隔离性更强。缺点是:在node上配置数十个网卡,可能并不好管理;另外由于不经过node上的协议栈,所以kube-proxy直接废掉。k8s集群内的负载均衡只能另寻他法了。

macvlan

有关macvlan的实践可以参考这篇文章。这里做一个简单的介绍:macvlan是linux kernal的特性,用于给一个物理网络接口(parent)配置虚拟化接口,虚拟化接口与parent网络接口拥有不同的mac地址,但parent接口上收到发给其对应的虚拟化接口的mac的包时,会分发给对应的虚拟化接口,有点像是将虚拟化接口和parent接口进行了'桥接'。给虚拟化网络接口配置了IP和路由后就能互相访问。

macvlan省去了linux bridge,但是配置macvlan后,容器不能访问parent接口的IP。

ipvlan

ipvlan与macvlan有点类似,但对于内核要求更高(3.19),ipvlan也会从一个网络接口创建出多个虚拟网络接口,但他们的mac地址是一样的, 只是IP不一样。通过路由可以实现不同虚拟网络接口之间的互联。

使用ipvlan也不需要linux bridge,但容器一样不能访问parent接口的IP。
关于ipvlan的内容可以参考这篇文章

关于macvlan和ipvlan,还可以参考这篇文章

meta 类CNI

meta组件通常进行一些额外的网络配置(tuning),或者二次调用(flannel)。

tuning

用于进行内核网络参数的配置。并将调用者的数据和配置后的内核参数返回给调用者。

有时候我们需要配置一些虚拟网络接口的内核参数,比如:网易云在早期经典网络方案中曾修改vethpair的proxy_arp参数(后面会介绍)。可以通过这个组件进行配置。
另外一些可能会改动的网络参数比如:

  • accept_redirects
  • send_redirects
  • proxy_delay
  • accept_local
  • arp_filter

可以在这里查看可配置的网络参数和释义。

portmap

用于在node上配置iptables规则,进行SNAT,DNAT和端口转发。

portmap组件通常在main组件执行完毕后执行,因为它的执行参数仰赖之前的组件提供

flannel

cni plugins中的flannel是开源网络方案flannel的“调用器”。这也是flannel网络方案适配CNI架构的一个产物。为了便于区分,以下我们称cni plugins中的flannel 为flanenl cni

我们知道flannel是一个容器的网络方案,通常使用flannel时,node上会运行一个daemon进程:flanneld,这个进程会返回该node上的flannel网络、subnet,MTU等信息。并保存到本地文件中。

如果对flannel网络方案有一定的了解,会知道他在做网络接口配置时,其实干的事情和bridge组件差不多。只不过flannel网络下的bridge会跟flannel0网卡互联,而flannel0网卡上的数据会被封包(udp、vxlan下)或直接转发(host-gw)。

flannel cni做的事情就是:

  • 执行ADD命令时,flannel cni会从本地文件中读取到flanneld的配置。然后根据命令的参数和文件的配置,生成一个新的cni配置文件(保存在本地,文件名包含容器id以作区分)。新的cni配置文件中会使用其他cni组件,并注入相关的配置信息。之后,flannel cni根据这个新的cni配置文件执行ADD命令。
  • 执行DEL命令时,flannel cni从本地根据容器id找到之前创建的cni配置文件,根据该配置文件执行DEL命令。

也就是说flannel cni此处是一个flannel网络模型的委托者,falnnel网络模型委托它去调用其他cni组件,进行网络配置。通常调用的是bridge和host-local。

几种常见的网络方案

上述所有的cni组件,能完成的事情就是建立容器到虚拟机上的网络。而要实现跨虚拟机的容器之间的网络,有几种可能的办法:

  • 容器的IP就是二层网络里分配的IP,这样容器相当于二层网络里的节点,那么就可以天然互访;
  • 容器的IP与node的IP不属于同一个网段,node上配置个到各个网段的路由(指向对应容器网段所部属的node IP),通过路由实现互访[flannel host-gw, calico bgp均是通过此方案实现];
  • 容器的IP与node的IP不属于同一个网段,node上有服务对容器发出的包进行封装,对发给容器的包进行解封。封装后的包通过node所在的网络进行传输。解封后的包通过网桥或路由直接发给容器,即overlay网络。[flannel udp/vxlan,calico ipip,openshift-sdn均通过此方案实现]

kubenet

了解常用的网络方案前,我们先了解一下kubenet,kubenet其实是k8s代码中内置的一个cni组件。如果我们要使用kubenet,就得在kubelet的启动参数中指定networkPlugin值为kubenet而不是cni

如果你阅读了kubernetes的源码,你就可以在一个名为kubenet_linux.go的文件中看到kubenet做了什么事情:

  • 身为一种networkPlugin,kubenet自然要实现networkPlugin的一些接口。比如SetUpPod,TearDownPod,GetPodNetworkStatus等等,kubelet通过这些接口进行容器网络的创建、解除、查询。
  • 身为一个代码中内置的cni,kubenet要主动生成一个cni配置文件(字节流数据),自己按照cni的规矩去读取配置文件,做类似ADD/DEL指令的工作。实现网络的创建、解除。

设计上其实挺蠢萌的。实际上是为了省事。我们可以看下自生成的配置文件:

{
  "cniVersion": "0.1.0",
  "name": "kubenet",
  "type": "bridge",
  "bridge": "%s", //通常这里默认是“cbr0”
  "mtu": %d,    //kubelet的启动参数中可以配置,默认使用机器上的最小mtu
  "addIf": "%s", //配置到容器中的网卡名字
  "isGateway": true,
  "ipMasq": false,
  "hairpinMode": %t, 
  "ipam": {
    "type": "host-local",
    "subnet": "%s", //node上容器ip所属子网,通常是kubelet的pod-cidr参数指定
    "gateway": "%s", //通过subnet可以确定gateway
    "routes": [
      { "dst": "0.0.0.0/0" }
    ]
  }
}

配置文件中明确了要使用的其他cni组件:bridge、host-local(这里代码中还会调用lo组件,通常lo组件会被k8s代码直接调用,所以不需要写到cni配置文件中)。之后的事情就是执行二进制而已。

为什么我们要学习kubenet?因为kubenet可以让用户以最简单的成本(配置networkPlugin和pod-cidr两个启动kubelet启动参数),配置出一个简单的、虚拟机本地的容器网络。结合上面提到的几种“跨虚拟机的容器之间的网络方案”,就是一个完整的k8s集群网络方案了。

通常kubenet不适合用于overlay网络方案,因为overlay网络方案定制化要求会比较高。

许多企业使用vpc网络时,使用自定义路由实现不同pod-cidr之间的路由,他们的网络方案里就会用到kubenet,比如azure AKS(基础网络)。

flannel

关于flannel,上面的文章也提到了一下。网上flannel的文章也是一搜一大把。这里简单介绍下flannel对k8s的支持,以及通用的几个flannel backend(后端网络配置方案)。

flannel for kubernetes

flannel在对kubernets进行支持时,flanneld启动参数中会增加--kube-subnet-mgr参数,flanneld会初始化一个kubernetes client,获取本地node的pod-cidr,这个pod-cidr将会作为flannel为node本地容器规划的ip网段。记录到/run/flannel/subnet.env。(flannel_cni组件会读取这个文件并写入到net-conf.json中,供cni使用)。

udp/vxlan

flannel的overlay方案。每个node节点上都有一个flanneld进程,和flannel0网桥,容器网络会与flannel0网桥互联,并经由flannel0发出,所以flanneld可以捕获到容器发出的报文,进行封装。udp方案下会给报文包装一个udp的头部,vxlan下会给报文包装一个vxlan协议的头部(配置了相同VNI的node,就能进行互联)。 目前flannel社区还提供了更多实验性的封装协议选择,比如ipip,但仍旧将vxlan作为默认的backend。

host-gw

flannel的三层路由方案。每个node节点上都会记录其他节点容器ip段的路由,通过路由,node A上的容器发给node B上的容器的数据,就能在node A上进行转发。

alloc

类似kubenet,只分配子网,不做其他任何事情。

支持云厂商的vpc

flannel支持了aliVPC、gce、aws等云厂商的vpc网络。原理都是一样的,就是当flanneld在某云厂商的机器上运行时,根据机器自身的vpc网络IP,和flanneld分配在该机器上的subnet,调用云厂商的api创建对应的自定义路由。

calico

calico是基于BGP路由实现的容器集群网络方案,对于使用者来说,基础的calico使用体验可能和flannel host-gw是基本一样的:node节点上做好对容器arp的响应。然后通过node上的路由将容器发出的包转发到对端容器所在node的IP。对端节点上再将包转发给对端容器。

ipip模式则如同flannel ipip模式。对报文封装一个ipip头部,头部中使用node ip。发送到对端容器所在node的IP,对端的网络组件再解包,并转发给容器。

不同之处在于flannel方案下路由都是通过代码逻辑进行配置。而calico则在每个节点建立bgp peer,bgp peer彼此之间会进行路由的共享和学习,所以自动生成并维护了路由。

一些大厂的容器服务网络方案

阿里云

通过上文flannel aliVPC模式可见一斑。阿里云中kubernetes服务里,k8s集群通常使用自定义路由的方案+flannel_cni组件,这个方案易于部署和管理,同时将容器IP和nodeIP区分,用户可以自定义集群网络范围。

(比较奇怪的是这里flanenl的backend配置成alloc而非aliVPC,在集群中另外部署了一个controller进行自定义路由的配置)

自定义路由是vpc网络中的一个常用功能,在vpc范围内可以自定义某个网络接口作为一个任意网段的网关。在flannel host-gw模式中,我们将这块的路由配置在node上,由内核执行,而自定义路由则是将类似的路由记录到vpc网络的数据库中,由vpc-router去执行。

azure

azure最近开放了kubernetes服务AKS,AKS支持两种网络方案:基础和高级。

基础网络方案与阿里云的自定义路由方案如出一辙。基础网络中k8s集群使用的网络组件是kubenet,简单的做了网络划分和本地的网络接口配置,自定义路由由其vpc实现。

高级网络方案中,node上的网络接口会创建并绑定多个(默认三十个)fixedIP,主FixedIP作为node IP,其余fixedIP则用于容器IP。
通过azure SDN的支持,不同node之间的容器网络变成一个大二层,他们可以直接互联。高级网络方案中,k8s集群使用azure开源的cni组件:azure-container-networking。这个cni组件包括了ipam和main两部分

azure cni的ipam负责将本地网络接口上绑定着的空闲的fixedIP配置给容器使用。一旦空闲的fixedIP耗尽,除非手动给网卡创建新的fixedIP,否则容器无法创建成功。

azure cni的main组件在node上创建了一个bridge,将node的网卡连接到网桥上,并将node网卡IP设置到网桥上,容器网卡均由vethpair实现,vethpair的node端也是连在网桥上。由此构成node的网络:网桥上的IP作为容器网络的网关,容器网络通过网桥与其他节点形成一个大二层的网络。

查看原文

manshu 收藏了文章 · 2月26日

kubebuilder2.0学习笔记——进阶使用

概述

本篇将继续深入学习kubebuilder开发,并介绍一些深入使用时遇到的问题。包括:finalizer、控制器对CRD的update status、kubebuilder注释等。并且会分享一些在开发过程中使用的小技巧

status

我们先看一个新建的crd的结构体:

// BucketStatus defines the observed state of Bucket
type BucketStatus struct {
    // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
    // Important: Run "make" to regenerate code after modifying this file
    Progress int32 `json:"progress"`
}

// +kubebuilder:object:root=true

// Bucket is the Schema for the buckets API
type Bucket struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec   BucketSpec   `json:"spec,omitempty"`
    Status BucketStatus `json:"status,omitempty"`
}

这里,Spec和Status均是Bucket的成员变量,Status并不像Pod.Status一样,是PodsubResource.因此,如果我们在controller的代码中调用到Status().Update(),会触发panic,并报错:the server could not find the requested resource

如果我们想像k8s中的设计那样,那么就要遵循k8s中status subresource的使用规范:

  • 用户只能指定一个CRD实例的spec部分;
  • CRD实例的status部分由控制器进行变更。

设计subresource风格的status

  • 需要在Bucket的注释中添加一行// +kubebuilder:subresource:status,变成如下:

    // +kubebuilder:subresource:status
    // +kubebuilder:object:root=true
    
    // Bucket is the Schema for the buckets API
    type Bucket struct {
        metav1.TypeMeta   `json:",inline"`
        metav1.ObjectMeta `json:"metadata,omitempty"`
    
        Spec   BucketSpec   `json:"spec,omitempty"`
        Status BucketStatus `json:"status,omitempty"`
    }
  • 创建Bucket资源时,即便我们填入了非空的status结构,也不会更新到apiserver中。Status只能通过对应的client进行更新。比如在controller中:

    if bucket.Status.Progress == 0 {
          bucket.Status.Progress = 1
          err := r.Status().Update(ctx, &bucket)
          if err != nil {
              return ctrl.Result{}, err
          }
      }

    这样,只要bucket实例的status.Progress为0时(比如我们创建一个bucket实例时,由于status.Progress无法配置,故初始化为默认值,即0),controller就会帮我们将它变更为1.

注意:
kubebuilder 2.0开发生成的crd模板,无法通过apiserver的crd校验。社区有相关的记录和修复https://github.com/kubernetes...,但是这个修复没有针对1.11.*版本。
所以1.11.*版本的k8s,要使用kubebuilder 2.0 必须给apiserver配置一个featuregate: - --feature-gates=CustomResourceValidation=false,关闭对crd的校验。

finalizer

finalizer即终结器,存在于每一个k8s内的资源实例中,即**.metadata.finalizers,它是一个字符串数组,每一个成员表示一个finalizer。控制器在删除某个资源时,会根据该资源的finalizers配置,进行异步预删除处理,所有的finalizer都执行完毕后,该资源会被真正删除。

这里的预删除处理,一般指对该资源的关联资源进行增删改操作。比如:一个A资源被删除时,其finalizer规定必须将A资源的Selector指向的所有service都删除。

当我们需要设计这类finalizer时,就可以自定义一个controller来实现。

因为finalizer的存在,资源的Delete操作,演变成了一个Update操作:给资源加入一个deletiontimestamp。我们设计controller时,需要对这个字段做好检查。

范例

我们设计一个Bucket类和一个Playbook类,Playbook.Spec.Selector是一个选择器,可以通过该选择器找到对应的Bucket。Playbook控制器需要做以下事情:

  • 如果一个Playbook对象没有删除时间戳(被创建或更新),我们检查并配置一个finalizer:testdelete给它
  • 如果一个Playbook有删除时间戳(被删除),我们检查是否该对象的finalizer包含testdelete.
  • 如果包含,我们检查该Playbook对象的spec.Selector是否不为空
  • 如果不为空,我们根据spec.Selector List相同namespace下所有的bucket,并将它们一一删除

Reconcile函数中增加如下代码:

    myplaybookFinalizerName := "testdelete"
    if book.ObjectMeta.DeletionTimestamp.IsZero() {
        if !containsString(book.ObjectMeta.Finalizers, myplaybookFinalizerName) {
            book.ObjectMeta.Finalizers = append(book.ObjectMeta.Finalizers, myplaybookFinalizerName)
            err := r.Update(ctx, &book)
            if err != nil {
                return ctrl.Result{}, err
            }
        }
    } else {
        if containsString(book.ObjectMeta.Finalizers, myplaybookFinalizerName) && book.Spec.Selector != nil {
            bList := &opsv1.BucketList{}
            err := r.List(ctx, bList, client.InNamespace(book.Namespace), client.MatchingLabels(book.Spec.Selector))
            if err != nil {
                return ctrl.Result{}, fmt.Errorf("can't find buckets match playbook, %s", err.Error())
            }
            for _, b := range bList.Items {
                err = r.Delete(ctx, &b)
                if err != nil {
                    return ctrl.Result{}, fmt.Errorf("can't delete buckets %s/%s, %s",b.Namespace, b.Name, err.Error())
                }
            }
            book.ObjectMeta.Finalizers = removeString(book.ObjectMeta.Finalizers, myplaybookFinalizerName)
            err = r.Update(ctx, &book)
            return ctrl.Result{}, err
        }
    }

cluster-scope

k8s中node、pv等资源是集群级别的,它们没有namespace字段,因此查询node资源时也无需规定要从哪个namespace查。

我们在进行k8s operator时经常也需要设计这样的字段,但是默认情况下,kubebuilder会给我们创建namespace scope的crd资源,可以通过如下方式修改:

在执行kubebuilder create api **** 后,我们在生成的资源的*_types.go文件中,找到资源的主结构体,增加一条注释kubebuilder:resource:scope=Cluster,比如:

// +kubebuilder:object:root=true
// +kubebuilder:resource:scope=Cluster

// Bookbox is the Schema for the bookboxes API
type Bookbox struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec   BookboxSpec   `json:"spec,omitempty"`
    Status BookboxStatus `json:"status,omitempty"`
}

这样执行make install,会在config/crd/bases/目录下生成对应的crd的yaml文件,里面就申明了该crd的scope:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  creationTimestamp: null
  name: bookboxes.ops.netease.com
spec:
  group: ops.netease.com
  names:
    kind: Bookbox
    plural: bookboxes
  scope: Cluster
  **

需要注意的是,// +kubebuilder:resource:scope=Cluster这句注释,必须放在结构体的上方、且必须是最靠近该结构体的一条kubebuilder注释,否则会失效。

kubebuilder 注释标记

我们注意到,在设计subresource风格的statuscluster-scope中我们都是用kubebuilder的注释标记,实现我们想要的资源形态,这里有更多关于注释标记的说明,比如:令crd支持kubectl scale,对crd实例进行基础的值校验,允许在kubectl get命令中显示crd的更多字段,等等.此处举两例:

kubectl get 时显示crd的status.replicas:

// +kubebuilder:printcolumn:JSONPath=".status.replicas",name=Replicas,type=string

限定字段的值为固定的几个:

type Host struct {
    ..
    Spec HostSpec
}
type HostSpec struct {
// +kubebuilder:validation:Enum=Wallace;Gromit;Chicken
    HostName string
}

kubebuilder 的log

kubebuilder的log使用了第三方包"github.com/go-logr/logr"。当我们在开发reconciler时,如果需要在某处打日志,我们需要在Reconcile方法中将

_ = r.Log.WithValues("playbook", req.NamespacedName)

改为

log := r.Log.WithValues("playbook", req.NamespacedName)

从而获得一个logger实例。之后的逻辑中,我们可以执行:

log.Info("this is the message", $KEY, $VALUE)

注意,这里KEY和VALUE都是interface{}结构,可以是字符串或整型等,他们表示在上下文中记录的键值对,反映到程序日志中,会是这个样子:

// code:
  log.Info("will try get bucket from changed","bucket-name", req.NamespacedName)
  
// output:
  2019-09-11T11:53:58.017+0800    INFO    controllers.Playbook    will try get bucket from changed    {"playbook": "default/playbook-sample", "bucket-name": {"namespace": "default", "name": "playbook-sample"}}

logr包提供的logger只有Info和Error两种类型,但可以通过V(int)配置日志级别。不管是Info还是Error,都采用上面例子的格式,即:

log.Info(string, {key, value} * n )
log.Error(string, {key, value} * n )
n>=0

如果不遵循这种格式,运行期间会抛出panic。

给Reconciler做扩展

增加eventer

我们需要在某些时候创建k8s event进行事件记录,但Reconciler默认是只有一个Client接口和一个Logger的:

type PlaybookReconciler struct {
    client.Client
    Log logr.Logger
}

我们可以往struct中添油加醋:

type PlaybookReconciler struct {
    client.Client
    Eventer record.EventRecorder
    Log logr.Logger
} 

PlaybookReconciler的初始化在main.go中,kubebuilder设计的manager自带了事件广播的生成方法,直接使用即可:

if err = (&controllers.PlaybookReconciler{
        Client: mgr.GetClient(),
        Eventer: mgr.GetEventRecorderFor("playbook-controller"),
        Log:    ctrl.Log.WithName("controllers").WithName("Playbook"),
    }).SetupWithManager(mgr); err != nil {
        setupLog.Error(err, "unable to create controller", "controller", "Playbook")
        os.Exit(1)
    }

注意这里要给我们的控制器配置好rbac,在kubebuilder 2.3.0中,可以在控制器代码文件的注释中添加:

// +kubebuilder:rbac:groups="",resources=events,verbs=get;list;watch;create;update;patch

reconciler监控多个资源变化

我们在开发过程中,可能需要开发一个类似service-->selector-->pods的资源逻辑,那么,在service的reconciler里,我们关注service的seletor的配置,并且检查匹配的pods是否有所变更(增加或减少),并更新到同名的endpoints里;同时,我们还要关注pod的更新,如果pod的label发生变化,那么要找出所有'之前匹配了这些pod'的service,检查service的selector是否仍然匹配pod的label,如有变动,也要更新endpoints。

这就意味着,我们需要能让reconciler能观察到service和pod两种资源的变更。我们在serviceReconciler的SetupWithManager方法中,可以看到:

func (r *ServiceReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&opsv1.Service{}).
        Complete(r)
}

只需要在For方法调用后再调用Watches方法即可:

func (r *ServiceReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&opsv1.Service{}).Watches(&source.Kind{Type: &opsv1.Pod{}}, &handler.EnqueueRequestForObject{}).
        Complete(r)
}

此外,我们可以将service设计为pod的owner,然后在podController的For方法后在调用Owns方法:

func (r *ServiceReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&opsv1.Service{}).Owns(&opsv1.Pod{}).
        Complete(r)
}

我们在Owns方法的定义注释中可以看到它与Watch方法其实是类似的:

// Owns defines types of Objects being *generated* by the ControllerManagedBy, and configures the ControllerManagedBy to respond to
// create / delete / update events by *reconciling the owner object*.  This is the equivalent of calling
// Watches(&handler.EnqueueRequestForOwner{&source.Kind{Type: <ForType-apiType>}, &handler.EnqueueRequestForOwner{OwnerType: apiType, IsController: true})
func (blder *Builder) Owns(apiType runtime.Object) *Builder {
    blder.managedObjects = append(blder.managedObjects, apiType)
    return blder
}

不论是For,Own,Watch,都是kubebuilder中的Builder提供的,Builder是kubebuilder开放给用户构建控制器的唯一合法入口(你还可以用更hack的手段去构建,可能对源码造成入侵),它还提供了许多有用的方法,可以让我们更灵活自由地初始化一个controller。

注意:kubebuilder 2.0中,构建一个reconciler时,可以用Own,Watch方法来额外监听一些资源,但是For方法必须要有,如果没有For方法,编译出来的程序运行时会报错,类似于"kind.Type should not be empty"

监听指定字段的变更

有时候我们想让自己的代码更加清晰,让控制器的工作更有针对性。比如上文中举了一个service通过selector绑定bod的设想:我们在service的controller中list一遍service实例的selector指向的pod,并与status中的pods记录进行对比,这意味着,所有对service和pod的操作,都会触发这个操作。

我们想要在控制器watch pod资源变更时,检查pod是否变更了label,如果label没有变更,就不去执行reconcile,以此省去反复的list pod操作带来的开销。要如何实现呢?

方法1:添加自定义的入队predicate

Builder为我们提供了另一个方法:

func (blder *Builder) WithEventFilter(p predicate.Predicate) *Builder

这个方法,是为Builder中每个Watch的对象设计一个变更过滤器:PredicatePredicate实现了几个方法:

type Predicate interface {
    // Create returns true if the Create event should be processed
    Create(event.CreateEvent) bool

    // Delete returns true if the Delete event should be processed
    Delete(event.DeleteEvent) bool

    // Update returns true if the Update event should be processed
    Update(event.UpdateEvent) bool

    // Generic returns true if the Generic event should be processed
    Generic(event.GenericEvent) bool
}

我们以此设计一个自己的predicate:

package controllers

import (
    "sigs.k8s.io/controller-runtime/pkg/predicate"
    "sigs.k8s.io/controller-runtime/pkg/event"
)

type ResourceLabelChangedPredicate struct {
    predicate.Funcs
}

func (rl *ResourceLabelChangedPredicate) Update (e event.UpdateEvent) bool{
    if !compareMaps(e.MetaOld.GetLabels(), e.MetaNew.GetLabels()) {
        return true
    }
    return false
}

然后修改注册控制器的方式:

func (r *ServiceReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&opsv1.Service{}).Watches(&source.Kind{Type: &opsv1.Pod{}}, &handler.EnqueueRequestForObject{}).WithEventFilter(&ResourceLabelChangedPredicate{}).
        Complete(r)
}

这样,ServiceReconciler在监听其关注的对象时,只会关注对象的label是否发生变更,只有当label发生变更时,才会入队并进入reconcile逻辑。

这个方法目前看应该是kubebuilder团队推荐使用的方法,但是有个问题是,加入了predicate后,会在Reconciler关注的所有的对象上生效。也就是说即使Service实例的label发生变更,也会触发reconcile。这不是我们想看到的,我们想看到的是Service的selector变更时会进行reconcile。这时候我们可能就需要在predicate中增加对象类型的判断,比如:

func (rl *ResourceLabelChangedPredicate) Update (e event.UpdateEvent) bool{
    oldobj, ok1 := e.ObjectOld.(*opsv1.Service)
    newobj, ok2 := e.ObjectNew.(*opsv1.Service)
    if ok1 && ok2 {
        if !compareMaps(oldobj.Spec.Selector, newobj.Spec.Selector) {
            return true
        } else {
            return false
        }
    }

    _, ok1 = e.ObjectOld.(*opsv1.Pod)
    _, ok2 = e.ObjectNew.(*opsv1.Pod)
    if ok1 && ok2 {
        if !compareMaps(e.MetaOld.GetLabels(), e.MetaNew.GetLabels()) {
            return true
        }
    }
    return false
}

记得通过注释添加rbac:

// +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch;create;update;patch;delete

方法2:自定义一个入队器

我们先看上面提到的Watch方法,这个方法允许用户自己设计handler.EventHandler接口,这个接口实现了Create,Update,Delete,Generic方法,用来在资源实例的不同生命阶段,进行判断与入队。

sigs.k8s.io/controller-runtime/pkg/handler/enqueue.go中就有一个默认的实现:EnqueueRequestForObject。我们可以参考它设计一个自己的接口实现——名为EnqueueRequestForLabelChanged的入队器.

重写该入队器的Update方法,改为判断新旧两个实例的label是否一致,不一致则进行入队:

func (e *EnqueueRequestForLabelChanged) Update(evt event.UpdateEvent, q workqueue.RateLimitingInterface) {
    if !compareMaps(evt.MetaOld.GetLabels(), evt.MetaNew.GetLabels()) {
        q.Add(reconcile.Request{NamespacedName: types.NamespacedName{
            Name: evt.MetaNew.GetName(),
            Namespace: evt.MetaNew.GetNamespace(),
        }})
    }
}

注册reconciler时,watches的eventhandler参数使用自定义的enqueue:

func (r *PlaybookReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&opsv1.Playbook{}).Watches(&source.Kind{Type: &opsv1.Bucket{}}, &EnqueueRequestForLabelChanged{}).
        Complete(r)
}

这样,ServiceReconciler将会监听service资源的所有变更,以及pod资源的label变更。

记得通过注释添加rbac:

// +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch;create;update;patch;delete

best way to watch

通过前文我们了解到:

  1. 使用WithEventFilter配置变更过滤器,可以针对reconcilerwatch的所有资源,统一地设置事件监听规则;
  2. 使用自己实现的EventHandler,可以在reconcilerwatch特定资源时,设置该资源的事件监听规则。

阅读controller-runtime的代码我们会发现,官方允许用户调用WithEventFilter配置变更过滤器,但没有提供一个公开的方法让用户配置入队器,用户只能自己主动实现。其实在1.X的kubebuilder中,Watch方法允许用户配置predicate,用户可以给不同资源配置不同的变更过滤器。但在2.0中,这个函数被重新封装,不再直接开放给用户。取而代之的是用WithEventFilter方法配置应用到所有资源的变更过滤器。

可能设计者认为,一个reconciler要负责的应该是一个/多个资源对象的一种/同种变化。

事实上,在开发operator的过程中,最好也是将一个reconciler的工作内容细粒度化。特别是:不应该在一个reconciler逻辑中进行两次资源的update(update status除外),否则会引发版本不一致的报错

使用非缓存的client

Reconciler中的client.Client是一个结构,提供了Get,List,Update,Delete等一系列k8s client的操作,但是其Get,List方法均是从cache中获取数据,如果Reconciler同步数据不及时(需要注意,实际上同步数据的是manager中的成员对象:cache,Reconciler直接引用了该对象),获取到的就是脏数据。

与EventRecorder类似地, manger中其实也初始化好了一个即时的client:apiReader,供我们使用,只需要调用mgr.GetAPIReader()即可获取。

注意到apiReader是一个只读client,,其使用方法与Reconciler的Client类似(Get方法,List方法):

r.ApiReader.Get(ctx, req.NamespacedName, bucket)

官方建议我们直接使用带cache的client即可,该client是一个分离的client,其读方法(get,list)均从一个cache中获取数据。写方法则直接更新到apiserver。

多版本切换

在crd的开发和演进过程中,必然会存在一个crd的不同版本。 kubebuilder支持以一个conversion webhook的方式,支持对一个crd资源以不同版本进行读取。简单地描述就是:

kubectl apply -f config/samples/batch_v2_cronjob.yaml

创建一个v2的cronjob后,可以通过v1和v2两种版本进行读取:

kubectl get cronjobs.v2.batch.tutorial.kubebuilder.io -o yaml
kubectl get cronjobs.v1.batch.tutorial.kubebuilder.io -o yaml

显然,get命令得到的v1和v2版本的cronjob会存在一些字段上的不同,conversion webhook会负责进行不同版本的cronjob之间的数据转换。

贴下学习资料:

https://book.kubebuilder.io/m...

在webhook中使用client

有时候我们需要在某个对象的webhook中查询集群中的其他资源,比如某个operator规定了一个PodBox,规定每个PodBox中只能有一个Pod,那么在validatecreate的webhook中就要ListPod By PodBox并计数。

kubebuilder 2.X 将webhook封装得太过简介,所以我们需要搞个新法子:

我们在types和webhook的目录下新建一个文件, 在里面构建一个全局client:

package v1

import (
    ctrl "sigs.k8s.io/controller-runtime"
    "sigs.k8s.io/controller-runtime/pkg/client"
)

var globalClient client.Client
var globalReader client.Reader

func InitClient(mgr ctrl.Manager) {
    globalClient = mgr.GetClient()
    globalReader = mgr.GetAPIReader()
}

在 main.go中, 各种SetupWithManager之前,先执行InitClient,初始化这些client, validateCreate方法中可以直接使用这些client。

添加自定义的webhook

我们开发的operator可能会需要对用户新建的pod进行注入,比如注入一些信息到annotations中, 也有可能要对原生对象的更新/删除操作进行判断,那么如何在我们的项目中添加这些对象的webhook?

社区提供了一个案例:https://github.com/kubernetes...

但是在该案例下,每次执行make generate时 会报错:

invalid field type interface{sigs.k8s.io/controller-runtime/pkg/client.Reader; sigs.k8s.io/controller-runtime/pkg/client.StatusClient; sigs.k8s.io/controller-runtime/pkg/client.Writer}

不过测试了一下 只要不执行generate,其他步骤都可以正常执行, 比如 make docker-build

使用索引

client-go支持构建带索引的缓存,并且允许用户自定义索引函数的名称和内容。当缓存的数据带有索引时,我们可以更快地通过索引List到想要的数据,既可以提高性能又可以减少代码量。

kubebuilder 2.0 提供了很简单的索引构建方式。 比如我们要以pod中的spec.NodeName为索引, 方便我们快速List查询某个节点上的pod:

// 要先构建好manager

mgr.GetFieldIndexer().IndexField(&v1.Pod{}, "indexNodeNameOfPod", func(o runtime.Object) []string {
        v := o.(*v1.Pod).Spec.NodeName
        return []string{v}
    })
    
    ...
    
// 使用manager的client进行List
podList := &v1.PodList{}
err := mgr.GetClient().List(context.TODO(), podList, &client.MatchingFields{"indexNodeNameOfPod": node.Name})
查看原文

manshu 赞了文章 · 2月26日

kubebuilder2.0学习笔记——进阶使用

概述

本篇将继续深入学习kubebuilder开发,并介绍一些深入使用时遇到的问题。包括:finalizer、控制器对CRD的update status、kubebuilder注释等。并且会分享一些在开发过程中使用的小技巧

status

我们先看一个新建的crd的结构体:

// BucketStatus defines the observed state of Bucket
type BucketStatus struct {
    // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
    // Important: Run "make" to regenerate code after modifying this file
    Progress int32 `json:"progress"`
}

// +kubebuilder:object:root=true

// Bucket is the Schema for the buckets API
type Bucket struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec   BucketSpec   `json:"spec,omitempty"`
    Status BucketStatus `json:"status,omitempty"`
}

这里,Spec和Status均是Bucket的成员变量,Status并不像Pod.Status一样,是PodsubResource.因此,如果我们在controller的代码中调用到Status().Update(),会触发panic,并报错:the server could not find the requested resource

如果我们想像k8s中的设计那样,那么就要遵循k8s中status subresource的使用规范:

  • 用户只能指定一个CRD实例的spec部分;
  • CRD实例的status部分由控制器进行变更。

设计subresource风格的status

  • 需要在Bucket的注释中添加一行// +kubebuilder:subresource:status,变成如下:

    // +kubebuilder:subresource:status
    // +kubebuilder:object:root=true
    
    // Bucket is the Schema for the buckets API
    type Bucket struct {
        metav1.TypeMeta   `json:",inline"`
        metav1.ObjectMeta `json:"metadata,omitempty"`
    
        Spec   BucketSpec   `json:"spec,omitempty"`
        Status BucketStatus `json:"status,omitempty"`
    }
  • 创建Bucket资源时,即便我们填入了非空的status结构,也不会更新到apiserver中。Status只能通过对应的client进行更新。比如在controller中:

    if bucket.Status.Progress == 0 {
          bucket.Status.Progress = 1
          err := r.Status().Update(ctx, &bucket)
          if err != nil {
              return ctrl.Result{}, err
          }
      }

    这样,只要bucket实例的status.Progress为0时(比如我们创建一个bucket实例时,由于status.Progress无法配置,故初始化为默认值,即0),controller就会帮我们将它变更为1.

注意:
kubebuilder 2.0开发生成的crd模板,无法通过apiserver的crd校验。社区有相关的记录和修复https://github.com/kubernetes...,但是这个修复没有针对1.11.*版本。
所以1.11.*版本的k8s,要使用kubebuilder 2.0 必须给apiserver配置一个featuregate: - --feature-gates=CustomResourceValidation=false,关闭对crd的校验。

finalizer

finalizer即终结器,存在于每一个k8s内的资源实例中,即**.metadata.finalizers,它是一个字符串数组,每一个成员表示一个finalizer。控制器在删除某个资源时,会根据该资源的finalizers配置,进行异步预删除处理,所有的finalizer都执行完毕后,该资源会被真正删除。

这里的预删除处理,一般指对该资源的关联资源进行增删改操作。比如:一个A资源被删除时,其finalizer规定必须将A资源的Selector指向的所有service都删除。

当我们需要设计这类finalizer时,就可以自定义一个controller来实现。

因为finalizer的存在,资源的Delete操作,演变成了一个Update操作:给资源加入一个deletiontimestamp。我们设计controller时,需要对这个字段做好检查。

范例

我们设计一个Bucket类和一个Playbook类,Playbook.Spec.Selector是一个选择器,可以通过该选择器找到对应的Bucket。Playbook控制器需要做以下事情:

  • 如果一个Playbook对象没有删除时间戳(被创建或更新),我们检查并配置一个finalizer:testdelete给它
  • 如果一个Playbook有删除时间戳(被删除),我们检查是否该对象的finalizer包含testdelete.
  • 如果包含,我们检查该Playbook对象的spec.Selector是否不为空
  • 如果不为空,我们根据spec.Selector List相同namespace下所有的bucket,并将它们一一删除

Reconcile函数中增加如下代码:

    myplaybookFinalizerName := "testdelete"
    if book.ObjectMeta.DeletionTimestamp.IsZero() {
        if !containsString(book.ObjectMeta.Finalizers, myplaybookFinalizerName) {
            book.ObjectMeta.Finalizers = append(book.ObjectMeta.Finalizers, myplaybookFinalizerName)
            err := r.Update(ctx, &book)
            if err != nil {
                return ctrl.Result{}, err
            }
        }
    } else {
        if containsString(book.ObjectMeta.Finalizers, myplaybookFinalizerName) && book.Spec.Selector != nil {
            bList := &opsv1.BucketList{}
            err := r.List(ctx, bList, client.InNamespace(book.Namespace), client.MatchingLabels(book.Spec.Selector))
            if err != nil {
                return ctrl.Result{}, fmt.Errorf("can't find buckets match playbook, %s", err.Error())
            }
            for _, b := range bList.Items {
                err = r.Delete(ctx, &b)
                if err != nil {
                    return ctrl.Result{}, fmt.Errorf("can't delete buckets %s/%s, %s",b.Namespace, b.Name, err.Error())
                }
            }
            book.ObjectMeta.Finalizers = removeString(book.ObjectMeta.Finalizers, myplaybookFinalizerName)
            err = r.Update(ctx, &book)
            return ctrl.Result{}, err
        }
    }

cluster-scope

k8s中node、pv等资源是集群级别的,它们没有namespace字段,因此查询node资源时也无需规定要从哪个namespace查。

我们在进行k8s operator时经常也需要设计这样的字段,但是默认情况下,kubebuilder会给我们创建namespace scope的crd资源,可以通过如下方式修改:

在执行kubebuilder create api **** 后,我们在生成的资源的*_types.go文件中,找到资源的主结构体,增加一条注释kubebuilder:resource:scope=Cluster,比如:

// +kubebuilder:object:root=true
// +kubebuilder:resource:scope=Cluster

// Bookbox is the Schema for the bookboxes API
type Bookbox struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec   BookboxSpec   `json:"spec,omitempty"`
    Status BookboxStatus `json:"status,omitempty"`
}

这样执行make install,会在config/crd/bases/目录下生成对应的crd的yaml文件,里面就申明了该crd的scope:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  creationTimestamp: null
  name: bookboxes.ops.netease.com
spec:
  group: ops.netease.com
  names:
    kind: Bookbox
    plural: bookboxes
  scope: Cluster
  **

需要注意的是,// +kubebuilder:resource:scope=Cluster这句注释,必须放在结构体的上方、且必须是最靠近该结构体的一条kubebuilder注释,否则会失效。

kubebuilder 注释标记

我们注意到,在设计subresource风格的statuscluster-scope中我们都是用kubebuilder的注释标记,实现我们想要的资源形态,这里有更多关于注释标记的说明,比如:令crd支持kubectl scale,对crd实例进行基础的值校验,允许在kubectl get命令中显示crd的更多字段,等等.此处举两例:

kubectl get 时显示crd的status.replicas:

// +kubebuilder:printcolumn:JSONPath=".status.replicas",name=Replicas,type=string

限定字段的值为固定的几个:

type Host struct {
    ..
    Spec HostSpec
}
type HostSpec struct {
// +kubebuilder:validation:Enum=Wallace;Gromit;Chicken
    HostName string
}

kubebuilder 的log

kubebuilder的log使用了第三方包"github.com/go-logr/logr"。当我们在开发reconciler时,如果需要在某处打日志,我们需要在Reconcile方法中将

_ = r.Log.WithValues("playbook", req.NamespacedName)

改为

log := r.Log.WithValues("playbook", req.NamespacedName)

从而获得一个logger实例。之后的逻辑中,我们可以执行:

log.Info("this is the message", $KEY, $VALUE)

注意,这里KEY和VALUE都是interface{}结构,可以是字符串或整型等,他们表示在上下文中记录的键值对,反映到程序日志中,会是这个样子:

// code:
  log.Info("will try get bucket from changed","bucket-name", req.NamespacedName)
  
// output:
  2019-09-11T11:53:58.017+0800    INFO    controllers.Playbook    will try get bucket from changed    {"playbook": "default/playbook-sample", "bucket-name": {"namespace": "default", "name": "playbook-sample"}}

logr包提供的logger只有Info和Error两种类型,但可以通过V(int)配置日志级别。不管是Info还是Error,都采用上面例子的格式,即:

log.Info(string, {key, value} * n )
log.Error(string, {key, value} * n )
n>=0

如果不遵循这种格式,运行期间会抛出panic。

给Reconciler做扩展

增加eventer

我们需要在某些时候创建k8s event进行事件记录,但Reconciler默认是只有一个Client接口和一个Logger的:

type PlaybookReconciler struct {
    client.Client
    Log logr.Logger
}

我们可以往struct中添油加醋:

type PlaybookReconciler struct {
    client.Client
    Eventer record.EventRecorder
    Log logr.Logger
} 

PlaybookReconciler的初始化在main.go中,kubebuilder设计的manager自带了事件广播的生成方法,直接使用即可:

if err = (&controllers.PlaybookReconciler{
        Client: mgr.GetClient(),
        Eventer: mgr.GetEventRecorderFor("playbook-controller"),
        Log:    ctrl.Log.WithName("controllers").WithName("Playbook"),
    }).SetupWithManager(mgr); err != nil {
        setupLog.Error(err, "unable to create controller", "controller", "Playbook")
        os.Exit(1)
    }

注意这里要给我们的控制器配置好rbac,在kubebuilder 2.3.0中,可以在控制器代码文件的注释中添加:

// +kubebuilder:rbac:groups="",resources=events,verbs=get;list;watch;create;update;patch

reconciler监控多个资源变化

我们在开发过程中,可能需要开发一个类似service-->selector-->pods的资源逻辑,那么,在service的reconciler里,我们关注service的seletor的配置,并且检查匹配的pods是否有所变更(增加或减少),并更新到同名的endpoints里;同时,我们还要关注pod的更新,如果pod的label发生变化,那么要找出所有'之前匹配了这些pod'的service,检查service的selector是否仍然匹配pod的label,如有变动,也要更新endpoints。

这就意味着,我们需要能让reconciler能观察到service和pod两种资源的变更。我们在serviceReconciler的SetupWithManager方法中,可以看到:

func (r *ServiceReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&opsv1.Service{}).
        Complete(r)
}

只需要在For方法调用后再调用Watches方法即可:

func (r *ServiceReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&opsv1.Service{}).Watches(&source.Kind{Type: &opsv1.Pod{}}, &handler.EnqueueRequestForObject{}).
        Complete(r)
}

此外,我们可以将service设计为pod的owner,然后在podController的For方法后在调用Owns方法:

func (r *ServiceReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&opsv1.Service{}).Owns(&opsv1.Pod{}).
        Complete(r)
}

我们在Owns方法的定义注释中可以看到它与Watch方法其实是类似的:

// Owns defines types of Objects being *generated* by the ControllerManagedBy, and configures the ControllerManagedBy to respond to
// create / delete / update events by *reconciling the owner object*.  This is the equivalent of calling
// Watches(&handler.EnqueueRequestForOwner{&source.Kind{Type: <ForType-apiType>}, &handler.EnqueueRequestForOwner{OwnerType: apiType, IsController: true})
func (blder *Builder) Owns(apiType runtime.Object) *Builder {
    blder.managedObjects = append(blder.managedObjects, apiType)
    return blder
}

不论是For,Own,Watch,都是kubebuilder中的Builder提供的,Builder是kubebuilder开放给用户构建控制器的唯一合法入口(你还可以用更hack的手段去构建,可能对源码造成入侵),它还提供了许多有用的方法,可以让我们更灵活自由地初始化一个controller。

注意:kubebuilder 2.0中,构建一个reconciler时,可以用Own,Watch方法来额外监听一些资源,但是For方法必须要有,如果没有For方法,编译出来的程序运行时会报错,类似于"kind.Type should not be empty"

监听指定字段的变更

有时候我们想让自己的代码更加清晰,让控制器的工作更有针对性。比如上文中举了一个service通过selector绑定bod的设想:我们在service的controller中list一遍service实例的selector指向的pod,并与status中的pods记录进行对比,这意味着,所有对service和pod的操作,都会触发这个操作。

我们想要在控制器watch pod资源变更时,检查pod是否变更了label,如果label没有变更,就不去执行reconcile,以此省去反复的list pod操作带来的开销。要如何实现呢?

方法1:添加自定义的入队predicate

Builder为我们提供了另一个方法:

func (blder *Builder) WithEventFilter(p predicate.Predicate) *Builder

这个方法,是为Builder中每个Watch的对象设计一个变更过滤器:PredicatePredicate实现了几个方法:

type Predicate interface {
    // Create returns true if the Create event should be processed
    Create(event.CreateEvent) bool

    // Delete returns true if the Delete event should be processed
    Delete(event.DeleteEvent) bool

    // Update returns true if the Update event should be processed
    Update(event.UpdateEvent) bool

    // Generic returns true if the Generic event should be processed
    Generic(event.GenericEvent) bool
}

我们以此设计一个自己的predicate:

package controllers

import (
    "sigs.k8s.io/controller-runtime/pkg/predicate"
    "sigs.k8s.io/controller-runtime/pkg/event"
)

type ResourceLabelChangedPredicate struct {
    predicate.Funcs
}

func (rl *ResourceLabelChangedPredicate) Update (e event.UpdateEvent) bool{
    if !compareMaps(e.MetaOld.GetLabels(), e.MetaNew.GetLabels()) {
        return true
    }
    return false
}

然后修改注册控制器的方式:

func (r *ServiceReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&opsv1.Service{}).Watches(&source.Kind{Type: &opsv1.Pod{}}, &handler.EnqueueRequestForObject{}).WithEventFilter(&ResourceLabelChangedPredicate{}).
        Complete(r)
}

这样,ServiceReconciler在监听其关注的对象时,只会关注对象的label是否发生变更,只有当label发生变更时,才会入队并进入reconcile逻辑。

这个方法目前看应该是kubebuilder团队推荐使用的方法,但是有个问题是,加入了predicate后,会在Reconciler关注的所有的对象上生效。也就是说即使Service实例的label发生变更,也会触发reconcile。这不是我们想看到的,我们想看到的是Service的selector变更时会进行reconcile。这时候我们可能就需要在predicate中增加对象类型的判断,比如:

func (rl *ResourceLabelChangedPredicate) Update (e event.UpdateEvent) bool{
    oldobj, ok1 := e.ObjectOld.(*opsv1.Service)
    newobj, ok2 := e.ObjectNew.(*opsv1.Service)
    if ok1 && ok2 {
        if !compareMaps(oldobj.Spec.Selector, newobj.Spec.Selector) {
            return true
        } else {
            return false
        }
    }

    _, ok1 = e.ObjectOld.(*opsv1.Pod)
    _, ok2 = e.ObjectNew.(*opsv1.Pod)
    if ok1 && ok2 {
        if !compareMaps(e.MetaOld.GetLabels(), e.MetaNew.GetLabels()) {
            return true
        }
    }
    return false
}

记得通过注释添加rbac:

// +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch;create;update;patch;delete

方法2:自定义一个入队器

我们先看上面提到的Watch方法,这个方法允许用户自己设计handler.EventHandler接口,这个接口实现了Create,Update,Delete,Generic方法,用来在资源实例的不同生命阶段,进行判断与入队。

sigs.k8s.io/controller-runtime/pkg/handler/enqueue.go中就有一个默认的实现:EnqueueRequestForObject。我们可以参考它设计一个自己的接口实现——名为EnqueueRequestForLabelChanged的入队器.

重写该入队器的Update方法,改为判断新旧两个实例的label是否一致,不一致则进行入队:

func (e *EnqueueRequestForLabelChanged) Update(evt event.UpdateEvent, q workqueue.RateLimitingInterface) {
    if !compareMaps(evt.MetaOld.GetLabels(), evt.MetaNew.GetLabels()) {
        q.Add(reconcile.Request{NamespacedName: types.NamespacedName{
            Name: evt.MetaNew.GetName(),
            Namespace: evt.MetaNew.GetNamespace(),
        }})
    }
}

注册reconciler时,watches的eventhandler参数使用自定义的enqueue:

func (r *PlaybookReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&opsv1.Playbook{}).Watches(&source.Kind{Type: &opsv1.Bucket{}}, &EnqueueRequestForLabelChanged{}).
        Complete(r)
}

这样,ServiceReconciler将会监听service资源的所有变更,以及pod资源的label变更。

记得通过注释添加rbac:

// +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch;create;update;patch;delete

best way to watch

通过前文我们了解到:

  1. 使用WithEventFilter配置变更过滤器,可以针对reconcilerwatch的所有资源,统一地设置事件监听规则;
  2. 使用自己实现的EventHandler,可以在reconcilerwatch特定资源时,设置该资源的事件监听规则。

阅读controller-runtime的代码我们会发现,官方允许用户调用WithEventFilter配置变更过滤器,但没有提供一个公开的方法让用户配置入队器,用户只能自己主动实现。其实在1.X的kubebuilder中,Watch方法允许用户配置predicate,用户可以给不同资源配置不同的变更过滤器。但在2.0中,这个函数被重新封装,不再直接开放给用户。取而代之的是用WithEventFilter方法配置应用到所有资源的变更过滤器。

可能设计者认为,一个reconciler要负责的应该是一个/多个资源对象的一种/同种变化。

事实上,在开发operator的过程中,最好也是将一个reconciler的工作内容细粒度化。特别是:不应该在一个reconciler逻辑中进行两次资源的update(update status除外),否则会引发版本不一致的报错

使用非缓存的client

Reconciler中的client.Client是一个结构,提供了Get,List,Update,Delete等一系列k8s client的操作,但是其Get,List方法均是从cache中获取数据,如果Reconciler同步数据不及时(需要注意,实际上同步数据的是manager中的成员对象:cache,Reconciler直接引用了该对象),获取到的就是脏数据。

与EventRecorder类似地, manger中其实也初始化好了一个即时的client:apiReader,供我们使用,只需要调用mgr.GetAPIReader()即可获取。

注意到apiReader是一个只读client,,其使用方法与Reconciler的Client类似(Get方法,List方法):

r.ApiReader.Get(ctx, req.NamespacedName, bucket)

官方建议我们直接使用带cache的client即可,该client是一个分离的client,其读方法(get,list)均从一个cache中获取数据。写方法则直接更新到apiserver。

多版本切换

在crd的开发和演进过程中,必然会存在一个crd的不同版本。 kubebuilder支持以一个conversion webhook的方式,支持对一个crd资源以不同版本进行读取。简单地描述就是:

kubectl apply -f config/samples/batch_v2_cronjob.yaml

创建一个v2的cronjob后,可以通过v1和v2两种版本进行读取:

kubectl get cronjobs.v2.batch.tutorial.kubebuilder.io -o yaml
kubectl get cronjobs.v1.batch.tutorial.kubebuilder.io -o yaml

显然,get命令得到的v1和v2版本的cronjob会存在一些字段上的不同,conversion webhook会负责进行不同版本的cronjob之间的数据转换。

贴下学习资料:

https://book.kubebuilder.io/m...

在webhook中使用client

有时候我们需要在某个对象的webhook中查询集群中的其他资源,比如某个operator规定了一个PodBox,规定每个PodBox中只能有一个Pod,那么在validatecreate的webhook中就要ListPod By PodBox并计数。

kubebuilder 2.X 将webhook封装得太过简介,所以我们需要搞个新法子:

我们在types和webhook的目录下新建一个文件, 在里面构建一个全局client:

package v1

import (
    ctrl "sigs.k8s.io/controller-runtime"
    "sigs.k8s.io/controller-runtime/pkg/client"
)

var globalClient client.Client
var globalReader client.Reader

func InitClient(mgr ctrl.Manager) {
    globalClient = mgr.GetClient()
    globalReader = mgr.GetAPIReader()
}

在 main.go中, 各种SetupWithManager之前,先执行InitClient,初始化这些client, validateCreate方法中可以直接使用这些client。

添加自定义的webhook

我们开发的operator可能会需要对用户新建的pod进行注入,比如注入一些信息到annotations中, 也有可能要对原生对象的更新/删除操作进行判断,那么如何在我们的项目中添加这些对象的webhook?

社区提供了一个案例:https://github.com/kubernetes...

但是在该案例下,每次执行make generate时 会报错:

invalid field type interface{sigs.k8s.io/controller-runtime/pkg/client.Reader; sigs.k8s.io/controller-runtime/pkg/client.StatusClient; sigs.k8s.io/controller-runtime/pkg/client.Writer}

不过测试了一下 只要不执行generate,其他步骤都可以正常执行, 比如 make docker-build

使用索引

client-go支持构建带索引的缓存,并且允许用户自定义索引函数的名称和内容。当缓存的数据带有索引时,我们可以更快地通过索引List到想要的数据,既可以提高性能又可以减少代码量。

kubebuilder 2.0 提供了很简单的索引构建方式。 比如我们要以pod中的spec.NodeName为索引, 方便我们快速List查询某个节点上的pod:

// 要先构建好manager

mgr.GetFieldIndexer().IndexField(&v1.Pod{}, "indexNodeNameOfPod", func(o runtime.Object) []string {
        v := o.(*v1.Pod).Spec.NodeName
        return []string{v}
    })
    
    ...
    
// 使用manager的client进行List
podList := &v1.PodList{}
err := mgr.GetClient().List(context.TODO(), podList, &client.MatchingFields{"indexNodeNameOfPod": node.Name})
查看原文

赞 19 收藏 7 评论 10

manshu 发布了文章 · 2019-12-04

ambassador envoy 简单性能测试

wrk

一个简易,并且高性能的测试工具。之前使用的是ab,但发现envoy默认不支持http 1.0,而ab目前只支持http 1.0,所以完美的错开了。envoy是可以支持http 1.0的,但是我配置了下,好像没有成功,就没有深入了,果断换了wrk,目前使用起来感觉这个工具很好用。

wkr一般线程数不宜过多, 核数的2到4倍足够了, 多了反而因为线程切换过多造成效率降低, 因为 wrk 不是使用每个连接一个线程的模型, 而是通过异步网络 io 提升并发量. 所以网络通信不会阻塞线程执行,这也是 wrk 可以用很少的线程模拟大量网路连接的原因。 而现在很多性能工具并没有采用这种方式, 而是采用提高线程数来实现高并发, 所以并发量一旦设的很高, 测试机自身压力就很大, 测试效果反而下降。

安装参考

refer:
https://www.cnblogs.com/xinzh...

命令行:

wrk -t100 -c10000 -d60s --timeout 60s http://ambassador.default.svc...:80/http-echo/
[root@master01 ambassador]# wrk
Usage: wrk <options> <url>                            
  Options:                                            
    -c, --connections <N>  Connections to keep open   
    -d, --duration    <T>  Duration of test           
    -t, --threads     <N>  Number of threads to use   
                                                      
    -s, --script      <S>  Load Lua script file       
    -H, --header      <H>  Add header to request      
        --latency          Print latency statistics   
        --timeout     <T>  Socket/request timeout     
    -v, --version          Print version details      
                                                      
  Numeric arguments may include a SI unit (1k, 1M, 1G)
  Time arguments may include a time unit (2s, 2m, 2h)

http sever

后台服务为go写的http server,参考: https://github.com/hashicorp/...

deployment.yaml

---
apiVersion: v1
kind: Service
metadata:
  name: http-echo
  annotations:
    getambassador.io/config: |
      ---
      apiVersion: ambassador/v0
      kind:  Mapping
      name:  httpecho_mapping
      prefix: /http-echo/
      service: http-echo
spec:
  selector:
    app: http-echo
  ports:
  - port: 80
    name: http-echo
    targetPort: 5678
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: http-echo
spec:
  replicas: 6
  strategy:
    type: RollingUpdate
  template:
    metadata:
      labels:
        app: http-echo
    spec:
      containers:
      - name: http-echo
        image: hashicorp/http-echo
        args: ["-text=\"hello world\""]
        ports:
        - name: httpa
          containerPort: 5678
        resources:
          limits:
            cpu: "1"
            memory: 512Mi

ambassador

install deploy

测试数据

环境

componentversion
docker1.13.1
k8s1.15.2
ambassador0.83.0
envoy1.12.0

资源

componentreplicaresource
http-echo3副本1C1G
ambassador10副本2C0.5G

测试数据

service

wrk -t100 -c10000 -d60s --timeout 60s http://http-echo.default.svc....:80

ambassador

wrk -t100 -c1000 -d60s --timeout 60s http://ambassador.default.svc...:80/http-echo/

couclusion

methodconcurrencytimerqstransfer/seclatency avglatency maxlatency std
service10001st53709.179.01MB31.36ms689.04ms80.48%
service10002nd53452.368.97MB31.55ms497.89ms80.89%
service100001st59413.339.97MB225.50ms6.90s91.98%
service100002nd59920.7610.06MB223.60ms8.71s92.36%
ambassador10001st30484.256.55MB38.44ms367.21ms68.18%
ambassador10002nd30688.216.59MB38.30ms304.27ms67.30%
ambassador100001st30512.206.59MB335.20ms4.24s70.35%
ambassador100002nd30977.126.69MB336.08ms3.69s71.70%
查看原文

赞 0 收藏 0 评论 0

manshu 发布了文章 · 2019-11-21

go mod 依赖管理的心路历程

在用kubebuilder写一个k8s operator defaultvm,主要是串起ovn网络和虚机的创建,为用户提供默认可用的虚机。

Kubebuilder 是一个基于 CRD 来构建 Kubernetes API 的框架,可以使用 CRD 来构建 API、Controller 和 Admission Webhook。

代码里面调用了 kubevirt.io/client-go 定义的 virtualmachine,使用go mod进行依赖管理,默认引用了 kubevirt.io/client-go v0.23.0,但在编译时就报错了,适中拉不到prometheus的包

go: github.com/prometheus/prometheus@v2.9.2+incompatible: unexpected status (https://goproxy.io/github.com/prometheus/prometheus/@v/v2.9.2+incompatible.info): 410 Gone

确认依赖问题

单独使用go mod download验证确实拉不到包。

[root@master01 queqiao]# go mod download github.com/prometheus/prometheus@v2.9.2+incompatible
go: finding github.com/prometheus/prometheus v2.9.2+incompatible
github.com/prometheus/prometheus@v2.9.2+incompatible: reading https://goproxy.io/github.com/prometheus/prometheus/@v/v2.9.2+incompatible.info: 410 Gone

google找到一种说法,因为国内墙的原因,只能使用export GOPROXY=https://goproxy.io && export GO111MODULE=on,然后goproxy.io缺少对prometheus@v2.9.2+incompatible的支持。可以将GOPROXY改成direct试试,export GOPROXY=direct,不过由于国内的fgw,改成direct后,就卡死了,只能放弃。

切低版本kubevirt

prometheus@v2.9.2+incompatible是kubevit的依赖,我们无法控制,我们只能控制kubevirt,从小伙伴哪儿的得知kubevit v0.19.0没有对prometheus v2.9.2+incompatible的依赖,

go mod edit -replace=kubevirt.io/client-go@v0.23.0=kubevirt.io/client-go@v0.19.0

使用go mod edit切换回kubevit v0.19.0,果然顺利编译通过了。不过可悲的是,调试运行一段时间后,触发了kubevirt新的bug,而这个bug在v0.23.0版本修复了

参考:https://github.com/kubevirt/k...

因此只能重新面对依赖这个问题

拉取kubevirt代码

为了不对kubevirt 依赖,我们决定直接抠出kubevirt 中对virtual machine这个cr的定义。这个方法确实行之有效,减去了对kubevirt的依赖,不过却造成了一系列我没预想到的包之间版本不匹配的问题

如:最开始遇到的报错

# k8s.io/client-go/rest
vendor/k8s.io/client-go/rest/request.go:598:31: not enough arguments in call to watch.NewStreamWatcher
    have (*versioned.Decoder)
    want (watch.Decoder, watch.Reporter)

go的依赖库都是用go mod进行自动管理的,没想到会有依赖库之间版本不匹配的问题。

匹配依赖库版本

既然go mod管理有问题,那只能从问题入手,人工匹配正确的依赖库版本。

参考: https://github.com/kubernetes...

That apimachinery change is in the master branch, and the call you linked to is updated in client-go on master.

The client-go@v11.0.0 tag works with the release-1.14 branch of apimachinery, which still has the signature that matches client-go v11.0.0:

https://github.com/kubernetes/apimachinery/blob/release-1.14/pkg/watch/streamwatcher.go#L51-L52

Ensure you are using the release-1.14 branch of apimachinery.

根据大佬的提示,使用release-1.14版本的apimachinery,

[root@master01 queqiao]# go mod download -json k8s.io/apimachinery@release-1.14
go: finding k8s.io/apimachinery release-1.14
go: finding k8s.io/apimachinery latest
{
    "Path": "k8s.io/apimachinery",
    "Version": "v0.0.0-20191004074956-c5d2f014d689",
    "Info": "/root/go/pkg/mod/cache/download/k8s.io/apimachinery/@v/v0.0.0-20191004074956-c5d2f014d689.info",
    "GoMod": "/root/go/pkg/mod/cache/download/k8s.io/apimachinery/@v/v0.0.0-20191004074956-c5d2f014d689.mod",
    "Zip": "/root/go/pkg/mod/cache/download/k8s.io/apimachinery/@v/v0.0.0-20191004074956-c5d2f014d689.zip",
    "Dir": "/root/go/pkg/mod/k8s.io/apimachinery@v0.0.0-20191004074956-c5d2f014d689",
    "Sum": "h1:q9CWH+mCm21qUeXH537D0Q9K1jdEkreNSRU5E7jh+QM=",
    "GoModSum": "h1:ccL7Eh7zubPUSh9A3USN90/OzHNSVN6zxzde07TDCL0="
}

确实解决了之前的报错。但是依然有新的报错,排查是哪个依赖库调用的报错,然后采用类似的办法,切换到release-1.14,最终go.mod中手动配置的依赖库版本为:

replace (
    k8s.io/api => k8s.io/api v0.0.0-20191004102349-159aefb8556b
    k8s.io/apiextensions-apiserver => k8s.io/apiextensions-apiserver v0.0.0-20191004105649-b14e3c49469a
    k8s.io/apimachinery => k8s.io/apimachinery v0.0.0-20191004074956-c5d2f014d689
    sigs.k8s.io/controller-runtime => sigs.k8s.io/controller-runtime v0.3.0
)

最后提一下sigs.k8s.io/controller-runtime ,查看了它的所有版本,然后一次次尝试,才发现v0.3.0可以编译通过。

感慨

这个依赖库问题花了我近两天的时间,期间搜了很多方法,踩了很多的坑,远比文中描述的糟心。正因为糟心,才写下来,一方面加深自己的体会,另一方面,以希望能够帮助到同样遇到这个问题的小伙伴。

in the peace

查看原文

赞 0 收藏 0 评论 0

认证与成就

  • 获得 20 次点赞
  • 获得 0 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 0 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2016-11-30
个人主页被 609 人浏览