Fork me on GitHub

分类 云原生 下的文章

实战 Kubernetes 的动态扩缩容

Kubernetes 使用小记 中,我们学习了 Kubernetes 中的 Pod、Deployment 和 Service 的一些基础知识,还学习了如何通过 kubectl scale 命令对应用进行扩容或缩容,以及 Kubernetes 的滚动更新机制。

虽然通过 kubectl scale 命令可以实现扩缩容功能,但是这个操作需要运维人员手工进行干预,不仅可能处理不及时,而且还可能误操作导致生产事故。如果我们能够根据系统当前的运行状态自动进行扩缩容,比如当检测到某个应用负载过高时自动对其扩容,这样就可以给运维人员带来极大的方便。为此,Kubernetes 提供了一种新的资源对象:Horizontal Pod Autoscaling(Pod 水平自动伸缩,简称 HPA),HPA 通过监控 Pod 的负载变化来确定是否需要调整 Pod 的副本数量,从而实现动态扩缩容。

Metrics Server

为了实现动态扩缩容,首先我们需要对 Kubernetes 集群的负载情况进行监控,Kubernetes 从 v1.8 开始提出了 Metrics API 的概念来解决这个问题,官方认为核心指标的监控应该是稳定的,版本可控的,并且和其他的 Kubernetes API 一样,可以直接被用户访问(如:kubectl top 命令),或被集群中其他控制器使用(如:HPA),为此专门开发了 Metrics Server 组件。

我们知道 Kubernetes 会在每个节点上运行一个 Kubelet 进程,这个进程对容器进行生命周期的管理,实际上,它还有另外一个作用,那就是监控所在节点的资源使用情况,并且可以通过 Summary API 来查询。Metrics Server 就是通过聚合各个节点的 Kubelet 的 Summary API,然后对外提供 Metrics API,并通过 API Server 的 API 聚合层(API Aggregation Layer) 将接口以 Kubernetes API 的形式暴露给用户或其他程序使用:

resource-metrics-pipeline.png

按照官方文档,我们使用下面的命令安装 Metrics Server:

$ kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml

安装之后,使用 kubectl get pods 可以看到 Metrics Server 虽然是 Running 状态,但是一直没有 Ready:

$ kubectl get pods -n kube-system
NAME                                     READY   STATUS    RESTARTS         AGE
metrics-server-847d45fd4f-rhsh4          0/1     Running   0                30s

使用 kubectl logs 查看日志报如下错误:

$ kubectl logs -f metrics-server-847d45fd4f-rhsh4 -n kube-system
E1119 04:47:02.299430       1 scraper.go:140] "Failed to scrape node" err="Get \"https://192.168.65.4:10250/metrics/resource\": x509: cannot validate certificate for 192.168.65.4 because it doesn't contain any IP SANs" node="docker-desktop"

这里的 /metrics/resource 接口就是 Kubelet 的 Summary API,这个报错的意思是证书校验没通过,因为证书中没有包含所请求的 IP 地址,我们不妨随便进一个 Pod 内部,用 openssl 看下这个证书的信息:

# openssl s_client -showcerts -connect 192.168.65.4:10250
CONNECTED(00000003)
depth=1 CN = docker-desktop-ca@1663714811
verify error:num=19:self signed certificate in certificate chain
verify return:0
---
Certificate chain
 0 s:/CN=docker-desktop@1663714811
   i:/CN=docker-desktop-ca@1663714811
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
 1 s:/CN=docker-desktop-ca@1663714811
   i:/CN=docker-desktop-ca@1663714811
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
---
Server certificate
subject=/CN=docker-desktop@1663714811
issuer=/CN=docker-desktop-ca@1663714811

可以看到这是一个自签名的证书,证书只签给了 docker-desktop@1663714811,没有签给 192.168.65.4 这个 IP,所以 Metrics Server 认为证书是无效的,为了解决这个问题,第一种方法是将这个证书加入 Metrics Server 的受信证书中,不过这种方法比较繁琐,另一种方法简单暴力,我们可以直接让 Metrics Server 跳过证书检查。我们将上面安装 Metrics Server 的那个 YAML 文件下载下来,在 Metrics Server 的启动参数中添加 --kubelet-insecure-tls

- args:
  - --cert-dir=/tmp
  - --secure-port=4443
  - --kubelet-insecure-tls
  - --kubelet-preferred-address-types=InternalIP,ExternalIP,Hostname
  - --kubelet-use-node-status-port
  - --metric-resolution=15s

然后使用 kubectl apply 重新安装:

$ kubectl apply -f metrics-server.yaml

再通过 kubectl get pods 确认 Metrics Server 已经成功运行起来了。这时,我们就可以通过 kubectl top 命令来获取 Kubernetes 集群状态了,比如查看所有节点的 CPU 和内存占用:

$ kubectl top nodes
NAME             CPU(cores)   CPU%   MEMORY(bytes)   MEMORY%
docker-desktop   489m         6%     1823Mi          29%

或者查看所有 Pod 的运行状态:

$ kubectl top pods -A
NAMESPACE     NAME                                     CPU(cores)   MEMORY(bytes)
default       kubernetes-bootcamp-857b45f5bb-hvkfx     0m           10Mi
kube-system   coredns-95db45d46-jx42f                  5m           23Mi
kube-system   coredns-95db45d46-nbvg9                  5m           62Mi
kube-system   etcd-docker-desktop                      48m          371Mi
kube-system   kube-apiserver-docker-desktop            61m          409Mi
kube-system   kube-controller-manager-docker-desktop   49m          130Mi
kube-system   kube-proxy-zwspl                         1m           62Mi
kube-system   kube-scheduler-docker-desktop            8m           68Mi
kube-system   metrics-server-5db9b4b966-zt6z4          6m           18Mi
kube-system   storage-provisioner                      4m           23Mi
kube-system   vpnkit-controller                        1m           8Mi

也可以查看某个节点的 CPU 和内存占用:

$ kubectl top node docker-desktop
NAME             CPU(cores)   CPU%   MEMORY(bytes)   MEMORY%
docker-desktop   425m         5%     1970Mi          31%

上面讲过,kubectl top 其实是通过 API Server 提供的接口来获取这些指标信息的,所以我们也可以直接通过接口来获取。上面的命令实际上就是调用了下面这个接口:

$ kubectl get --raw "/apis/metrics.k8s.io/v1beta1/nodes/docker-desktop" | jq '.'
{
  "kind": "NodeMetrics",
  "apiVersion": "metrics.k8s.io/v1beta1",
  "metadata": {
    "name": "docker-desktop",
    "creationTimestamp": "2022-11-19T14:57:48Z",
    "labels": {
      "beta.kubernetes.io/arch": "amd64",
      "beta.kubernetes.io/os": "linux",
      "kubernetes.io/arch": "amd64",
      "kubernetes.io/hostname": "docker-desktop",
      "kubernetes.io/os": "linux",
      "node-role.kubernetes.io/control-plane": "",
      "node.kubernetes.io/exclude-from-external-load-balancers": ""
    }
  },
  "timestamp": "2022-11-19T14:57:38Z",
  "window": "12.21s",
  "usage": {
    "cpu": "476038624n",
    "memory": "2008740Ki"
  }
}

我们也可以查看某个 Pod 的运行状态:

$ kubectl top pod kubernetes-bootcamp-857b45f5bb-hvkfx
NAME                                   CPU(cores)   MEMORY(bytes)
kubernetes-bootcamp-857b45f5bb-hvkfx   0m           20Mi

类似的,这个命令和下面这个接口是一样的:

$ kubectl get --raw "/apis/metrics.k8s.io/v1beta1/namespaces/default/pods/kubernetes-bootcamp-857b45f5bb-hvkfx" | jq '.'
{
  "kind": "PodMetrics",
  "apiVersion": "metrics.k8s.io/v1beta1",
  "metadata": {
    "name": "kubernetes-bootcamp-857b45f5bb-hvkfx",
    "namespace": "default",
    "creationTimestamp": "2022-11-19T14:59:11Z",
    "labels": {
      "app": "kubernetes-bootcamp",
      "pod-template-hash": "857b45f5bb"
    }
  },
  "timestamp": "2022-11-19T14:59:01Z",
  "window": "15.038s",
  "containers": [
    {
      "name": "kubernetes-bootcamp",
      "usage": {
        "cpu": "0",
        "memory": "21272Ki"
      }
    }
  ]
}

基于 CPU 自动扩缩容

Metrics Server 安装之后,我们就可以创建 HPA 来对 Pod 自动扩缩容了。首先我们使用 jocatalin/kubernetes-bootcamp:v1 镜像创建一个 Deployment:

$ kubectl create deployment kubernetes-bootcamp --image=jocatalin/kubernetes-bootcamp:v1
deployment.apps/kubernetes-bootcamp created

然后再执行 kubectl autoscale 命令创建一个 HPA:

$ kubectl autoscale deployment kubernetes-bootcamp --cpu-percent=10 --min=1 --max=10
horizontalpodautoscaler.autoscaling/kubernetes-bootcamp autoscaled

上面的命令为 kubernetes-bootcamp 这个 Deployment 创建了一个 HPA,其中 --cpu-percent=10 参数表示 HPA 会根据 CPU 使用率来动态调整 Pod 数量,当 CPU 占用超过 10% 时,HPA 就会自动对 Pod 进行扩容,当 CPU 占用低于 10% 时,HPA 又会自动对 Pod 进行缩容,而且扩缩容的大小由 --min=1 --max=10 参数限定,最小副本数为 1,最大副本数为 10。

除了 kubectl autoscale 命令,我们也可以使用下面的 YAML 来创建 HPA:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: kubernetes-bootcamp
  namespace: default
spec:
  minReplicas: 1
  maxReplicas: 10
  metrics:
  - resource:
      name: cpu
      target:
        averageUtilization: 10
        type: Utilization
    type: Resource
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: kubernetes-bootcamp

创建好 HPA 之后,可以使用 kubectl get hpa 进行查看:

$ kubectl get hpa
NAME                  REFERENCE                        TARGETS         MINPODS   MAXPODS   REPLICAS   AGE
kubernetes-bootcamp   Deployment/kubernetes-bootcamp   <unknown>/10%   1         10        0          29s

这里的 TARGETS 一列表示 当前 CPU 占用 / 目标 CPU 占用,可以看到这里貌似有点问题,当前 CPU 占用显示的是 <unknown>,我们执行 kubectl describe hpa 看下这个 HPA 的详情:

$ kubectl describe hpa kubernetes-bootcamp
Name:                                                  kubernetes-bootcamp
Namespace:                                             default
Labels:                                                <none>
Annotations:                                           <none>
CreationTimestamp:                                     Sun, 20 Nov 2022 10:51:00 +0800
Reference:                                             Deployment/kubernetes-bootcamp
Metrics:                                               ( current / target )
  resource cpu on pods  (as a percentage of request):  <unknown> / 10%
Min replicas:                                          1
Max replicas:                                          10
Deployment pods:                                       1 current / 0 desired
Conditions:
  Type           Status  Reason                   Message
  ----           ------  ------                   -------
  AbleToScale    True    SucceededGetScale        the HPA controller was able to get the target's current scale
  ScalingActive  False   FailedGetResourceMetric  the HPA was unable to compute the replica count: failed to get cpu utilization: missing request for cpu

从详情中可以看到报错信息 failed to get cpu utilization: missing request for cpu,这是因为我们上面创建 Deployment 时,没有为 Pod 对象配置资源请求,这样 HPA 就不知道 Pod 运行需要多少 CPU,也就无法计算 CPU 的利用率了,所以如果要想让 HPA 生效,对应的 Pod 必须添加资源请求声明。我们使用 YAML 文件重新创建 Deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: kubernetes-bootcamp
  name: kubernetes-bootcamp
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: kubernetes-bootcamp
  strategy:
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 25%
    type: RollingUpdate
  template:
    metadata:
      labels:
        app: kubernetes-bootcamp
    spec:
      containers:
      - image: jocatalin/kubernetes-bootcamp:v1
        imagePullPolicy: IfNotPresent
        name: kubernetes-bootcamp
        resources:
          requests: 
            memory: 50Mi
            cpu: 50m
      restartPolicy: Always

在上面的 YAML 文件中,我们使用 resources.requests.cpuresources.requests.memory 声明了运行这个 Pod 至少需要 50m 的 CPU 和 50MiB 内存。稍等片刻,就能看到 HPA 状态已经正常了:

$ kubectl get hpa
NAME                  REFERENCE                        TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
kubernetes-bootcamp   Deployment/kubernetes-bootcamp   0%/10%    1         10        1          65m

接下来,我们对 Pod 进行简单的压测,使用 kubectl exec 进入容器中:

$ kubectl exec -it pod/kubernetes-bootcamp-69d7dddfc-k9wj7 -- bash

并使用一个 while 循环对 Pod 不断发起请求:

root@kubernetes-bootcamp-69d7dddfc-k9wj7:/# while true; do wget -q -O- http://localhost:8080; done

很快,Pod 的 CPU 占用就开始飙升了,而且能看到副本数量也开始不断增加:

$ kubectl get hpa
NAME                  REFERENCE                        TARGETS    MINPODS   MAXPODS   REPLICAS   AGE
kubernetes-bootcamp   Deployment/kubernetes-bootcamp   219%/10%   1         10        10         93m

一段时间之后,副本数量增加到 10 个,并不再增加:

$ kubectl get pods
NAME                                  READY   STATUS    RESTARTS   AGE
kubernetes-bootcamp-69d7dddfc-9ws7c   1/1     Running   0          2m5s
kubernetes-bootcamp-69d7dddfc-bbpfv   1/1     Running   0          3m6s
kubernetes-bootcamp-69d7dddfc-bqlhj   1/1     Running   0          3m6s
kubernetes-bootcamp-69d7dddfc-fzlnq   1/1     Running   0          2m6s
kubernetes-bootcamp-69d7dddfc-jkx9g   1/1     Running   0          65s
kubernetes-bootcamp-69d7dddfc-k9wj7   1/1     Running   0          28m
kubernetes-bootcamp-69d7dddfc-l5bf7   1/1     Running   0          2m5s
kubernetes-bootcamp-69d7dddfc-q4b5f   1/1     Running   0          2m5s
kubernetes-bootcamp-69d7dddfc-ttptc   1/1     Running   0          65s
kubernetes-bootcamp-69d7dddfc-vtjqj   1/1     Running   0          3m6s

然后我们再停止发起请求,等待一段时间,Pod 数量又重新恢复如初:

$ kubectl get hpa
NAME                  REFERENCE                        TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
kubernetes-bootcamp   Deployment/kubernetes-bootcamp   0%/10%    1         10        1          101m

基于内存自动扩缩容

目前,HPA 有两个正式版本:v1 和 v2,v2 又有两个 beta 版本,v2beta1 和 v2beta2,这两个 beta 版本在最新的 Kubernetes 版本中都已经废弃。HPA v1 版本 只支持基于 CPU 的自动扩缩容,如果要使用基于内存的自动扩缩容,必须使用 HPA v2 版本

创建基于内存的 HPA 和基于 CPU 的 HPA 几乎完全一样,我们只需要将 resource 名称改为 memory 即可:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: kubernetes-bootcamp
  namespace: default
spec:
  minReplicas: 1
  maxReplicas: 10
  metrics:
  - resource:
      name: memory
      target:
        averageUtilization: 60
        type: Utilization
    type: Resource
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: kubernetes-bootcamp

创建好的 HPA 如下:

$ kubectl get hpa
NAME                      REFERENCE                        TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
kubernetes-bootcamp-mem   Deployment/kubernetes-bootcamp   22%/60%   1         10        1          4m17s

接下来可以想办法提高 Pod 的内存占用,在 Kubernetes HPA 使用详解 这篇文章中作者使用了一种比较有趣的方法可供参考。首先需要开启 Pod 的特权模式 securityContext.privileged: true,然后进入 Pod 执行下面的命令:

# mkdir /tmp/memory
# mount -t tmpfs -o size=40M tmpfs /tmp/memory
# dd if=/dev/zero of=/tmp/memory/block
dd: writing to '/tmp/memory/block': No space left on device
81921+0 records in
81920+0 records out
41943040 bytes (42 MB) copied, 0.11175 s, 375 MB/s

原理很简单,通过向 tmpfs 中写入数据来模拟内存占用,执行之后,可以看到 Pod 内存占用变成了差不多 100%,并且触发了 HPA 的动态扩容:

$ kubectl get hpa
NAME                      REFERENCE                        TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
kubernetes-bootcamp-mem   Deployment/kubernetes-bootcamp   99%/60%   1         10        2          4h8m

如果要释放内存占用,执行下面的命令即可:

# umount /tmp/memory

基于自定义指标自动扩缩容

上面提到过,Metrics Server 其实是通过 API Server 将自己的接口暴露出去的,这个接口的地址一般以 /apis/metrics.k8s.io/v1beta1/ 作为前缀,这个接口又被称为 Resource Metrics API,它的作用是暴露诸如 CPU 或内存等核心指标。但是仅仅有 CPU 或内存信息往往不能满足某些自动扩缩容的场景,比如要根据应用的 QPS 来自动扩容,就得使用 QPS 这个自定义指标。为了让 HPA 支持更多的其他指标,人们很快又提出了 Custom Metrics APIExternal Metrics API 规范,而 Prometheus Adapter 就是该规范最常见的一个实现。

这一节将学习如何使用 Prometheus Adapter 来实现自定义指标的自动扩缩容。

部署一个带指标的应用

我们直接使用 Spring Boot 生产就绪特性 Actuator 中的例子。首先需要将其构建成 Docker 镜像并推送到 DockerHub,镜像命名为 aneasystone/hello-actuator:v1

$ cd week014-spring-boot-actuator/demo
$ docker build -t aneasystone/hello-actuator:v1 .
$ docker push aneasystone/hello-actuator:v1

然后编写 hello-actuator.yaml 文件,将该镜像部署到我们的 Kubernetes 集群:

$ kubectl apply -f ./hello-actuator.yaml

查看部署的应用:

$ kubectl get pods -l app=hello-actuator
NAME                             READY   STATUS    RESTARTS   AGE
hello-actuator-b49545c55-l9m59   1/1     Running   0          2m43s

查看应用端口:

$ kubectl get svc -l app=hello-actuator
NAME             TYPE       CLUSTER-IP      EXTERNAL-IP   PORT(S)          AGE
hello-actuator   NodePort   10.102.39.146   <none>        8080:31086/TCP   3m39s

我们通过 31086 端口访问应用的 /hello 接口,确保应用能正常访问:

$ curl -s http://localhost:31086/hello
hello

查看 Prometheus 端点,可以看到有一个 counter 类型的指标 hello_counter_total,该指标表示接口的调用总数,每次请求 /hello 接口时指标的值就会自增:

$ curl -s http://localhost:31086/actuator/prometheus | grep hello_counter
# HELP hello_counter_total
# TYPE hello_counter_total counter
hello_counter_total{app="demo",} 1.0

部署 Prometheus Operator

接下来继续部署 Prometheus Operator,参考官方的 快速入门,我们首先下载 kube-prometheus 源码:

$ git clone https://github.com/prometheus-operator/kube-prometheus.git

然后第一步将 Prometheus Operator 相关的命名空间和 CRD 创建好:

$ kubectl create -f manifests/setup

接着部署 Prometheus Operator 其他组件:

$ kubectl create -f manifests/

等待一段时间,确定所有组件都启动完成:

$ kubectl get pods -n monitoring
NAME                                   READY   STATUS             RESTARTS      AGE
alertmanager-main-0                    2/2     Running            0             4m11s
alertmanager-main-1                    2/2     Running            1 (91s ago)   4m11s
alertmanager-main-2                    2/2     Running            1 (85s ago)   4m11s
blackbox-exporter-59cccb5797-fljpj     3/3     Running            0             7m16s
grafana-7b8db9f4d-tk6b9                1/1     Running            0             7m15s
kube-state-metrics-6d68f89c45-2klv4    3/3     Running            0             7m15s
node-exporter-2b6hn                    1/2     CrashLoopBackOff   4 (73s ago)   7m14s
prometheus-adapter-757f9b4cf9-j8qzd    1/1     Running            0             7m13s
prometheus-adapter-757f9b4cf9-tmdt2    1/1     Running            0             7m13s
prometheus-k8s-0                       1/2     Running            0             4m9s
prometheus-k8s-1                       2/2     Running            0             4m9s
prometheus-operator-67f59d65b8-tvdxr   2/2     Running            0             7m13s

我们看到除 node-exporter 之外,其他的组件都已经启动成功了。可以使用 kubectl describe 看下 node-exporter 启动详情:

$ kubectl describe pod node-exporter-2b6hn -n monitoring
...
Events:
  Type     Reason     Age                    From               Message
  ----     ------     ----                   ----               -------
  Normal   Scheduled  8m57s                  default-scheduler  Successfully assigned monitoring/node-exporter-2b6hn to docker-desktop
  Normal   Pulling    8m53s                  kubelet            Pulling image "quay.io/prometheus/node-exporter:v1.4.0"
  Normal   Pulled     6m12s                  kubelet            Successfully pulled image "quay.io/prometheus/node-exporter:v1.4.0" in 2m41.2451104s
  Normal   Pulling    6m11s                  kubelet            Pulling image "quay.io/brancz/kube-rbac-proxy:v0.13.1"
  Normal   Pulled     4m28s                  kubelet            Successfully pulled image "quay.io/brancz/kube-rbac-proxy:v0.13.1" in 1m43.3407752s
  Normal   Created    4m28s                  kubelet            Created container kube-rbac-proxy
  Normal   Started    4m27s                  kubelet            Started container kube-rbac-proxy
  Warning  Failed     4m14s                  kubelet            Error: failed to start container "node-exporter": Error response from daemon: path / is mounted on / but it is not a shared or slave mount
  Warning  BackOff    3m11s (x6 over 4m26s)  kubelet            Back-off restarting failed container
  Normal   Created    2m56s (x5 over 6m11s)  kubelet            Created container node-exporter
  Warning  Failed     2m56s (x4 over 6m11s)  kubelet            Error: failed to start container "node-exporter": Error response from daemon: path /sys is mounted on /sys but it is not a shared or slave mount
  Normal   Pulled     2m56s (x4 over 4m27s)  kubelet            Container image "quay.io/prometheus/node-exporter:v1.4.0" already present on machine

从 Events 中我们可以看到这样的错误日志:path / is mounted on / but it is not a shared or slave mount,于是搜索这个错误日志,在 prometheus-community/helm-charts 项目的 Issues 中我们找到了一个非常类似的问题 Issue-467,查看下面的解决办法是将 nodeExporter.hostRootfs 设置为 false。不过我们这里不是使用 Helm 安装的,于是查看它的源码,了解到这个参数实际上就是将 node-exporter 的启动参数 --path.rootfs=/host/root 以及相关的一些挂载去掉而已,于是打开 nodeExporter-daemonset.yaml 文件,删掉下面这些内容:

...
        - --path.sysfs=/host/sys
        - --path.rootfs=/host/root
...
        volumeMounts:
        - mountPath: /host/sys
          mountPropagation: HostToContainer
          name: sys
          readOnly: true
        - mountPath: /host/root
          mountPropagation: HostToContainer
          name: root
          readOnly: true
...
      volumes:
      - hostPath:
          path: /sys
        name: sys
      - hostPath:
          path: /
        name: root

然后重新部署 node-exporter 即可:

$ kubectl apply -f manifests/nodeExporter-daemonset.yaml

至此,Prometheus Operator 我们就部署好了。如果要访问 Prometheus,可以使用 kubectl port-forward 将其端口暴露出来:

$ kubectl port-forward svc/prometheus-k8s 9090 -n monitoring

然后在浏览器中访问 http://localhost:9090/ 即可。

不过这个时候 Prometheus 还不知道怎么去抓取我们的应用指标,我们需要创建 PodMonitorServiceMonitor 对象告诉 Prometheus 去哪里抓取我们的 Pod 或 Service 暴露的指标,我们不妨创建一个 ServiceMonitor 试试:

apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  labels:
    app: hello-actuator
  name: hello-actuator
  namespace: default
spec:
  endpoints:
  - interval: 30s
    port: 8080-8080
    path: /actuator/prometheus
  jobLabel: hello-actuator
  namespaceSelector:
    matchNames:
    - default
  selector:
    matchLabels:
      app: hello-actuator

创建之后,就能在 Prometheus 的 Targets 页面看到我们的应用了:

prometheus-targets.png

然后我们运行一段简单的脚本对应用进行测试,每隔 1s 发起一次请求:

$ while true; do wget -q -O- http://localhost:31086/hello; sleep 1; done

持续一段时间后,就能在 Prometheus 的 Graph 页面看到 hello_counter_total 指标在持续增加:

hello-counter-total.png

部署 Prometheus Adapter

其实在上面部署 Prometheus Operator 的时候,Prometheus Adapter 也已经一起部署了。而且我们可以打开 prometheusAdapter-apiService.yaml 文件看看,Prometheus Adapter 也提供了 metrics.k8s.io 指标 API 的实现,所以我们完全可以使用 Prometheus Adapter 来代替 Metrics Server。

我们修改这个文件,加上一个新的 APIService,用于实现 custom.metrics.k8s.io 自定义指标 API:

apiVersion: apiregistration.k8s.io/v1
kind: APIService
metadata:
  labels:
    app.kubernetes.io/component: metrics-adapter
    app.kubernetes.io/name: prometheus-adapter
    app.kubernetes.io/part-of: kube-prometheus
    app.kubernetes.io/version: 0.10.0
  name: v1beta1.metrics.k8s.io
spec:
  group: metrics.k8s.io
  groupPriorityMinimum: 100
  insecureSkipTLSVerify: true
  service:
    name: prometheus-adapter
    namespace: monitoring
  version: v1beta1
  versionPriority: 100
---
apiVersion: apiregistration.k8s.io/v1
kind: APIService
metadata:
  name: v1beta1.custom.metrics.k8s.io
spec:
  group: custom.metrics.k8s.io
  groupPriorityMinimum: 100
  insecureSkipTLSVerify: true
  service:
    name: prometheus-adapter
    namespace: monitoring
  version: v1beta1
  versionPriority: 100

使用下面的命令可以查看 metrics.k8s.io 接口:

$ kubectl get --raw /apis/metrics.k8s.io/v1beta1 | jq .
{
  "kind": "APIResourceList",
  "apiVersion": "v1",
  "groupVersion": "metrics.k8s.io/v1beta1",
  "resources": [
    {
      "name": "nodes",
      "singularName": "",
      "namespaced": false,
      "kind": "NodeMetrics",
      "verbs": [
        "get",
        "list"
      ]
    },
    {
      "name": "pods",
      "singularName": "",
      "namespaced": true,
      "kind": "PodMetrics",
      "verbs": [
        "get",
        "list"
      ]
    }
  ]
}

使用下面的命令可以查看 custom.metrics.k8s.io 接口:

$ kubectl get --raw /apis/custom.metrics.k8s.io/v1beta1 | jq .
{
    "kind": "APIResourceList",
    "apiVersion": "v1",
    "groupVersion": "custom.metrics.k8s.io/v1beta1",
    "resources": []
}

这两个接口实际上都是被 API Server 转发到 prometheus-adapter 服务的,可以发现目前还没有自定义指标,这是因为我们还没有对 prometheus-adapter 进行配置。打开 prometheusAdapter-configMap.yaml 文件,在 config.yaml 中添加以下内容:

  config.yaml: |-
    "rules":
    - "seriesQuery": 'hello_counter_total{namespace!="",pod!=""}'
      "resources":
        "template": "<<.Resource>>"
      "name":
        "matches": "^(.*)_total"
        "as": "${1}_per_second"
      "metricsQuery": |
        sum by (<<.GroupBy>>) (
          irate (
            <<.Series>>{<<.LabelMatchers>>}[1m]
          )
        )

上面是一个简单的 Prometheus Adapter 规则,每个规则都包括了 4 个参数:

  • seriesQuery:这个参数指定要查询哪个 Prometheus 指标;
  • resources:这个参数指定要将指标和哪个 Kubernetes 资源进行关联;
  • name:为自定义指标进行重命名,由于这里我们要使用 RPS 来对容器组进行扩容,所以将指标重名为 hello_counter_per_second
  • metricsQuery:这个参数表示真实的 Prometheus 查询语句;我们使用 irate() 函数将请求总数指标变成了 RPS 指标;

这 4 个参数也分别对应 Prometheus Adapter 处理的 4 个步骤:发现(Discovery)分配(Association)命名(Naming)查询(Querying)。关于参数的详细说明可以参考 官方文档

然后我们更新配置文件,并重启 Prometheus Adapter:

$ kubectl apply -f manifests/prometheusAdapter-configMap.yaml
$ kubectl rollout restart deployment prometheus-adapter -n monitoring

再次查看 custom.metrics.k8s.io 接口,就能看到我们上面定义的自定义指标了:

$ kubectl get --raw /apis/custom.metrics.k8s.io/v1beta1 | jq .
{
  "kind": "APIResourceList",
  "apiVersion": "v1",
  "groupVersion": "custom.metrics.k8s.io/v1beta1",
  "resources": [
    {
      "name": "pods/hello_counter_per_second",
      "singularName": "",
      "namespaced": true,
      "kind": "MetricValueList",
      "verbs": [
        "get"
      ]
    },
    {
      "name": "services/hello_counter_per_second",
      "singularName": "",
      "namespaced": true,
      "kind": "MetricValueList",
      "verbs": [
        "get"
      ]
    },
    {
      "name": "jobs.batch/hello_counter_per_second",
      "singularName": "",
      "namespaced": true,
      "kind": "MetricValueList",
      "verbs": [
        "get"
      ]
    },
    {
      "name": "namespaces/hello_counter_per_second",
      "singularName": "",
      "namespaced": false,
      "kind": "MetricValueList",
      "verbs": [
        "get"
      ]
    }
  ]
}

我们还可以使用下面的命令查询 hello_counter_per_second 这个指标的值:

$ kubectl get --raw "/apis/custom.metrics.k8s.io/v1beta1/namespaces/default/pods/*/hello_counter_per_second" | jq .
{
  "kind": "MetricValueList",
  "apiVersion": "custom.metrics.k8s.io/v1beta1",
  "metadata": {},
  "items": [
    {
      "describedObject": {
        "kind": "Pod",
        "namespace": "default",
        "name": "hello-actuator-b49545c55-r6whf",
        "apiVersion": "/v1"
      },
      "metricName": "hello_counter_per_second",
      "timestamp": "2022-11-27T13:11:38Z",
      "value": "967m",
      "selector": null
    }
  ]
}

上面的 "value": "967m" 就是我们自定义指标的值,也就是接口的请求频率。这里要注意的是,指标使用的是 Kubernetes 风格的计量单位,被称为 Quantity967m 其实就是 0.967,这和我们每隔一秒请求一次是能对应上的。

为了验证这个值能准确地反应出我们接口的请求频率,我们不妨将上面那个测试脚本改成每隔 0.5s 发送一次请求:

$ while true; do wget -q -O- http://localhost:31086/hello; sleep 0.5; done

等待片刻之后,我们再次查询 hello_counter_per_second 指标:

$ kubectl get --raw "/apis/custom.metrics.k8s.io/v1beta1/namespaces/default/pods/*/hello_counter_per_second" | jq .
{
  "kind": "MetricValueList",
  "apiVersion": "custom.metrics.k8s.io/v1beta1",
  "metadata": {},
  "items": [
    {
      "describedObject": {
        "kind": "Pod",
        "namespace": "default",
        "name": "hello-actuator-b49545c55-r6whf",
        "apiVersion": "/v1"
      },
      "metricName": "hello_counter_per_second",
      "timestamp": "2022-11-27T13:42:57Z",
      "value": "1833m",
      "selector": null
    }
  ]
}

可以看到,指标的值差不多翻了一倍,和我们的请求频率完全一致。

在我的测试过程中发现了一个非常奇怪的现象,当部署完 Prometheus Operator 之后,整个集群的网络就好像出了问题,从 Pod 内部无法访问 Service 暴露的 IP 和端口。经过反复的调试和验证后发现,如果将 alertmanager-service.yamlprometheus-service.yaml 文件中的 sessionAffinity: ClientIP 配置删除掉就没有这个问题。目前尚不清楚具体原因。

部署 HPA 实现自动扩缩容

万事俱备,只欠东风。接下来,我们就可以通过 HPA 来实现自动扩缩容了,首先创建一个 HPA 如下:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: hello-actuator
  namespace: default
spec:
  minReplicas: 1
  maxReplicas: 10
  metrics:
  - type: Pods
    pods:
      metric:
        name: hello_counter_per_second
      target:
        type: AverageValue
        averageValue: 2
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: hello-actuator

大体内容和上面基于 CPU 或内存的 HPA 是差不多的,只是 metrics 字段有一些区别:首先指标的类型不是 Resource 而是 Pods,其次 target 类型为 AverageValue(表示绝对值) 而不是 Utilization(表示百分比),而且要注意的是,Pods 类型的指标只支持 AverageValue

HPA 创建好了以后,我们通过 kubectl get hpa 查看该 HPA 的状态:

$ kubectl get hpa
NAME             REFERENCE                   TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
hello-actuator   Deployment/hello-actuator   966m/2    1         10        1          2m6s

注意看 TARGETS 列显示的 966m/2,其中分子部分 966m 表示当前我们应用的 RPS 为 0.966 次/秒,分母 2 表示的是当 RPS 达到 2 时开始扩容。如果 TARGETS 列显示 <unknown>/2,可能是因为 HPA 还没有开始抓取指标,需要多刷新几次才能显示;或者因为抓取指标报错了,可以返回上一节确认获取指标的接口是否正常。

接下来我们将接口的请求频率调整为 5 次/秒

$ while true; do wget -q -O- http://localhost:31086/hello; sleep 0.2; done

很快,就触发了 HPA 的扩容:

$ kubectl get hpa
NAME             REFERENCE                   TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
hello-actuator   Deployment/hello-actuator   1949m/2   1         10        2          20m

而且扩容之后,对 Service 的请求平均摊给了两个 Pod,每个 Pod 的 RPS 都是 2 次/秒 左右,达到了稳定状态,从而不再继续扩容。

$ kubectl get --raw "/apis/custom.metrics.k8s.io/v1beta1/namespaces/default/pods/*/hello_counter_per_second" | jq .
{
  "kind": "MetricValueList",
  "apiVersion": "custom.metrics.k8s.io/v1beta1",
  "metadata": {},
  "items": [
    {
      "describedObject": {
        "kind": "Pod",
        "namespace": "default",
        "name": "hello-actuator-b49545c55-2zdz7",
        "apiVersion": "/v1"
      },
      "metricName": "hello_counter_per_second",
      "timestamp": "2022-11-28T01:01:20Z",
      "value": "2066m",
      "selector": null
    },
    {
      "describedObject": {
        "kind": "Pod",
        "namespace": "default",
        "name": "hello-actuator-b49545c55-r6whf",
        "apiVersion": "/v1"
      },
      "metricName": "hello_counter_per_second",
      "timestamp": "2022-11-28T01:01:20Z",
      "value": "1933m",
      "selector": null
    }
  ]
}

从 Prometheus 的图形界面可以更直观地看出整个扩容的过程:

prometheus-irate.png

一开始只有一个 Pod,请求频率为 1 次/秒,然后突然请求频率飙升到了 3.5 次/秒 左右,很快便触发了 HPA 扩容,扩容后,两个 Pod 平摊请求,对 Pod 的请求频率又降为 2 次/秒,从而达到稳定状态。

可以看出通过 HPA 的自动扩缩容机制,我们可以很轻松地应付一些突发情况,大大地减轻了运维人员的负担。

参考

  1. Pod 水平自动扩缩
  2. Kubernetes HPA 使用详解
  3. 对 Kubernetes 应用进行自定义指标扩缩容
  4. 自动伸缩 | Kuboard
  5. 自动伸缩-例子 | Kuboard
  6. 你真的理解 K8s 中的 requests 和 limits 吗?
  7. Prometheus Adapter Walkthrough
  8. Prometheus Adapter Configuration Walkthroughs

更多

Kubernetes Autoscaler

HPA 的全称为 Horizontal Pod Autoscaling,表示对 Pod 进行水平自动伸缩,这里水平伸缩的意思是增加或减少 Pod 的副本数。既然有水平伸缩,那么肯定也有垂直伸缩,那就是 VPA,全称为 Vertical Pod Autoscaler,它可以根据 Pod 的负载情况对 Pod 的资源请求和限制进行调整。

另外还有一种 Autoscaler 叫做 Cluster Autoscaler,它可以动态的调整 Kubernetes 集群的大小,比如当集群中某个节点比较空闲,且运行在这个节点上的 Pod 也可以移到其他节点上运行时,那么就可以将这个节点去掉,减小集群规模,从而帮你节约成本。

Custom Metrics API

https://github.com/kubernetes-sigs/custom-metrics-apiserver

Kubernetes Apiserver Aggregation

https://github.com/kubernetes-sigs/apiserver-builder-alpha

扫描二维码,在手机上阅读!

基于 OpenTelemetry 的可观测性实战

可观测性(Observability)这个词来源于控制理论,它是由匈牙利裔美国工程师 Rudolf E. Kálmán 针对线性动态控制系统所提出的一个概念,表示 通过系统外部输出推到其内部状态的程度

Observability is a measure of how well internal states of a system can be inferred from knowledge of its external outputs.

在可观测性这个概念被引入软件行业之前,我们对一个软件系统的观测一般都是从日志、指标和链路跟踪三个方面独立进行,并且在每个领域都积累了丰富的经验,也诞生了大量优秀的产品。比如说到日志收集和分析方面,大家基本上都会想到 Elastic Stack 技术栈(ELK、EFK);而对于指标监控,Prometheus 差不多已经成为了这方面的事实标准;另外,还有 SkyWalkingZipkinJaeger 这些链路跟踪的开源项目。

渐渐地,大家也意识到这三个方面并不是完全独立的,而是存在互相重合的现象,比如运维人员在查看系统 CPU 或内存等指标的图表时,如果发现异常,我们希望能快速定位到这个时间段的日志,看看有没有什么错误信息(从指标到日志);或者在日志系统中看到一条错误日志时,我们希望追踪到链路的入口位置,看看最源头的请求参数是什么(从日志到链路)。

metrics-to-logs.png

2017 年,德国工程师 Peter Bourgon 写了一篇非常有名的博客文章Metrics, Tracing, and Logging,他在这篇文章中系统阐述了指标、日志和链路跟踪三者的定义和它们之间的关系:

metrics-logging-tracing.png

他总结到:

  • 指标的特点是 它是可聚合的(Aggregatable),比如接收的 HTTP 请求数这个指标我们可以建模为计数器(counter),每次 HTTP 请求就是对其做加法聚合;
  • 日志的特点是 它是对离散事件的处理(Events),比如我们经常在代码中打印的调试日志或错误日志,系统的审计日志等;
  • 链路跟踪的特点是 它是对请求范围内的信息的处理(Request-scoped),任何数据都可以绑定到这个事务对象的生命周期中,比如对于一个 HTTP 请求的链路,我们可以记录每个请求节点的状态,节点的耗时,谁接收了这个请求等等;

我们从图中可以看到这三者之间是有部分重合的,比如上面讲的 HTTP 请求数这个指标,很显然可以绑定到这个请求的链路中,这被称为 请求范围内的指标(Request-scoped metrics),当然也有些指标不是请求范围内的,比如机器的 CPU 利用率、内存占用、磁盘空间等。而对于一些日志,比如请求报错,也可以绑定到请求链路中,称为 请求范围内的事件(Request-scoped events)

通过这样的划分,我们可以对系统中的日志和指标等数据进行更合理地设计,也对后来所有的可观测性产品提供了边界。2018 年,Apple 的工程师 Cindy Sridharan 在他新出版的书籍 Distributed Systems Observability 中正式提出了分布式系统可观测性的概念,介绍了可观测性和传统监控的区别,以及如何通过可观测性的三大支柱(指标、日志和链路跟踪)构建完整的观测模型,从而实现分布式系统的故障诊断、根因分析和快速恢复。同年,CNCF 社区也将可观测性引入 Cloud Native Landscape 作为云原生领域不可或缺的一部分。

cncf-landscape.png

OpenTelemetry 缘起

看着 Cloud Native Landscape 上琳琅满目的项目,可以看出可观测性这个领域已经是一片红海,那么为什么现在又要推出 OpenTelemetry 这样一个新项目呢?而且通过上图中三大类产品的组合,我们也可以快速搭建一个可观测性系统出来。

不过也正是因为这方面的产品太多,各个产品的数据模型都不一样,因此每个产品都有自己的数据处理和数据分析组件,这也导致了用户需要部署的组件很多,维护的成本也很高,而且这三套系统是完全独立的,不能很好的处理指标、日志和链路跟踪之间的关联关系,用户需要在不同产品之间来回切换,定位和排查问题非常痛苦。

针对这个问题,CNCF 在 2019 年正式推出 OpenTelemetry 项目(也被简写为 OTel),该项目雄心勃勃,旨在统一指标、日志和链路跟踪三种数据,实现可观测性的大一统。从 A brief history of OpenTelemetry (So Far) 这篇文章中我们了解到,在 OpenTelemetry 推出之前,其实已经有一些项目在做这方面的尝试了,比如早在 2016 年 11 月,CNCF 就推出了 OpenTracing 项目,这是一套与平台无关、与厂商无关、与语言无关的追踪协议规范,只要遵循 OpenTracing 规范,任何公司的追踪探针、存储、界面都可以随时切换,也可以相互搭配使用,很快,几乎所有业界有名的追踪系统,譬如 Zipkin、Jaeger、SkyWalking 等纷纷宣布支持 OpenTracing;不过谁也没想到,半路却杀出了个程咬金,这个时候 Google 突然跳出来反对,而且还提出了一个自己的 OpenCensus 规范,更令人想不到的是,随后又得到了 Microsoft 的大力支持。面对这两大巨头的搅局,可观测性的标准和规范不仅没有得到改善,反而变得更加混乱了。好在最终双方握手言和,在 2019 年,OpenTracing 和 OpenCensus 宣布合并,并提出了一个可观测性的终极解决方案,这就是 OpenTelemetry。

OpenTelemetry 具备以下特点:

  • 它为指标、日志和链路跟踪提出了统一的数据模型,可以轻松地实现互相关联;
  • 它采用了统一的 Agent 对所有可观察性数据进行采集和传输,使系统整体架构变得更加简单;
  • 它是厂商无关的,用户可以自由选择和更换适合自己的服务提供商;
  • 它具备很好的兼容性,可以和 CNCF 下各种可观察性方案进行集成;

OpenTelemetry 最核心的功能总结为一句话就是,以统一的数据模型对可观测性数据进行采集、处理和导出,至于数据的可视化或分析工作则交给后端的各种其他服务,整体架构如下图所示:

otel.png

其中包括两个主要部分:

  • OTel Library:也就是 OpenTelemetry API 各种语言的 SDK 实现,用于生成统一格式的可观测性数据;
  • OTel Collector:用来接收这些可观测性数据,并支持把数据传输到各种类型的后端系统。

快速开始

为了让用户能快速地体验和上手 OpenTelemetry,官方提供了一个名为 Astronomy Shop 的演示服务,接下来我们就按照 Quick Start 的步骤,使用 Docker 来部署这个演示服务,一睹 OpenTelemetry 的真容。

除了使用 Docker 部署,官方也提供了 Kubernetes 部署方式

首先下载仓库代码:

$ git clone https://github.com/open-telemetry/opentelemetry-demo.git

进入代码目录后直接执行 docker compose up 命令:

$ cd opentelemetry-demo/
$ docker compose up --no-build

参数 --no-build 用于直接从镜像仓库拉取镜像,如果去掉这个参数则会使用本地的代码来构建镜像。这个命令会启动 20 个容器:

[+] Running 20/0
 ⠿ Container prometheus               Created                                                                          0.0s
 ⠿ Container postgres                 Created                                                                          0.0s
 ⠿ Container grafana                  Created                                                                          0.0s
 ⠿ Container feature-flag-service     Created                                                                          0.0s
 ⠿ Container jaeger                   Created                                                                          0.0s
 ⠿ Container redis-cart               Created                                                                          0.0s
 ⠿ Container otel-col                 Created                                                                          0.0s
 ⠿ Container payment-service          Created                                                                          0.0s
 ⠿ Container ad-service               Created                                                                          0.0s
 ⠿ Container shipping-service         Created                                                                          0.0s
 ⠿ Container email-service            Created                                                                          0.0s
 ⠿ Container product-catalog-service  Created                                                                          0.0s
 ⠿ Container recommendation-service   Created                                                                          0.0s
 ⠿ Container quoteservice             Created                                                                          0.0s
 ⠿ Container currency-service         Created                                                                          0.0s
 ⠿ Container cart-service             Created                                                                          0.0s
 ⠿ Container checkout-service         Created                                                                          0.0s
 ⠿ Container frontend                 Created                                                                          0.0s
 ⠿ Container load-generator           Created                                                                          0.0s
 ⠿ Container frontend-proxy           Created                                                                          0.0s
Attaching to ad-service, cart-service, checkout-service, currency-service, email-service, feature-flag-service, frontend, frontend-proxy, grafana, jaeger, load-generator, otel-col, payment-service, postgres, product-catalog-service, prometheus, quoteservice, recommendation-service, redis-cart, shipping-service

耐心等待所有的镜像下载完毕,且所有的服务启动成功后,即可通过浏览器访问下面这些页面:

演示服务架构

这个演示服务中包含了很多微服务,并且为了起到演示作用,使用了各种不同的编程语言进行开发,用户可以根据自己的兴趣了解不同服务的具体实现:

这些微服务组成的架构图如下所示:

demo-architecture.png

除了这些微服务组件,还部署了下面这些中间件:

  • Prometheus
  • Postgres
  • Grafana
  • Jaeger
  • Redis
  • OpenTelemetry Collector
  • Envoy(Frontend Proxy)

体验演示服务

这个演示服务是一个天文爱好者的网上商城,访问 http://localhost:8080/ 进入商城首页:

demo-shop.png

商城具有浏览商品、商品推荐、添加购物车、下单等功能:

demo-shop-cart.png

商城运行起来之后,Load Generator 服务就会自动对商城进行负载测试,它是一个使用开源工具 Locust 编写的负载测试服务,可以模拟用户访问网站。

访问 http://localhost:8080/loadgen/ 进入 Load Generator 页面:

locust-load-gen.png

可以在这里查看测试用例,开启或停止测试,修改模拟的用户数和用户访问的频率等。还提供了图表页面展示测试的 RPS(Request per Second)、响应时间、活跃用户数等指标:

locust-rps.png

locust-response-times.png

体验 OpenTelemetry

我们知道,OpenTelemetry 的作用是以统一的方式采集和导出可观测性数据,上面所列出来的这些微服务分别使用了不同语言的 OpenTelemetry SDK 采集数据,对于采集的数据,我们有两种处理方式:一种是直接将其导出到某个后端服务,比如直接导出到 Prometheus、Jaeger 等,这种方式简单明了,适用于开发环境或小规模环境;另一种方式是导出到 OpenTelemetry Collector 服务,这也是官方推荐的做法,这样你的服务可以更聚焦于快速地导出数据,而对于请求重试、批量导出、数据加密、数据压缩或敏感数据过滤这些数据处理操作统统交给 OpenTelemetry Collector 来处理。

在这个演示服务中,我们就是通过 OpenTelemetry Collector 来收集数据的,打开 docker-compose.yaml 文件,我们来看看 OpenTelemetry Collector 的配置:

otelcol:
  image: otel/opentelemetry-collector-contrib:0.61.0
  container_name: otel-col
  deploy:
    resources:
      limits:
        memory: 100M
  restart: always
  command: [ "--config=/etc/otelcol-config.yml", "--config=/etcotelcol-config-extras.yml" ]
  volumes:
    - ./src/otelcollector/otelcol-config.yml:/etc/otelcol-config.yml
    - ./src/otelcollector/otelcol-config-extras.yml:/etc/otelcol-config-extras.yml
  ports:
    - "4317"          # OTLP over gRPC receiver
    - "4318:4318"     # OTLP over HTTP receiver
    - "9464"          # Prometheus exporter
    - "8888"          # metrics endpoint
  depends_on:
    - jaeger
  logging: *logging

这里可以看到 OpenTelemetry Collector 暴露了四个端口:43174318 这两个端口是用于收集数据的,一个是 gRPC 协议,一个是 HTTP 协议,数据必须符合 OTLP 规范(OpenTelemetry Protocol);94648888 这两个端口是 OpenTelemetry Collector 的指标端口,8888 暴露的是 OpenTelemetry Collector 本身的指标:

otelcol-metrics.png

9464 暴露是的 OpenTelemetry Collector 收集到的指标:

otelcol-service-metrics.png

这两个端口都配置在 Prometheus 的配置文件 prometheus-config.yaml 中:

global:
  evaluation_interval: 30s
  scrape_interval: 5s
scrape_configs:
- job_name: otel
  static_configs:
  - targets:
    - 'otelcol:9464'
- job_name: otel-collector
  static_configs:
  - targets:
    - 'otelcol:8888'

我们打开 Prometheus 的 Targets 页面:

prometheus-targets.png

接下来我们看下 OpenTelemetry Collector 的配置文件 otelcol-config.yml,文件的内容如下:

receivers:
  otlp:
    protocols:
      grpc:
      http:
        cors:
          allowed_origins:
            - "http://*"
            - "https://*"

exporters:
  otlp:
    endpoint: "jaeger:4317"
    tls:
      insecure: true
  logging:
  prometheus:
    endpoint: "otelcol:9464"

processors:
  batch:
  spanmetrics:
    metrics_exporter: prometheus

OpenTelemetry Collector 主要由以下三部分组成:

暴露给 Prometheus 的指标端口 9464 我们在上面的 Prometheus 配置中已经看到了,导出到 Jaeger 的端口 4317 也可以在 Jaeger 的配置文件中看到:

jaeger:
  image: jaegertracing/all-in-one
  container_name: jaeger
  command: ["--memory.max-traces", "10000", "--query.base-path", "/jaeger/ui"]
  deploy:
    resources:
      limits:
        memory: 275M
  restart: always
  ports:
    - "16686"                    # Jaeger UI
    - "4317"                     # OTLP gRPC default port
  environment:
    - COLLECTOR_OTLP_ENABLED=true
  logging: *logging

Jaeger 从 v1.35 版本开始支持 OTLP 协议的链路跟踪数据,通过在环境变量中添加 COLLECTOR_OTLP_ENABLED=true 即可开启该功能。

除了上面配置的这些,我们还可以在 opentelemetry-collectoropentelemetry-collector-contrib 找到更多的官方或第三方的 receivers、processors 和 exporters。

OpenTelemetry Collector 的总体示意图如下所示:

otel-collector.png

配置好 receivers、processors 和 exporters 之后,我们还需要通过在 服务(service) 中添加流水线(pipeline)将其串起来:

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [spanmetrics, batch]
      exporters: [logging, otlp]
    metrics:
      receivers: [otlp]
      processors: [batch]
      exporters: [prometheus, logging]

上面的配置中我们开启了两个流水线:

  • traces 流水线用于处理链路跟踪数据:通过 otlp 接收数据,再通过 spanmetrics 和 batch 处理器进行处理,最后导出到 logging 和 otlp;
  • metrics 流水线用于处理指标数据:通过 otlp 接收数据,再通过 batch 处理器进行处理,最后导出到 prometheus 和 logging;

除此之外,还可以添加 logs 流水线用于处理日志数据。

在这个演示服务中 Prometheus 的端口并没有对外暴露,而是通过 Grafana 来做可视化展示,我们打开 http://localhost:8080/grafana/ 页面,可以在 Grafana 中找到两个面板,一个是 OpenTelemetry 本身的指标面板:

grafana-otel-metrics.png

这个面板中展示了 receivers、processors 和 exporters 的指标信息。另一个是 OpenTelemetry 收集的服务的指标面板:

grafana-service-metrics.png

这个面板中展示了每个服务的 CPU 利用率,内存,调用次数,错误率等指标信息。

在 Grafana 中配置了两个数据源,一个 Prometheus,一个 Jaeger,不过 Jaeger 数据源貌似没有用。我们可以打开 http://localhost:8080/jaeger/ui/ 页面,在 Search 页面搜索不同服务的 trace 数据:

jaeger-search.png

随便点击一条 trace 数据,可以进入该 trace 的详细页面,这个页面展示了该 trace 在不同服务之间的详细链路:

jaeger-trace-detail.png

更厉害的是,Jaeger 还能根据 trace 数据生成整个系统的架构图(连线处还标出了服务之间的调用量):

jaeger-system-architecture.png

使用 OpenTelemetry 快速排错

示例服务提供了几个 特性开关(Feature Flags) 用于模拟服务异常的场景。我们在浏览器中输入 http://localhost:8080/feature/ 进入 Feature Flag UI 页面:

feature-flags-ui.png

这里有两个特性开关:

  • productCatalogFailure - 打开这个开关后,访问商品 OLJCESPC7Z 时会报错;
  • recommendationCache - 打开这个开关后,recommendationservice 会偶发性的报内存溢出错误;

Using Metrics and Traces to diagnose a memory leak 这篇官方文档中,给出了排查内存溢出错误的步骤。我们这里不妨就排查下商品接口报错这个问题,首先,我们将 productCatalogFailure 这个特性开关打开,然后在浏览器中访问 http://localhost:8080/product/OLJCESPC7Z 页面:

product-error.png

果然,页面报错了。在下面的开发者工具中可以看到是 http://localhost:8080/api/products/OLJCESPC7Z 这个接口报了 500 Internal Server Error 错误。

为了排查这个错误,我们打开 Jaeger UI 进入 Search 页面,在 Service 列表中选择 productcatalogservice,Tags 中输入 error=true,可以在搜索结果中看到很多红色的点,这些都是出错的请求:

jaeger-errors.png

我们点开其中一个查看详情:

jaeger-error-detail.png

在左侧的树形结构中,我们看到出错的服务上都有一个红色的感叹号,展开 productcatalogservice,在 Logs 中就能看到错误的日志了:

Error: ProductCatalogService Fail Feature Flag Enabled

开发指南

这一节我们将学习如何在自己的项目中集成 OpenTelemetry SDK,OpenTelemetry 支持大多数开发语言,其中 对 Java 的支持 最为完善。不仅提供了 Java API 和 SDK,我们可以使用 SDK 手动采集数据,而且还提供了 Java Agent 让我们不用写一行代码就能 自动采集数据,并且支持 大多数的 Java 库和框架

Automatic Instrumentation

首先准备两个简单的 Spring Boot 项目:demo-server 作为服务端,提供了一个 /greeting 接口;demo-client 作为客户端,使用 RestTemplate 调用 /greeting 接口。

然后下载最新版本的 opentelemetry-javaagent.jar,将其作为 -javaagent 参数启动 demo-server

$ java -javaagent:../opentelemetry-javaagent.jar \
  -Dotel.service.name=demo-server \
  -Dotel.exporter.otlp.endpoint=http://localhost:4317 \
  -jar ./target/demo-server-0.0.1-SNAPSHOT.jar

然后再启动 demo-client

$ java -javaagent:../opentelemetry-javaagent.jar \
  -Dotel.service.name=demo-client \
  -Dotel.exporter.otlp.endpoint=http://localhost:4317 \
  -jar ./target/demo-client-0.0.1-SNAPSHOT.jar

上面通过参数 otel.service.name 指定服务名称,通过参数 otel.exporter.otlp.endpoint 指定 OpenTelemetry Collector 地址。

运行之后,打开 Jaeger UI 页面,就可以看到 demo-client 的这次请求:

jaeger-demo.png

点开可以看到完整的链路详情:

jaeger-demo-detail.png

Manual Instrumentation

通过 Java Agent 自动收集指标虽然简单,但是有时候我们还需要手动收集一些其他信息,比如处理过程中的一些步骤日志或耗时指标等。

官方在 这里提供了大量的示例 供初学者学习参考,上面 Astronomy Shop 演示服务中的 Ad Service 也是使用 Java 实现的,我们也可以 参考它的代码

首先我们在 demo-serverpom.xml 文件中添加依赖:

<project>
  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>io.opentelemetry</groupId>
        <artifactId>opentelemetry-bom</artifactId>
        <version>1.20.0</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>io.opentelemetry</groupId>
      <artifactId>opentelemetry-api</artifactId>
    </dependency>
  </dependencies>
</project>

然后在处理 /greeting 请求时,对当前的 Span 添加一个新的属性:

@GetMapping("/greeting")
public String greeting(@RequestParam(value = "name", defaultValue = "World") String name) {

  Span span = Span.current();
  span.setAttribute("user.name", name);

  return String.format("Hello, %s", name);
}

重新编译和启动,再次运行 demo-client,可以在 Jaeger UI 中看到我们添加的属性:

jaeger-demo-span-attr.png

参考

  1. OpenTelemetry Documentation
  2. OpenTelemetry 中文文档
  3. OpenTelemetry 可观测性的未来 - 作者 Ted Young,译者 Jimmy Song
  4. OpenTelemetry 简析 - 阿里云云原生
  5. End-to-end tracing with OpenTelemetry - Nicolas Fränkel
  6. OpenTelemetry初體驗:實踐Chaos Engineering來Drive the Observability's best practice - Johnny Pan
  7. 淺談DevOps與Observability 系列
  8. 可观测性 - 凤凰架构
  9. Kratos 学习笔记 - 基于 OpenTelemetry 的链路追踪

更多

1. 执行 docker compose up 报错 'compose' is not a docker command.

Docker Compose V2docker-compose 的重大版本升级,使用 Go 完全重写了对之前 V1 的 Python 代码,并且和 V1 不同的是,V2 不再是独立的可执行程序,而是作为 Docker 的命令行插件来运行。所以需要先将其安装到 Docker 的插件目录:

$ mkdir -p ~/.docker/cli-plugins
$ curl -fsSL "https://github.com/docker/compose/releases/download/v2.12.2/docker-compose-linux-x86_64" -o ~/.docker/cli-plugins/docker-compose
$ chmod +x ~/.docker/cli-plugins/docker-compose

安装完成后检查是否生效:

$ docker compose version
Docker Compose version v2.12.2

如果你需要兼容 Docker Compose V1 时的 docker-compose 命令,官方提供了一个名为 Compose Switch 的工具,它可以将 docker-compose 命令自动转换为 docker compose 命令。如果你的机器上没有安装过 Docker Compose V1,可以直接下载 compose-switch 并改名为 docker-compose

$ sudo curl -fsSL https://github.com/docker
/compose-switch/releases/download/v1.0.5/docker-compose-linux-amd64 -o /usr/local/bin/docker-compose
$ sudo chmod +x /usr/local/bin/docker-compose

如果已经安装过 Docker Compose V1,你可以将其先卸载掉再安装 compose-switch,或者根据官方文档使用 update-alternatives 之类的工具进行版本切换。

2. Dapr 的可观测性实战

扫描二维码,在手机上阅读!

搭建自己的镜像仓库

镜像仓库(Docker Registry)是用于存储和管理镜像的地方,方便将镜像分发到世界各地,镜像仓库一般分为公共仓库和私有仓库两种形式。

Docker 官方的 Docker Hub 是最常用的公共仓库之一,包含很多高质量的官方镜像,这也是 Docker 默认使用的仓库,除此之外,还有 Red Hat 的 Quay.io,Google 的 Google Container Registry(Kubernetes 就是使用 GCR 作为默认的镜像仓库),以及 GitHub 的 ghcr.io 等。国内一些云服务商也提供类似的服务,比如 网易云镜像服务DaoCloud 镜像市场阿里云容器镜像服务(ACR) 等。另外还有些服务商提供了针对 Docker Hub 的镜像服务(Registry Mirror),这些镜像服务被称为 加速器,比如 DaoCloud 加速器,使用加速器会直接从国内的地址下载 Docker Hub 的镜像,比直接从 Docker Hub 下载快得多。

除公开仓库外,用户也可以在本地搭建私有镜像仓库。通过官方提供的 Docker Registry 镜像,可以很容易搭建一个自己的镜像仓库服务,这个仓库服务提供了 Docker Registry API 相关的接口,并没有图形界面,不过对 Docker 命令来说已经足够了。如果还需要一些高级特性,可以尝试 HarborSonatype Nexus,他们不仅提供了图形界面,还具有镜像维护、用户管理、访问控制等高级功能。

使用 Docker Registry 搭建私有镜像仓库

首先下载 Docker Registry 镜像:

$ docker pull registry:latest

目前最新的 registry 版本是 2.8,它是基于 Distribution 实现的,老版本的 registry 是基于 docker-registry 实现的,现在已经几乎不用了。DistributionOCI Distribution Specification 的开源实现,很多其他的镜像仓库项目如 Docker Hub、GitHub Container Registry、GitLab Container Registry、DigitalOcean Container Registry、Harbor Project、VMware Harbor Registry 都是以 Distribution 为基础开发的。

使用 docker 命令启动镜像仓库:

$ docker run -d -p 5000:5000 --name registry registry:latest

这样我们的私有镜像仓库就搭建好了。为了验证这个镜像仓库是否可用,我们可以从官方随便下载一个镜像(这里我使用的是 hello-world 镜像),然后通过 docker tag 在镜像名前面加上私有仓库的地址 localhost:5000/,再通过 docker push 就可以将这个镜像推送到我们的私有仓库里了:

$ docker pull hello-world
$ docker tag hello-world localhost:5000/hello-world
$ docker push localhost:5000/hello-world

使用 Docker Registry API 访问仓库

我们可以通过 Docker Registry API 暴露的一些接口来访问仓库,比如使用 /v2/_catalog 接口查询仓库中有哪些镜像:

$ curl -s http://localhost:5000/v2/_catalog | jq
{
  "repositories": [
    "hello-world"
  ]
}

使用 /v2/<name>/tags/list 接口查询某个镜像的标签:

$ curl -s http://localhost:5000/v2/hello-world/tags/list | jq
{
  "name": "hello-world",
  "tags": [
    "latest"
  ]
}

使用 /v2/<name>/manifests/<reference> 接口查询某个镜像版本的详细信息:

$ curl -s http://localhost:5000/v2/hello-world/manifests/latest | jq
{
  "schemaVersion": 1,
  "name": "hello-world",
  "tag": "latest",
  "architecture": "amd64",
  "fsLayers": [
    {
      "blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
    },
    {
      "blobSum": "sha256:2db29710123e3e53a794f2694094b9b4338aa9ee5c40b930cb8063a1be392c54"
    }
  ],
  "history": [
    {
      "v1Compatibility": "{\"architecture\":\"amd64\",\"config\":{\"Hostname\":\"\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":[\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"],\"Cmd\":[\"/hello\"],\"Image\":\"sha256:b9935d4e8431fb1a7f0989304ec86b3329a99a25f5efdc7f09f3f8c41434ca6d\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":null,\"Labels\":null},\"container\":\"8746661ca3c2f215da94e6d3f7dfdcafaff5ec0b21c9aff6af3dc379a82fbc72\",\"container_config\":{\"Hostname\":\"8746661ca3c2\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":[\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"],\"Cmd\":[\"/bin/sh\",\"-c\",\"#(nop) \",\"CMD [\\\"/hello\\\"]\"],\"Image\":\"sha256:b9935d4e8431fb1a7f0989304ec86b3329a99a25f5efdc7f09f3f8c41434ca6d\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":null,\"Labels\":{}},\"created\":\"2021-09-23T23:47:57.442225064Z\",\"docker_version\":\"20.10.7\",\"id\":\"a1f125167a7f2cffa48b7851ff3f75e983824c16e8da61f20765eb55f7b3a594\",\"os\":\"linux\",\"parent\":\"cd13bf215b21e9bc78460fa5070860a498671e2ac282d86d15042cf0c26e6e8b\",\"throwaway\":true}"
    },
    {
      "v1Compatibility": "{\"id\":\"cd13bf215b21e9bc78460fa5070860a498671e2ac282d86d15042cf0c26e6e8b\",\"created\":\"2021-09-23T23:47:57.098990892Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) COPY file:50563a97010fd7ce1ceebd1fa4f4891ac3decdf428333fb2683696f4358af6c2 in / \"]}}"
    }
  ],
  "signatures": [
    {
      "header": {
        "jwk": {
          "crv": "P-256",
          "kid": "6GC6:JFLS:HP3P:WWBW:V4RI:BJKW:64GB:NSAO:Y4U6:UT6M:MSLJ:QG6K",
          "kty": "EC",
          "x": "Q1gHvt0A-Q-Pu8hfm2o-hLST0b-XZlEQcn9kYHZzAi0",
          "y": "oNddnJzLNOMcRcEebuEqZiapZHHmQSZHnnnaSkvYUaE"
        },
        "alg": "ES256"
      },
      "signature": "NthpjcYe39XSmnKRz9dlSWZBcpIgqIXuFGhQ4bxALK97NsWAZPE6CSiLwEn3ECjm1ovKzjJthOAuK_CW92ju-Q",
      "protected": "eyJmb3JtYXRMZW5ndGgiOjIwOTIsImZvcm1hdFRhaWwiOiJDbjAiLCJ0aW1lIjoiMjAyMi0wOS0xNFQyMzo1ODozM1oifQ"
    }
  ]
}

除了这三个比较常用的查询类接口,Docker Registry API 还有一些用于上传和下载的接口,具体的内容可以查看这里的 接口列表

使用 crane 工具操作仓库

使用 Docker Registry API 操作镜像仓库不是那么方便,使用 docker 命令访问镜像仓库必须依赖 Docker Daemon 服务,有没有一款命令行工具可以直接用来操作镜像仓库呢?

google/go-containerregistry 是 Google 开源的一个专门操作镜像仓库的 Go 类库,并且 Google 还基于此类库实现了一个小工具 crane,大大地方便了我们查看或管理远程仓库中的镜像。

如果我们的镜像仓库开启了用户认证(参见下一节),需要在使用之前先通过 crane auth login 登录:

$ crane auth login localhost:5000 -u admin -p passw0rd

使用 crane catalog 列出所有镜像:

$ crane catalog localhost:5000 --insecure

使用 crane ls 列出某个镜像的所有标签:

$ crane ls localhost:5000/hello-world --insecure

使用 crane pull 下载镜像,并保存到一个 tar 文件中:

$ crane pull localhost:5000/hello-world hello-world.tar --insecure

使用 crane push 将一个 tar 文件上传到镜像仓库:

$ crane push hello-world.tar localhost:5000/hello-world --insecure

删除镜像

使用 crane 删除镜像要稍微复杂一点,首先使用 crane digest 获取某个镜像的摘要值(digest value):

$ crane digest localhost:5000/hello-world:latest --insecure
sha256:3d1aa3d49e778503d60d3ba718eaf04bc8fa2262bff980edf3fb8c01779cd8a9

然后通过这个摘要值来删除镜像:

$ crane delete localhost:5000/hello-world@sha256:3d1aa3d49e778503d60d3ba718eaf04bc8fa2262bff980edf3fb8c01779cd8a9 --insecure

不过默认情况下会报错,因为我们没有开启删除镜像的功能:

Error: DELETE https://localhost:5000/v2/hello-world/manifests/sha256:3d1aa3d49e778503d60d3ba718eaf04bc8fa2262bff980edf3fb8c01779cd8a9: UNSUPPORTED: The operation is unsupported.

可以在启动镜像仓库时加上 REGISTRY_STORAGE_DELETE_ENABLED=true 环境变量,或在配置文件中加上该配置:

registry:
  storage:
    delete:
      enabled: true

删除之后,通过 crane ls 确认 hello-world:latest 已删除:

$ crane ls localhost:5000/hello-world --insecure

不过此时使用 crane catalog 还是能看到这个镜像的,说明只是标签被删了:

$ crane catalog localhost:5000 --insecure
hello-world

根据 distribution 项目上的一个 Issuse 大概了解到,Docker Registry API 只能删除 manifests 和 layers,不能删除 repository。如果真要删除,可以手工把 /var/lib/registry/docker/registry/v2/repositories/hello-world 这个目录删掉。

另外有一点需要注意的是,通过 API 删除镜像并不能删除磁盘上未引用的 Blobs 文件,所以如果你想要释放磁盘空间,还需要手工对 Docker Registry 执行 垃圾回收(Garbage collection)

进入 registry 容器:

$ docker exec -it registry /bin/sh

通过 registry garbage-collect 命令垃圾回收,真正执行前可以先加上一个 --dry-run 参数,可以看到执行后命令会删除哪些内容:

/ # registry garbage-collect --delete-untagged --dry-run /etc/docker/registry/config.yml
hello-world

0 blobs marked, 3 blobs and 0 manifests eligible for deletion
blob eligible for deletion: sha256:3d1aa3d49e778503d60d3ba718eaf04bc8fa2262bff980edf3fb8c01779cd8a9
blob eligible for deletion: sha256:7f760066df116957589ba45a8ca84fe03373d15fdf1752c9b60f24ecbff5a870
blob eligible for deletion: sha256:feb5d9fea6a5e9606aa995e879d862b825965ba48de054caab5ef356dc6b3412

确认无误后,开始执行垃圾回收:

/ # registry garbage-collect --delete-untagged /etc/docker/registry/config.yml
hello-world

0 blobs marked, 3 blobs and 0 manifests eligible for deletion
blob eligible for deletion: sha256:3d1aa3d49e778503d60d3ba718eaf04bc8fa2262bff980edf3fb8c01779cd8a9
INFO[0000] Deleting blob: /docker/registry/v2/blobs/sha256/3d/3d1aa3d49e778503d60d3ba718eaf04bc8fa2262bff980edf3fb8c01779cd8a9  go.version=go1.16.15 instance.id=74e3fb6e-ac44-4c96-923a-5cc6d5e5342a service=registry
blob eligible for deletion: sha256:7f760066df116957589ba45a8ca84fe03373d15fdf1752c9b60f24ecbff5a870
INFO[0000] Deleting blob: /docker/registry/v2/blobs/sha256/7f/7f760066df116957589ba45a8ca84fe03373d15fdf1752c9b60f24ecbff5a870  go.version=go1.16.15 instance.id=74e3fb6e-ac44-4c96-923a-5cc6d5e5342a service=registry
blob eligible for deletion: sha256:feb5d9fea6a5e9606aa995e879d862b825965ba48de054caab5ef356dc6b3412
INFO[0000] Deleting blob: /docker/registry/v2/blobs/sha256/fe/feb5d9fea6a5e9606aa995e879d862b825965ba48de054caab5ef356dc6b3412  go.version=go1.16.15 instance.id=74e3fb6e-ac44-4c96-923a-5cc6d5e5342a service=registry

执行结束后查看磁盘空间是否释放:

/ # df -h | grep /var/lib/registry

开启 TLS 和安全认证

上面我们用一行命令就搭建了一个私有仓库,不过这个私有仓库几乎没什么安全性,只能在本地测试用。Docker 默认是以 HTTPS 方式连接除 localhost 之外的仓库的,当从其他机器访问这个不安全的仓库地址时会报下面这样的错:

$ docker pull 192.168.1.39:5000/hello-world
Using default tag: latest
Error response from daemon: Get "https://192.168.1.39:5000/v2/": http: server gave HTTP response to HTTPS client

如果真要用也可以,需要修改 Docker 的配置文件 /etc/docker/daemon.json,将这个地址添加到 insecure-registries 配置项中:

{
  "insecure-registries" : ["192.168.1.39:5000"]
}

然后重启 Docker 后即可。

不过这种方式非常不安全,不仅容易遭受中间人攻击(MITM),而且有一些限制,比如不能开启用户认证,也就是说你这个镜像仓库是赤裸裸的暴露在任何人面前。如果要搭建一套生产环境使用的镜像仓库,我们必须做得更安全一点。

开启 TLS 安全

我们首先需要 获得一份 TLS 证书,官方文档中要求你拥有一个 HTTPS 域名,并从权威的 CA 机构获得一份证书,不过大多数人估计都没这个条件,所以我们 使用自签名证书(self-signed certificate)。

创建一个 certs 目录:

$ mkdir -p certs

使用 openssl req 生成证书:

$ openssl req \
  -newkey rsa:4096 -nodes -sha256 -keyout certs/domain.key \
  -addext "subjectAltName = IP:192.168.1.39" \
  -x509 -days 365 -out certs/domain.crt

注意这里要使用 -addext 参数添加 subjectAltName 扩展项,也就是 Subject Alternative Name,一般缩写为 SAN,表示我们的证书使用者是 IP 192.168.1.39,如果你有自己的域名,可以在 SAN 中指定 DNS:-addext "subjectAltName = DNS:example.hub"

如果没有在证书的 SAN 中指定 IP,会报如下错误:

$ docker push 192.168.1.39:5000/registry:latest
The push refers to repository [192.168.1.39:5000/registry]
Get "https://192.168.1.39:5000/v2/": x509: cannot validate certificate for 192.168.1.39 because it doesn't contain any IP SANs

或者证书中 IP 和实际 IP 不匹配,也会报错:

$ docker push 192.168.1.39:5000/registry:latest
The push refers to repository [192.168.1.39:5000/registry]
Get "https://192.168.1.39:5000/v2/": x509: certificate is valid for 192.168.1.40, not 192.168.1.39

生成证书的过程中,还会提示你填写一些信息,这些信息被称为 Distinguished Name(简称 DN),可以酌情填写重要的,不想填的输入 . 留空即可:

You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:CN
State or Province Name (full name) [Some-State]:AH
Locality Name (eg, city) []:HF
Organization Name (eg, company) [Internet Widgits Pty Ltd]:.
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:example.hub
Email Address []:

命令执行成功后会在 certs 目录生成 domain.crtdomain.key 两个文件,然后通过下面的命令重新启动镜像仓库(先删除之前启动的),开启 TLS 安全功能:

$ docker run -d \
  -v "$(pwd)"/certs:/certs \
  -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt \
  -e REGISTRY_HTTP_TLS_KEY=/certs/domain.key \
  -p 5000:5000 \
  --name registry \
  registry:latest

不过由于这个证书是我们自己颁发的,并不是系统可信证书,在使用时会报错:

$ docker push 192.168.1.39:5000/registry:latest
The push refers to repository [192.168.1.39:5000/registry]
Get "https://192.168.1.39:5000/v2/": x509: certificate signed by unknown authority

有两种方法可以解决这个问题,第一种方法和上面的 HTTP 形式一样,在配置文件中添加 insecure-registries 配置:

{
  "insecure-registries" : ["192.168.1.39:5000"]
}

第二种方法是让 Docker 服务信任这个证书。如果是 Linux 机器,可以将 domain.crt 文件放到 Docker 的证书目录 /etc/docker/certs.d/192.168.1.39:5000/ca.crt 下,立即生效,不用重启 Docker 服务;如果是 Windows 机器,直接双击证书文件,将证书安装到当前用户或本地计算机:

windows-cert-install.png

然后选择 根据证书类型,自动选择证书存储,默认会将证书安装到 中间证书颁发机构 下,也可以手工选择安装到 受信任的根证书颁发机构 下。安装完成后可以打开证书管理器(如果安装时存储位置选择的是当前用户,运行 certmgr.msc;如果是本地计算机,运行 certlm.msc)查看我们的证书:

windows-cert.png

需要重启 Docker 服务后生效。

开启用户认证

开启 TLS 之后,为了保证镜像仓库的安全性,还可以通过客户端认证机制只允许特定的客户端连接,配置参数为 http.tls.clientcas,可以配置多个客户端证书:

http:
  tls:
    certificate: /path/to/x509/public
    key: /path/to/x509/private
    clientcas:
      - /path/to/ca.pem
      - /path/to/another/ca.pem

不过这种方式不是很常用,更多的是使用 Basic 认证机制。Docker Registry 支持通过 Apache htpasswd 文件 来实现用户认证功能,首先使用 htpasswd 命令来生成密码文件:

$ mkdir -p auth
$ docker run --entrypoint htpasswd httpd:2 -Bbn admin passw0rd > auth/htpasswd

htpasswd 命令内置在 Apache HTTPD 服务器里,我们这里直接使用了 httpd:2 镜像,生成的密码文件保存在 auth 目录下,密码是通过 Bcrypt 算法 加密的,文件内容是这种格式:

admin:$2y$05$mzoaYiDmF7Bm2p/JWf4kje7naTzkyYpqgg5v8mZPq0HdDuSXZ1d0i

然后运行下面的命令,同时开启 TLS 功能和用户认证功能:

$ docker run -d \
  -v "$(pwd)"/certs:/certs \
  -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt \
  -e REGISTRY_HTTP_TLS_KEY=/certs/domain.key \
  -v "$(pwd)"/auth:/auth \
  -e "REGISTRY_AUTH=htpasswd" \
  -e "REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm" \
  -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \
  -p 5000:5000 \
  --name registry \
  registry:latest

这时访问镜像仓库报错:

$ docker push 192.168.1.39:5000/hello-world
no basic auth credentials

需要先 docker login 登录:

$ docker login 192.168.1.39:5000

登录成功后,就可以正常操作镜像仓库了。需要特别注意的是,登录后的用户名和密码会以 BASE64 的形式保存在 ~/.docker/config.json 文件中:

{
  "auths": {
    "192.168.1.39:5000": {
      "auth": "YWRtaW46cGFzc3cwcmQ="
    }
  }
}

这是很不安全的,你可以考虑使用其他的 Credentials store 来保存你的用户名和密码。

另外,除了使用 Basic 用户认证,Docker Registry 还支持 使用认证服务器实现更复杂的 OAuth 2.0 认证

参考

  1. 仓库 - Docker — 从入门到实践
  2. 私有仓库 - Docker — 从入门到实践
  3. Docker Registry
  4. How to delete images from a private docker registry?
  5. 你必须知道的Docker镜像仓库的搭建
  6. distribution/distribution - The toolkit to pack, ship, store, and deliver container content
  7. google/go-containerregistry - Go library and CLIs for working with container registries
  8. Housekeep your local image registry

更多

1. 设置镜像仓库的存储驱动

镜像仓库本质上是一个文件存储和内容分发系统,可以通过 Storage API 接口实现不同的存储驱动,官方支持下面这些存储驱动:

2. 使用图形界面管理镜像仓库

这篇文章主要介绍了通过 Docker Registry API 和命令行两种方式对镜像仓库进行管理,这也是管理镜像仓库最基础的方式。篇幅有限,还有很多其他图形化的方式没有介绍,这些项目的底层依赖或实现原理大抵是一样的,下面是一个简单的列表:

  • Docker Registry UI - The simplest and most complete UI for your private registry
  • SUSE/Portus - Authorization service and frontend for Docker registry (v2)
  • Harbor - An open source trusted cloud native registry project that stores, signs, and scans content.
  • Sonatype Nexus - Manage binaries and build artifacts across your software supply chain.
扫描二维码,在手机上阅读!

etcd 学习笔记

etcd 是一个使用 Go 语言编写的用于存储分布式系统中的数据的高可用键值数据库(key-value store),它是 CoreOS 团队在 2013 年 6 月发起的开源项目,并在 2018 年 12 月正式加入 CNCF。我们知道在 Linux 操作系统中有一个目录叫 /etc,它是专门用来存储操作系统配置的地方,etcd 这个名词就是源自于此,etcd = etc + distibuted,所以它的目的就是用来存储分布式系统中的关键数据。

etcd.png

etcd 内部采用 Raft 一致性算法,以一致和容错的方式存储元数据。利用 etcd 可以实现包括配置管理、服务发现和协调分布式任务这些功能,另外 etcd 还提供了一些常用的分布式模式,包括领导选举,分布式锁和监控机器活动等。

etcd 已经被各大公司和开源项目广泛使用,最著名的莫过于 Kubernetes 就是使用 etcd 来存储配置数据的,etcd 的一致性对于正确安排和运行服务至关重要,Kubernetes 的 API Server 将集群状态持久化在 etcd 中,通过 etcd 的 Watch API 监听集群,并发布关键的配置更改。

k8s-apiserver-etcd.png

快速开始

这一节我们将学习如何在本地快速启动一个单节点的 etcd 服务,并学习 etcd 的基本使用。

安装

首先从 GitHub Releases 页面下载和你的操作系统对应的最新版本:

$ curl -LO https://github.com/etcd-io/etcd/releases/download/v3.4.20/etcd-v3.4.20-linux-amd64.tar.gz

解压并安装到 /usr/local/etcd 目录:

$ tar xzvf etcd-v3.4.20-linux-amd64.tar.gz -C /usr/local/etcd --strip-components=1

目录下包含了 etcdetcdctl 两个可执行文件,etcdctletcd 的命令行客户端。另外,还包含一些 Markdown 文档:

$ ls /usr/local/etcd
Documentation  README-etcdctl.md  README.md  READMEv2-etcdctl.md  etcd  etcdctl

然后将 /usr/local/etcd 目录添加到 PATH 环境变量(如果要让配置永远生效,可以将下面一行添加到 ~/.profile 文件中):

export PATH=$PATH:/usr/local/etcd

至此,etcd 就安装好了,使用 etcd --version 检查版本:

$ etcd --version
etcd Version: 3.4.20
Git SHA: 1e26823
Go Version: go1.16.15
Go OS/Arch: linux/amd64

启动

直接不带参数运行 etcd 命令,即可(a single-member cluster of etcd):

$ etcd
[WARNING] Deprecated '--logger=capnslog' flag is set; use '--logger=zap' flag instead
2022-09-06 07:21:49.244548 I | etcdmain: etcd Version: 3.4.20
2022-09-06 07:21:49.253270 I | etcdmain: Git SHA: 1e26823
2022-09-06 07:21:49.253486 I | etcdmain: Go Version: go1.16.15
2022-09-06 07:21:49.253678 I | etcdmain: Go OS/Arch: linux/amd64
2022-09-06 07:21:49.253846 I | etcdmain: setting maximum number of CPUs to 8, total number of available CPUs is 8
2022-09-06 07:21:49.253981 W | etcdmain: no data-dir provided, using default data-dir ./default.etcd
2022-09-06 07:21:49.254907 N | etcdmain: the server is already initialized as member before, starting as etcd member...
[WARNING] Deprecated '--logger=capnslog' flag is set; use '--logger=zap' flag instead
2022-09-06 07:21:49.264084 I | embed: name = default
2022-09-06 07:21:49.264296 I | embed: data dir = default.etcd
2022-09-06 07:21:49.264439 I | embed: member dir = default.etcd/member
2022-09-06 07:21:49.264667 I | embed: heartbeat = 100ms
2022-09-06 07:21:49.264885 I | embed: election = 1000ms
2022-09-06 07:21:49.265079 I | embed: snapshot count = 100000
2022-09-06 07:21:49.265244 I | embed: advertise client URLs = http://localhost:2379
2022-09-06 07:21:49.265389 I | embed: initial advertise peer URLs = http://localhost:2380
2022-09-06 07:21:49.265681 I | embed: initial cluster =
2022-09-06 07:21:49.302456 I | etcdserver: restarting member 8e9e05c52164694d in cluster cdf818194e3a8c32 at commit index 5
raft2022/09/06 07:21:49 INFO: 8e9e05c52164694d switched to configuration voters=()
raft2022/09/06 07:21:49 INFO: 8e9e05c52164694d became follower at term 2
raft2022/09/06 07:21:49 INFO: newRaft 8e9e05c52164694d [peers: [], term: 2, commit: 5, applied: 0, lastindex: 5, lastterm: 2]
2022-09-06 07:21:49.312871 W | auth: simple token is not cryptographically signed
2022-09-06 07:21:49.319814 I | etcdserver: starting server... [version: 3.4.20, cluster version: to_be_decided]
raft2022/09/06 07:21:49 INFO: 8e9e05c52164694d switched to configuration voters=(10276657743932975437)
2022-09-06 07:21:49.329816 I | etcdserver/membership: added member 8e9e05c52164694d [http://localhost:2380] to cluster cdf818194e3a8c32
2022-09-06 07:21:49.331278 N | etcdserver/membership: set the initial cluster version to 3.4
2022-09-06 07:21:49.331489 I | etcdserver/api: enabled capabilities for version 3.4
2022-09-06 07:21:49.333146 I | embed: listening for peers on 127.0.0.1:2380
raft2022/09/06 07:21:50 INFO: 8e9e05c52164694d is starting a new election at term 2
raft2022/09/06 07:21:50 INFO: 8e9e05c52164694d became candidate at term 3
raft2022/09/06 07:21:50 INFO: 8e9e05c52164694d received MsgVoteResp from 8e9e05c52164694d at term 3
raft2022/09/06 07:21:50 INFO: 8e9e05c52164694d became leader at term 3
raft2022/09/06 07:21:50 INFO: raft.node: 8e9e05c52164694d elected leader 8e9e05c52164694d at term 3
2022-09-06 07:21:50.419379 I | etcdserver: published {Name:default ClientURLs:[http://localhost:2379]} to cluster cdf818194e3a8c32
2022-09-06 07:21:50.419988 I | embed: ready to serve client requests
2022-09-06 07:21:50.427600 N | embed: serving insecure client requests on 127.0.0.1:2379, this is strongly discouraged!

该命令会在当前位置创建一个 ./default.etcd 目录作为数据目录,并监听 2379 和 2380 两个端口,http://localhost:2379advertise-client-urls,表示建议使用的客户端通信 url,可以配置多个,etcdctl 就是通过这个 url 来访问 etcd 的;http://localhost:2380advertise-peer-urls,表示用于节点之间通信的 url,也可以配置多个,集群内部通过这些 url 进行数据交互(如选举,数据同步等)。

测试

打开另一个终端,输入 etcdctl put 命令可以向 etcd 中以键值对的形式写入数据:

$ etcdctl put hello world
OK

然后使用 etcdctl get 命令可以根据键值读取数据:

$ etcdctl get hello
hello
world

搭建 etcd 集群

上面的例子中,我们在本地启动了一个单节点的 etcd 服务,一般用于开发和测试。在生产环境中,我们需要搭建高可用的 etcd 集群。

启动一个 etcd 集群要求集群中的每个成员都要知道集群中的其他成员,最简单的做法是在启动 etcd 服务时加上 --initial-cluster 参数告诉 etcd 初始集群中有哪些成员,这种方法也被称为 静态配置

我们在本地打开三个终端,并在三个终端中分别运行下面三个命令:

$ etcd --name infra1 \
    --listen-client-urls http://127.0.0.1:12379 \
    --advertise-client-urls http://127.0.0.1:12379 \
    --listen-peer-urls http://127.0.0.1:12380 \
    --initial-advertise-peer-urls http://127.0.0.1:12380 \
    --initial-cluster-token etcd-cluster-demo \
    --initial-cluster 'infra1=http://127.0.0.1:12380,infra2=http://127.0.0.1:22380,infra3=http://127.0.0.1:32380' \
    --initial-cluster-state new
$ etcd --name infra2 \
    --listen-client-urls http://127.0.0.1:22379 \
    --advertise-client-urls http://127.0.0.1:22379 \
    --listen-peer-urls http://127.0.0.1:22380 \
    --initial-advertise-peer-urls http://127.0.0.1:22380 \
    --initial-cluster-token etcd-cluster-demo \
    --initial-cluster 'infra1=http://127.0.0.1:12380,infra2=http://127.0.0.1:22380,infra3=http://127.0.0.1:32380' \
    --initial-cluster-state new
$ etcd --name infra3 \
    --listen-client-urls http://127.0.0.1:32379 \
    --advertise-client-urls http://127.0.0.1:32379 \
    --listen-peer-urls http://127.0.0.1:32380 \
    --initial-advertise-peer-urls http://127.0.0.1:32380 \
    --initial-cluster-token etcd-cluster-demo \
    --initial-cluster 'infra1=http://127.0.0.1:12380,infra2=http://127.0.0.1:22380,infra3=http://127.0.0.1:32380' \
    --initial-cluster-state new

我们可以使用 Foreman 这个小工具来简化上面的过程,Foreman 通过一个 Procfile 文件在本地启动和管理多个进程。

我们随便选择一个节点,通过 member list 都可以查询集群中的所有成员:

$ etcdctl --write-out=table --endpoints=localhost:12379 member list
+------------------+---------+--------+------------------------+------------------------+------------+
|        ID        | STATUS  |  NAME  |       PEER ADDRS       |      CLIENT ADDRS      | IS LEARNER |
+------------------+---------+--------+------------------------+------------------------+------------+
| b217c7a319e4e4f8 | started | infra2 | http://127.0.0.1:22380 | http://127.0.0.1:22379 |      false |
| d35bfbeb1c7fbfcf | started | infra1 | http://127.0.0.1:12380 | http://127.0.0.1:12379 |      false |
| d425e5b1e0d8a751 | started | infra3 | http://127.0.0.1:32380 | http://127.0.0.1:32379 |      false |
+------------------+---------+--------+------------------------+------------------------+------------+

测试往集群中写入数据:

$ etcdctl --endpoints=localhost:12379 put hello world
OK

换一个节点也可以查出数据:

$ etcdctl --endpoints=localhost:22379 get hello
hello
world

但是一般为了保证高可用,我们会在 --endpoints 里指定集群中的所有成员:

$ etcdctl --endpoints=localhost:12379,localhost:22379,localhost:32379 get hello
hello
world

然后我们停掉一个 etcd 服务,可以发现集群还可以正常查询,说明高可用生效了,然后我们再停掉一个 etcd 服务,此时集群就不可用了,这是因为 Raft 协议必须要保证集群中一半以上的节点存活才能正常工作,可以看到集群中的唯一节点也异常退出了:

panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x18 pc=0x75745b]

goroutine 188 [running]:
go.uber.org/zap.(*Logger).check(0x0, 0x1, 0x10a3ea5, 0x36, 0xc002068900)
        /root/go/pkg/mod/go.uber.org/zap@v1.10.0/logger.go:264 +0x9b
go.uber.org/zap.(*Logger).Warn(0x0, 0x10a3ea5, 0x36, 0xc002068900, 0x2, 0x2)
        /root/go/pkg/mod/go.uber.org/zap@v1.10.0/logger.go:194 +0x45
go.etcd.io/etcd/etcdserver.(*EtcdServer).requestCurrentIndex(0xc0002b4000, 0xc001fef200, 0xbfcf831fa9e8b606, 0x0, 0x0, 0x0)
        /tmp/etcd-release-3.4.20/etcd/release/etcd/etcdserver/v3_server.go:805 +0x873
go.etcd.io/etcd/etcdserver.(*EtcdServer).linearizableReadLoop(0xc0002b4000)
        /tmp/etcd-release-3.4.20/etcd/release/etcd/etcdserver/v3_server.go:721 +0x2d6
go.etcd.io/etcd/etcdserver.(*EtcdServer).goAttach.func1(0xc0002b4000, 0xc00011c4a0)
        /tmp/etcd-release-3.4.20/etcd/release/etcd/etcdserver/server.go:2698 +0x57
created by go.etcd.io/etcd/etcdserver.(*EtcdServer).goAttach
        /tmp/etcd-release-3.4.20/etcd/release/etcd/etcdserver/server.go:2696 +0x1b1

使用静态的方法运行 etcd 集群虽然简单,但是这种方法必须提前规划好集群中所有成员的 IP 和端口,在有些场景下成员的地址是无法提前知道的,这时我们可以使用 动态配置 的方法来初始化集群,etcd 提供了两种动态配置的机制:etcd DiscoveryDNS Discovery,具体的内容可以参考 Clustering GuideDiscovery service protocol

操作 etcd

有多种不同的途径来操作 etcd,比如使用 etcdctl 命令行,使用 API 接口,或者使用 etcd 提供的不同语言的 SDK。

使用 etcdctl 命令行操作 etcd

在快速开始一节,我们使用 etcdctl putetcdctl get 来测试 etcd 是否可以正常写入和读取数据,这是 etcdctl 最常用的命令。

查看版本

通过 etcdctl version 查看 etcdctl 客户端和 etcd API 的版本:

$ etcdctl version
etcdctl version: 3.4.20
API version: 3.4

写入数据

通过 etcdctl put 将数据存储在 etcd 的某个键中,每个存储的键通过 Raft 协议复制到 etcd 集群的所有成员来实现一致性和可靠性。下面的命令将键 hello 的值设置为 world:

$ etcdctl put hello world
OK

etcd 支持为每个键设置 TTL 过期时间,这是通过租约(--lease)实现的。首先我们创建一个 100s 的租约:

$ etcdctl lease grant 100
lease 694d8324b408010a granted with TTL(100s)

然后写入键值时带上这个租约:

$ etcdctl put foo bar --lease=694d8324b408010a
OK

foo 这个键将在 100s 之后自动过期。

读取数据

我们提前往 etcd 中写入如下数据:

$ etcdctl put foo bar
$ etcdctl put foo1 bar1
$ etcdctl put foo2 bar2
$ etcdctl put foo3 bar3

读取某个键的值:

$ etcdctl get foo
foo
bar

默认情况下会将键和值都打印出来,使用 --print-value-only 可以只打印值:

$ etcdctl get foo --print-value-only
bar

或者使用 --keys-only 只打印键。

我们也可以使用两个键进行范围查询:

$ etcdctl get foo foo3
foo
bar
foo1
bar1
foo2
bar2

可以看出查询的范围为半开区间 [foo, foo3)

或者使用 --prefix 参数进行前缀查询,比如查询所有 foo 开头的键:

$ etcdctl get foo --prefix
foo
bar
foo1
bar1
foo2
bar2
foo3
bar3

我们甚至可以使用 etcdctl get "" --prefix 查询出所有的键。使用 --limit 限制查询数量:

$ etcdctl get "" --prefix --limit=2
foo
bar
foo1
bar1

删除数据

etcdctl get 一样,我们可以从 etcd 中删除一个键:

$ etcdctl del foo
1

也可以范围删除:

$ etcdctl del foo foo3
2

或者根据前缀删除:

$ etcdctl del --prefix foo
1

历史版本

etcd 会记录所有键的修改历史版本,每次对 etcd 进行修改操作时,版本号就会加一。使用 etcdctl get foo 查看键值时,可以通过 --write-out=json 参数看到版本号:

$ etcdctl get foo --write-out=json | jq
{
    "header": {
        "cluster_id": 14841639068965178418,
        "member_id": 10276657743932975437,
        "revision": 9,
        "raft_term": 2
    },
    "kvs": [
        {
            "key": "Zm9v",
            "create_revision": 5,
            "mod_revision": 5,
            "version": 1,
            "value": "YmFy"
        }
    ],
    "count": 1
}

其中 "revision": 9 就是当前的版本号,注意 JSON 格式输出中 key 和 value 是 BASE64 编码的。

修改 foo 的值:

$ etcdctl put foo bar_new
OK

查看最新的键值和版本:

$ etcdctl get foo --write-out=json | jq
{
    "header": {
        "cluster_id": 14841639068965178418,
        "member_id": 10276657743932975437,
        "revision": 10,
        "raft_term": 2
    },
    "kvs": [
        {
            "key": "Zm9v",
            "create_revision": 5,
            "mod_revision": 10,
            "version": 2,
            "value": "YmFyX25ldw=="
        }
    ],
    "count": 1
}

发现版本号已经加一变成了 10,此时如果使用 etcdctl get foo 查看的当前最新版本的值,也可以通过 --rev 参数查看历史版本的值:

$ etcdctl get foo
foo
bar_new

$ etcdctl get foo --rev=9
foo
bar

过多的历史版本会占用 etcd 的资源,我们可以将不要的历史记录清除掉:

$ etcdctl compact 10
compacted revision 10

上面的命令将版本号为 10 之前的历史记录删除掉,此时再查询 10 之前版本就会报错:

$ etcdctl get foo --rev=9
{"level":"warn","ts":"2022-09-10T09:48:41.021+0800","caller":"clientv3/retry_interceptor.go:62","msg":"retrying of unary invoker failed","target":"endpoint://client-ae27c266-06c4-4be8-9659-47b9318ec8f4/127.0.0.1:2379","attempt":0,"error":"rpc error: code = OutOfRange desc = etcdserver: mvcc: required revision has been compacted"}
Error: etcdserver: mvcc: required revision has been compacted

监听键值变化

使用 etcdctl watch 可以监听某个键的变动情况:

$ etcdctl watch foo

打开另一个终端修改 foo 的值,然后将键删除:

$ etcdctl put foo bar_new
OK
$ etcdctl del foo
1

在监听窗口我们可以实时看到键值的变化:

$ etcdctl watch foo
PUT
foo
bar_new
DELETE
foo

也可以监听某个范围内的键:

$ etcdctl watch foo foo3

或以监听指定前缀的所有键:

$ etcdctl watch --prefix foo

如果要监听的键没有相同的前缀,也不是在某个范围内,可以通过 watch -i 以交互的形式手工设置监听多个键:

$ etcdctl watch -i
watch foo
watch hello

另外通过 watch 命令还可以查看某个键的所有历史修改记录:

$ etcdctl watch --rev=1 foo
PUT
foo
bar
DELETE
foo

PUT
foo
bar
PUT
foo
bar_new

租约

在上面使用 etcdctl put 写入数据时,已经介绍了可以通过租约实现 TTL 功能,每个租约都带有一个存活时间,一旦租约到期,它绑定的所有键都将被删除。

创建一个租约:

$ etcdctl lease grant 100
lease 694d8324b4080150 granted with TTL(100s)

查询租约信息:

$ etcdctl lease timetolive 694d8324b4080150
lease 694d8324b4080150 granted with TTL(100s), remaining(96s)

查询租约信息时,也可以加上 --keys 参数查询该租约关联的键:

$ etcdctl lease timetolive --keys 694d8324b4080150
lease 694d8324b4080150 granted with TTL(100s), remaining(93s), attached keys([foo])

还可以通过 keep-alive 命令自动维持租约:

$ etcdctl lease keep-alive 694d8324b4080150

etcd 会每隔一段时间刷新该租约的到期时间,保证该租约一直处于存活状态。

最后我们通过 revoke 命令撤销租约:

$ etcdctl lease revoke 694d8324b4080150
lease 694d8324b4080150 revoked

租约撤销后,和该租约关联的键也会一并被删除掉。

其他命令

使用 etcdctl --help 查看支持的其他命令。

使用 etcd API 操作 etcd

etcd 支持的大多数基础 API 都定义在 api/etcdserverpb/rpc.proto 文件中,官方的 API reference 就是根据这个文件生成的。

etcd 将这些 API 分为 6 大类:

  • service KV

    • Range
    • Put
    • DeleteRange
    • Txn
    • Compact
  • service Watch

    • Watch
  • service Lease

    • LeaseGrant
    • LeaseRevoke
    • LeaseKeepAlive
    • LeaseTimeToLive
    • LeaseLeases
  • service Cluster

    • MemberAdd
    • MemberRemove
    • MemberUpdate
    • MemberList
    • MemberPromote
  • service Maintenance

    • Alarm
    • Status
    • Defragment
    • Hash
    • HashKV
    • Snapshot
    • MoveLeader
    • Downgrade
  • service Auth

    • AuthEnable
    • AuthDisable
    • AuthStatus
    • Authenticate
    • UserAdd
    • UserGet
    • UserList
    • UserDelete
    • UserChangePassword
    • UserGrantRole
    • UserRevokeRole
    • RoleAdd
    • RoleGet
    • RoleList
    • RoleDelete
    • RoleGrantPermission
    • RoleRevokePermission

实际上,每个接口都对应一个 HTTP 请求,比如上面执行的 etcdctl put 命令就是调用 KV.Put 接口,而 etcdctl get 命令就是调用 KV.Range 接口。

调用 KV.Put 写入数据(注意接口中键值对使用 BASE64 编码,这里的键为 key,值为 value):

$ curl -L http://localhost:2379/v3/kv/put \
  -X POST -d '{"key": "a2V5", "value": "dmFsdWU="}'
{"header":{"cluster_id":"14841639068965178418","member_id":"10276657743932975437","revision":"24","raft_term":"3"}}

调用 KV.Range 查询数据:

curl -L http://localhost:2379/v3/kv/range \
  -X POST -d '{"key": "a2V5"}'
{"header":{"cluster_id":"14841639068965178418","member_id":"10276657743932975437","revision":"24","raft_term":"3"},"kvs":[{"key":"a2V5","create_revision":"24","mod_revision":"24","version":"1","value":"dmFsdWU="}],"count":"1"}

这和使用 etcdctl 命令行查询效果是一样的:

$ etcdctl get key
key
value

除了这 6 类基础 API,etcd 还提供了两个并发类 API,包括分布式锁和集群选主:

使用 Go 语言操作 etcd

etcd 提供了各个语言的 SDK 用于操作 etcd,比如 Go 的 etcd/client/v3、Java 的 coreos/jetcd、Python 的 kragniz/python-etcd3 等,官方文档 Libraries and tools 列出了更多其他语言的 SDK。这一节通过一个简单的 Go 语言示例来学习 SDK 的用法。

首先安装依赖:

$ go get go.etcd.io/etcd/client/v3

然后通过 clientv3.New 创建一个 etcd 连接:

cli, err := clientv3.New(clientv3.Config{
    Endpoints:   []string{"localhost:2379"},
    DialTimeout: 5 * time.Second,
})
if err != nil {
    panic("Connect etcd server error")
}
defer cli.Close()

然后就可以通过 cli 来对 etcd 操作了,比如调用 cli.Put 写入数据:

_, err = cli.Put(ctx, "hello", "world")
if err != nil {
    panic("Put kv error")
}

调用 cli.Get 查询数据:

resp, err := cli.Get(ctx, "hello")
if err != nil {
    panic("Get kv error")
}
for _, kv := range resp.Kvs {
    fmt.Printf("%s: %s\n", kv.Key, kv.Value)
}

不过在运行的时候,报了下面这样一个奇怪的错:

go run .\main.go
# github.com/coreos/etcd/clientv3/balancer/resolver/endpoint
C:\Users\aneasystone\go\pkg\mod\github.com\coreos\etcd@v3.3.27+incompatible\clientv3\balancer\resolver\endpoint\endpoint.go:114:87: undefined: resolver.BuildOption
C:\Users\aneasystone\go\pkg\mod\github.com\coreos\etcd@v3.3.27+incompatible\clientv3\balancer\resolver\endpoint\endpoint.go:182:40: undefined: resolver.ResolveNowOption
# github.com/coreos/etcd/clientv3/balancer/picker
C:\Users\aneasystone\go\pkg\mod\github.com\coreos\etcd@v3.3.27+incompatible\clientv3\balancer\picker\err.go:37:53: undefined: balancer.PickOptions
C:\Users\aneasystone\go\pkg\mod\github.com\coreos\etcd@v3.3.27+incompatible\clientv3\balancer\picker\roundrobin_balanced.go:55:63: undefined: balancer.PickOptions

这里 有个解决方案,说是 grpc 版本较高导致的,需要降低 grpc 的版本:

$ go get google.golang.org/grpc@v1.26.0

etcd 和 其他键值存储的区别

etcd vs. Redis

Redis 和 etcd 一样,支持键值存储,而且也支持分布式特性,他们之间的差异如下:

  • Redis 支持的数据类型比 etcd 更丰富
  • Redis 在分布式环境下不是强一致的,可能会丢数据或读取不到最新数据
  • Redis 的数据监听机制没有 etcd 完善
  • etcd 为了保证强一致性,性能要低于 Redis

综上考虑,Redis 适用于缓存,需要频繁读写,但对系统没有强一致性的要求,etcd 适用于系统读写较少,但是对系统有强一致性要求的场景,比如存储分布式系统的元数据。

etcd vs. ZooKeeper

ZooKeeper 和 etcd 的定位都是分布式协调系统,ZooKeeper 起源于 Hadoop 生态系统,etcd 则是跟着 Kubernetes 的流行而流行。他们都是顺序一致性的(满足 CAP 的 CP),意味着无论你访问任意节点,都将获得最终一致的数据。他们之间的差异如下:

  • ZooKeeper 从逻辑上来看是一种目录结构,而 etcd 从逻辑上来看就是一个 KV 结构,不过 etcd 的 Key 可以是任意字符串,所以也可以模拟出目录结构
  • etcd 使用 Raft 算法实现一致性,比 ZooKeeper 的 ZAB 算法更简单
  • ZooKeeper 采用 Java 编写,etcd 采用 Go 编写,相比而言 ZooKeeper 的部署复杂度和维护成本要高一点,而且 ZooKeeper 的官方只提供了 Java 和 C 的客户端,对其他编程语言不是很友好
  • ZooKeeper 属于 Apache 基金会顶级项目,发展较缓慢,而 etcd 得益于云原生,近几年发展势头迅猛
  • etcd 提供了 gRPC 或 HTTP 接口使用起来更简单
  • ZooKeeper 使用 SASL 进行安全认证,而 etcd 支持 TLS 客户端安全认证,更容易使用

总体来说,ZooKeeper 和 etcd 还是很相似的,在 新技术学习笔记:ZooKeeper 这篇文章中介绍了一些 ZooKeeper 的使用场景,我们使用 etcd 同样也都可以实现。在具体选型上,我们应该更关注是否契合自己所使用的技术栈。

官方有一个比较完整的表格 Comparison chart,从不同的维度对比了 etcd 和 ZooKeeper、Consul 以及一些 NewSQL(Cloud Spanner、CockroachDB、TiDB)的区别。

参考

  1. Etcd Quickstart - Get etcd up and running in less than 5 minutes!
  2. Etcd 中文文档
  3. Etcd 官方文档中文版
  4. Etcd 教程 | 编程宝库
  5. etcd 教程 | 梯子教程
  6. 七张图了解Kubernetes内部的架构

更多

etcd 的安全性

开启用户角色认证

etcd 支持开启 RBAC 认证,但是开启认证之前,我们需要提前创建 root 用户和 root 角色。

创建 root 用户:

$ etcdctl user add root
Password of root:
Type password of root again for confirmation:
User root created

创建 root 角色:

$ etcdctl role add root
Role root created

root 用户赋权 root 角色:

$ etcdctl user grant-role root root
Role root is granted to user root

开启认证:

$ etcdctl auth enable
Authentication Enabled

此时访问 etcd 就必须带上用户名和密码认证了,否则会报错:

$ etcdctl put hello world
{"level":"warn","ts":"2022-09-11T09:59:07.093+0800","caller":"clientv3/retry_interceptor.go:62","msg":"retrying of unary invoker failed","target":"endpoint://client-9b824d27-c8d4-4195-a17e-31eb0cf70a1c/127.0.0.1:2379","attempt":0,"error":"rpc error: code = InvalidArgument desc = etcdserver: user name is empty"}
Error: etcdserver: user name is empty

使用 --user 带上用户名和密码访问:

$ etcdctl --user root:123456 put hello world
OK

开启 TLS 证书认证

etcd 还支持开启 TLS 安全模式,使用 TLS 可以从多个维度保证 etcd 通信的安全性,包括客户端和服务器之间的安全通信,客户端认证,Peer 之间的安全通信和认证等,官方文档 Transport security model 中列举了四种使用 TLS 的常用场景:

  • Client-to-server transport security with HTTPS
  • Client-to-server authentication with HTTPS client certificates
  • Transport security & client certificates in a cluster
  • Automatic self-signed transport security

首先我们需要创建一个自签名的证书(self-signed certificate),CFSSL 是 CDN 服务商 Cloudflare 开源的一套 PKI/TLS 工具集,可以非常方便的创建 TLS 证书。从 GitHub Release 页面下载并安装 CFSSL:

$ curl -LO https://github.com/cloudflare/cfssl/releases/download/v1.6.2/cfssl_1.6.2_linux_amd64
$ chmod +x cfssl_1.6.2_linux_amd64
$ cp cfssl_1.6.2_linux_amd64 /usr/local/bin/cfssl


$ curl -LO https://github.com/cloudflare/cfssl/releases/download/v1.6.2/cfssljson_1.6.2_linux_amd64
$ chmod +x cfssljson_1.6.2_linux_amd64
$ cp cfssljson_1.6.2_linux_amd64 /usr/local/bin/cfssljson

然后准备一个配置文件 ca-csr.json,内容如下:

{
  "CN": "etcd",
  "key": {
    "algo": "rsa",
    "size": 2048
  },
  "names": [
    {
      "O": "autogenerated",
      "OU": "etcd",
      "L": "internet"
    }
  ]
}

再通过 cfssl gencert 命令生成 CA 证书:

$ mkdir -p certs
$ cfssl gencert -initca ca-csr.json | cfssljson -bare certs/ca

cfssl gencert 命令输出结果为 JSON 格式,需要通过 cfssljson 转换为证书文件,上面的命令会在 certs 目录下生成三个文件:

  • ca-key.pem
  • ca.csr
  • ca.pem

其中 ca.csr证书签名请求(CSR,Certificate Signing Request) 文件,我们这里用不上,ca.pemca-key.pem 两个文件需要妥善保管,后面就是使用它们来生成其他证书的。ca.pem 文件可以公开发送给任意人,客户端可以将其添加到可信机构列表中,ca-key.pem 是私钥文件,绝对不能泄露,要不然别人就可以通过该文件冒充你生成任意证书了。

有了 CA 证书后,我们就可以签发其他证书了。接着创建一个 ca-config.json 文件:

{
  "signing": {
    "default": {
        "usages": [
          "signing",
          "key encipherment",
          "server auth",
          "client auth"
        ],
        "expiry": "876000h"
    }
  }
}

以及一个 req-csr.json 文件:

{
  "CN": "etcd",
  "key": {
    "algo": "rsa",
    "size": 2048
  },
  "names": [
    {
      "O": "autogenerated",
      "OU": "etcd",
      "L": "internet"
    }
  ],
  "hosts": [
    "localhost",
    "127.0.0.1"
  ]
}

ca-config.json 文件包含一些证书签名的信息,比如证书有效时间和证书用途等,req-csr.json 文件用于生成证书,和上面的 ca-csr.json 文件类似,只是多了 hosts 字段,用于表示这个证书只能用于 localhost127.0.0.1,也可以在这里添加其他 IP 地址。

使用 cfssl gencert 生成 etcd 服务端证书:

$ cfssl gencert \
    -ca certs/ca.pem \
    -ca-key certs/ca-key.pem \
    -config ca-config.json \
    req-csr.json | cfssljson -bare certs/etcd

上面的命令在 certs 目录下也生成了三个文件:

  • etcd-key.pem
  • etcd.csr
  • etcd.pem

接下来开启 TLS 功能了:

$ etcd --name infra0 --data-dir infra0 \
    --cert-file=./certs/etcd.pem --key-file=./certs/etcd-key.pem \
    --advertise-client-urls=https://127.0.0.1:2379 \
    --listen-client-urls=https://127.0.0.1:2379

注意此时 advertise-client-urlslisten-client-urls 都是 HTTPS 地址,所以执行 etcdctl 命令时需要指定 --endpoints=https://localhost:2379

$ etcdctl --endpoints=https://localhost:2379 get hello
{"level":"warn","ts":"2022-09-12T13:43:38.384+0800","caller":"clientv3/retry_interceptor.go:62","msg":"retrying of unary invoker failed","target":"endpoint://client-5e888f67-4ad6-4893-a0b4-954aba00f984/localhost:2379","attempt":0,"error":"rpc error: code = DeadlineExceeded desc = latest balancer error: all SubConns are in TransientFailure, latest connection error: connection error: desc = \"transport: authentication handshake failed: x509: certificate signed by unknown authority\""}
Error: context deadline exceeded

不过由于证书是我们自己签发的,所以是不可信的,命令会报 certificate signed by unknown authority 这样的错,我们可以通过 --cacert 参数指定 CA 证书:

$ etcdctl --endpoints=https://localhost:2379 get hello \
    --cacert=./certs/ca.pem

这时客户端和服务端之间都是通过 TLS 加密通信的,保证了客户端和服务端之间的安全性,此外,我们还可以在启动 etcd 时加上 --client-cert-auth 参数开启客户端认证功能进一步加强 etcd 服务的安全,同时还需要加上 --trusted-ca-file 参数用于指定 CA 证书:

$ etcd --name infra0 --data-dir infra0 \
    --client-cert-auth --trusted-ca-file=./certs/ca.pem \
    --cert-file=./certs/etcd.pem --key-file=./certs/etcd-key.pem \
    --advertise-client-urls=https://127.0.0.1:2379 \
    --listen-client-urls=https://127.0.0.1:2379  

再和上面一样执行 etcdctl 命令,发现就算带上了 --cacert 参数,也一样会报错:

$ etcdctl --endpoints=https://localhost:2379 get hello \
    --cacert=./cfssl/certs/ca.pem
{"level":"warn","ts":"2022-09-12T14:00:16.057+0800","caller":"clientv3/retry_interceptor.go:62","msg":"retrying of unary invoker failed","target":"endpoint://client-9c6cf82a-7cf1-488e-b1fa-8713a1b036a9/localhost:2379","attempt":0,"error":"rpc error: code = DeadlineExceeded desc = latest balancer error: all SubConns are in TransientFailure, latest connection error: connection closed"}
Error: context deadline exceeded

这是因为 etcd 服务端接收到请求后,会对客户端的证书进行校验,而我们的请求中并没有带上证书。接下来我们就通过 CA 为客户端生成一个证书,首先创建一个 client-csr.json 文件:

{
  "CN": "etcd client",
  "key": {
    "algo": "rsa",
    "size": 2048
  },
  "names": [
    {
      "O": "autogenerated",
      "OU": "etcd",
      "L": "internet"
    }
  ]
}

使用 cfssl gencert 生成客户端证书:

$ cfssl gencert \
    -ca certs/ca.pem \
    -ca-key certs/ca-key.pem \
    -config ca-config.json \
    client-csr.json | cfssljson -bare certs/client

最后使用生成的客户端证书就可以连接 etcd 了:

$ etcdctl --endpoints=https://localhost:2379 get hello\
    --cacert=./certs/ca.pem \
    --cert=./certs/client.pem \
    --key=./certs/client-key.pem

由于客户端和服务端用的是同一个 CA 生成的证书,所以客户端认证通过,服务端认为该客户端是合法的。

使用 etcd 实现服务注册和发现

扫描二维码,在手机上阅读!

写一个简单的 Kubernetes Operator

Kubernetes Operator 这一概念是由 CoreOS 的工程师于 2016 年提出的,它是一种通过 自定义资源custom resourceCR)来包装、运行和管理 Kubernetes 应用的方式。Kubernetes 1.7 版本以来就引入了自定义资源的概念,该功能可以让开发人员扩展新功能或更新现有功能,并且可以自动执行一些管理任务,这些自定义资源就像 Kubernetes 的原生组件一样。

通过自定义资源,我们可以将应用抽象为一个整体,而不用去关心该应用是由哪些 Kubernetes 原生组件构成的,什么 Pods、Deployments、Services 或 ConfigMaps 统统交给 Operator 来管理。创建 Operator 的关键是自定义资源的设计,通过直接调用 Kubernetes API,编写自定义规则自动管理和维护 Kubernetes 集群中的应用,包括自动化安装、配置、更新、故障转移、备份恢复等等。这样的应用也被称为 Kubernetes 原生应用(Kubernetes-native application)。可以把 Operator 当做是一个运维人员,它以软件的形式帮助我们管理 Kubernetes 中运行的应用。Operator 可以帮我们实现下面这些运维工作:

operator-capabilitiy-model.png

这个图也被称为 Operator 的能力模型,将 Operator 的能力由低到高分成了 5 个等级。

控制器循环

Kubernetes Operator 遵循 control loop 原则,这是 Kubernetes 的核心原则之一,也是机器人和自动化领域中一种常见的持续运行动态系统的机制。它依赖于一种快速调整工作负载需求的能力,进而能够尽可能准确地适应现有资源。

reconciliation-loop.png

在 Kubernetes 中,这个循环被称为 reconciliation loop。在这个循环中,有一个非常重要的角色:控制器(Controller),它可以对集群的变化做出响应,并执行相应的动作。控制器首先观察 Kubernetes 对象的当前状态,然后通过 Kubernetes API 进行持续调整,直到将对象的当前状态变成所需状态为止。

第一个 Kubernetes Controller 是 kube-controller-manager,它被认为是所有 Operator 的鼻祖。

使用 Operator Framework 开发 Operator

Operator Framework 是 CoreOS 开源的一个用于快速开发或管理 Operator 的工具包,主要分为三大部分:

  • Operator SDKBuild, test, iterate. 你无需了解复杂的 Kubernetes API 特性,就可以根据你自己的专业知识构建一个 Operator 应用。
  • Operator Lifecycle Managerinstall, manage, update. OLM 是一款帮助你安装、更新和管理 Kubernetes Operator 的工具。
  • OperatorHub.ioPublish & share. OperatorHub 是一个类似 DockerHub 的仓库,你可以在这里搜索你想要的 Operator,或者将你的 Operator 发布并分享给其他人。

通过 Operator SDK 我们可以快速开发一个 Kubernetes Operator,它不仅提供了一套 High level API 来方便我们处理业务逻辑,还提供了一个命令行工具用于快速生成一个 Operator 的脚手架项目。

安装 operator-sdk

在开发 Operator 之前,先确保你已经有一个能访问的 Kubernetes 集群环境,Kubernetes 的安装可以参考 Kubernetes 安装小记。查看 Kubernetes 集群信息:

$ kubectl cluster-info
Kubernetes control plane is running at https://kubernetes.docker.internal:6443
CoreDNS is running at https://kubernetes.docker.internal:6443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy

To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.

另外,Go 的开发环境也是必不可少的,可以参考 Go 的 官方文档 下载并安装。

$ curl -LO https://go.dev/dl/go1.19.linux-amd64.tar.gz
$ sudo tar -C /usr/local -xzf go1.19.linux-amd64.tar.gz

将路径 /usr/local/go/bin 添加到 PATH 环境变量,或者将下面这行添加到 ~/.profile 文件中:

$ export PATH=$PATH:/usr/local/go/bin

查看 Go 版本:

$ go version
go version go1.19 linux/amd64

接下来,我们继续安装 Operator SDK。我们在 Operator SDK 的 Releases 页面 找到合适的版本并下载:

$ curl -LO https://github.com/operator-framework/operator-sdk/releases/download/v1.23.0/operator-sdk_linux_amd64

将其移动到 /usr/local/bin/ 目录即可完成安装:

$ chmod +x operator-sdk_linux_amd64 && sudo mv operator-sdk_linux_amd64 /usr/local/bin/operator-sdk

查看已安装的 operator-sdk 版本:

$ operator-sdk version
operator-sdk version: "v1.23.0", commit: "1eaeb5adb56be05fe8cc6dd70517e441696846a4", kubernetes version: "1.24.2", go version: "go1.18.5", GOOS: "linux", GOARCH: "amd64"

另外,operator-sdk 依赖于 makegcc,确保系统上已经安装了 makegcc 工具。

使用 operator-sdk 初始化 Operator 项目

Operator SDK 提供了三种方式开发 Operator:

我们这里将使用 Go 来开发 Operator,这种方式也是最灵活的,你可以使用 client-go 调用 Kubernetes API 来对 Kubernetes 对象进行操作。首先使用 operator-sdk init 初始化项目结构:

$ operator-sdk init --domain example.com --project-name memcached-operator --repo github.com/example/memcached-operator
Writing kustomize manifests for you to edit...
Writing scaffold for you to edit...
Get controller runtime:
$ go get sigs.k8s.io/controller-runtime@v0.12.2
Update dependencies:
$ go mod tidy
Next: define a resource with:
$ operator-sdk create api

其中 --project-name 参数可以省略,默认项目名称就是目录名。--domain--project-name 两个参数用于组成 Operator 的镜像名称 example.com/memcached-operator,而 --repo 参数用于定义 Go 模块名:

module github.com/example/memcached-operator

初始化后的完整项目结构如下:

$ tree .
.
├── Dockerfile
├── Makefile
├── PROJECT
├── README.md
├── config
│   ├── default
│   │   ├── kustomization.yaml
│   │   ├── manager_auth_proxy_patch.yaml
│   │   └── manager_config_patch.yaml
│   ├── manager
│   │   ├── controller_manager_config.yaml
│   │   ├── kustomization.yaml
│   │   └── manager.yaml
│   ├── manifests
│   │   └── kustomization.yaml
│   ├── prometheus
│   │   ├── kustomization.yaml
│   │   └── monitor.yaml
│   ├── rbac
│   │   ├── auth_proxy_client_clusterrole.yaml
│   │   ├── auth_proxy_role.yaml
│   │   ├── auth_proxy_role_binding.yaml
│   │   ├── auth_proxy_service.yaml
│   │   ├── kustomization.yaml
│   │   ├── leader_election_role.yaml
│   │   ├── leader_election_role_binding.yaml
│   │   ├── role_binding.yaml
│   │   └── service_account.yaml
│   └── scorecard
│       ├── bases
│       │   └── config.yaml
│       ├── kustomization.yaml
│       └── patches
│           ├── basic.config.yaml
│           └── olm.config.yaml
├── go.mod
├── go.sum
├── hack
│   └── boilerplate.go.txt
└── main.go

主要包括以下几个文件:

  • go.mod - 用于定义 Go 项目的依赖信息
  • PROJECT - 用于保存项目的配置信息
  • Makefile - 包含一些有用的项目构建目标(make targets
  • config - 该目录下包含一些用于项目部署的 YAML 文件
  • main.go - Operator 的主程序入口

创建 API

初始化项目之后,接着就可以使用 operator-sdk create api 命令创建 API 了:

$ operator-sdk create api --group cache --version v1alpha1 --kind Memcached --resource --controller
Writing kustomize manifests for you to edit...
Writing scaffold for you to edit...
api/v1alpha1/memcached_types.go
controllers/memcached_controller.go
Update dependencies:
$ go mod tidy
Running make:
$ make generate
./memcached-operator/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
Next: implement your new API and generate the manifests (e.g. CRDs,CRs) with:
$ make manifests

使用 operator-sdk create 命令可以生成 apiwebhook 的脚手架代码,我们这里生成的是 api,包括两部分内容:自定义资源(--resource)和控制器相关的逻辑代码(--controller),其中 --group--version--kind 分别用来设置资源的分组、版本和类型。

接下来可以从这些文件开始入手:

  • api/v1beta1/memcached_types.go
  • controllers/memcached_controller.go
  • controllers/suite_test.go

memcached_types.go 文件用于定义资源的接口规范,我们在 MemcachedSpec 中添加一个新字段 Size 如下(默认已经生成了一个 Foo 字段):

// MemcachedSpec defines the desired state of Memcached
type MemcachedSpec struct {
    // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
    // Important: Run "make" to regenerate code after modifying this file

    // Foo is an example field of Memcached. Edit memcached_types.go to remove/update
    Foo string `json:"foo,omitempty"`
    Size int32 `json:"size"`
}

接着打开 memcached_controller.go 文件,其中 Reconcile 方法就是上面所介绍的 reconciliation loop 的核心代码,可以在这里实现自己的业务逻辑,比如调用 Kubernetes API 创建、删除或更新各种 Kubernetes 资源。我们这里只是简单地将资源的属性值打印出来(官方对 memcached-operator 有完整的示例代码,可以 参考这里):

func (r *MemcachedReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    _ = log.FromContext(ctx)

    instance := &cachev1alpha1.Memcached{}
    err := r.Get(context.TODO(), req.NamespacedName, instance)
    if err != nil {
        fmt.Println("Get instance err")
        return ctrl.Result{}, err
    }

    fmt.Printf("Foo = %s, Size = %d\n", instance.Spec.Foo, instance.Spec.Size)

    return ctrl.Result{}, nil
}

然后执行下面的命令生成自定义资源文件:

$ make manifests
test -s ./memcached-operator/bin/controller-gen || GOBIN=./memcached-operator/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.9.2
./memcached-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases

生成的自定义资源文件位于 config/crd/bases/cache.example.com_memcacheds.yaml,文件内容如下:

---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  annotations:
    controller-gen.kubebuilder.io/version: v0.9.2
  creationTimestamp: null
  name: memcacheds.cache.example.com
spec:
  group: cache.example.com
  names:
    kind: Memcached
    listKind: MemcachedList
    plural: memcacheds
    singular: memcached
  scope: Namespaced
  versions:
  - name: v1alpha1
    schema:
      openAPIV3Schema:
        description: Memcached is the Schema for the memcacheds API
        properties:
          apiVersion:
            description: 'APIVersion defines the versioned schema of this representation
              of an object. Servers should convert recognized schemas to the latest
              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
            type: string
          kind:
            description: 'Kind is a string value representing the REST resource this
              object represents. Servers may infer this from the endpoint the client
              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
            type: string
          metadata:
            type: object
          spec:
            description: MemcachedSpec defines the desired state of Memcached
            properties:
              foo:
                description: Foo is an example field of Memcached. Edit memcached_types.go
                  to remove/update
                type: string
              size:
                format: int32
                type: integer
            required:
            - size
            type: object
          status:
            description: MemcachedStatus defines the observed state of Memcached
            type: object
        type: object
    served: true
    storage: true
    subresources:
      status: {}

在这个文件中,我们定义了一个名为 Memcached 的自定义资源(Custom Resource Definition,简称 CRD),并定义了 foosize 两个属性,且 size 属性为必填项。

本地调试 Operator

至此,一个简单的 Operator 就开发好了,接下来我们运行 make install 命令,该命令使用 kustomize build 生成 CRD 配置文件并执行 kubectl apply 将 CRD 安装到 Kubernetes 集群中:

$ make install
test -s ./memcached-operator/bin/controller-gen || GOBIN=./memcached-operator/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.9.2
./memcached-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
./memcached-operator/bin/kustomize build config/crd | kubectl apply -f -
customresourcedefinition.apiextensions.k8s.io/memcacheds.cache.example.com created

通过 kubectl get crds 可以查看集群中的自定义资源是否创建成功:

$ kubectl get crds
NAME                           CREATED AT
memcacheds.cache.example.com   2022-08-26T09:24:19Z

可以看到集群中多了一个自定义资源 memcacheds.cache.example.com。然后运行 make run 命令在本地启动控制器:

$ make run
test -s ./memcached-operator/bin/controller-gen || GOBIN=./memcached-operator/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.9.2
./memcached-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
./memcached-operator/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
go fmt ./...
api/v1alpha1/groupversion_info.go
go vet ./...
go run ./main.go
1.6615063195978441e+09  INFO    controller-runtime.metrics      Metrics server is starting to listen    {"addr": ":8080"}
1.6615063195986106e+09  INFO    setup   starting manager
1.6615063195992978e+09  INFO    Starting server {"path": "/metrics", "kind": "metrics", "addr": "[::]:8080"}
1.6615063195993063e+09  INFO    Starting server {"kind": "health probe", "addr": "[::]:8081"}
1.661506319599374e+09   INFO    Starting EventSource    {"controller": "memcached", "controllerGroup": "cache.example.com", "controllerKind": "Memcached", "source": "kind source: *v1alpha1.Memcached"}
1.6615063196000834e+09  INFO    Starting Controller     {"controller": "memcached", "controllerGroup": "cache.example.com", "controllerKind": "Memcached"}
1.6615063197010505e+09  INFO    Starting workers        {"controller": "memcached", "controllerGroup": "cache.example.com", "controllerKind": "Memcached", "worker count": 1}

接下来我们就可以创建一个自定义资源实例测试一下。首先修改 config/samples/cache_v1alpha1_memcached.yaml 文件,填入 foosize 两个属性:

apiVersion: cache.example.com/v1alpha1
kind: Memcached
metadata:
  name: memcached-sample
spec:
  foo: Hello World
  size: 10

然后执行 kubectl apply 命令创建自定义资源实例:

$ kubectl apply -f config/samples/cache_v1alpha1_memcached.yaml
memcached.cache.example.com/memcached-sample created

此时查看控制器的输出如下:

Foo = Hello World, Size = 10

说明控制器监听到了自定义资源的创建,并输出了它的属性值。使用 kubectl get 查看刚刚创建的自定义资源:

$ kubectl get memcached.cache.example.com/memcached-sample
NAME               AGE
memcached-sample   13m

然后我们测试下自定义资源更新时的情况,修改 cache_v1alpha1_memcached.yaml 文件,比如将 size 改为 9,重新执行 kubectl apply 命令,控制器会立即监听到该修改,并输出新的属性值:

Foo = Hello World, Size = 9

部署 Operator

Operator 开发完成后,我们需要将它部署到 Kubernetes 集群中。首先我们将其构建成 Docker 镜像,可以使用下面的命令构建,并将镜像推送到镜像仓库:

$ make docker-build docker-push IMG="aneasystone/memcached-operator:v0.0.1"
test -s ./memcached-operator/bin/controller-gen || GOBIN=./memcached-operator/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.9.2
./memcached-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
./memcached-operator/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
go fmt ./...
go vet ./...
KUBEBUILDER_ASSETS="/home/aneasystone/.local/share/kubebuilder-envtest/k8s/1.24.2-linux-amd64" go test ./... -coverprofile cover.out
?       github.com/example/memcached-operator   [no test files]
?       github.com/example/memcached-operator/api/v1alpha1      [no test files]
ok      github.com/example/memcached-operator/controllers       8.935s  coverage: 0.0% of statements
docker build -t aneasystone/memcached-operator:v0.0.1 .
[+] Building 3.3s (18/18) FINISHED                                                                                                                                
 => [internal] load build definition from Dockerfile
 => => transferring dockerfile: 38B                                                         0.0s
 => [internal] load .dockerignore                                                           0.0s
 => => transferring context: 35B                                                            0.0s
 => [internal] load metadata for gcr.io/distroless/static:nonroot                           0.7s
 => [internal] load metadata for docker.io/library/golang:1.18                              3.0s
 => [auth] library/golang:pull token for registry-1.docker.io0.0s
 => [builder 1/9] FROM docker.io/library/golang:1.18@sha256:5540a6a6b3b612c382accc545b3f6702de21e77b15d89ad947116c94b5f42993        0.0s
 => [internal] load build context                                                           0.1s
 => => transferring context: 3.84kB                                                         0.0s
 => [stage-1 1/3] FROM gcr.io/distroless/static:nonroot@sha256:1f580b0a1922c3e54ae15b0758b5747b260bd99d39d40c2edb3e7f6e2452298b     0.0s
 => CACHED [builder 2/9] WORKDIR /workspace                                                 0.0s
 => CACHED [builder 3/9] COPY go.mod go.mod                                                 0.0s
 => CACHED [builder 4/9] COPY go.sum go.sum                                                 0.0s
 => CACHED [builder 5/9] RUN go mod download                                                0.0s
 => CACHED [builder 6/9] COPY main.go main.go                                               0.0s
 => CACHED [builder 7/9] COPY api/ api/                                                     0.0s
 => CACHED [builder 8/9] COPY controllers/ controllers/                                     0.0s
 => CACHED [builder 9/9] RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o manager main.go                                   0.0s
 => CACHED [stage-1 2/3] COPY --from=builder /workspace/manager .                           0.0s
 => exporting to image                                                                      0.0s
 => => exporting layers                                                                     0.0s
 => => writing image sha256:84df51146080fec45fb74d5be29705f41c27de062e1192cb7c43a3a80c22977e                                        0.0s
 => => naming to docker.io/aneasystone/memcached-operator:v0.0.1                            0.0s
docker push aneasystone/memcached-operator:v0.0.1
The push refers to repository [docker.io/aneasystone/memcached-operator]
b399109810db: Pushed 
c456571abc85: Pushed 
v0.0.1: digest: sha256:60822319ac3578e3f62a73530c5ca08472014bf7861b75de6dd88502ee11d088 size: 739

上面我将镜像推送到 Docker 官方镜像仓库 docker.io,你也可以配置成自己的镜像仓库地址。

然后就可以将镜像部署到 Kubernetes 集群中了,官方提供了两种部署方式:直接部署 或 使用 OLM 部署。

直接部署

运行下面的 make deploy 命令:

$ make deploy IMG="aneasystone/memcached-operator:v0.0.1"
test -s ./memcached-operator/bin/controller-gen || GOBIN=./memcached-operator/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.9.2
./memcached-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
test -s ./memcached-operator/bin/kustomize || { curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash -s -- 3.8.7 ./memcached-operator/bin; }
cd config/manager && ./memcached-operator/bin/kustomize edit set image controller=aneasystone/memcached-operator:v0.0.1
./memcached-operator/bin/kustomize build config/default | kubectl apply -f -
namespace/memcached-operator-system created
customresourcedefinition.apiextensions.k8s.io/memcacheds.cache.example.com unchanged
serviceaccount/memcached-operator-controller-manager created
role.rbac.authorization.k8s.io/memcached-operator-leader-election-role created
clusterrole.rbac.authorization.k8s.io/memcached-operator-manager-role created
clusterrole.rbac.authorization.k8s.io/memcached-operator-metrics-reader created
clusterrole.rbac.authorization.k8s.io/memcached-operator-proxy-role created
rolebinding.rbac.authorization.k8s.io/memcached-operator-leader-election-rolebinding created
clusterrolebinding.rbac.authorization.k8s.io/memcached-operator-manager-rolebinding created
clusterrolebinding.rbac.authorization.k8s.io/memcached-operator-proxy-rolebinding created
configmap/memcached-operator-manager-config created
service/memcached-operator-controller-manager-metrics-service created
deployment.apps/memcached-operator-controller-manager created

从日志可以看到部署了一堆的东西,包括一个名字空间:

  • namespace/memcached-operator-system created

一个自定义资源:

  • customresourcedefinition.apiextensions.k8s.io/memcacheds.cache.example.com unchanged

一个 ConfigMap、Service 和 Deployment(这就是我们的 Operator):

  • configmap/memcached-operator-manager-config created
  • service/memcached-operator-controller-manager-metrics-service created
  • deployment.apps/memcached-operator-controller-manager created

还有一堆账户角色这些和安全相关的资源:

  • serviceaccount/memcached-operator-controller-manager created
  • role.rbac.authorization.k8s.io/memcached-operator-leader-election-role created
  • clusterrole.rbac.authorization.k8s.io/memcached-operator-manager-role created
  • clusterrole.rbac.authorization.k8s.io/memcached-operator-metrics-reader created
  • clusterrole.rbac.authorization.k8s.io/memcached-operator-proxy-role created
  • rolebinding.rbac.authorization.k8s.io/memcached-operator-leader-election-rolebinding created
  • clusterrolebinding.rbac.authorization.k8s.io/memcached-operator-manager-rolebinding created
  • clusterrolebinding.rbac.authorization.k8s.io/memcached-operator-proxy-rolebinding created

这些和正常的 Kubernetes 资源是完全一样的,我们可以使用 kubectl get 查询各个资源的详情,注意指定名字空间(-n memcached-operator-system):

$ kubectl get deployment -n memcached-operator-system
NAME                                    READY   UP-TO-DATE   AVAILABLE   AGE
memcached-operator-controller-manager   1/1     1            1           9m6s
$ kubectl get pods -n memcached-operator-system
NAME                                                     READY   STATUS    RESTARTS   AGE
memcached-operator-controller-manager-689d94c9bf-bqv2q   2/2     Running   0          8m54s
$ kubectl get service -n memcached-operator-system
NAME                                                    TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)    AGE
memcached-operator-controller-manager-metrics-service   ClusterIP   10.96.197.28   <none>        8443/TCP   11m

同样的,也可以使用 kubectl logs 查看 Operator 的日志:

$ kubectl logs -f memcached-operator-controller-manager-689d94c9bf-bqv2q -n memcached-operator-system

如果要卸载 Operator,执行 make undeploy 命令即可:

$ make undeploy
./memcached-operator/bin/kustomize build config/default | kubectl delete --ignore-not-found=false -f -
namespace "memcached-operator-system" deleted
customresourcedefinition.apiextensions.k8s.io "memcacheds.cache.example.com" deleted
serviceaccount "memcached-operator-controller-manager" deleted
role.rbac.authorization.k8s.io "memcached-operator-leader-election-role" deleted
clusterrole.rbac.authorization.k8s.io "memcached-operator-manager-role" deleted
clusterrole.rbac.authorization.k8s.io "memcached-operator-metrics-reader" deleted
clusterrole.rbac.authorization.k8s.io "memcached-operator-proxy-role" deleted
rolebinding.rbac.authorization.k8s.io "memcached-operator-leader-election-rolebinding" deleted
clusterrolebinding.rbac.authorization.k8s.io "memcached-operator-manager-rolebinding" deleted
clusterrolebinding.rbac.authorization.k8s.io "memcached-operator-proxy-rolebinding" deleted
configmap "memcached-operator-manager-config" deleted
service "memcached-operator-controller-manager-metrics-service" deleted
deployment.apps "memcached-operator-controller-manager" deleted

使用 OLM 部署

OLM 的全称为 Operator Lifecycle Manager,是一款用于 Operator 的管理工具,可以使用 OLM 来帮你安装或更新 Kubernetes Operator。我们首先通过 operator-sdk 安装 OLM:

$ operator-sdk olm install
INFO[0001] Fetching CRDs for version "latest"
INFO[0001] Fetching resources for resolved version "latest"
I0827 15:01:42.199954   12688 request.go:601] Waited for 1.0471208s due to client-side throttling, not priority and fairness, request: GET:https://kubernetes.docker.internal:6443/apis/autoscaling/v1?timeout=32s
INFO[0012] Creating CRDs and resources
INFO[0012]   Creating CustomResourceDefinition "catalogsources.operators.coreos.com"
INFO[0012]   Creating CustomResourceDefinition "clusterserviceversions.operators.coreos.com"
INFO[0012]   Creating CustomResourceDefinition "installplans.operators.coreos.com"
INFO[0012]   Creating CustomResourceDefinition "olmconfigs.operators.coreos.com"
INFO[0012]   Creating CustomResourceDefinition "operatorconditions.operators.coreos.com"
INFO[0012]   Creating CustomResourceDefinition "operatorgroups.operators.coreos.com"
INFO[0012]   Creating CustomResourceDefinition "operators.operators.coreos.com"
INFO[0012]   Creating CustomResourceDefinition "subscriptions.operators.coreos.com"
INFO[0012]   Creating Namespace "olm"
INFO[0012]   Creating Namespace "operators"
INFO[0012]   Creating ServiceAccount "olm/olm-operator-serviceaccount"
INFO[0012]   Creating ClusterRole "system:controller:operator-lifecycle-manager"
INFO[0012]   Creating ClusterRoleBinding "olm-operator-binding-olm"
INFO[0012]   Creating OLMConfig "cluster"
INFO[0015]   Creating Deployment "olm/olm-operator"
INFO[0015]   Creating Deployment "olm/catalog-operator"
INFO[0015]   Creating ClusterRole "aggregate-olm-edit"
INFO[0015]   Creating ClusterRole "aggregate-olm-view"
INFO[0015]   Creating OperatorGroup "operators/global-operators"
INFO[0015]   Creating OperatorGroup "olm/olm-operators"
INFO[0015]   Creating ClusterServiceVersion "olm/packageserver"
INFO[0015]   Creating CatalogSource "olm/operatorhubio-catalog"
INFO[0016] Waiting for deployment/olm-operator rollout to complete
INFO[0016]   Waiting for Deployment "olm/olm-operator" to rollout: 0 of 1 updated replicas are available
INFO[0019]   Deployment "olm/olm-operator" successfully rolled out
INFO[0019] Waiting for deployment/catalog-operator rollout to complete
INFO[0019]   Deployment "olm/catalog-operator" successfully rolled out
INFO[0019] Waiting for deployment/packageserver rollout to complete
INFO[0019]   Waiting for Deployment "olm/packageserver" to rollout: 0 of 2 updated replicas are available
INFO[0033]   Deployment "olm/packageserver" successfully rolled out
INFO[0033] Successfully installed OLM version "latest"

NAME                                            NAMESPACE    KIND                        STATUS
catalogsources.operators.coreos.com                          CustomResourceDefinition    Installed
clusterserviceversions.operators.coreos.com                  CustomResourceDefinition    Installed
installplans.operators.coreos.com                            CustomResourceDefinition    Installed
olmconfigs.operators.coreos.com                              CustomResourceDefinition    Installed
operatorconditions.operators.coreos.com                      CustomResourceDefinition    Installed
operatorgroups.operators.coreos.com                          CustomResourceDefinition    Installed
operators.operators.coreos.com                               CustomResourceDefinition    Installed
subscriptions.operators.coreos.com                           CustomResourceDefinition    Installed
olm                                                          Namespace                   Installed
operators                                                    Namespace                   Installed
olm-operator-serviceaccount                     olm          ServiceAccount              Installed
system:controller:operator-lifecycle-manager                 ClusterRole                 Installed
olm-operator-binding-olm                                     ClusterRoleBinding          Installed
cluster                                                      OLMConfig                   Installed
olm-operator                                    olm          Deployment                  Installed
catalog-operator                                olm          Deployment                  Installed
aggregate-olm-edit                                           ClusterRole                 Installed
aggregate-olm-view                                           ClusterRole                 Installed
global-operators                                operators    OperatorGroup               Installed
olm-operators                                   olm          OperatorGroup               Installed
packageserver                                   olm          ClusterServiceVersion       Installed
operatorhubio-catalog                           olm          CatalogSource               Installed

如上所示,OLM 会在 Kubernetes 集群中安装一堆的资源,可以看到 OLM 本身也包含了两个 Operator:OLM Operator 和 Catalog Operator。关于他们的作用可以参考 《如何管理越来越多的 operator?OLM 给你答案》 这篇文章。

OLM 通过 Bundle 形式来组织和管理 Operator,使用 make bundle 生成 Bundle 相关的配置文件:

$ make bundle IMG="aneasystone/memcached-operator:v0.0.1"
test -s ./memcached-operator/bin/controller-gen || GOBIN=./memcached-operator/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.9.2
./memcached-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
test -s ./memcached-operator/bin/kustomize || { curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash -s -- 3.8.7 ./memcached-operator/bin; }
operator-sdk generate kustomize manifests -q

Display name for the operator (required):
> memcached-operator

Description for the operator (required):
> memcached operator

Provider's name for the operator (required):
> aneasystone

Any relevant URL for the provider name (optional):
> https://www.aneasystone.com

Comma-separated list of keywords for your operator (required):
> memcached

Comma-separated list of maintainers and their emails (e.g. 'name1:email1, name2:email2') (required):
> aneasystone@gmail.com
cd config/manager && ./memcached-operator/bin/kustomize edit set image controller=aneasystone/memcached-operator:v0.0.1
./memcached-operator/bin/kustomize build config/manifests | operator-sdk generate bundle -q --overwrite --version 0.0.1
INFO[0001] Creating bundle.Dockerfile
INFO[0001] Creating bundle/metadata/annotations.yaml
INFO[0001] Bundle metadata generated suceessfully
operator-sdk bundle validate ./bundle
INFO[0001] All validation tests have completed successfully

然后将 Bundle 构建成镜像并推送到镜像仓库:

$ make bundle-build bundle-push BUNDLE_IMG="aneasystone/memcached-operator-bundle:v0.0.1"
docker build -f bundle.Dockerfile -t aneasystone/memcached-operator-bundle:v0.0.1 .
[+] Building 0.6s (7/7) FINISHED
 => [internal] load build definition from bundle.Dockerfile                                                            0.1s
 => => transferring dockerfile: 971B                                                                                   0.0s
 => [internal] load .dockerignore                                                                                      0.1s
 => => transferring context: 35B                                                                                       0.0s
 => [internal] load build context                                                                                      0.0s
 => => transferring context: 12.72kB                                                                                   0.0s
 => [1/3] COPY bundle/manifests /manifests/                                                                            0.0s
 => [2/3] COPY bundle/metadata /metadata/                                                                              0.1s
 => [3/3] COPY bundle/tests/scorecard /tests/scorecard/                                                                0.1s
 => exporting to image                                                                                                 0.1s
 => => exporting layers                                                                                                0.1s
 => => writing image sha256:849fde8bbc55db7a1cd884ccdc7c61bfdca343650f72eb65e616c98c17193bca                           0.0s
 => => naming to docker.io/aneasystone/memcached-operator-bundle:v0.0.1                                                0.0s
make docker-push IMG=aneasystone/memcached-operator-bundle:v0.0.1
make[1]: Entering directory './memcached-operator'
docker push aneasystone/memcached-operator-bundle:v0.0.1
The push refers to repository [docker.io/aneasystone/memcached-operator-bundle]
ee3ff18c6586: Pushed
1cca854eb4c8: Pushed
2fa3c5f0ef35: Pushed
v0.0.1: digest: sha256:c42ec3c4f9d461128c640f5568886b006e0332ea0d4a173008e97addefbfd3f9 size: 939
make[1]: Leaving directory './memcached-operator'

运行 Bundle 将我们的 Operator 部署到 Kubernetes 集群中:

$ operator-sdk run bundle docker.io/aneasystone/memcached-operator-bundle:v0.0.1
INFO[0023] Creating a File-Based Catalog of the bundle "docker.io/aneasystone/memcached-operator-bundle:v0.0.1"
INFO[0028] Generated a valid File-Based Catalog
INFO[0033] Created registry pod: docker-io-aneasystone-memcached-operator-bundle-v0-0-1
INFO[0033] Created CatalogSource: memcached-operator-catalog
INFO[0033] OperatorGroup "operator-sdk-og" created
INFO[0033] Created Subscription: memcached-operator-v0-0-1-sub
INFO[0037] Approved InstallPlan install-z264c for the Subscription: memcached-operator-v0-0-1-sub
INFO[0037] Waiting for ClusterServiceVersion "default/memcached-operator.v0.0.1" to reach 'Succeeded' phase
INFO[0037]   Waiting for ClusterServiceVersion "default/memcached-operator.v0.0.1" to appear
INFO[0056]   Found ClusterServiceVersion "default/memcached-operator.v0.0.1" phase: Pending
INFO[0058]   Found ClusterServiceVersion "default/memcached-operator.v0.0.1" phase: Installing
INFO[0069]   Found ClusterServiceVersion "default/memcached-operator.v0.0.1" phase: Succeeded
INFO[0069] OLM has successfully installed "memcached-operator.v0.0.1"

可以使用 kubectl get 检查 Operator 运行的状态,和上一节直接部署不一样的是,Operator 被安装在默认的 default 名字空间里了,其他的几乎没啥区别。可以更新 config/samples/cache_v1alpha1_memcached.yaml 文件来对 Operator 进行测试。

如果要卸载 Operator,执行下面的命令:

$ operator-sdk cleanup memcached-operator

卸载 OLM:

$ operator-sdk olm uninstall

使用 kubernetes-sigs/kubebuilder 开发 Operator

operator-sdkkubebuilder 都是为了方便用户创建和管理 Operator 而生的脚手架项目,其实 operator-sdk 在底层也使用了 kubebuilder,比如 operator-sdk 的命令行工具就是直接调用 kubebuilder 的命令行工具。无论由 operator-sdk 还是 kubebuilder 创建的 Operator 项目都是调用的 controller-runtime 接口,具有相同的项目目录结构。

参考

  1. Kubernetes 文档 / 概念 / 扩展 Kubernetes / Operator 模式
  2. Kubernetes Operator 基础入门
  3. Kubernetes Operator 快速入门教程
  4. Kubernetes Operators 入门笔记
  5. 亲历者说:Kubernetes API 与 Operator,不为人知的开发者战争
  6. 《Kubernetes Operators eBook》By Jason Dobies & Joshua Wood
  7. Quickstart for Go-based Operators
  8. What is a Kubernetes operator?
  9. Introducing Operators: Putting Operational Knowledge into Software
  10. Kubernetes Operators 101, Part 1: Overview and key features
  11. Kubernetes Operators 101, Part 2: How operators work
  12. 如何管理越来越多的 operator?OLM 给你答案

更多

1. 安装 gcc 报 404 Not Found 错

在 Ubuntu 上使用 sudo apt install gcc 安装 gcc 时,报如下错误:

E: Failed to fetch http://security.ubuntu.com/ubuntu/pool/main/l/linux/linux-libc-dev_4.15.0-189.200_amd64.deb  404  Not Found [IP: 2001:67c:1562::15 80]
E: Unable to fetch some archives, maybe run apt-get update or try with --fix-missing?

解决方法很简单,执行 sudo apt update 更新软件源中的所有软件列表即可。

2. 使用 operator-sdk 创建 API 报错

执行 operator-sdk create api 命令创建 API 时,报如下错误:

/usr/local/go/src/net/cgo_linux.go:12:8: no such package located
Error: not all generators ran successfully
run `controller-gen object:headerFile=hack/boilerplate.go.txt paths=./... -w` to see all available markers, or `controller-gen object:headerFile=hack/boilerplate.go.txt paths=./... -h` for usage
Makefile:94: recipe for target 'generate' failed
make: *** [generate] Error 1
Error: failed to create API: unable to run post-scaffold tasks of "base.go.kubebuilder.io/v3": exit status 2

没有安装 gcc 工具,使用 sudo apt install gcc 安装 gcc 即可。

3. make buildmake test 时报错

在公司电脑开发 Operator 时遇到了这个问题,执行 make buildmake test 时报下面这样的错:

STEP: bootstrapping test environment
1.6621765789962418e+09  DEBUG   controller-runtime.test-env     starting control plane

1.6621765802518039e+09  ERROR   controller-runtime.test-env     unable to start the controlplane        {"tries": 0, "error": "timeout waiting for process etcd to start successfully (it may have failed to start, or stopped unexpectedly before becoming ready)"}

看报错信息猜测可能是和 etcd 有关,使用 ps aux | grep etcd 确实可以看到在执行测试时启动了一个 etcd 的进程:

$ ps aux | grep etcd
aneasystone  2609 23.0  0.1 737148 22560 pts/0    Sl   13:34   0:00 /home/aneasystone/.local/share/kubebuilder-envtest/k8s/1.24.1-linux-amd64/etcd --advertise-client-urls=http://127.0.0.1:52467 --data-dir=/tmp/k8s_test_framework_3831360890 --listen-client-urls=http://127.0.0.1:52467 --listen-peer-urls=http://127.0.0.1:52468 --unsafe-no-fsync=true

于是我试着手工运行这个命令,发现 etcd 服务启动时报错了:

2022-09-03 11:42:28.499748 E | etcdserver: publish error: etcdserver: request timed out
2022-09-03 11:42:35.501458 E | etcdserver: publish error: etcdserver: request timed out

使用 etcdctl 也连不上该 etcd 服务。一开始我以为是 kubebuilder 自带的 etcd 文件有问题,于是就自己安装了一个 etcd,直接运行时也是报错,只不过报错信息有点不一样:

panic: invalid page type: 0: 4

goroutine 1 [running]:

github.com/etcd-io/bbolt.(*Cursor).search(0xc00005be18, {0xc00005be70, 0x8, 0x8}, 0xc00005bdb8?)

看报错是位于 etcd-io/bbolt 这个包,BoltDB 是 etcd 使用的内存 KV 数据库。使用 boltpanic: invalid page type 为关键字,很快就在 microsoft/WSL 里找到了一个相关的 Issue:BoltDB panics on cursor search since April update,根据 Issue 里的描述,写了一个 BoltDB 的简单示例:

package main

import (
    "fmt"
    "log"
    "os"

    "github.com/boltdb/bolt"
)

func main() {
    os.Remove("test.db")
    db, err := bolt.Open("test.db", 0600, nil)
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    db.Update(func(tx *bolt.Tx) error {
        _, err := tx.CreateBucket([]byte("MyBucket"))
        if err != nil {
            return fmt.Errorf("create bucket: %s", err)
        }
        return err
    })

    db.View(func(tx *bolt.Tx) error {
        b := tx.Bucket([]byte("MyBucket"))
        c := b.Cursor()
        c.Seek([]byte("test"))
        return nil
    })
    os.Remove("test.db")
}

运行代码后也是和上面几乎一模一样的报错:

$ go run main.go
panic: invalid page type: 0: 4

goroutine 1 [running]:
github.com/boltdb/bolt.(*Cursor).search(0xc00005be18, {0xc00005be70, 0x8, 0x8}, 0xc00005bdb8?)

至此大概可以推断这应该是 WSL 的问题,WSL 目前最新版本是 WSL 2,不过要注意的是,根据 Microsoft 官方的升级指南,WSL 2 只支持 Windows 10 Build 18362 之后的版本:

Builds lower than 18362 do not support WSL 2. Use the Windows Update Assistant to update your version of Windows.

打开 Windows 更新,更新完成后重启,问题解决。

4. Operator 示例

扫描二维码,在手机上阅读!

Kubernetes 使用小记

Kubernetes 常常被简称为 K8S(发音:Kate's),是 Google 在 2014 年发布的一个开源容器编排引擎。它诞生自 Google 内部的一款容器管理系统 Borg,据说,Borg 管理着 Google 数据中心里 20 多亿个容器服务。自发布以来,Kubernetes 迅速获得开源社区的追捧,包括 Red Hat、VMware、Canonical 在内的很多有影响力的公司都加入到它的开发和推广阵营中,目前,AWS、Azure、Google、阿里云、腾讯云等厂商都推出了基于 Kubernetes 的 CaaS 或 PaaS 产品。

Kubernetes 属于平台级技术,覆盖的技术范围非常广,包括计算、网络、存储、高可用、监控、日志等多个方面,而且在 Kubernetes 中有很多新的概念和设计理念,所以有一定的入门门槛。

Kubernetes 安装小记 中,我们学习了如何使用 Kind、Minikube 和 Kubeadmin 安装一个 Kubernetes 集群。这一节我们将照着 官方教程,学习如何使用它,了解并掌握 Kubernetes 的基本概念。

使用 Minikube 创建集群

一个 Kubernetes 集群包含两种类型的资源:

  • Master(也被称为控制平面 Control Plane

用于管理整个集群,比如调度应用、维护应用状态、应用扩容和更新等。

  • Node

每个 Node 上都运行着 Kubelet 程序,它负责运行应用,并且是和 Master 通信的代理。每个 Node 上还要有运行容器的工具,如 Docker 或 rkt。

我们可以使用 Minikube 创建一个单节点的简单集群。首先确保机器上已经安装 Minikube(安装步骤参考 Kubernetes 安装小记):

$ minikube version
minikube version: v1.18.0
commit: ec61815d60f66a6e4f6353030a40b12362557caa-dirty

然后执行 minikube start 启动一个 Kubernetes 集群:

$ minikube start
* minikube v1.18.0 on Ubuntu 18.04 (amd64)
* Using the none driver based on existing profile

X The requested memory allocation of 2200MiB does not leave room for system overhead (total system memory: 2460MiB). You may face stability issues.
* Suggestion: Start minikube with less memory allocated: 'minikube start --memory=2200mb'

* Starting control plane node minikube in cluster minikube
* Running on localhost (CPUs=2, Memory=2460MB, Disk=194868MB) ...
* OS release is Ubuntu 18.04.5 LTS
* Preparing Kubernetes v1.20.2 on Docker 19.03.13 ...
  - kubelet.resolv-conf=/run/systemd/resolve/resolv.conf
  - Generating certificates and keys ...
  - Booting up control plane ...-
  - Configuring RBAC rules ...
* Configuring local host environment ...
* Verifying Kubernetes components...
  - Using image gcr.io/k8s-minikube/storage-provisioner:v4
* Enabled addons: default-storageclass, storage-provisioner
* Done! kubectl is now configured to use "minikube" cluster and "default" namespace by default

这样一个 Kubernetes 集群就安装好了,接下来我们就可以使用 kubectl 来管理这个集群。使用 kubectl version 查看客户端和服务端的版本信息:

$ kubectl version
Client Version: version.Info{Major:"1", Minor:"20", GitVersion:"v1.20.4", GitCommit:"e87da0bd6e03ec3fea7933c4b5263d151aafd07c", GitTreeState:"clean", BuildDate:"2021-02-18T16:12:00Z", GoVersion:"go1.15.8", Compiler:"gc", Platform:"linux/amd64"}
Server Version: version.Info{Major:"1", Minor:"20", GitVersion:"v1.20.2", GitCommit:"faecb196815e248d3ecfb03c680a4507229c2a56", GitTreeState:"clean", BuildDate:"2021-01-13T13:20:00Z", GoVersion:"go1.15.5", Compiler:"gc", Platform:"linux/amd64"}

使用 kubectl cluster-info 查看集群详情:

$ kubectl cluster-info
Kubernetes control plane is running at https://10.0.0.8:8443
KubeDNS is running at https://10.0.0.8:8443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy

To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.

使用 kubectl get nodes 查看集群中的节点信息:

$ kubectl get nodes
NAME       STATUS   ROLES                  AGE     VERSION
minikube   Ready    control-plane,master   4m51s   v1.20.2

使用 kubectl 创建 Deployment

一旦我们有了一个可用的 Kubernetes 集群,我们就可以在集群里部署应用程序了。在 Kubernetes 中,Deployment 负责创建和更新应用程序的实例,所以我们需要创建一个 Deployment,然后 Kubernetes Master 就会将应用程序实例调度到集群中的各个节点上。

而且,Kubernetes 还提供了一种自我修复机制,当应用程序实例创建之后,Deployment 控制器会持续监视这些实例,如果托管实例的节点关闭或被删除,则 Deployment 控制器会将该实例替换为集群中另一个节点上的实例。

使用命令行工具 kubectl 可以创建和管理 Deployment,它通过 Kubernetes API 与集群进行交互。使用 kubectl create deployment 部署我们的第一个应用程序:

$ kubectl create deployment kubernetes-bootcamp --image=jocatalin/kubernetes-bootcamp:v1
deployment.apps/kubernetes-bootcamp created

其中 kubernetes-bootcamp 为 Deployment 名称,--image 为要运行的应用程序镜像地址。

可以使用 kubectl get deployments 查看所有的 Deployment:

$ kubectl get deployments
NAME                  READY   UP-TO-DATE   AVAILABLE   AGE
kubernetes-bootcamp   1/1     1            1           5m8s

这里的几个字段分别表示:

  • NAME - Deployment 的名称。
  • READY - 应用程序的可用副本数,显示格式为:就绪个数/期望个数。
  • UP-TO-DATE - 显示为了达到期望状态已经更新的副本数。
  • AVAILABLE - 可供用户使用的副本数。
  • AGE - 应用程序运行的时间。

可以看到 kubernetes-bootcamp 这个 Deployment 里包含了一个应用实例,并且运行在 Pod 中。

$ kubectl get pods
NAME                                   READY   STATUS    RESTARTS   AGE
kubernetes-bootcamp-57978f5f5d-fwmqq   1/1     Running   0          19m

Pod 处于一个完全隔离的网络,默认情况下,只能从集群内的其他 Pod 或 Service 访问,从集群外面是不能访问的。我们会在后面的内容中学习如何访问 Pod 里的内容。

kubectl 是通过 Kubernetes API 来创建和管理我们的 Pod 的,我们可以使用 kubectl 启动一个代理,通过代理我们也可以访问 Kubernetes API:

$ kubectl proxy
Starting to serve on 127.0.0.1:8001

使用下面的 API 接口检查代理是否正常运行:

$ curl http://localhost:8001/version
{
  "major": "1",
  "minor": "20",
  "gitVersion": "v1.20.2",
  "gitCommit": "faecb196815e248d3ecfb03c680a4507229c2a56",
  "gitTreeState": "clean",
  "buildDate": "2021-01-13T13:20:00Z",
  "goVersion": "go1.15.5",
  "compiler": "gc",
  "platform": "linux/amd64"
}

然后通过下面的 API 接口获取 Pod 信息(其中 kubernetes-bootcamp-57978f5f5d-fwmqq 是 Pod 名称,可以通过上面的 kubectl get pods 查看):

$ curl http://localhost:8001/api/v1/namespaces/default/pods/kubernetes-bootcamp-57978f5f5d-fwmqq/
{
  "kind": "Pod",
  "apiVersion": "v1",
  "metadata": {
    "name": "kubernetes-bootcamp-57978f5f5d-fwmqq",
    "generateName": "kubernetes-bootcamp-57978f5f5d-",
    "namespace": "default",
    "uid": "7bc3c22e-aa33-4290-a1b4-62b80f593cc9",
    "resourceVersion": "714",
    "creationTimestamp": "2022-06-15T23:39:52Z",
    "labels": {
      "app": "kubernetes-bootcamp",
      "pod-template-hash": "57978f5f5d"
    },
    "ownerReferences": [
      {
        "apiVersion": "apps/v1",
        "kind": "ReplicaSet",
        "name": "kubernetes-bootcamp-57978f5f5d",
        "uid": "a786a3e5-9d41-44be-8b1c-44d38e9bc3db",
        "controller": true,
        "blockOwnerDeletion": true
      }
    ],
    "managedFields": [
      {
        "manager": "kube-controller-manager",
        "operation": "Update",
        "apiVersion": "v1",
        "time": "2022-06-15T23:39:52Z",
        "fieldsType": "FieldsV1",
        "fieldsV1": {"f:metadata":{"f:generateName":{},"f:labels":{".":{},"f:app":{},"f:pod-template-hash":{}},"f:ownerReferences":{".":{},"k:{\"uid\":\"a786a3e5-9d41-44be-8b1c-44d38e9bc3db\"}":{".":{},"f:apiVersion":{},"f:blockOwnerDeletion":{},"f:controller":{},"f:kind":{},"f:name":{},"f:uid":{}}}},"f:spec":{"f:containers":{"k:{\"name\":\"kubernetes-bootcamp\"}":{".":{},"f:image":{},"f:imagePullPolicy":{},"f:name":{},"f:resources":{},"f:terminationMessagePath":{},"f:terminationMessagePolicy":{}}},"f:dnsPolicy":{},"f:enableServiceLinks":{},"f:restartPolicy":{},"f:schedulerName":{},"f:securityContext":{},"f:terminationGracePeriodSeconds":{}}}
      },
      {
        "manager": "kubelet",
        "operation": "Update",
        "apiVersion": "v1",
        "time": "2022-06-15T23:39:55Z",
        "fieldsType": "FieldsV1",
        "fieldsV1": {"f:status":{"f:conditions":{"k:{\"type\":\"ContainersReady\"}":{".":{},"f:lastProbeTime":{},"f:lastTransitionTime":{},"f:status":{},"f:type":{}},"k:{\"type\":\"Initialized\"}":{".":{},"f:lastProbeTime":{},"f:lastTransitionTime":{},"f:status":{},"f:type":{}},"k:{\"type\":\"Ready\"}":{".":{},"f:lastProbeTime":{},"f:lastTransitionTime":{},"f:status":{},"f:type":{}}},"f:containerStatuses":{},"f:hostIP":{},"f:phase":{},"f:podIP":{},"f:podIPs":{".":{},"k:{\"ip\":\"172.18.0.6\"}":{".":{},"f:ip":{}}},"f:startTime":{}}}
      }
    ]
  },
  "spec": {
    "volumes": [
      {
        "name": "default-token-cctqg",
        "secret": {
          "secretName": "default-token-cctqg",
          "defaultMode": 420
        }
      }
    ],
    "containers": [
      {
        "name": "kubernetes-bootcamp",
        "image": "jocatalin/kubernetes-bootcamp:v1",
        "resources": {
          
        },
        "volumeMounts": [
          {
            "name": "default-token-cctqg",
            "readOnly": true,
            "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount"
          }
        ],
        "terminationMessagePath": "/dev/termination-log",
        "terminationMessagePolicy": "File",
        "imagePullPolicy": "IfNotPresent"
      }
    ],
    "restartPolicy": "Always",
    "terminationGracePeriodSeconds": 30,
    "dnsPolicy": "ClusterFirst",
    "serviceAccountName": "default",
    "serviceAccount": "default",
    "nodeName": "minikube",
    "securityContext": {
      
    },
    "schedulerName": "default-scheduler",
    "tolerations": [
      {
        "key": "node.kubernetes.io/not-ready",
        "operator": "Exists",
        "effect": "NoExecute",
        "tolerationSeconds": 300
      },
      {
        "key": "node.kubernetes.io/unreachable",
        "operator": "Exists",
        "effect": "NoExecute",
        "tolerationSeconds": 300
      }
    ],
    "priority": 0,
    "enableServiceLinks": true,
    "preemptionPolicy": "PreemptLowerPriority"
  },
  "status": {
    "phase": "Running",
    "conditions": [
      {
        "type": "Initialized",
        "status": "True",
        "lastProbeTime": null,
        "lastTransitionTime": "2022-06-15T23:39:52Z"
      },
      {
        "type": "Ready",
        "status": "True",
        "lastProbeTime": null,
        "lastTransitionTime": "2022-06-15T23:39:55Z"
      },
      {
        "type": "ContainersReady",
        "status": "True",
        "lastProbeTime": null,
        "lastTransitionTime": "2022-06-15T23:39:55Z"
      },
      {
        "type": "PodScheduled",
        "status": "True",
        "lastProbeTime": null,
        "lastTransitionTime": "2022-06-15T23:39:52Z"
      }
    ],
    "hostIP": "10.0.0.9",
    "podIP": "172.18.0.6",
    "podIPs": [
      {
        "ip": "172.18.0.6"
      }
    ],
    "startTime": "2022-06-15T23:39:52Z",
    "containerStatuses": [
      {
        "name": "kubernetes-bootcamp",
        "state": {
          "running": {
            "startedAt": "2022-06-15T23:39:54Z"
          }
        },
        "lastState": {
          
        },
        "ready": true,
        "restartCount": 0,
        "image": "jocatalin/kubernetes-bootcamp:v1",
        "imageID": "docker-pullable://jocatalin/kubernetes-bootcamp@sha256:0d6b8ee63bb57c5f5b6156f446b3bc3b3c143d233037f3a2f00e279c8fcc64af",
        "containerID": "docker://f00a2e64ec2a46d03f98ddd300dffdefde2ef306f545f873e4e596e2fa74c359",
        "started": true
      }
    ],
    "qosClass": "BestEffort"
  }
}

查看 Pod 和工作节点

在上一节中,我们使用 kubectl get pods 查看集群中运行的 PodPod 是 Kubernetes 中的原子单元,当我们在 Kubernetes 上创建 Deployment 时,该 Deployment 会在其中创建包含容器的 Pod,而不是直接创建容器。每个 Pod 都包含了一组应用程序容器(一个或多个),这些容器之间共享存储和网络,它们始终位于同一位置并且共同调度。

一个 Pod 总是运行在工作节点,工作节点是 Kubernetes 中参与计算的机器,每个工作节点由主节点管理,主节点会根据每个工作节点上的可用资源自动调度 Pod。每个工作节点上至少运行着:

  • Kubelet,负责主节点和工作节点之间的通信,它还负责管理工作节点上的 Pod 和容器;
  • 容器运行时

接下来我们使用 kubectl 命令对 Pod 展开更深入的了解,大多数命令和 Docker 命令很类似,如果有一定的 Docker 基础,可以很快上手。使用 kubectl get pods 可以查看 Pod 列表,列表中显示着 Pod 名称和状态一些简单的信息,如果需要更详细的信息,可以使用 kubectl describe 命令:

$ kubectl describe pods
Name:         kubernetes-bootcamp-fb5c67579-8sm7d
Namespace:    default
Priority:     0
Node:         minikube/10.0.0.8
Start Time:   Thu, 16 Jun 2022 22:38:03 +0000
Labels:       app=kubernetes-bootcamp
              pod-template-hash=fb5c67579
Annotations:  <none>
Status:       Running
IP:           172.18.0.3
IPs:
  IP:           172.18.0.3
Controlled By:  ReplicaSet/kubernetes-bootcamp-fb5c67579
Containers:
  kubernetes-bootcamp:
    Container ID:   docker://ac8e5d785a8c7d8a550febdec1720f6d2a1ebe66f90ce970a963340b9f33c032
    Image:          jocatalin/kubernetes-bootcamp:v1
    Image ID:       docker-pullable://jocatalin/kubernetes-bootcamp@sha256:0d6b8ee63bb57c5f5b6156f446b3bc3b3c143d233037f3a2f00e279c8fcc64af
    Port:           8080/TCP
    Host Port:      0/TCP
    State:          Running
      Started:      Thu, 16 Jun 2022 22:38:06 +0000
    Ready:          True
    Restart Count:  0
    Environment:    <none>
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from default-token-rn6wn (ro)
Conditions:
  Type              Status
  Initialized       True 
  Ready             True 
  ContainersReady   True 
  PodScheduled      True 
Volumes:
  default-token-rn6wn:
    Type:        Secret (a volume populated by a Secret)
    SecretName:  default-token-rn6wn
    Optional:    false
QoS Class:       BestEffort
Node-Selectors:  <none>
Tolerations:     node.kubernetes.io/not-ready:NoExecute op=Exists for 300s
                 node.kubernetes.io/unreachable:NoExecute op=Exists for 300s
Events:
  Type    Reason     Age   From               Message
  ----    ------     ----  ----               -------
  Normal  Scheduled  77s   default-scheduler  Successfully assigned default/kubernetes-bootcamp-fb5c67579-8sm7d to minikube
  Normal  Pulled     75s   kubelet            Container image "jocatalin/kubernetes-bootcamp:v1" already present on machine
  Normal  Created    75s   kubelet            Created container kubernetes-bootcamp
  Normal  Started    74s   kubelet            Started container kubernetes-bootcamp

这里不仅显示了 Pod 的名称和状态,还显示了 Pod 的 IP 地址,Pod 里的容器信息,以及 Pod 生命周期中的一些关键事件。

我们可以直接使用 Pod 这里的 IP 地址来访问应用程序:

$ curl 172.18.0.3:8080
Hello Kubernetes bootcamp! | Running on: kubernetes-bootcamp-fb5c67579-8sm7d | v=1

但是要注意的是,Kubernetes 每次调度 Pod 的时候,都会随机分配一个新的 IP 地址,所以这种做法是不推荐的,后面我们会学习更好的做法。

当我们的应用程序有问题时,查看日志是最常用的排查问题的方法,要查看 Pod 的日志,使用 kubectl logs 命令:

$ kubectl logs kubernetes-bootcamp-fb5c67579-8sm7d
Kubernetes Bootcamp App Started At: 2022-06-16T22:38:06.372Z | Running On:  kubernetes-bootcamp-fb5c67579-8sm7d 

注意由于我们的 Pod 里只有一个容器,所以不需要指定容器名。

当一个 Pod 是运行中状态时,我们可以使用 kubectl exec 在 Pod 中直接执行命令,比如下面的命令列出容器内的环境变量:

$ kubectl exec kubernetes-bootcamp-fb5c67579-8sm7d -- env
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=kubernetes-bootcamp-fb5c67579-8sm7d
KUBERNETES_PORT_443_TCP_PROTO=tcp
KUBERNETES_PORT_443_TCP_PORT=443
KUBERNETES_PORT_443_TCP_ADDR=10.96.0.1
KUBERNETES_SERVICE_HOST=10.96.0.1
KUBERNETES_SERVICE_PORT=443
KUBERNETES_SERVICE_PORT_HTTPS=443
KUBERNETES_PORT=tcp://10.96.0.1:443
KUBERNETES_PORT_443_TCP=tcp://10.96.0.1:443
NPM_CONFIG_LOGLEVEL=info
NODE_VERSION=6.3.1
HOME=/root

下面的命令在容器内启动一个可交互的 Shell:

$ kubectl exec -ti kubernetes-bootcamp-fb5c67579-8sm7d -- bash

在这个 Shell 中我们可以做很多事情,和操作远程 SSH 完全一样,比如使用 cat 查看 server.js 的源码:

root@kubernetes-bootcamp-fb5c67579-8sm7d:/# cat server.js
var http = require('http');
var requests=0;
var podname= process.env.HOSTNAME;
var startTime;
var host;
var handleRequest = function(request, response) {
  response.setHeader('Content-Type', 'text/plain');
  response.writeHead(200);
  response.write("Hello Kubernetes bootcamp! | Running on: ");
  response.write(host);
  response.end(" | v=1\n");
  console.log("Running On:" ,host, "| Total Requests:", ++requests,"| App Uptime:", (new Date() - startTime)/1000 , "seconds", "| Log Time:",new Date());
}
var www = http.createServer(handleRequest);
www.listen(8080,function () {
    startTime = new Date();;
    host = process.env.HOSTNAME;
    console.log ("Kubernetes Bootcamp App Started At:",startTime, "| Running On: " ,host, "\n" );
});

在容器里使用 curl 访问我们的应用:

root@kubernetes-bootcamp-fb5c67579-8sm7d:/# curl localhost:8080
Hello Kubernetes bootcamp! | Running on: kubernetes-bootcamp-fb5c67579-8sm7d | v=1

注意这里我们访问的是 localhost,这是因为我们现在处于容器内部。最后使用 exit 退出 Shell:

root@kubernetes-bootcamp-fb5c67579-8sm7d:/# exit

使用 Service 暴露你的应用

在上一节中,我们了解到我们的应用程序运行在 Pod 中,外部是无法访问的,虽然通过 Pod 的内部 IP 可以访问,但是要注意的是,Pod 是转瞬即逝的,当一个工作节点挂掉后,该工作节点上的 Pod 也会消亡,ReplicaSet 会自动地创建新的 Pod 让集群恢复到目标状态,保证应用程序正常运行。所以我们需要一种固定的方式来访问 Pod 中的应用,无论 Pod 如何变化,Kubernetes 通过服务(也就是 Service)来实现这一点。

Service 有如下几种类型:

  • ClusterIP - 这是 Service 的默认类型,在集群内部 IP 上公开 Service,这种类型的 Service 只能从集群内部访问。
  • NodePort - 使用 NAT 在集群中每个选定 Node 的相同端口上公开 Service,可以通过 NodeIP:NodePort 从集群外部访问 Service,是 ClusterIP 的超集。
  • LoadBalancer - 在集群中创建一个外部负载均衡器(如果支持的话),并为 Service 分配一个固定的外部 IP,是 NodePort 的超集。
  • ExternalName - 通过返回带有该名称的 CNAME 记录,使用任意名称公开 Service,需要 kube-dns v1.7 或更高版本。

可以使用 kubectl get services 命令查看目前集群中的 Service:

$ kubectl get services
NAME         TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
kubernetes   ClusterIP   10.96.0.1    <none>        443/TCP   71s

从上面可以看出,minikube 在创建集群时默认会创建一个名为 kubernetes 的 Service。使用 kubectl expose 创建一个新的 Service:

$ kubectl expose deployment/kubernetes-bootcamp --type="NodePort" --port 8080
service/kubernetes-bootcamp exposed

这里使用了 --type="NodePort" 参数指定了 Service 的类型为 NodePort,这让我们可以从集群外部访问我们的应用,另外 --port 8080 表示希望将 8080 端口暴露出去,暴露出去的端口号随机生成。再次执行 kubectl get services 命令:

$ kubectl get services
NAME                  TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)          AGE
kubernetes            ClusterIP   10.96.0.1        <none>        443/TCP          50s
kubernetes-bootcamp   NodePort    10.111.158.145   <none>        8080:31006/TCP   4s

此时集群中多了一个名为 kubernetes-bootcamp 的 Service,这个 Service 在集群内的 IP 为 10.111.158.145,暴露的端口为 8080:31006,这两个端口很容易混淆,8080 为集群内端口,31006 为集群外端口。

使用 Service 在集群内的 IP 和端口来访问服务:

$ curl 10.111.158.145:8080
Hello Kubernetes bootcamp! | Running on: kubernetes-bootcamp-fb5c67579-8sm7d | v=1

从集群外访问服务:

$ curl $(minikube ip):31006
Hello Kubernetes bootcamp! | Running on: kubernetes-bootcamp-fb5c67579-8sm7d | v=1

还可以使用 kubectl describe 查看 Service 的详细信息:

$ kubectl describe services/kubernetes-bootcamp
Name:                     kubernetes-bootcamp
Namespace:                default
Labels:                   app=kubernetes-bootcamp
Annotations:              <none>
Selector:                 app=kubernetes-bootcamp
Type:                     NodePort
IP Families:              <none>
IP:                       10.111.158.145
IPs:                      10.111.158.145
Port:                     <unset>  8080/TCP
TargetPort:               8080/TCP
NodePort:                 <unset>  31006/TCP
Endpoints:                172.18.0.3:8080
Session Affinity:         None
External Traffic Policy:  Cluster
Events:                   <none>

注意这里还有一个 IP 地址和上面的 IP 也很容易混淆,那就是 Endpoints,这个是 Pod 的地址。

创建 Service 时,该 Service 下的 Pod 是由标签(Label)和选择器(Selector)来匹配的,可以通过这种方式来对 Kubernetes 中的对象进行逻辑分组。标签(Label)是附加在对象上的键值对,可以以多种方式使用:

  • 用于区分开发,测试和生产环境
  • 用于区分不同的版本
  • 使用 Label 将对象进行分类

实际上,当我们创建 Deployment 时,会为我们的 Pod 自带一个默认的 Label,使用 kubectl describe deployment 可以看出:

$ kubectl describe deployment
Name:                   kubernetes-bootcamp
Namespace:              default
CreationTimestamp:      Sat, 18 Jun 2022 01:24:48 +0000
Labels:                 app=kubernetes-bootcamp
Annotations:            deployment.kubernetes.io/revision: 1
Selector:               app=kubernetes-bootcamp
Replicas:               1 desired | 1 updated | 1 total | 1 available | 0 unavailable
StrategyType:           RollingUpdate
MinReadySeconds:        0
RollingUpdateStrategy:  25% max unavailable, 25% max surge
Pod Template:
  Labels:  app=kubernetes-bootcamp
  Containers:
   kubernetes-bootcamp:
    Image:        jocatalin/kubernetes-bootcamp:v1
    Port:         8080/TCP
    Host Port:    0/TCP
    Environment:  <none>
    Mounts:       <none>
  Volumes:        <none>
Conditions:
  Type           Status  Reason
  ----           ------  ------
  Available      True    MinimumReplicasAvailable
  Progressing    True    NewReplicaSetAvailable
OldReplicaSets:  <none>
NewReplicaSet:   kubernetes-bootcamp-fb5c67579 (1/1 replicas created)
Events:
  Type    Reason             Age   From                   Message
  ----    ------             ----  ----                   -------
  Normal  ScalingReplicaSet  39m   deployment-controller  Scaled up replica set kubernetes-bootcamp-fb5c67579 to 1

执行 kubectl get 命令时,通过 -l 参数可以按指定标签查询列表,比如查询 Pod 列表:

$ kubectl get pods -l app=kubernetes-bootcamp
NAME                                  READY   STATUS    RESTARTS   AGE
kubernetes-bootcamp-fb5c67579-8sm7d   1/1     Running   0          49m

同样的,使用标签查询 Service 列表:

$ kubectl get services -l app=kubernetes-bootcamp
NAME                  TYPE       CLUSTER-IP       EXTERNAL-IP   PORT(S)          AGE
kubernetes-bootcamp   NodePort   10.111.158.145   <none>        8080:31006/TCP   51m

当然,我们也可以给某个对象手工指定标签,执行 kubectl label 命令:

$ kubectl label pods kubernetes-bootcamp-fb5c67579-8sm7d version=v1
pod/kubernetes-bootcamp-fb5c67579-8sm7d labeled

执行 kubectl describe pods 命令确认标签是否添加成功:

$ kubectl describe pods kubernetes-bootcamp-fb5c67579-8sm7d
Name:         kubernetes-bootcamp-fb5c67579-8sm7d
Namespace:    default
Priority:     0
Node:         minikube/10.0.0.8
Start Time:   Thu, 16 Jun 2022 22:38:03 +0000
Labels:       app=kubernetes-bootcamp
              pod-template-hash=fb5c67579
              version=v1
......

使用新标签查询 Pod 列表:

$ kubectl get pods -l version=v1
NAME                                  READY   STATUS    RESTARTS   AGE
kubernetes-bootcamp-fb5c67579-8sm7d   1/1     Running   0          7m52s

最后,使用 kubectl delete service 删除刚刚创建的 Service,可以使用 -l 根据标签删除指定的 Service:

$ kubectl delete service -l app=kubernetes-bootcamp
service "kubernetes-bootcamp" deleted

确认下 Service 已经删除:

$ kubectl get services
NAME         TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
kubernetes   ClusterIP   10.96.0.1    <none>        443/TCP   8m59s

此时,从集群外已经无法访问我们的服务了:

$ curl $(minikube ip):31006
curl: (7) Failed to connect to 10.0.0.10 port 31006: Connection refused

但是,我们的服务还是处于运行状态的,可以通过 Pod 的 IP 访问或进入 Pod 内部访问:

$ kubectl exec -ti kubernetes-bootcamp-fb5c67579-8sm7d -- curl localhost:8080
Hello Kubernetes bootcamp! | Running on: kubernetes-bootcamp-fb5c67579-8sm7d | v=1

如果要停止服务,需要将 Deployment 删除掉才可以。

运行应用程序的多个实例

通过上面的学习,我们创建了一个 Deployment,然后通过 Service 让其可以从外部访问。默认情况下,Deployment 为我们的应用程序创建了一个 Pod,当然,我们可以根据需要,对我们的应用程序进行扩缩(扩容或缩容),改变 Pod 的副本集数量。

ReplicaSet 实现了 Pod 的多副本管理,使用 Deployment 时会自动创建 ReplicaSet,我们通常不需要直接使用 ReplicaSet。Deployment 提供了比 ReplicaSet 更丰富的功能,比如版本记录和回滚等。

使用 kubectl get rs 查看集群中运行中的 ReplicaSet 列表:

$ kubectl get rs
NAME                            DESIRED   CURRENT   READY   AGE
kubernetes-bootcamp-fb5c67579   1         1         1       28m

如果要调整副本集数量,使用 kubectl scale 命令:

$ kubectl scale deployments/kubernetes-bootcamp --replicas=4
deployment.apps/kubernetes-bootcamp scaled

其中,--replicas=4 表示将应用程序扩容到 4 个副本:

$ kubectl get rs
NAME                            DESIRED   CURRENT   READY   AGE
kubernetes-bootcamp-fb5c67579   4         4         4       30m

使用 kubectl get pods 可以看到启动了 4 个 Pod,每个 Pod 都分配了一个独立的 IP:

$ kubectl get pods -o wide
NAME                                  READY   STATUS    RESTARTS   AGE   IP           NODE       NOMINATED NODE   READINESS GATES
kubernetes-bootcamp-fb5c67579-cgwjm   1/1     Running   0          65s   172.18.0.7   minikube   <none>           <none>
kubernetes-bootcamp-fb5c67579-d7l4n   1/1     Running   0          65s   172.18.0.9   minikube   <none>           <none>
kubernetes-bootcamp-fb5c67579-mpn68   1/1     Running   0          30m   172.18.0.2   minikube   <none>           <none>
kubernetes-bootcamp-fb5c67579-tk2lm   1/1     Running   0          65s   172.18.0.8   minikube   <none>           <none>

执行 kubectl describe deployments 可以看到副本集调整的事件:

$ kubectl describe deployments/kubernetes-bootcamp
Name:                   kubernetes-bootcamp
Namespace:              default
CreationTimestamp:      Sun, 19 Jun 2022 03:16:25 +0000
Labels:                 app=kubernetes-bootcamp
Annotations:            deployment.kubernetes.io/revision: 1
Selector:               app=kubernetes-bootcamp
Replicas:               4 desired | 4 updated | 4 total | 4 available | 0 unavailable
StrategyType:           RollingUpdate
MinReadySeconds:        0
RollingUpdateStrategy:  25% max unavailable, 25% max surge
Pod Template:
  Labels:  app=kubernetes-bootcamp
  Containers:
   kubernetes-bootcamp:
    Image:        jocatalin/kubernetes-bootcamp:v1
    Port:         8080/TCP
    Host Port:    0/TCP
    Environment:  <none>
    Mounts:       <none>
  Volumes:        <none>
Conditions:
  Type           Status  Reason
  ----           ------  ------
  Progressing    True    NewReplicaSetAvailable
  Available      True    MinimumReplicasAvailable
OldReplicaSets:  <none>
NewReplicaSet:   kubernetes-bootcamp-fb5c67579 (4/4 replicas created)
Events:
  Type    Reason             Age    From                   Message
  ----    ------             ----   ----                   -------
  Normal  ScalingReplicaSet  32m    deployment-controller  Scaled up replica set kubernetes-bootcamp-fb5c67579 to 1
  Normal  ScalingReplicaSet  2m38s  deployment-controller  Scaled up replica set kubernetes-bootcamp-fb5c67579 to 4

执行 kubectl describe services 可以看到 Endpoints 变成了多个:

$ kubectl describe services/kubernetes-bootcamp
Name:                     kubernetes-bootcamp
Namespace:                default
Labels:                   app=kubernetes-bootcamp
Annotations:              <none>
Selector:                 app=kubernetes-bootcamp
Type:                     NodePort
IP Families:              <none>
IP:                       10.96.61.243
IPs:                      10.96.61.243
Port:                     <unset>  8080/TCP
TargetPort:               8080/TCP
NodePort:                 <unset>  31955/TCP
Endpoints:                172.18.0.2:8080,172.18.0.7:8080,172.18.0.8:8080 + 1 more...
Session Affinity:         None
External Traffic Policy:  Cluster
Events:                   <none>

Service 会对请求自动进行负载均衡,我们发送多次请求,可以看到请求会落到不同的 Pod 上:

$ curl $(minikube ip):31955
Hello Kubernetes bootcamp! | Running on: kubernetes-bootcamp-fb5c67579-d7l4n | v=1

$ curl $(minikube ip):31955
Hello Kubernetes bootcamp! | Running on: kubernetes-bootcamp-fb5c67579-mpn68 | v=1

$ curl $(minikube ip):31955
Hello Kubernetes bootcamp! | Running on: kubernetes-bootcamp-fb5c67579-cgwjm | v=1

$ curl $(minikube ip):31955
Hello Kubernetes bootcamp! | Running on: kubernetes-bootcamp-fb5c67579-tk2lm | v=1

我们再次执行 kubectl scale,将副本数改成 2:

$ kubectl scale deployments/kubernetes-bootcamp --replicas=2
deployment.apps/kubernetes-bootcamp scaled

查看 Deployment 列表显示当前的副本集个数:

$ kubectl get deployments
NAME                  READY   UP-TO-DATE   AVAILABLE   AGE
kubernetes-bootcamp   2/2     2            2           45m

再次查看 Pod 列表,可以发现有两个 Pod 被停止(Terminating)了:

$ kubectl get pods -o wide
NAME                                  READY   STATUS        RESTARTS   AGE   IP           NODE       NOMINATED NODE   READINESS GATES
kubernetes-bootcamp-fb5c67579-cgwjm   1/1     Terminating   0          15m   172.18.0.7   minikube   <none>           <none>
kubernetes-bootcamp-fb5c67579-d7l4n   1/1     Running       0          15m   172.18.0.9   minikube   <none>           <none>
kubernetes-bootcamp-fb5c67579-mpn68   1/1     Running       0          45m   172.18.0.2   minikube   <none>           <none>
kubernetes-bootcamp-fb5c67579-tk2lm   1/1     Terminating   0          15m   172.18.0.8   minikube   <none>           <none>

执行滚动更新

当我们的应用程序运行多个实例时,我们就可以对我们的应用进行零停机的滚动更新(Rolling Update),而且所有的更新都是经过版本控制的,任何 Deployment 更新都可以恢复到以前的(稳定)版本。

下面使用 kubectl set image 命令执行滚动更新,将应用版本升级到 v2:

$ kubectl set image deployments/kubernetes-bootcamp kubernetes-bootcamp=jocatalin/kubernetes-bootcamp:v2
deployment.apps/kubernetes-bootcamp image updated

等待应用更新完毕后,再次访问我们的应用,成功升级到 v2:

$ curl $(minikube ip):31955
Hello Kubernetes bootcamp! | Running on: kubernetes-bootcamp-7d44784b7c-4ntgh | v=2

也可以使用 kubectl rollout status 查看更新是否成功:

$ kubectl rollout status deployments/kubernetes-bootcamp
deployment "kubernetes-bootcamp" successfully rolled out

如果我们更新的时候出错了,比如更新到一个不存在的镜像:

$ kubectl set image deployments/kubernetes-bootcamp kubernetes-bootcamp=jocatalin/kubernetes-bootcamp:v10
deployment.apps/kubernetes-bootcamp image updated

可以发现 Kubernetes 并不会直接将四个 Pod 一股脑都升级到错误的镜像,而是先停止一个 Pod,等待这个 Pod 更新成功,再更新下一个,保证应用服务一直是可访问的(也就是零停机)。

执行 kubectl get deployments 可以看到当前有 3 个可用的应用实例,有 2 个正在更新:

$ kubectl get deployments
NAME                  READY   UP-TO-DATE   AVAILABLE   AGE
kubernetes-bootcamp   3/4     2            3           13m

使用 kubectl get pods 可以看到有 2 个 Pod 一直处于 ImagePullBackOff 状态(因为这个镜像不存在):

$ kubectl get pods
NAME                                   READY   STATUS             RESTARTS   AGE
kubernetes-bootcamp-59b7598c77-8nfsh   0/1     ImagePullBackOff   0          2m35s
kubernetes-bootcamp-59b7598c77-rcdfq   0/1     ImagePullBackOff   0          2m35s
kubernetes-bootcamp-7d44784b7c-9vwn2   1/1     Running            0          13m
kubernetes-bootcamp-7d44784b7c-qcgnn   1/1     Running            0          13m
kubernetes-bootcamp-7d44784b7c-vfn79   1/1     Running            0          13m

下面是 kubectl rollout status 命令的输出:

$ kubectl rollout status deployments/kubernetes-bootcamp
Waiting for deployment "kubernetes-bootcamp" rollout to finish: 2 out of 4 new replicas have been updated...

这时可以使用 kubectl rollout undo 执行回滚操作,应用程序将会全部恢复到之前的 v2 版本:

$ kubectl rollout undo deployments/kubernetes-bootcamp
deployment.apps/kubernetes-bootcamp rolled back

$ kubectl rollout status deployments/kubernetes-bootcamp
deployment "kubernetes-bootcamp" successfully rolled out

参考

  1. 学习 Kubernetes 基础知识
  2. Kubernetes 术语表
  3. Kubernetes 指南

更多

了解 Kubernetes 基本概念

Cluster

Cluster 是计算、存储和网络资源的集合,Kubernete 利用这些资源运行各种基于容器的应用。

Master

Master 的主要职责是调度,决定将应用放在哪个 Node 上运行。

Node

Node 的主要职责是运行容器应用,同时监控并汇报容器状态。

Pod

Kubernetes 以 Pod 作为最小工作单元进行调度和管理,每个 Pod 包含一个或多个容器,Pod 中的容器会作为一个整体被调度到一个 Node 上运行。

Pod 中的所有容器使用同一个网络,即相同的 IP 地址和 Port 空间,它们可以直接使用 localhost 通信。这些容器之间也共享存储,当 Kubernetes 挂载 Volumne 到 Pod,本质上是将 Volumne 挂载到 Pod 里的每个容器。

Controller

Kubernetes 通常不会直接创建 Pod,而是通过 Controller 来管理 Pod 的。为满足不同的业务场景,Kubernete 提供了多种 Controller:

  1. Deployment - 通过 ReplicaSet 实现 Pod 的多副本管理,还提供版本升级和回滚等功能
  2. ReplicaSet - 实现 Pod 的多副本管理
  3. DaemonSet - 每个 Node 最多只运行一个 Pod 副本
  4. StatefulSet - 保证 Pod 的每个副本在整个生命周期中名称不变
  5. Job - 运行一次性的任务,运行结束后自动删除

Service

Deployment 可以部署多个副本,每个 Pod 都有自己的 IP,但是我们不能通过 Pod 的 IP 直接来访问,因为 Pod 很可能会被频繁地销毁和重启,它们的 IP 会发生变化。

于是引入了 Service 的概念,Service 提供了访问一组特定 Pod 的方式,它有自己的 IP 和端口,并为 Pod 提供负载均衡。

Namespace

Namespace 一般用于多租户隔离,它将一个物理的 Cluster 划分成多个虚拟 Cluster,不同 Namespace 里的资源是完全隔离的。Kubernetes 默认创建了两个 Namespace:

$ kubectl get namespace
NAME                   STATUS   AGE
default                Active   28s
kube-system            Active   29s

创建资源时如果不指定 Namespace,默认会放在 default 中,Kubernetes 自己创建的系统资源放在 kube-system 中。

Kubernetes 架构

Kubernetes 主要由以下几个核心组件组成:

  • etcd 保存了整个集群的状态;
  • kube-apiserver 提供了资源操作的唯一入口,并提供认证、授权、访问控制、API 注册和发现等机制;
  • kube-controller-manager 负责维护集群的状态,比如故障检测、自动扩展、滚动更新等;
  • kube-scheduler 负责资源的调度,按照预定的调度策略将 Pod 调度到相应的机器上;
  • kubelet 负责维持容器的生命周期,同时也负责 Volume(CVI)和网络(CNI)的管理;
  • Container runtime 负责镜像管理以及 Pod 和容器的真正运行(CRI);
  • kube-proxy 负责为 Service 提供 cluster 内部的服务发现和负载均衡;

components.png

Kubernetes 其他教程

https://kubernetes.io/docs/tutorials/

Kubernetes 任务

https://kubernetes.io/docs/tasks/

扫描二维码,在手机上阅读!

Kubernetes 安装小记

Kubernetes 集群环境的安装比较复杂,需要考虑网络、存储等一系列的问题,在这篇笔记中,我们先学习使用 kind 或 minikube 安装单机环境,在对 Kubernetes 的组件和基本概念有一定认识之后,再尝试部署集群环境。

安装 kubectl

在安装 Kubernetes 之前,我们首先需要安装 kubectl,这是 Kubernetes 的命令行工具,用来在 Kubernetes 集群上运行命令,你可以使用 kubectl 来部署应用、监测和管理集群资源以及查看日志。安装 kubectl 最简单的方式是使用 curl 命令,首先执行下面的命令下载 kubectl

$ curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"

然后将 kubectl 安装到 /usr/local/bin 目录:

$ sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl

installcp 命令类似,都可以将文件或目录拷贝到指定的地方,不过 install 允许你控制文件的属性。-o, --owner 参数用来设置所有者,-g, --group 参数用来设置组,-m, --mode 类似于 chmod 的设定文件权限模式。

安装完成后,运行 kubectl version 查看版本的详细信息:

[root@localhost ~]# kubectl version --client --output=json
{
  "clientVersion": {
    "major": "1",
    "minor": "24",
    "gitVersion": "v1.24.0",
    "gitCommit": "4ce5a8954017644c5420bae81d72b09b735c21f0",
    "gitTreeState": "clean",
    "buildDate": "2022-05-03T13:46:05Z",
    "goVersion": "go1.18.1",
    "compiler": "gc",
    "platform": "linux/amd64"
  },
  "kustomizeVersion": "v4.5.4"
}

由于此时还没有安装 Kubernetes,所以使用 --client 仅显示客户端的版本。

使用 kind 安装 Kubernetes

kind 是 Kubernetes IN Docker 的简写,是一个使用 Docker 容器作为 Nodes,在本地创建和运行 Kubernetes 集群的工具。适用于在本机创建 Kubernetes 集群环境进行开发和测试。

安装 kind

和安装 kubectl 类似,首先使用 curl 下载:

$ curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.12.0/kind-linux-amd64

再使用 install 安装:

$ sudo install -o root -g root -m 0755 kind /usr/local/bin/kind

使用 kind --help 查看帮助:

[root@localhost ~]# kind --help
kind creates and manages local Kubernetes clusters using Docker container 'nodes'

Usage:
  kind [command]

Available Commands:
  build       Build one of [node-image]
  completion  Output shell completion code for the specified shell (bash, zsh or fish)
  create      Creates one of [cluster]
  delete      Deletes one of [cluster]
  export      Exports one of [kubeconfig, logs]
  get         Gets one of [clusters, nodes, kubeconfig]
  help        Help about any command
  load        Loads images into nodes
  version     Prints the kind CLI version

Flags:
  -h, --help              help for kind
      --loglevel string   DEPRECATED: see -v instead
  -q, --quiet             silence all stderr output
  -v, --verbosity int32   info log verbosity, higher value produces more output
      --version           version for kind

Use "kind [command] --help" for more information about a command.

创建 Kubernetes 集群

使用简单的一句命令 kind create cluster 就可以在本地创建一整套 Kubernetes 集群,这样的环境用于实验再合适不过:

[root@localhost ~]# kind create cluster
Creating cluster "kind" ...
 ✓ Ensuring node image (kindest/node:v1.23.4) 🖼 
 ✓ Preparing nodes 📦  
 ✓ Writing configuration 📜 
 ✓ Starting control-plane 🕹️
 ✓ Installing CNI 🔌 
 ✓ Installing StorageClass 💾 
Set kubectl context to "kind-kind"
You can now use your cluster with:

kubectl cluster-info --context kind-kind

Thanks for using kind! 😊

此时再运行 kubectl version 命令,就可以看到 Kubernetes 服务端的信息了:

[root@localhost ~]# kubectl version --output=json
{
  "clientVersion": {
    "major": "1",
    "minor": "24",
    "gitVersion": "v1.24.0",
    "gitCommit": "4ce5a8954017644c5420bae81d72b09b735c21f0",
    "gitTreeState": "clean",
    "buildDate": "2022-05-03T13:46:05Z",
    "goVersion": "go1.18.1",
    "compiler": "gc",
    "platform": "linux/amd64"
  },
  "kustomizeVersion": "v4.5.4",
  "serverVersion": {
    "major": "1",
    "minor": "23",
    "gitVersion": "v1.23.4",
    "gitCommit": "e6c093d87ea4cbb530a7b2ae91e54c0842d8308a",
    "gitTreeState": "clean",
    "buildDate": "2022-03-06T21:32:53Z",
    "goVersion": "go1.17.7",
    "compiler": "gc",
    "platform": "linux/amd64"
  }
}

使用 docker ps 可以看到一个名为 kind-control-plane 的容器,他暴露出来的端口 127.0.0.1:45332 就是我们 kubectl 访问的端口。

[root@localhost ~]# docker ps
CONTAINER ID   IMAGE                  COMMAND                  CREATED       STATUS       PORTS                       NAMES
2d2f2ed13eaa   kindest/node:v1.23.4   "/usr/local/bin/entr…"   2 hours ago   Up 2 hours   127.0.0.1:45332->6443/tcp   kind-control-plane

kind 将整个 Kubernetes 组件内置在 kindest/node 镜像中,可以使用该镜像创建多个 Kubernetes 集群。默认创建的集群名为 kind,也可以使用 --name 参数指定集群名:

[root@localhost ~]# kind create cluster --name kind-2

获取集群列表:

[root@localhost ~]# kind get clusters
kind
kind2

使用 kubectl cluster-info 切换集群:

kubectl cluster-info --context kind-kind
kubectl cluster-info --context kind-kind-2

我们使用 docker exec 进入容器内部看看:

[root@localhost ~]# docker exec -it 2d2 bash
root@kind-control-plane:/# ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.1  16544  1084 ?        Ss   09:43   0:00 /sbin/init
root         192  0.0  0.1  19448  1504 ?        S<s  09:44   0:00 /lib/systemd/systemd-journald
root         204  3.3  2.2 1437696 23204 ?       Ssl  09:44   4:09 /usr/local/bin/containerd
root         310  0.0  0.3 713276  3600 ?        Sl   09:44   0:02 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id 6e308f31e4045e7f5e3f8ab7
root         317  0.0  0.3 713276  3428 ?        Sl   09:44   0:01 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id 9de51bcebdf67ce484709b90
root         351  0.0  0.3 713276  3432 ?        Sl   09:44   0:02 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id b0094313aab3af65958f7a74
root         363  0.0  0.4 713276  4060 ?        Sl   09:44   0:01 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id 12136290af44076c5b5faa19
root         483  3.1  4.1 11214772 41872 ?      Ssl  09:44   3:56 etcd --advertise-client-urls=https://172.18.0.2:2379 --cert-file=/etc/kubernetes/pki/
root         562  6.5 18.7 1056224 190292 ?      Ssl  09:44   8:08 kube-apiserver --advertise-address=172.18.0.2 --allow-privileged=true --authorization
root         651  3.1  4.3 1402784 44492 ?       Ssl  09:44   3:55 /usr/bin/kubelet --bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubelet.conf --kube
root         899  0.0  0.3 713276  3284 ?        Sl   09:46   0:01 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id 383bf2636b6a0b1e14b8cd08
root         915  0.0  0.3 713020  3596 ?        Sl   09:46   0:01 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id b0fc9bc1eaf15846855b4e5e
root         983  0.0  0.7 733188  7908 ?        Ssl  09:46   0:04 /bin/kindnetd
root        1023  0.0  1.0 748152 11136 ?        Ssl  09:46   0:04 /usr/local/bin/kube-proxy --config=/var/lib/kube-proxy/config.conf --hostname-overrid
root        1234  2.9  4.1 767820 42316 ?        Ssl  09:47   3:34 kube-controller-manager --allocate-node-cidrs=true --authentication-kubeconfig=/etc/k
root        1274  0.4  1.9 754000 19516 ?        Ssl  09:47   0:34 kube-scheduler --authentication-kubeconfig=/etc/kubernetes/scheduler.conf --authoriza
root        1367  0.0  0.3 713020  3772 ?        Sl   09:47   0:01 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id dc694d8f939cfec4277911fe
root        1371  0.0  0.4 713276  4848 ?        Sl   09:47   0:01 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id 41b361804234b5c0fc353ff6
root        1393  0.0  0.3 713276  4044 ?        Sl   09:47   0:01 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id e4befc1237963effcb2a594b
root        1499  0.1  1.1 750568 11884 ?        Ssl  09:47   0:12 /coredns -conf /etc/coredns/Corefile
root        1526  0.1  1.1 750568 11772 ?        Ssl  09:47   0:13 /coredns -conf /etc/coredns/Corefile
root        2904  0.2  0.1   4580  1040 pts/1    Ss   11:47   0:00 bash
root        2980  0.6  0.6 136664  6392 ?        Ssl  11:48   0:00 local-path-provisioner --debug start --helper-image k8s.gcr.io/build-image/debian-bas
root        3010  0.0  0.1   6900  1420 pts/1    R+   11:48   0:00 ps aux

可以看到这些进程:

  • kindnetd - 一款简单的 CNI 插件
  • containerd - 使用 containerd 作为容器运行时,弃用 Dockershim 对 kind 没有影响
  • containerd-shim-runc-v2
  • pause
  • coredns - 为集群提供 DNS 和服务发现的功能
  • etcd - 服务发现的后端,并存储集群状态和配置
  • kubelet - 运行在每个节点上的代理,用来处理 Master 节点下发到本节点的任务
  • kube-apiserver - 提供集群管理的 REST API 接口,是模块之间的数据交互和通信的枢纽,只有 apiserver 能访问 etcd
  • kube-proxy - 实现 Kubernetes Service 的通信与负载均衡
  • kube-controller-manager - 是 Kubernetes 的大脑,它通过 apiserver 监控整个集群的状态,并确保集群处于预期的工作状态
  • kube-scheduler - 负责分配调度 Pod 到集群内的节点上,它监听 apiserver,查询还未分配 Node 的 Pod,然后根据调度策略为这些 Pod 分配节点
  • local-path-provisioner - 本地持久化存储

使用 minikube 安装 Kubernetes

minikube 是由 Google 发布的一款轻量级工具,让开发者可以在本机上轻易运行一个 Kubernetes 集群,快速上手 Kubernetes 的指令与环境。minikube 会在本机运行一个容器或虚拟机,并且在这个容器或虚拟机中启动一个 single-node Kubernetes 集群,它不支持 HA,不推荐在生产环境使用。

安装 minikube

minikube 的安装也和上面的 kindkubectl 一样,先使用 curl 下载:

$ curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64

再通过 install 将其安装到 /usr/local/bin 目录:

$ sudo install minikube-linux-amd64 /usr/local/bin/minikube

创建 Kubernetes 集群

使用 minikube 创建 Kubernetes 集群比 kind 稍微多一些限制:

  • 2 CPUs or more
  • 2GB of free memory
  • 20GB of free disk space

否则会报下面这些错误。

CPU 核数不够:

X Exiting due to RSRC_INSUFFICIENT_CORES: Requested cpu count 2 is greater than the available cpus of 1

内存不够:

X Exiting due to RSRC_INSUFFICIENT_CONTAINER_MEMORY: docker only has 990MiB available, less than the required 1800MiB for Kubernetes

另外,当我们使用 Docker 作为驱动时,需要以非 root 用户运行:

X Exiting due to DRV_AS_ROOT: The "docker" driver should not be used with root privileges.

Docker 在安装时会默认创建一个叫 docker 的用户组,可以在 /etc/group 文件中找到 docker 用户组的 id,然后使用 adduser 在该用户组下添加一个 docker 用户,su - docker 切换到 docker 用户就可以以非 root 用户运行 Docker 了:

[root@localhost ~]# grep docker /etc/group
docker:x:995:

[root@localhost ~]# adduser -g 995 -c "Docker" docker

[root@localhost ~]# id docker
uid=1000(docker) gid=995(docker) 组=995(docker)

[root@localhost ~]# su - docker

一切准备就绪,执行 minikube start 创建 Kubernetes 集群:

[docker@localhost ~]$ minikube start
* Centos 7.9.2009 上的 minikube v1.25.2
* 根据现有的配置文件使用 docker 驱动程序
* Starting control plane node minikube in cluster minikube
* Pulling base image ...
    > index.docker.io/kicbase/sta...: 0 B [____________________] ?% ? p/s 6m29s
! minikube was unable to download gcr.io/k8s-minikube/kicbase:v0.0.30, but successfully downloaded docker.io/kicbase/stable:v0.0.30 as a fallback image
* Creating docker container (CPUs=2, Memory=2200MB) ...

X Docker is nearly out of disk space, which may cause deployments to fail! (90% of capacity)
* 建议:

    Try one or more of the following to free up space on the device:
    
    1. Run "docker system prune" to remove unused Docker data (optionally with "-a")
    2. Increase the storage allocated to Docker for Desktop by clicking on:
    Docker icon > Preferences > Resources > Disk Image Size
    3. Run "minikube ssh -- docker system prune" if using the Docker container runtime
* Related issue: https://github.com/kubernetes/minikube/issues/9024

! This container is having trouble accessing https://k8s.gcr.io
* To pull new external images, you may need to configure a proxy: https://minikube.sigs.k8s.io/docs/reference/networking/proxy/
* 正在 Docker 20.10.12 中准备 Kubernetes v1.23.3…
  - kubelet.housekeeping-interval=5m
  - Generating certificates and keys ...
  - Booting up control plane ...
  - Configuring RBAC rules ...
* Verifying Kubernetes components...
  - Using image gcr.io/k8s-minikube/storage-provisioner:v5
* Enabled addons: default-storageclass, storage-provisioner
* Done! kubectl is now configured to use "minikube" cluster and "default" namespace by default

使用 docker ps 可以看到 minikube 使用 kicbase/stable 镜像启动了一个容器,该容器暴露了以下几个端口:

  • 49157->22
  • 49156->2376
  • 49155->5000
  • 49154->8443
  • 49153->32443
[docker@localhost ~]$ docker ps -a
CONTAINER ID   IMAGE                    COMMAND                  CREATED         STATUS         PORTS                                                                                                                                  NAMES
d7e2ffaba188   kicbase/stable:v0.0.30   "/usr/local/bin/entr…"   2 minutes ago   Up 2 minutes   127.0.0.1:49157->22/tcp, 127.0.0.1:49156->2376/tcp, 127.0.0.1:49155->5000/tcp, 127.0.0.1:49154->8443/tcp, 127.0.0.1:49153->32443/tcp   minikube

使用 kubeadm 安装 Kubernetes

kubeadm 是 Kubernetes 社区提供的集群构建工具,它负责构建一个最小化可用集群并执行启动等必要的基本步骤,简单来讲,kubeadm 是 Kubernetes 集群全生命周期管理工具,可用于实现集群的部署、升级/降级及卸载等。按照设计,它只关注启动引导,而非配置机器。同样的,安装各种 “锦上添花” 的扩展,例如 Kubernetes Dashboard、监控方案、以及特定云平台的扩展,都不在讨论范围内。

安装 kubeadm、kubelet 和 kubectl

首先我们需要安装这三个组件:

  • kubeadm - 用于启动集群
  • kubelet - 运行在集群中的每一台机器上,用于启动 Pod 和 容器
  • kubectl - 用于管理集群

虽然官方提供了 yum 和 apt-get 的安装方式,但是这里我打算手工安装下,这样可以更好的加深理解。

下载 CNI 插件

绝大多数 Pod 网络都需要 CNI 插件。

[root@localhost ~]# mkdir -p /opt/cni/bin
[root@localhost ~]# curl -L "https://github.com/containernetworking/plugins/releases/download/v0.8.2/cni-plugins-linux-amd64-v0.8.2.tgz" | sudo tar -C /opt/cni/bin -xz

可以看到这里提供了很多不同的 CNI 插件:

[root@localhost ~]# ls /opt/cni/bin/
bandwidth  bridge  dhcp  firewall  flannel  host-device  host-local  ipvlan  loopback  macvlan  portmap  ptp  sbr  static  tuning  vlan

安装 crictl

crictlCRI 兼容的容器运行时命令行接口。你可以使用它来检查和调试 Kubernetes 节点上的容器运行时和应用程序。

[root@localhost ~]# curl -L "https://github.com/kubernetes-sigs/cri-tools/releases/download/v1.22.0/crictl-v1.22.0-linux-amd64.tar.gz" | sudo tar -C /usr/local/bin -xz

使用 crictl --help 查看帮助:

[root@localhost ~]# crictl --help
NAME:
   crictl - client for CRI

USAGE:
   crictl [global options] command [command options] [arguments...]

VERSION:
   v1.22.0

COMMANDS:
   attach              Attach to a running container
   create              Create a new container
   exec                Run a command in a running container
   version             Display runtime version information
   images, image, img  List images
   inspect             Display the status of one or more containers
   inspecti            Return the status of one or more images
   imagefsinfo         Return image filesystem info
   inspectp            Display the status of one or more pods
   logs                Fetch the logs of a container
   port-forward        Forward local port to a pod
   ps                  List containers
   pull                Pull an image from a registry
   run                 Run a new container inside a sandbox
   runp                Run a new pod
   rm                  Remove one or more containers
   rmi                 Remove one or more images
   rmp                 Remove one or more pods
   pods                List pods
   start               Start one or more created containers
   info                Display information of the container runtime
   stop                Stop one or more running containers
   stopp               Stop one or more running pods
   update              Update one or more running containers
   config              Get and set crictl client configuration options
   stats               List container(s) resource usage statistics
   completion          Output shell completion code
   help, h             Shows a list of commands or help for one command

GLOBAL OPTIONS:
   --config value, -c value            Location of the client config file. If not specified and the default does not exist, the program's directory is searched as well (default: "/etc/crictl.yaml") [$CRI_CONFIG_FILE]
   --debug, -D                         Enable debug mode (default: false)
   --image-endpoint value, -i value    Endpoint of CRI image manager service (default: uses 'runtime-endpoint' setting) [$IMAGE_SERVICE_ENDPOINT]
   --runtime-endpoint value, -r value  Endpoint of CRI container runtime service (default: uses in order the first successful one of [unix:///var/run/dockershim.sock unix:///run/containerd/containerd.sock unix:///run/crio/crio.sock]). Default is now deprecated and the endpoint should be set instead. [$CONTAINER_RUNTIME_ENDPOINT]
   --timeout value, -t value           Timeout of connecting to the server in seconds (e.g. 2s, 20s.). 0 or less is set to default (default: 2s)
   --help, -h                          show help (default: false)
   --version, -v                       print the version (default: false)

不过在执行 crictl ps 的时候报错了:

[root@localhost ~]# crictl ps
WARN[0000] runtime connect using default endpoints: [unix:///var/run/dockershim.sock unix:///run/containerd/containerd.sock unix:///run/crio/crio.sock]. As the default settings are now deprecated, you should set the endpoint instead. 
ERRO[0002] connect endpoint 'unix:///var/run/dockershim.sock', make sure you are running as root and the endpoint has been started: context deadline exceeded 
WARN[0002] image connect using default endpoints: [unix:///var/run/dockershim.sock unix:///run/containerd/containerd.sock unix:///run/crio/crio.sock]. As the default settings are now deprecated, you should set the endpoint instead. 
ERRO[0004] connect endpoint 'unix:///var/run/dockershim.sock', make sure you are running as root and the endpoint has been started: context deadline exceeded 
FATA[0004] listing containers: rpc error: code = Unimplemented desc = unknown service runtime.v1alpha2.RuntimeService 

前面几行报错信息是因为 crictl 默认会按照顺序:dockershim.sock -> containerd.sock -> crio.sock 来检查系统内是否存在对应的运行时环境,所以会报 dockershim.sock 的连接报错信息,我们这里通过下面的命令将 crictl 的默认运行时修改成 containerd.sock

[root@localhost ~]# crictl config runtime-endpoint unix:///run/containerd/containerd.sock
[root@localhost ~]# crictl config image-endpoint unix:///run/containerd/containerd.sock

而最后一行报错,是一个比较坑的地方,是因为 containerd 的配置有问题,我们检查 containerd 的配置文件 /etc/containerd/config.toml

[root@localhost ~]# cat /etc/containerd/config.toml
#   Copyright 2018-2020 Docker Inc.

#   Licensed under the Apache License, Version 2.0 (the "License");
#   you may not use this file except in compliance with the License.
#   You may obtain a copy of the License at

#       http://www.apache.org/licenses/LICENSE-2.0

#   Unless required by applicable law or agreed to in writing, software
#   distributed under the License is distributed on an "AS IS" BASIS,
#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#   See the License for the specific language governing permissions and
#   limitations under the License.

disabled_plugins = ["cri"]

#root = "/var/lib/containerd"
#state = "/run/containerd"
#subreaper = true
#oom_score = 0

#[grpc]
#  address = "/run/containerd/containerd.sock"
#  uid = 0
#  gid = 0

#[debug]
#  address = "/run/containerd/debug.sock"
#  uid = 0
#  gid = 0
#  level = "info"

发现里面有一行 disabled_plugins = ["cri"],这是 Docker 默认安装时的配置,我们将这个配置删除,并重启 containerd:

[root@localhost ~]# rm /etc/containerd/config.toml
[root@localhost ~]# systemctl restart containerd

安装 kubeadm、kubelet 和 kubectl

[root@localhost ~]# cd /usr/local/bin
[root@localhost bin]# curl -L --remote-name-all https://storage.googleapis.com/kubernetes-release/release/v1.24.0/bin/linux/amd64/{kubeadm,kubelet,kubectl}
[root@localhost bin]# chmod +x {kubeadm,kubelet,kubectl}

这三个组件安装好之后,我们需要将 kubelet 添加到 systemd 服务。首先直接从官方下载服务定义的模板,修改其中 kubelet 的路径:

[root@localhost ~]# curl -sSL "https://raw.githubusercontent.com/kubernetes/release/v0.4.0/cmd/kubepkg/templates/latest/deb/kubelet/lib/systemd/system/kubelet.service" | sed "s:/usr/bin:/usr/local/bin:g" | tee /etc/systemd/system/kubelet.service

[Unit]
Description=kubelet: The Kubernetes Node Agent
Documentation=https://kubernetes.io/docs/home/
Wants=network-online.target
After=network-online.target

[Service]
ExecStart=/usr/local/bin/kubelet
Restart=always
StartLimitInterval=0
RestartSec=10

[Install]
WantedBy=multi-user.target

然后再下载 kubeadm 的配置文件:

[root@localhost ~]# mkdir -p /etc/systemd/system/kubelet.service.d
[root@localhost ~]# curl -sSL "https://raw.githubusercontent.com/kubernetes/release/v0.4.0/cmd/kubepkg/templates/latest/deb/kubeadm/10-kubeadm.conf" | sed "s:/usr/bin:/usr/local/bin:g" | tee /etc/systemd/system/kubelet.service.d/10-kubeadm.conf

# Note: This dropin only works with kubeadm and kubelet v1.11+
[Service]
Environment="KUBELET_KUBECONFIG_ARGS=--bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubelet.conf --kubeconfig=/etc/kubernetes/kubelet.conf"
Environment="KUBELET_CONFIG_ARGS=--config=/var/lib/kubelet/config.yaml"
# This is a file that "kubeadm init" and "kubeadm join" generates at runtime, populating the KUBELET_KUBEADM_ARGS variable dynamically
EnvironmentFile=-/var/lib/kubelet/kubeadm-flags.env
# This is a file that the user can use for overrides of the kubelet args as a last resort. Preferably, the user should use
# the .NodeRegistration.KubeletExtraArgs object in the configuration files instead. KUBELET_EXTRA_ARGS should be sourced from this file.
EnvironmentFile=-/etc/default/kubelet
ExecStart=
ExecStart=/usr/local/bin/kubelet $KUBELET_KUBECONFIG_ARGS $KUBELET_CONFIG_ARGS $KUBELET_KUBEADM_ARGS $KUBELET_EXTRA_ARGS

最后,启动 kubelet 服务:

[root@localhost ~]# systemctl enable --now kubelet
Created symlink from /etc/systemd/system/multi-user.target.wants/kubelet.service to /etc/systemd/system/kubelet.service.

使用 kubeadm init 创建 Kubernetes 集群

接下来我们使用 kubeadm init 来初始化 Kubernetes 集群,这个命令的作用是帮助你启动和 master 节点相关的组件:kube-apiserverkube-controller-managerkube-scheduleretcd 等。在运行之前,我们可以使用 kubeadm config images list 命令查看使用 kubeadm 创建 Kubernetes 集群所需要的镜像:

[root@localhost ~]# kubeadm config images list
k8s.gcr.io/kube-apiserver:v1.24.0
k8s.gcr.io/kube-controller-manager:v1.24.0
k8s.gcr.io/kube-scheduler:v1.24.0
k8s.gcr.io/kube-proxy:v1.24.0
k8s.gcr.io/pause:3.7
k8s.gcr.io/etcd:3.5.3-0
k8s.gcr.io/coredns/coredns:v1.8.6

使用 kubeadm config images pull 提前将镜像下载下来:

[root@localhost ~]# kubeadm config images pull
failed to pull image "k8s.gcr.io/kube-apiserver:v1.24.0": output: time="2022-05-15T12:18:29+08:00" level=fatal msg="pulling image: rpc error: code = Unimplemented desc = unknown service runtime.v1alpha2.ImageService"
, error: exit status 1
To see the stack trace of this error execute with --v=5 or higher

我们发现下载镜像报错,这是因为国内没办法访问 k8s.gcr.io,而且无论是在环境变量中设置代理,还是为 Docker Daemon 设置代理,都不起作用。后来才意识到,kubeadm config images pull 命令貌似不走 docker 服务,而是直接请求 containerd 服务,所以我们为 containerd 服务设置代理:

[root@localhost ~]# mkdir /etc/systemd/system/containerd.service.d
[root@localhost ~]# vi /etc/systemd/system/containerd.service.d/http_proxy.conf

文件内容如下:

[Service]
Environment="HTTP_PROXY=192.168.1.36:10809"
Environment="HTTPS_PROXY=192.168.1.36:10809"

重启 containerd 服务:

[root@localhost ~]# systemctl daemon-reload
[root@localhost ~]# systemctl restart containerd

然后重新下载镜像:

[root@localhost ~]# kubeadm config images pull
[config/images] Pulled k8s.gcr.io/kube-apiserver:v1.24.0
[config/images] Pulled k8s.gcr.io/kube-controller-manager:v1.24.0
[config/images] Pulled k8s.gcr.io/kube-scheduler:v1.24.0
[config/images] Pulled k8s.gcr.io/kube-proxy:v1.24.0
[config/images] Pulled k8s.gcr.io/pause:3.7
[config/images] Pulled k8s.gcr.io/etcd:3.5.3-0
[config/images] Pulled k8s.gcr.io/coredns/coredns:v1.8.6

接下来使用 kubeadm init 初始化 Kubernetes 的控制平面:

[root@localhost ~]# kubeadm init
W0515 14:36:22.763487   21958 version.go:103] could not fetch a Kubernetes version from the internet: unable to get URL "https://dl.k8s.io/release/stable-1.txt": Get "https://dl.k8s.io/release/stable-1.txt": x509: certificate has expired or is not yet valid: current time 2022-05-15T14:36:22+08:00 is before 2022-05-17T21:21:32Z
W0515 14:36:22.763520   21958 version.go:104] falling back to the local client version: v1.24.0
[init] Using Kubernetes version: v1.24.0
[preflight] Running pre-flight checks
    [WARNING Firewalld]: firewalld is active, please ensure ports [6443 10250] are open or your cluster may not function correctly
    [WARNING Swap]: swap is enabled; production deployments should disable swap unless testing the NodeSwap feature gate of the kubelet
[preflight] Pulling images required for setting up a Kubernetes cluster
[preflight] This might take a minute or two, depending on the speed of your internet connection
[preflight] You can also perform this action in beforehand using 'kubeadm config images pull'
[certs] Using certificateDir folder "/etc/kubernetes/pki"
[certs] Generating "ca" certificate and key
[certs] Generating "apiserver" certificate and key
[certs] apiserver serving cert is signed for DNS names [kubernetes kubernetes.default kubernetes.default.svc kubernetes.default.svc.cluster.local localhost.localdomain] and IPs [10.96.0.1 10.0.2.10]
[certs] Generating "apiserver-kubelet-client" certificate and key
[certs] Generating "front-proxy-ca" certificate and key
[certs] Generating "front-proxy-client" certificate and key
[certs] Generating "etcd/ca" certificate and key
[certs] Generating "etcd/server" certificate and key
[certs] etcd/server serving cert is signed for DNS names [localhost localhost.localdomain] and IPs [10.0.2.10 127.0.0.1 ::1]
[certs] Generating "etcd/peer" certificate and key
[certs] etcd/peer serving cert is signed for DNS names [localhost localhost.localdomain] and IPs [10.0.2.10 127.0.0.1 ::1]
[certs] Generating "etcd/healthcheck-client" certificate and key
[certs] Generating "apiserver-etcd-client" certificate and key
[certs] Generating "sa" key and public key
[kubeconfig] Using kubeconfig folder "/etc/kubernetes"
[kubeconfig] Writing "admin.conf" kubeconfig file
[kubeconfig] Writing "kubelet.conf" kubeconfig file
[kubeconfig] Writing "controller-manager.conf" kubeconfig file
[kubeconfig] Writing "scheduler.conf" kubeconfig file
[kubelet-start] Writing kubelet environment file with flags to file "/var/lib/kubelet/kubeadm-flags.env"
[kubelet-start] Writing kubelet configuration to file "/var/lib/kubelet/config.yaml"
[kubelet-start] Starting the kubelet
[control-plane] Using manifest folder "/etc/kubernetes/manifests"
[control-plane] Creating static Pod manifest for "kube-apiserver"
[control-plane] Creating static Pod manifest for "kube-controller-manager"
[control-plane] Creating static Pod manifest for "kube-scheduler"
[etcd] Creating static Pod manifest for local etcd in "/etc/kubernetes/manifests"
[wait-control-plane] Waiting for the kubelet to boot up the control plane as static Pods from directory "/etc/kubernetes/manifests". This can take up to 4m0s
[kubelet-check] Initial timeout of 40s passed.
[kubelet-check] It seems like the kubelet isn't running or healthy.
[kubelet-check] The HTTP call equal to 'curl -sSL http://localhost:10248/healthz' failed with error: Get "http://localhost:10248/healthz": dial tcp [::1]:10248: connect: connection refused.
[kubelet-check] It seems like the kubelet isn't running or healthy.
[kubelet-check] The HTTP call equal to 'curl -sSL http://localhost:10248/healthz' failed with error: Get "http://localhost:10248/healthz": dial tcp [::1]:10248: connect: connection refused.
[kubelet-check] It seems like the kubelet isn't running or healthy.
[kubelet-check] The HTTP call equal to 'curl -sSL http://localhost:10248/healthz' failed with error: Get "http://localhost:10248/healthz": dial tcp [::1]:10248: connect: connection refused.
[kubelet-check] It seems like the kubelet isn't running or healthy.
[kubelet-check] The HTTP call equal to 'curl -sSL http://localhost:10248/healthz' failed with error: Get "http://localhost:10248/healthz": dial tcp [::1]:10248: connect: connection refused.
[kubelet-check] It seems like the kubelet isn't running or healthy.
[kubelet-check] The HTTP call equal to 'curl -sSL http://localhost:10248/healthz' failed with error: Get "http://localhost:10248/healthz": dial tcp [::1]:10248: connect: connection refused.

Unfortunately, an error has occurred:
    timed out waiting for the condition

This error is likely caused by:
    - The kubelet is not running
    - The kubelet is unhealthy due to a misconfiguration of the node in some way (required cgroups disabled)

If you are on a systemd-powered system, you can try to troubleshoot the error with the following commands:
    - 'systemctl status kubelet'
    - 'journalctl -xeu kubelet'

Additionally, a control plane component may have crashed or exited when started by the container runtime.
To troubleshoot, list all containers using your preferred container runtimes CLI.
Here is one example how you may list all running Kubernetes containers by using crictl:
    - 'crictl --runtime-endpoint unix:///var/run/containerd/containerd.sock ps -a | grep kube | grep -v pause'
    Once you have found the failing container, you can inspect its logs with:
    - 'crictl --runtime-endpoint unix:///var/run/containerd/containerd.sock logs CONTAINERID'
error execution phase wait-control-plane: couldn't initialize a Kubernetes cluster
To see the stack trace of this error execute with --v=5 or higher

根据报错信息,是因为 kubelet 服务没有启动成功。使用 systemctl status 查看 kubelet 服务状态为 code=exited, status=1/FAILURE

[root@localhost ~]# systemctl status kubelet
● kubelet.service - kubelet: The Kubernetes Node Agent
   Loaded: loaded (/etc/systemd/system/kubelet.service; enabled; vendor preset: disabled)
  Drop-In: /etc/systemd/system/kubelet.service.d
           └─10-kubeadm.conf
   Active: activating (auto-restart) (Result: exit-code) since 五 2022-05-20 06:46:25 CST; 951ms ago
     Docs: https://kubernetes.io/docs/home/
  Process: 2787 ExecStart=/usr/local/bin/kubelet $KUBELET_KUBECONFIG_ARGS $KUBELET_CONFIG_ARGS $KUBELET_KUBEADM_ARGS $KUBELET_EXTRA_ARGS (code=exited, status=1/FAILURE)
 Main PID: 2787 (code=exited, status=1/FAILURE)

使用 journalctl -xefu kubelet 进一步排查日志,我们发现这么一行线索(日志非常多,排查需要一点耐心):

5月 20 06:49:29 localhost.localdomain kubelet[3009]: Error: failed to run Kubelet: running with swap on is not supported, please disable swap! or set --fail-swap-on flag to false.

应该是 swap 的问题,通过下面的命令关闭 swap

[root@localhost ~]# swapoff  -a

然后重新执行 kubeadm init,注意要先执行 kubeadm reset

[root@localhost ~]# kubeadm reset
[reset] Reading configuration from the cluster...
[reset] FYI: You can look at this config file with 'kubectl -n kube-system get cm kubeadm-config -o yaml'
W0515 15:03:11.771080   25796 reset.go:103] [reset] Unable to fetch the kubeadm-config ConfigMap from cluster: failed to get config map: Get "https://10.0.2.10:6443/api/v1/namespaces/kube-system/configmaps/kubeadm-config?timeout=10s": dial tcp 10.0.2.10:6443: connect: connection refused
W0515 15:03:11.773814   25796 preflight.go:55] [reset] WARNING: Changes made to this host by 'kubeadm init' or 'kubeadm join' will be reverted.
[reset] Are you sure you want to proceed? [y/N]: y
[preflight] Running pre-flight checks
W0515 15:03:13.272040   25796 removeetcdmember.go:84] [reset] No kubeadm config, using etcd pod spec to get data directory
[reset] Stopping the kubelet service
[reset] Unmounting mounted directories in "/var/lib/kubelet"
[reset] Deleting contents of directories: [/etc/kubernetes/manifests /etc/kubernetes/pki]
[reset] Deleting files: [/etc/kubernetes/admin.conf /etc/kubernetes/kubelet.conf /etc/kubernetes/bootstrap-kubelet.conf /etc/kubernetes/controller-manager.conf /etc/kubernetes/scheduler.conf]
[reset] Deleting contents of stateful directories: [/var/lib/etcd /var/lib/kubelet /var/lib/dockershim /var/run/kubernetes /var/lib/cni]

The reset process does not clean CNI configuration. To do so, you must remove /etc/cni/net.d

The reset process does not reset or clean up iptables rules or IPVS tables.
If you wish to reset iptables, you must do so manually by using the "iptables" command.

If your cluster was setup to utilize IPVS, run ipvsadm --clear (or similar)
to reset your system's IPVS tables.

The reset process does not clean your kubeconfig files and you must remove them manually.
Please, check the contents of the $HOME/.kube/config file.

再次执行 kubeadm init 成功:

[root@localhost ~]# kubeadm init
W0515 15:03:21.229843   25821 version.go:103] could not fetch a Kubernetes version from the internet: unable to get URL "https://dl.k8s.io/release/stable-1.txt": Get "https://dl.k8s.io/release/stable-1.txt": x509: certificate has expired or is not yet valid: current time 2022-05-15T15:03:21+08:00 is before 2022-05-17T21:21:32Z
W0515 15:03:21.229869   25821 version.go:104] falling back to the local client version: v1.24.0
[init] Using Kubernetes version: v1.24.0
[preflight] Running pre-flight checks
    [WARNING Firewalld]: firewalld is active, please ensure ports [6443 10250] are open or your cluster may not function correctly
[preflight] Pulling images required for setting up a Kubernetes cluster
[preflight] This might take a minute or two, depending on the speed of your internet connection
[preflight] You can also perform this action in beforehand using 'kubeadm config images pull'
[certs] Using certificateDir folder "/etc/kubernetes/pki"
[certs] Generating "ca" certificate and key
[certs] Generating "apiserver" certificate and key
[certs] apiserver serving cert is signed for DNS names [kubernetes kubernetes.default kubernetes.default.svc kubernetes.default.svc.cluster.local localhost.localdomain] and IPs [10.96.0.1 10.0.2.10]
[certs] Generating "apiserver-kubelet-client" certificate and key
[certs] Generating "front-proxy-ca" certificate and key
[certs] Generating "front-proxy-client" certificate and key
[certs] Generating "etcd/ca" certificate and key
[certs] Generating "etcd/server" certificate and key
[certs] etcd/server serving cert is signed for DNS names [localhost localhost.localdomain] and IPs [10.0.2.10 127.0.0.1 ::1]
[certs] Generating "etcd/peer" certificate and key
[certs] etcd/peer serving cert is signed for DNS names [localhost localhost.localdomain] and IPs [10.0.2.10 127.0.0.1 ::1]
[certs] Generating "etcd/healthcheck-client" certificate and key
[certs] Generating "apiserver-etcd-client" certificate and key
[certs] Generating "sa" key and public key
[kubeconfig] Using kubeconfig folder "/etc/kubernetes"
[kubeconfig] Writing "admin.conf" kubeconfig file
[kubeconfig] Writing "kubelet.conf" kubeconfig file
[kubeconfig] Writing "controller-manager.conf" kubeconfig file
[kubeconfig] Writing "scheduler.conf" kubeconfig file
[kubelet-start] Writing kubelet environment file with flags to file "/var/lib/kubelet/kubeadm-flags.env"
[kubelet-start] Writing kubelet configuration to file "/var/lib/kubelet/config.yaml"
[kubelet-start] Starting the kubelet
[control-plane] Using manifest folder "/etc/kubernetes/manifests"
[control-plane] Creating static Pod manifest for "kube-apiserver"
[control-plane] Creating static Pod manifest for "kube-controller-manager"
[control-plane] Creating static Pod manifest for "kube-scheduler"
[etcd] Creating static Pod manifest for local etcd in "/etc/kubernetes/manifests"
[wait-control-plane] Waiting for the kubelet to boot up the control plane as static Pods from directory "/etc/kubernetes/manifests". This can take up to 4m0s
[apiclient] All control plane components are healthy after 22.259518 seconds
[upload-config] Storing the configuration used in ConfigMap "kubeadm-config" in the "kube-system" Namespace
[kubelet] Creating a ConfigMap "kubelet-config" in namespace kube-system with the configuration for the kubelets in the cluster
[upload-certs] Skipping phase. Please see --upload-certs
[mark-control-plane] Marking the node localhost.localdomain as control-plane by adding the labels: [node-role.kubernetes.io/control-plane node.kubernetes.io/exclude-from-external-load-balancers]
[mark-control-plane] Marking the node localhost.localdomain as control-plane by adding the taints [node-role.kubernetes.io/master:NoSchedule node-role.kubernetes.io/control-plane:NoSchedule]
[bootstrap-token] Using token: cjpeqg.yvf2lka5i5epqcis
[bootstrap-token] Configuring bootstrap tokens, cluster-info ConfigMap, RBAC Roles
[bootstrap-token] Configured RBAC rules to allow Node Bootstrap tokens to get nodes
[bootstrap-token] Configured RBAC rules to allow Node Bootstrap tokens to post CSRs in order for nodes to get long term certificate credentials
[bootstrap-token] Configured RBAC rules to allow the csrapprover controller automatically approve CSRs from a Node Bootstrap Token
[bootstrap-token] Configured RBAC rules to allow certificate rotation for all node client certificates in the cluster
[bootstrap-token] Creating the "cluster-info" ConfigMap in the "kube-public" namespace
[kubelet-finalize] Updating "/etc/kubernetes/kubelet.conf" to point to a rotatable kubelet client certificate and key
[addons] Applied essential addon: CoreDNS
[addons] Applied essential addon: kube-proxy

Your Kubernetes control-plane has initialized successfully!

To start using your cluster, you need to run the following as a regular user:

  mkdir -p $HOME/.kube
  sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
  sudo chown $(id -u):$(id -g) $HOME/.kube/config

Alternatively, if you are the root user, you can run:

  export KUBECONFIG=/etc/kubernetes/admin.conf

You should now deploy a pod network to the cluster.
Run "kubectl apply -f [podnetwork].yaml" with one of the options listed at:
  https://kubernetes.io/docs/concepts/cluster-administration/addons/

Then you can join any number of worker nodes by running the following on each as root:

kubeadm join 10.0.2.10:6443 --token cjpeqg.yvf2lka5i5epqcis \
    --discovery-token-ca-cert-hash sha256:2c662bccbb9491d97b141a2b4b578867f240614ddcc399949c803d1f5093bba5 

根据提示,我们将配置文件复制到 ~/.kube 目录:

[root@localhost ~]# mkdir -p $HOME/.kube
[root@localhost ~]# cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
[root@localhost ~]# chown $(id -u):$(id -g) $HOME/.kube/config

然后安装一个 网络插件,网络插件是用于 Pod 之间的网络通信,这里我们选择安装 flannel

[root@localhost ~]# curl -LO https://raw.githubusercontent.com/flannel-io/flannel/master/Documentation/kube-flannel.yml

[root@localhost ~]# kubectl apply -f kube-flannel.yml
Warning: policy/v1beta1 PodSecurityPolicy is deprecated in v1.21+, unavailable in v1.25+
podsecuritypolicy.policy/psp.flannel.unprivileged created
clusterrole.rbac.authorization.k8s.io/flannel created
clusterrolebinding.rbac.authorization.k8s.io/flannel created
serviceaccount/flannel created
configmap/kube-flannel-cfg created
daemonset.apps/kube-flannel-ds created

安装结束后,可以使用 kubectl get pods -A 查看 Pod 的状态:

[root@localhost ~]# kubectl get pods -A
NAMESPACE     NAME                                            READY   STATUS              RESTARTS        AGE
kube-system   coredns-6d4b75cb6d-6xvx5                        0/1     ContainerCreating   0               41m
kube-system   coredns-6d4b75cb6d-d4pd6                        0/1     ContainerCreating   0               41m
kube-system   etcd-localhost.localdomain                      1/1     Running             0               42m
kube-system   kube-apiserver-localhost.localdomain            1/1     Running             0               42m
kube-system   kube-controller-manager-localhost.localdomain   1/1     Running             0               42m
kube-system   kube-flannel-ds-2mgf5                           0/1     CrashLoopBackOff    9 (2m31s ago)   24m
kube-system   kube-proxy-ftmb9                                1/1     Running             0               42m
kube-system   kube-scheduler-localhost.localdomain            1/1     Running             0               42m

我们发现 flannel 一直处于 CrashLoopBackOff 的状态,使用 kubectl logs 检查 flannel 的启动日志:

[root@localhost ~]# kubectl logs kube-flannel-ds-2mgf5 -n kube-system
Defaulted container "kube-flannel" out of: kube-flannel, install-cni-plugin (init), install-cni (init)
I0515 07:43:21.480968       1 main.go:205] CLI flags config: {etcdEndpoints:http://127.0.0.1:4001,http://127.0.0.1:2379 etcdPrefix:/coreos.com/network etcdKeyfile: etcdCertfile: etcdCAFile: etcdUsername: etcdPassword: version:false kubeSubnetMgr:true kubeApiUrl: kubeAnnotationPrefix:flannel.alpha.coreos.com kubeConfigFile: iface:[] ifaceRegex:[] ipMasq:true subnetFile:/run/flannel/subnet.env publicIP: publicIPv6: subnetLeaseRenewMargin:60 healthzIP:0.0.0.0 healthzPort:0 iptablesResyncSeconds:5 iptablesForwardRules:true netConfPath:/etc/kube-flannel/net-conf.json setNodeNetworkUnavailable:true}
W0515 07:43:21.480968       1 client_config.go:614] Neither --kubeconfig nor --master was specified.  Using the inClusterConfig.  This might not work.
I0515 07:43:21.882364       1 kube.go:120] Waiting 10m0s for node controller to sync
I0515 07:43:21.975972       1 kube.go:378] Starting kube subnet manager
I0515 07:43:22.977270       1 kube.go:127] Node controller sync successful
I0515 07:43:22.977294       1 main.go:225] Created subnet manager: Kubernetes Subnet Manager - localhost.localdomain
I0515 07:43:22.977298       1 main.go:228] Installing signal handlers
I0515 07:43:22.977380       1 main.go:454] Found network config - Backend type: vxlan
I0515 07:43:22.977398       1 match.go:189] Determining IP address of default interface
I0515 07:43:22.978102       1 match.go:242] Using interface with name enp0s3 and address 10.0.2.10
I0515 07:43:22.978128       1 match.go:264] Defaulting external address to interface address (10.0.2.10)
I0515 07:43:22.978176       1 vxlan.go:138] VXLAN config: VNI=1 Port=0 GBP=false Learning=false DirectRouting=false
E0515 07:43:22.978490       1 main.go:317] Error registering network: failed to acquire lease: node "localhost.localdomain" pod cidr not assigned
I0515 07:43:22.978337       1 main.go:434] Stopping shutdownHandler...
W0515 07:43:22.979266       1 reflector.go:436] github.com/flannel-io/flannel/subnet/kube/kube.go:379: watch of *v1.Node ended with: an error on the server ("unable to decode an event from the watch stream: context canceled") has prevented the request from succeeding

其中有一行错误信息比较明显:Error registering network: failed to acquire lease: node "localhost.localdomain" pod cidr not assigned。想起来在执行 kubeadm init 时确实有一个 cidr 的参数,但是我并没有设置,因为不知道设置啥,我们可以打开 kube-flannel.yml 文件,其中的 ConfigMap 里的内容给了我们答案(当然,不同的网络插件 cidr 也可能不一样,你也可以改成自己想要的值):

kind: ConfigMap
apiVersion: v1
metadata:
  name: kube-flannel-cfg
  namespace: kube-system
  labels:
    tier: node
    app: flannel
data:
  cni-conf.json: |
    {
      "name": "cbr0",
      "cniVersion": "0.3.1",
      "plugins": [
        {
          "type": "flannel",
          "delegate": {
            "hairpinMode": true,
            "isDefaultGateway": true
          }
        },
        {
          "type": "portmap",
          "capabilities": {
            "portMappings": true
          }
        }
      ]
    }
  net-conf.json: |
    {
      "Network": "10.244.0.0/16",
      "Backend": {
        "Type": "vxlan"
      }
    }

于是使用 kubeadm resetkubeadm init 重新安装一次:

[root@localhost ~]# kubeadm reset
[root@localhost ~]# kubeadm init --pod-network-cidr 10.244.0.0/16

然后和上面的步骤一样,先复制配置文件到 ~/.kube/config,然后安装 flannel,最终 flannel 安装完成:

[root@localhost ~]# kubectl get pods -A
NAMESPACE     NAME                                            READY   STATUS    RESTARTS      AGE
kube-system   coredns-6d4b75cb6d-kfsrb                        0/1     Running   0             6m25s
kube-system   coredns-6d4b75cb6d-mwmhg                        0/1     Running   0             6m25s
kube-system   etcd-localhost.localdomain                      1/1     Running   2 (14s ago)   6m44s
kube-system   kube-apiserver-localhost.localdomain            1/1     Running   1             7m1s
kube-system   kube-controller-manager-localhost.localdomain   1/1     Running   0             7m
kube-system   kube-flannel-ds-s89f2                           1/1     Running   0             5m6s
kube-system   kube-proxy-9rsb4                                1/1     Running   0             6m24s
kube-system   kube-scheduler-localhost.localdomain            1/1     Running   3             6m44s

可以看到 coredns 的状态也变成了 Running,在网络插件没有安装时,它的状态一直是 ContainerCreating

使用 kubeadm join 加入 Kubernetes 集群

这时 Kubernetes 的 master 节点(又叫做 控制平面)就安装好了,接下来可以在另一台机器上执行 kubeadm join 将工作节点加入 Kubernetes 集群(这台机器也需要提前安装好 kubeadm):

[root@localhost ~]# kubeadm join 10.0.2.10:6443 --token cjpeqg.yvf2lka5i5epqcis \
    --discovery-token-ca-cert-hash sha256:2c662bccbb9491d97b141a2b4b578867f240614ddcc399949c803d1f5093bba5 

不过我这里就只有一台机器,能不能让一台机器既是 master 节点又是工作节点呢?当然可以!

默认情况下 Kubernetes 在分配 Pod 的时候,是不会分配到 master 节点的,这是因为 kubeadm 给我们的 master 节点打上了一个 taint 信息(又被称为 污点)。我们通过下面的 kubectl taint 命令,将 master 节点的污点信息去掉:

[root@localhost ~]# kubectl taint node localhost.localdomain node-role.kubernetes.io/master-
node/localhost.localdomain untainted

其中 node-role.kubernetes.io/master 就是污点的名字,后面的 - 号表示删除,localhost.localdomain 是 master 节点的名字,可以通过 kubectl get nodes 查看:

[root@localhost ~]# kubectl get nodes
NAME                    STATUS   ROLES           AGE   VERSION
localhost.localdomain   Ready    control-plane   42m   v1.24.0

在最新版本的 Kubernetes 中,污点的名字被改为了 node-role.kubernetes.io/control-plane,我们也把它去掉:

[root@localhost ~]# kubectl taint node localhost.localdomain node-role.kubernetes.io/control-plane-
node/localhost.localdomain untainted

如果要查看一个节点的污点信息,可以使用 kubectl describe node <node-name> 命令。

到这里一个单机版的 Kubernetes 最小集群就搭建好了。

参考

  1. kubectl 安装文档
  2. kind 官方文档
  3. kind:Kubernetes in Docker,单机运行 Kubernetes 群集的最佳方案
  4. minikube 官方文档
  5. Bootstrapping clusters with kubeadm
  6. How to use a Single VM as a Kubernetes Cluster with Kubeadm
  7. 一文搞懂容器运行时 Containerd
  8. 重学容器02: 部署容器运行时Containerd

更多

1. 为 Docker 设置代理

第一种情况是 为 Docker Daemon 设置代理,影响 docker pull 下载镜像。首先创建如下目录:

[root@localhost ~]# mkdir -p /etc/systemd/system/docker.service.d

在该目录下创建文件 http-proxy.conf

[root@localhost ~]# cd /etc/systemd/system/docker.service.d
[root@localhost docker.service.d]# vi http-proxy.conf

文件内容如下:

[Service]
Environment="HTTP_PROXY=192.168.1.36:10809"
Environment="HTTPS_PROXY=192.168.1.36:10809"

重启 Docker 服务:

[root@localhost ~]# systemctl daemon-reload
[root@localhost ~]# systemctl restart docker

验证代理设置是否生效:

[root@localhost ~]# systemctl show --property=Environment docker
Environment=HTTP_PROXY=192.168.1.36:10809 HTTPS_PROXY=192.168.1.36:10809

第二种情况是 为 Docker 容器设置代理,影响容器内访问外部网络。这个配置比较简单,只需要在用户目录下创建一个 ~/.docker/config.json 文件:

[root@localhost ~]# mkdir -p ~/.docker
[root@localhost ~]# vi ~/.docker/config.json

文件内容如下:

{
  "proxies":
  {
    "default":
    {
      "httpProxy": "192.168.1.36:10809",
      "httpsProxy": "192.168.1.36:10809"
    }
  }
}

使用 alpine/curl 镜像启动一个容器,验证配置是否生效:

[root@localhost ~]# docker run --rm alpine/curl -fsSL ifconfig.me
103.168.154.81

2. 其他部署或管理 Kubernetes 的工具

部署工具

管理平台

扫描二维码,在手机上阅读!

Envoy 学习笔记

Envoy 是一款专为大型的 SOA 架构(面向服务架构,service oriented architectures)设计的 L7 代理和通信总线,它的诞生源于以下理念:

对应用程序而言,网络应该是透明的。当网络和应用程序出现故障时,应该能够很容易确定问题的根源。

要实现上面的目标是非常困难的,为了做到这一点,Envoy 提供了以下特性:

  • 进程外架构

Envoy 是一个独立的进程,伴随着每个应用程序运行。所有的 Envoy 形成一个透明的通信网络,每个应用程序发送消息到本地主机或从本地主机接收消息,不需要知道网络拓扑。进程外架构的好处是与应用程序的语言无关,Envoy 可以和任意语言的应用程序一起工作,另外,Envoy 的部署和升级也非常方便。

这种模式也被称为 边车模式(Sidecar)

  • L3/L4 过滤器架构

Envoy 是一个 L3/L4 网络代理,通过插件化的 过滤器链(filter chain) 机制处理各种 TCP/UDP 代理任务,支持 TCP 代理,UDP 代理,TLS 证书认证,Redis 协议,MongoDB 协议,Postgres 协议等。

  • HTTP L7 过滤器架构

Envoy 不仅支持 L3/L4 代理,也支持 HTTP L7 代理,通过 HTTP 连接管理子系统(HTTP connection management subsystem) 可以实现诸如缓存、限流、路由等代理任务。

  • 支持 HTTP/2

在 HTTP 模式下,Envoy 同时支持 HTTP/1.1 和 HTTP/2。在 service to service 配置中,官方也推荐使用 HTTP/2 协议。

  • 支持 HTTP/3(alpha)

从 1.19.0 版本开始,Envoy 支持 HTTP/3。

  • HTTP L7 路由

Envoy 可以根据请求的路径(path)、认证信息(authority)、Content Type、运行时参数等来配置路由和重定向。这在 Envoy 作为前端代理或边缘代理时非常有用。

  • 支持 gRPC

gRPC 是 Google 基于 HTTP/2 开发的一个 RPC 框架。Envoy 完美的支持 HTTP/2,也可以很方便的支持 gRPC。

  • 服务发现和动态配置

Envoy 可以通过一套动态配置 API 来进行中心化管理,这套 API 被称为 xDS:EDS(Endpoint Discovery Service)、CDS(Cluster Discovery Service)、RDS(Route Discovery Service)、VHDS(Virtual Host Discovery Service)、LDS(Listener Discovery Service)、SDS(Secret Discovery Service)等等。

  • 健康状态检查

Envoy 通过对上游服务集群进行健康状态检查,并根据服务发现和健康检查的结果来决定负载均衡的目标。

  • 高级负载均衡

Envoy 支持很多高级负载均衡功能,比如:自动重试、熔断、全局限流、流量跟踪(request shadowing)、异常检测(outlier detection)等。

  • 支持前端代理和边缘代理

Envoy 一般有三种部署方式:

  1. Front Proxy:前端代理,也叫边缘代理,通常是部署在整个服务网格的边缘,用于接收来自于服务网格外的请求;
  2. Ingress Listener:服务代理,通常部署在服务网格内服务的前面,用于接收发给该服务的请求,并转发给该服务;
  3. Egress Listener:与 Ingress Listener 相反,用于代理服务发出的所有请求,并将请求转发给其他服务(可能是网格内服务,也可能是网格外服务)。

envoy-deployment.png

  • 可观测性

Envoy 的主要目标是使网络透明,可以生成许多流量方面的统计数据,这是其它代理软件很难取代的地方,内置 stats 模块,可以集成诸如 prometheus/statsd 等监控方案。还可以集成分布式追踪系统,对请求进行追踪。

Envoy 整体架构与基本概念

下图是 Envoy 代理的整体架构图:图片来源

envoy-architecture.png

Envoy 接收到请求后,会经过过滤器链(filter chain),通过 L3/L4 或 L7 的过滤器对请求进行微处理,然后路由到指定集群,并通过负载均衡获取一个目标地址,最后再转发出去。这个过程中的每一个环节,可以静态配置,也可以通过 xDS 动态配置。

  • Downstream:即客户端(Client),向 Envoy 发起请求的终端。
  • Upstream:后端服务器,处理客户端请求的服务。
  • Listener:监听器,它的作用就是打开一个监听端口,用于接收来自 Downstream 的请求。
  • Cluster:一组逻辑上相似的上游主机组成一个集群。
  • Route:用于将请求路由到不同的集群。
  • xDS:各种服务发现 API 的统称,如:CDS、EDS、LDS、RDS 和 SDS 等。

安装和运行 Envoy

安装 Envoy 最简单的方式是使用官方的 Docker 镜像,首先获取镜像:

[root@localhost ~]# docker pull envoyproxy/envoy:v1.22-latest

使用 docker run 运行:

[root@localhost ~]# docker run -d -p 10000:10000 -p 9901:9901 envoyproxy/envoy:v1.22-latest

此时使用的是 Envoy 的默认配置文件,默认会监听两个端口,9901 为 Envoy 的管理端口,10000 为 Envoy 监听的代理端口,后端地址为 Envoy 官网:www.envoyproxy.io

我们进入容器,查看 Envoy 配置文件如下:

root@localhost:/# cat /etc/envoy/envoy.yaml 
admin:
  address:
    socket_address:
      protocol: TCP
      address: 0.0.0.0
      port_value: 9901
static_resources:
  listeners:
  - name: listener_0
    address:
      socket_address:
        protocol: TCP
        address: 0.0.0.0
        port_value: 10000
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          scheme_header_transformation:
            scheme_to_overwrite: https
          stat_prefix: ingress_http
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["*"]
              routes:
              - match:
                  prefix: "/"
                route:
                  host_rewrite_literal: www.envoyproxy.io
                  cluster: service_envoyproxy_io
          http_filters:
          - name: envoy.filters.http.router
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
  clusters:
  - name: service_envoyproxy_io
    connect_timeout: 30s
    type: LOGICAL_DNS
    # Comment out the following line to test on v6 networks
    dns_lookup_family: V4_ONLY
    lb_policy: ROUND_ROBIN
    load_assignment:
      cluster_name: service_envoyproxy_io
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: www.envoyproxy.io
                port_value: 443
    transport_socket:
      name: envoy.transport_sockets.tls
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
        sni: www.envoyproxy.io

我们打开浏览器,访问 http://127.0.0.1:10000 就可以看到 Envoy 的首页了。

如果要使用自己的配置文件,可以写一个 yaml 文件,并挂载到容器中覆盖 /etc/envoy/envoy.yaml 文件,或者通过 -c 参数指定配置文件:

[root@localhost ~]# docker run -d \
    -v $(pwd)/envoy-custom.yaml:/envoy-custom.yaml \
    -p 10000:10000 \
    -p 9901:9901 \
    envoyproxy/envoy:v1.22-latest -c /envoy-custom.yaml

Envoy 静态配置

Envoy 的配置遵循 xDS API v3 规范,Evnoy 的配置文件也被称为 Bootstrap 配置,使用的接口为 config.bootstrap.v3.Bootstrap,它的整体结构如下:

{
  "node": "{...}",
  "static_resources": "{...}",
  "dynamic_resources": "{...}",
  "cluster_manager": "{...}",
  "hds_config": "{...}",
  "flags_path": "...",
  "stats_sinks": [],
  "stats_config": "{...}",
  "stats_flush_interval": "{...}",
  "stats_flush_on_admin": "...",
  "watchdog": "{...}",
  "watchdogs": "{...}",
  "tracing": "{...}",
  "layered_runtime": "{...}",
  "admin": "{...}",
  "overload_manager": "{...}",
  "enable_dispatcher_stats": "...",
  "header_prefix": "...",
  "stats_server_version_override": "{...}",
  "use_tcp_for_dns_lookups": "...",
  "dns_resolution_config": "{...}",
  "typed_dns_resolver_config": "{...}",
  "bootstrap_extensions": [],
  "fatal_actions": [],
  "default_socket_interface": "...",
  "inline_headers": [],
  "perf_tracing_file_path": "..."
}

Envoy 自带的默认配置文件中包含了两个部分:adminstatic_resources

admin 部分是 Envoy 管理接口的配置,接口定义为 config.bootstrap.v3.Admin

admin:
  address:
    socket_address:
      protocol: TCP
      address: 0.0.0.0
      port_value: 9901

该配置让 Envoy 暴露出 9901 的管理端口,我们可以通过 http://127.0.0.1:9901 访问 Envoy 的管理页面。

static_resources 部分就是 Envoy 的静态配置,接口定义为 config.bootstrap.v3.Bootstrap.StaticResources,结构如下:

{
  "listeners": [],
  "clusters": [],
  "secrets": []
}

其中,listeners 用于配置 Envoy 的监听地址,Envoy 会暴露一个或多个 Listener 来监听客户端的请求。而 clusters 用于配置服务集群,Envoy 通过服务发现定位集群成员并获取服务,具体路由到哪个集群成员由负载均衡策略决定。

listeners 的接口定义为 config.listener.v3.Listener,其中最重要的几项有:nameaddressfilter_chain

listeners:
- name: listener_0
  address:
    socket_address:
      protocol: TCP
      address: 0.0.0.0
      port_value: 10000
  filter_chains:
  - filters:
    - name: envoy.filters.network.http_connection_manager
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
        scheme_header_transformation:
          scheme_to_overwrite: https
        stat_prefix: ingress_http
        route_config:
          name: local_route
          virtual_hosts:
          - name: local_service
            domains: ["*"]
            routes:
            - match:
                prefix: "/"
              route:
                host_rewrite_literal: www.envoyproxy.io
                cluster: service_envoyproxy_io
        http_filters:
        - name: envoy.filters.http.router
          typed_config:
            "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router

其中 address 表示 Envoy 监听的地址,filter_chain 为过滤器链,Envoy 通过一系列的过滤器对请求进行处理,下面是 Envoy 包含的过滤器链示意图:

envoy-filters.png

这里使用的是 http_connection_manager 来代理 HTTP 请求,route_config 为路由配置,当请求路径以 / 开头时(match prefix "/"),路由到 service_envoyproxy_io 集群。集群使用 clusters 来配置,它的接口定义为 config.cluster.v3.Cluster,配置的内容如下:

clusters:
- name: service_envoyproxy_io
  connect_timeout: 30s
  type: LOGICAL_DNS
  # Comment out the following line to test on v6 networks
  dns_lookup_family: V4_ONLY
  lb_policy: ROUND_ROBIN
  load_assignment:
    cluster_name: service_envoyproxy_io
    endpoints:
    - lb_endpoints:
      - endpoint:
          address:
            socket_address:
              address: www.envoyproxy.io
              port_value: 443
  transport_socket:
    name: envoy.transport_sockets.tls
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
      sni: www.envoyproxy.io

Envoy 通过服务发现来定位集群成员,服务发现的方式 有以下几种:

然后 Envoy 使用某种负载均衡策略从集群中找出一个服务来调用,Envoy 支持的 负载均衡策略有

而在我们的例子中,集群的服务发现方式为 LOGICAL_DNS,并使用 ROUND_ROBIN 方式来负载均衡。

Envoy 动态配置

在上面的例子中,我们配置的地址都是固定的,但在实际应用中,我们更希望以一种动态的方式来配置,比如 K8S 环境下服务的地址随 Pod 地址变化而变化,我们不可能每次都去手工修改 Envoy 的配置文件。Envoy 使用了一套被称为 xDS 的 API 来动态发现资源。xDS 包括 LDS(Listener Discovery Service)、CDS(Cluster Discovery Service)、RDS(Route Discovery Service)、EDS(Endpoint Discovery Service),以及 ADS(Aggregated Discovery Service),每个 xDS 都对应着配置文件中的一小块内容,如下所示(图片来源):

xds.png

Envoy 通过订阅的方式来获取资源,如监控指定路径下的文件、启动 gRPC 流或轮询 REST-JSON URL。后两种方式会发送 DiscoveryRequest 请求消息,发现的对应资源则包含在响应消息 DiscoveryResponse 中。

基于文件的动态配置

基于文件的动态配置比较简单,Envoy 通过监听文件的变动来动态更新配置,我们创建一个文件 envoy.yaml,内容如下:

node:
  id: id_1
  cluster: test

admin:
  address:
    socket_address:
      protocol: TCP
      address: 0.0.0.0
      port_value: 9901

dynamic_resources:
  cds_config:
    path_config_source:
      path: /var/lib/envoy/cds.yaml
  lds_config:
    path_config_source:
      path: /var/lib/envoy/lds.yaml

这里我们可以看出,和静态配置的 static_resources 不一样,没有 clusterslisteners 的配置了,而是在 dynamic_resources 中定义了 cds_configlds_config,并指定了 clusterslisteners 的配置文件的路径。

注意:在动态配置中,node 参数是必须的,用于区分 Envoy 是属于哪个集群。

我们再分别创建 cds.yaml 文件和 lds.yaml 文件。cds.yaml 的内容如下:

resources:
- "@type": type.googleapis.com/envoy.config.cluster.v3.Cluster
  name: service_envoyproxy_io
  connect_timeout: 30s
  type: LOGICAL_DNS
  # Comment out the following line to test on v6 networks
  dns_lookup_family: V4_ONLY
  lb_policy: ROUND_ROBIN
  load_assignment:
    cluster_name: service_envoyproxy_io
    endpoints:
    - lb_endpoints:
      - endpoint:
          address:
            socket_address:
              address: www.envoyproxy.io
              port_value: 443
  transport_socket:
    name: envoy.transport_sockets.tls
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
      sni: www.envoyproxy.io

和静态配置的 clusters 是一样的,只不过换成了 resources 配置,并指定了 typeenvoy.config.cluster.v3.Clusterlds.yaml 的内容如下:

resources:
- "@type": type.googleapis.com/envoy.config.listener.v3.Listener
  name: listener_0
  address:
    socket_address:
      protocol: TCP
      address: 0.0.0.0
      port_value: 10000
  filter_chains:
  - filters:
    - name: envoy.filters.network.http_connection_manager
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
        scheme_header_transformation:
          scheme_to_overwrite: https
        stat_prefix: ingress_http
        route_config:
          name: local_route
          virtual_hosts:
          - name: local_service
            domains: ["*"]
            routes:
            - match:
                prefix: "/"
              route:
                host_rewrite_literal: www.envoyproxy.io
                cluster: service_envoyproxy_io
        http_filters:
        - name: envoy.filters.http.router
          typed_config:
            "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router

这个配置和静态配置的 listeners 是一样的,只不过换成了 resources 配置,并指定了 typeenvoy.config.listener.v3.Listener

然后,运行下面的命令:

[root@localhost ~]# docker run -d \
    -v $(pwd):/var/lib/envoy \
    -p 10000:10000 \
    -p 9901:9901 \
    envoyproxy/envoy:v1.22-latest -c /var/lib/envoy/envoy.yaml

我们打开浏览器,访问 http://127.0.0.1:10000 就可以看到 Envoy 的首页了。

然后我们使用 sed 将文件中的 www.envoy.io 替换为 www.baidu.com

[root@localhost ~]# sed -i s/www.envoyproxy.io/www.baidu.com/ lds.yaml cds.yaml

刷新浏览器,可以看到页面变成了 Baidu 的首页了,我们没有重启 Envoy,就实现了配置的动态更新。

这里有一点需要特别注意,如果我们直接使用 vi 去编辑 lds.yamlcds.yaml 文件,有时候可能不会生效,这时我们可以将文件复制一份出来,编辑,然后再替换原文件,才可以让配置生效。而 sed -i 命令的 inplace edit 功能就是这样实现的。为什么需要这样做呢?因为 Docker 在有些环境下对 inotify 的支持不是很好,特别是 VirtualBox 环境。

基于控制平面(Control Plane)的动态配置

网络层一般被分为 数据平面(Data Plane)控制平面(Control Plane)。控制平面主要为数据包的快速转发准备必要信息;而数据平面则主要负责高速地处理和转发数据包。这样划分的目的是把不同类型的工作分离开,避免不同类型的处理相互干扰。数据平面的转发工作无疑是网络层的重要工作,需要最高的优先级;而控制平面的路由协议等不需要在短时间内处理大量的包,可以将其放到次一级的优先级中。数据平面可以专注使用定制序列化等各种技术来提高传输速率,而控制平面则可以借助于通用库来达到更好的控制与保护效果。

得益于 Envoy 的高性能易扩展等特性,Envoy 可以说已经是云原生时代数据平面的事实标准。新兴微服务网关如 GlooAmbassador 都基于 Envoy 进行扩展开发;而在服务网格中,Istio、Kong 社区的 Kuma、亚马逊的 AWS App Mesh 都使用 Envoy 作为默认的数据平面。那么作为数据平面的 Envoy 要怎么通过控制平面来动态配置呢?答案就是:xDS 协议

有很多已经实现的控制平面可以直接使用:

control-plane.png

也可以使用 Envoy 的 xDS API 开发自己的控制平面,官方提供了 Go 和 Java 的示例可供参考:

在官网的 Dynamic configuration (control plane) 例子中,使用 Go 实现了一个控制平面。

在控制平面服务启动之后,Envoy 的配置文件类似下面这样:

node:
  cluster: test-cluster
  id: test-id

dynamic_resources:
  ads_config:
    api_type: GRPC
    transport_api_version: V3
    grpc_services:
    - envoy_grpc:
        cluster_name: xds_cluster
  cds_config:
    resource_api_version: V3
    ads: {}
  lds_config:
    resource_api_version: V3
    ads: {}

static_resources:
  clusters:
  - type: STRICT_DNS
    typed_extension_protocol_options:
      envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
        "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
        explicit_http_config:
          http2_protocol_options: {}
    name: xds_cluster
    load_assignment:
      cluster_name: xds_cluster
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: go-control-plane
                port_value: 18000

其中,static_resources 中定义了控制平面的地址,而 dynamic_resources 中的 ads_config 则指定使用控制平面来动态获取配置。

参考

  1. Envoy 官方文档
  2. Envoy 官方文档中文版(ServiceMesher)
  3. Istio 服务网格进阶实战(ServiceMesher)
  4. Envoy 官方文档中文版(CloudNative)
  5. Envoy 基础教程(Jimmy Song)
  6. Kubernetes 中文指南(Jimmy Song)
  7. Envoy Handbook(米开朗基杨)
  8. What is Envoy
  9. Envoy基础介绍
  10. 史上最全的高性能代理服务器 Envoy 中文实战教程 !
  11. Envoy 中的 xDS REST 和 gRPC 协议详解
  12. Dynamic configuration (filesystem)
  13. Dynamic configuration (control plane)
  14. 通过 xDS 实现 Envoy 动态配置
  15. 如何为 Envoy 构建一个控制面来管理集群网络流量

更多

1. Envoy 的管理页面

默认情况下,Envoy 会暴露出 9901 的管理端口,我们访问 http://127.0.0.1:9901 可以看到 Envoy 的管理页面:

envoy-admin.png

这里可以看到有很多很有用的功能,比如:查看 Envoy 统计信息,查看 Prometheus 监控指标,开启或关闭 CPU Profiler,开启或关闭 Heap Profiler 等等。

关于管理接口可以参考官方文档 Administration interface

2. 体验 Sandboxes

Envoy 通过 Docker Compose 创建了很多沙盒环境用于测试 Envoy 的特性,感兴趣的同学可以挨个体验一下:

3. 如何从 Nginx 迁移到 Envoy?

https://github.com/yangchuansheng/envoy-handbook/blob/master/content/zh/docs/practice/migrating-from-nginx-to-envoy.md

扫描二维码,在手机上阅读!

Dapr 学习笔记

Dapr 的全称为 Distributed Application Runtime(分布式应用运行时),顾名思义,它的目的就是为分布式应用提供运行所依赖的的执行环境。Dapr 为开发者提供了服务间调用(service to service invocation)、消息队列(message queue)和事件驱动(event driven)等服务模型,它可以让开发人员更聚焦业务代码,而不用去关心分布式系统所带来的其他复杂挑战,比如:服务发现(service discovery)、状态存储(state management)、加密数据存储(secret management)、可观察性(observability)等。

下面这张图说明了 Dapr 在分布式系统中所承担的作用:

service-invocation.png

这是分布式系统中最常用的一种场景,你的应用需要去调用系统中的另一个应用提供的服务。在引入 Dapr 之后,Dapr 通过边车模式运行在你的应用之上,Dapr 会通过服务发现机制为你去调用另一个应用的服务,并使用 mTLS 提供了服务间的安全访问,而且每个 Dapr 会集成 OpenTelemetry 自动为你提供服务之间的链路追踪、日志和指标等可观察性功能。

下图是基于事件驱动模型的另一种调用场景:

pubsub.png

安装 Dapr

1. 安装 Dapr CLI

首先使用下面的一键安装脚本安装 Dapr CLI

[root@localhost ~]# wget -q https://raw.githubusercontent.com/dapr/cli/master/install/install.sh -O - | /bin/bash
Getting the latest Dapr CLI...
Your system is linux_amd64
Installing Dapr CLI...

Installing v1.6.0 Dapr CLI...
Downloading https://github.com/dapr/cli/releases/download/v1.6.0/dapr_linux_amd64.tar.gz ...
dapr installed into /usr/local/bin successfully.
CLI version: 1.6.0 
Runtime version: n/a

To get started with Dapr, please visit https://docs.dapr.io/getting-started/

使用 dapr -v 校验是否安装成功:

[root@localhost ~]# dapr -v
CLI version: 1.6.0 
Runtime version: n/a

注意,这里我们可以看到 Runtime versionn/a,所以下一步我们还需要安装 Dapr runtime

2. 初始化 Dapr

虽然 Dapr 也可以在非 Docker 环境下运行,但是官方更推荐使用 Docker,首先确保机器上已经安装有 Docker 环境,然后执行下面的 dapr init 命令:

[root@localhost ~]# dapr init
> Making the jump to hyperspace...
>> Installing runtime version 1.6.1
Dapr runtime installed to /root/.dapr/bin, you may run the following to add it to your path if you want to run daprd directly:
    export PATH=$PATH:/root/.dapr/bin
> Downloading binaries and setting up components...
> Downloaded binaries and completed components set up.
>> daprd binary has been installed to /root/.dapr/bin.
>> dapr_placement container is running.
>> dapr_redis container is running.
>> dapr_zipkin container is running.
>> Use `docker ps` to check running containers.
> Success! Dapr is up and running. To get started, go here: https://aka.ms/dapr-getting-started

从运行结果可以看到,Dapr 的初始化过程包含以下几个部分:

  • 安装 Dapr 运行时(daprd),安装位置为 /root/.dapr/bin,同时会创建一个 components 目录用于默认组件的定义
  • 运行 dapr_placement 容器,dapr placement 服务 用于实现本地 actor 支持
  • 运行 dapr_redis 容器,用于本地状态存储(local state store)和消息代理(message broker
  • 运行 dapr_zipkin 容器,用于实现服务的可观察性(observability

初始化完成后,再次使用 dapr -v 校验是否安装成功:

[root@localhost ~]# dapr -v
CLI version: 1.6.0 
Runtime version: 1.6.1

并使用 docker ps 查看容器运行状态:

[root@localhost ~]# docker ps
CONTAINER ID   IMAGE               COMMAND                  CREATED             STATUS                       PORTS                                                 NAMES
63dd751ec5eb   daprio/dapr:1.6.1   "./placement"            About an hour ago   Up About an hour             0.0.0.0:50005->50005/tcp, :::50005->50005/tcp         dapr_placement
a8d3a7c93e12   redis               "docker-entrypoint.s…"   About an hour ago   Up About an hour             0.0.0.0:6379->6379/tcp, :::6379->6379/tcp             dapr_redis
52586882abea   openzipkin/zipkin   "start-zipkin"           About an hour ago   Up About an hour (healthy)   9410/tcp, 0.0.0.0:9411->9411/tcp, :::9411->9411/tcp   dapr_zipkin

使用 Dapr API

dapr run 是最常用的 Dapr 命令之一, 这个命令用于启动一个应用,同时启动一个 Dapr 边车进程,你也可以不指定应用,只启动 Dapr 边车:

[root@localhost ~]# dapr run --app-id myapp --dapr-http-port 3500
WARNING: no application command found.
ℹ️  Starting Dapr with id myapp. HTTP Port: 3500. gRPC Port: 39736
ℹ️  Checking if Dapr sidecar is listening on HTTP port 3500
INFO[0000] starting Dapr Runtime -- version 1.6.1 -- commit 2fa6bd832d34f5a78c5e336190207d46b761093a  app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] log level set to: info                        app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] metrics server started on :46773/             app_id=myapp instance=localhost.localdomain scope=dapr.metrics type=log ver=1.6.1
INFO[0000] standalone mode configured                    app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] app id: myapp                                 app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] mTLS is disabled. Skipping certificate request and tls validation  app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] local service entry announced: myapp -> 10.0.2.8:45527  app_id=myapp instance=localhost.localdomain scope=dapr.contrib type=log ver=1.6.1
INFO[0000] Initialized name resolution to mdns           app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] loading components                            app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] component loaded. name: pubsub, type: pubsub.redis/v1  app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] waiting for all outstanding components to be processed  app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] detected actor state store: statestore        app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] component loaded. name: statestore, type: state.redis/v1  app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] all outstanding components processed          app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] enabled gRPC tracing middleware               app_id=myapp instance=localhost.localdomain scope=dapr.runtime.grpc.api type=log ver=1.6.1
INFO[0000] enabled gRPC metrics middleware               app_id=myapp instance=localhost.localdomain scope=dapr.runtime.grpc.api type=log ver=1.6.1
INFO[0000] API gRPC server is running on port 39736      app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] enabled metrics http middleware               app_id=myapp instance=localhost.localdomain scope=dapr.runtime.http type=log ver=1.6.1
INFO[0000] enabled tracing http middleware               app_id=myapp instance=localhost.localdomain scope=dapr.runtime.http type=log ver=1.6.1
INFO[0000] http server is running on port 3500           app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] The request body size parameter is: 4         app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] enabled gRPC tracing middleware               app_id=myapp instance=localhost.localdomain scope=dapr.runtime.grpc.internal type=log ver=1.6.1
INFO[0000] enabled gRPC metrics middleware               app_id=myapp instance=localhost.localdomain scope=dapr.runtime.grpc.internal type=log ver=1.6.1
INFO[0000] internal gRPC server is running on port 45527  app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
WARN[0000] app channel is not initialized. did you make sure to configure an app-port?  app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] actor runtime started. actor idle timeout: 1h0m0s. actor scan interval: 30s  app_id=myapp instance=localhost.localdomain scope=dapr.runtime.actor type=log ver=1.6.1
WARN[0000] app channel not initialized, make sure -app-port is specified if pubsub subscription is required  app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
WARN[0000] failed to read from bindings: app channel not initialized   app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] dapr initialized. Status: Running. Init Elapsed 267.56237799999997ms  app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
ℹ️  Checking if Dapr sidecar is listening on GRPC port 39736
ℹ️  Dapr sidecar is up and running.
✅  You're up and running! Dapr logs will appear here.

INFO[0002] placement tables updated, version: 0          app_id=myapp instance=localhost.localdomain scope=dapr.runtime.actor.internal.placement type=log ver=1.6.1

上面的命令通过参数 --app-id myapp 启动了一个名为 myapp 的空白应用,并让 Dapr 监听 3500 HTTP 端口(--dapr-http-port 3500)。由于没有指定组件目录,Dapr 会使用 dapr init 时创建的默认组件定义,可以在 /root/.dapr/components 目录查看:

[root@localhost ~]# ls /root/.dapr/components
pubsub.yaml  statestore.yaml

这个目录默认有两个组件:pubsub 和 statestore。我们查看组件的定义,可以发现两个组件都是基于 Redis 实现的。

pubsub 定义:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: pubsub
spec:
  type: pubsub.redis
  version: v1
  metadata:
  - name: redisHost
    value: localhost:6379
  - name: redisPassword
    value: ""

statestore 定义:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: statestore
spec:
  type: state.redis
  version: v1
  metadata:
  - name: redisHost
    value: localhost:6379
  - name: redisPassword
    value: ""
  - name: actorStateStore
    value: "true"

statestore 组件可以为分布式应用提供状态管理功能,你可以使用 Dapr 的状态管理 API 来对状态进行保存、读取和查询等。通过配置,Dapr 支持 不同类型的存储后端,如:Redis、MySQL、MongoDB、Zookeeper 等,如下图所示:

state-management-overview.png

下面我们来体验下 Dapr 的状态管理 API。

使用 POST 请求向 statestore 中保存一个 keynamevalueBruce Wayne 的键值对(注意这里的 statestore 必须和组件定义中的 name 一致):

[root@localhost ~]# curl -X POST -H "Content-Type: application/json" -d '[{ "key": "name", "value": "Bruce Wayne"}]' http://localhost:3500/v1.0/state/statestore

使用 GET 请求查询 statestorekeyname 的值:

[root@localhost ~]# curl http://localhost:3500/v1.0/state/statestore/name
"Bruce Wayne"

为了进一步了解状态信息是如何保存在 Redis 中的,可以使用下面的命令进到容器里查看:

[root@localhost ~]# docker exec -it dapr_redis redis-cli
127.0.0.1:6379> keys *
1) "myapp||name"
127.0.0.1:6379> hgetall myapp||name
1) "data"
2) "\"Bruce Wayne\""
3) "version"
4) "1"
127.0.0.1:6379> exit

组件和构建块

Dapr 有两个基本概念我们需要了解:组件(component)构建块(building block)。每个组件代表一个原子功能,有一个接口定义,并且可以有不同的实现,比如 statestore 组件,可以使用 Redis 实现,也可以使用 MySQL 实现。而构建块是基于一个或多个组件实现的一套用于在分布式系统中使用的 API 接口(HTTP 或 gRPC),比如上面的例子中我们使用的接口 /v1.0/state/** 就是 一个 State management 构建块,它是由 statestore 组件组成的,而这个 statestore 组件是由 Redis 实现的。下面是构建块和组件的示例图:

concepts-building-blocks.png

Dapr 提供了下面这些组件:

可以在 这里 查看 Dapr 支持的组件接口定义和实现。

Dapr 提供了下面这些构建块:

下面我们来体验下最常用的两个构建块:Service-to-service invocationPublish and subscribe

服务调用(Service-to-service Invocation)

使用 Dapr 的服务调用构建块,可以让你的应用可靠并安全地与其他应用进行通信。

service-invocation-overview.png

Dapr 采用了边车架构,每个应用都有一个 Dapr 作为反向代理,在调用其他应用时,实际上是调用本应用的 Dapr 代理,并由 Dapr 来调用其他应用的 Dapr 代理,最后请求到你要调用的应用。这样做的好处是由 Dapr 来管理你的所有请求,实现了下面这些特性:

  • 命名空间隔离(Namespace scoping)
  • 服务调用安全(Service-to-service security)
  • 访问控制(Access control)
  • 重试(Retries)
  • 插件化的服务发现机制(Pluggable service discovery)
  • mDNS 负载均衡(Round robin load balancing with mDNS)
  • 链路跟踪和监控指标(Tracing and metrics with observability)
  • 服务调用 API(Service invocation API)
  • gRPC 代理(gRPC proxying)

关于服务调用构建块,官方提供了多种语言的示例,可以下载下面的源码后,在 service_invocation 目录下找到你需要的语言:

[root@localhost ~]# git clone https://github.com/dapr/quickstarts.git

这里我们使用 Java 的示例来体验一下 Dapr 的服务调用,首先使用 mvn clean package 分别构建 checkoutorder-processor 两个项目,得到 CheckoutService-0.0.1-SNAPSHOT.jarOrderProcessingService-0.0.1-SNAPSHOT.jar 两个文件。

我们先使用 dapr run 启动 order-processor 服务:

[root@localhost ~]# dapr run --app-id order-processor \
  --app-port 6001 \
  --app-protocol http \
  --dapr-http-port 3501 \
  -- java -jar target/OrderProcessingService-0.0.1-SNAPSHOT.jar

这时我们同时启动了两个进程,Dapr 服务监听在 3501 端口,order-processor 服务监听在 6001 端口。order-processor 服务是一个非常简单的 Spring Boot Web 项目,暴露了一个 /orders API 模拟订单处理流程,我们可以使用 curl 直接访问它的接口:

[root@localhost ~]# curl -H Content-Type:application/json \
  -X POST \
  --data '{"orderId":"123"}' \
  http://127.0.0.1:6001/orders

当然这里我们更希望通过 Dapr 来调用它的接口:

[root@localhost ~]# curl -H Content-Type:application/json \
  -H dapr-app-id:order-processor \
  -X POST \
  --data '{"orderId":"123"}' \
  http://127.0.0.1:3501/orders

这里我们请求的是 Dapr 代理的端口,接口地址仍然是 /orders,特别注意的是,我们在请求里加了一个 dapr-app-id 头部,它的值就是我们要调用的应用 ID order-processor。但是这种做法官方是不推荐的,官方推荐每个应用都调用自己的 Dapr 代理,而不要去调别人的 Dapr 代理。

于是我们使用 dapr run 启动 checkout 服务:

[root@localhost ~]# dapr run --app-id checkout \
  --app-protocol http \
  --dapr-http-port 3500 \
  -- java -jar target/CheckoutService-0.0.1-SNAPSHOT.jar

这个命令同样会启动两个进程,checkout 服务是一个简单的命令行程序,它的 Dapr 代理服务监听在 3500 端口,可以看到想让 checkout 调用 order-processor,只需要调用它自己的 Dapr 代理即可,并在请求里加了一个 dapr-app-id 头部:

private static final String DAPR_HTTP_PORT = System.getenv().getOrDefault("DAPR_HTTP_PORT", "3500");

public static void main(String[] args) throws InterruptedException, IOException {
  String dapr_url = "http://localhost:"+DAPR_HTTP_PORT+"/orders";
  for (int i=1; i<=10; i++) {
    int orderId = i;
    JSONObject obj = new JSONObject();
    obj.put("orderId", orderId);

    HttpRequest request = HttpRequest.newBuilder()
        .POST(HttpRequest.BodyPublishers.ofString(obj.toString()))
        .uri(URI.create(dapr_url))
        .header("Content-Type", "application/json")
        .header("dapr-app-id", "order-processor")
        .build();

    HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
    System.out.println("Order passed: "+ orderId);
    TimeUnit.MILLISECONDS.sleep(1000);
  }
}

我们还可以使用同一个 app-id 启动多个实例(要注意端口号不要和之前的冲突):

[root@localhost ~]# dapr run --app-id order-processor \
  --app-port 6002 \
  --app-protocol http \
  --dapr-http-port 3502 \
  -- java -jar target/OrderProcessingService-0.0.1-SNAPSHOT.jar --server.port=6002

这样在请求 order-processor 服务时,Dapr 会为我们自动进行负载均衡。

发布订阅(Publish and Subscribe)

在分布式系统中,发布订阅是另一种常见的服务模型,应用之间通过消息或事件来进行通信,而不是直接调用,可以实现应用之间的解耦。

Dapr 的发布订阅构建块如下图所示:

pubsub-diagram.png

和上面一样,我们使用 Java 语言的示例来体验一下 Dapr 的发布订阅。使用 mvn clean package 分别构建 checkoutorder-processor 两个项目,得到 CheckoutService-0.0.1-SNAPSHOT.jarOrderProcessingService-0.0.1-SNAPSHOT.jar 两个文件。

使用 dapr run 启动 order-processor 服务:

[root@localhost ~]# dapr run --app-id order-processor \
  --app-port 6001 \
  --components-path ./components \
  -- java -jar target/OrderProcessingService-0.0.1-SNAPSHOT.jar

和上面的服务调用示例相比,少了 --app-protocol http--dapr-http-port 3501 两个参数,这是因为我们不再使用 HTTP 来进行服务间调用了,而是改成通过消息来进行通信。既然是消息通信,那么就少不了消息中间件,Dapr 支持常见的消息中间件来作为 Pub/sub brokers,如:Kafka、RabbitMQ、Redis 等。

我们在 components 目录下准备了一个 pubsub.yaml 文件,在这里对 Pub/sub brokers 组件进行配置:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: order_pub_sub
spec:
  type: pubsub.redis
  version: v1
  metadata:
  - name: redisHost
    value: localhost:6379
  - name: redisPassword
    value: ""

其中,metadata.name 定义了组件名称为 order_pub_sub,应用就是使用这个名称来和组件进行通信的;spec.type 定义了组件类型是 pubsub,并使用 redis 实现;spec.metadata 为组件的一些配置信息。

在运行 dapr run 时,通过 --components-path 参数来指定应用运行所需要的组件的定义,components 目录下可同时包含多个组件定义。

和之前的例子一样,order-processor 服务是一个非常简单的 Spring Boot Web 项目,它仍然是暴露 /orders 接口来模拟订单处理流程,我们可以使用 curl 直接访问它的接口:

[root@localhost ~]# curl -H Content-Type:application/json \
  -X POST \
  --data '{"data":{"orderId":"123"}}' \
  http://127.0.0.1:6001/orders

不过这里的数据结构发生了一点变化,对于消息通信的应用,Dapr 使用了一个统一的类来表示接受到的消息:

public class SubscriptionData<T> {
    private T data;
}

查看 order-processor 的日志,可以看到它成功接收到了订单:

== APP == 2022-04-03 16:26:27.265  INFO 16263 --- [nio-6001-exec-6] c.s.c.OrderProcessingServiceController   : Subscriber received: 123

既然 order-processor 也是使用 /orders 接口,那和服务调用的例子有啥区别呢?仔细翻看代码我们可以发现,order-processor 除了有一个 /orders 接口,还新增了一个 /dapr/subscribe 接口:

@GetMapping(path = "/dapr/subscribe", produces = MediaType.APPLICATION_JSON_VALUE)
public DaprSubscription[] getSubscription() {
    DaprSubscription daprSubscription = DaprSubscription.builder()
            .pubSubName("order_pub_sub")
            .topic("orders")
            .route("orders")
            .build();
    logger.info("Subscribed to Pubsubname {} and topic {}", "order_pub_sub", "orders");
    DaprSubscription[] arr = new DaprSubscription[]{daprSubscription};
    return arr;
}

这是一个简单的 GET 接口,直接返回下面这样的数据:

[
    {
        "pubSubName": "order_pub_sub",
        "topic": "orders",
        "route": "orders"
    }
]

这实际上是 Dapr 的一种接口规范,被称为 Programmatically subscriptions(编程式订阅)。Dapr 在启动时会调用这个接口,这样 Dapr 就知道你的应用需要订阅什么主题,接受到消息后通过什么接口来处理。

除了编程式订阅,Dapr 还支持一种被称为 Declarative subscriptions(声明式订阅)的方式,这种方式的好处是对代码没有侵入性,可以不改任何代码就能将一个已有应用改造成主题订阅的模式。我们将声明式订阅定义在 subscription.yaml 文件中:

apiVersion: dapr.io/v1alpha1
kind: Subscription
metadata:
  name: order-pub-sub
spec:
  topic: orders
  route: orders
  pubsubname: order_pub_sub
scopes:
- order-processor
- checkout

声明式订阅和编程式订阅的效果是一样的,其目的都是让 Dapr 订阅 某个 Pub/subpubsubname: order_pub_sub)的 某个主题topic: orders),并将接受到的消息路由到 某个接口route: orders)来处理。区别在于,声明式订阅方便一个主题注册多个应用,编程式订阅方便一个应用注册多个主题。

我们使用 dapr publish 发布一个消息:

[root@localhost ~]# dapr publish --publish-app-id order-processor \
  --pubsub order_pub_sub \
  --topic orders \
  --data '{"orderId": 100}'
✅  Event published successfully

查看 order-processor 的日志,可以看到它又一次成功接收到了订单。这一次我们不是直接调用 /orders 接口,而是通过 order-processor 的 Dapr 将消息发送到 Redis,同时 order-processor 的 Dapr 通过订阅接受到该消息,并转发给 /orders 接口。

实际上这里我偷懒了,发布和订阅用的都是 order-processor 的 Dapr,在真实场景下,发布者会使用发布者自己的 Dapr。我们启一个新的 Dapr 实例(注意使用和 order-processor 相同的组件):

[root@localhost ~]# dapr run --app-id checkout \
  --dapr-http-port 3601 \
  --components-path ./components/

然后通过 checkout 发布消息:

[root@localhost ~]# dapr publish --publish-app-id checkout \
  --pubsub order_pub_sub \
  --topic orders \
  --data '{"orderId": 100}'
✅  Event published successfully

到这里我们实现了两个 Dapr 之间的消息通信,一个 Dapr 发布消息,另一个 Dapr
订阅消息,并将接受到的消息转发给我们的应用接口。不过这里我们是使用 dapr publish 命令来发布消息的,在我们的应用代码中,当然不能使用这种方式,而应用使用发布订阅构建块提供的接口。

我们使用 dapr run 启动 checkout 服务:

[root@localhost ~]# dapr run --app-id checkout \
  --components-path ./components \
  -- java -jar target/CheckoutService-0.0.1-SNAPSHOT.jar

查看 checkout 和 order-processor 的日志,它们分别完成了订单信息的发布和接受处理。浏览代码可以发现,checkout 服务其实使用了发布订阅构建块提供的 /v1.0/publish 接口:

private static final String PUBSUB_NAME = "order_pub_sub";
private static final String TOPIC = "orders";
private static String DAPR_HOST = System.getenv().getOrDefault("DAPR_HOST", "http://localhost");
private static String DAPR_HTTP_PORT = System.getenv().getOrDefault("DAPR_HTTP_PORT", "3500");

public static void main(String[] args) throws InterruptedException, IOException {
    String uri = DAPR_HOST +":"+ DAPR_HTTP_PORT + "/v1.0/publish/"+PUBSUB_NAME+"/"+TOPIC;
    for (int i = 0; i <= 10; i++) {
        int orderId = i;
        JSONObject obj = new JSONObject();
        obj.put("orderId", orderId);

        // Publish an event/message using Dapr PubSub via HTTP Post
        HttpRequest request = HttpRequest.newBuilder()
                .POST(HttpRequest.BodyPublishers.ofString(obj.toString()))
                .uri(URI.create(uri))
                .header("Content-Type", "application/json")
                .build();

        HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
        logger.info("Published data: {}", orderId);
        TimeUnit.MILLISECONDS.sleep(3000);
    }
}

知道原理后,使用 curl 命令也可以模拟出完全相同的请求:

[root@localhost ~]# curl -H Content-Type:application/json \
  -X POST \
  --data '{"orderId": "100"}' \
  http://localhost:3601/v1.0/publish/order_pub_sub/orders

参考

  1. https://github.com/dapr/dapr
  2. Dapr 官方文档
  3. Dapr 知多少 | 分布式应用运行时

更多

1. Dapr Tutorials

https://docs.dapr.io/getting-started/tutorials/

2. Dapr 组件一览

  • State stores

    • Aerospike
    • Apache Cassandra
    • Couchbase
    • Hashicorp Consul
    • Hazelcast
    • Memcached
    • MongoDB
    • MySQL
    • PostgreSQL
    • Redis
    • RethinkDB
    • Zookeeper
    • AWS DynamoDB
    • GCP Firestore
    • Azure Blob Storage
    • Azure CosmosDB
    • Azure SQL Server
    • Azure Table Storage
    • OCI Object Storage
  • Name resolution

    • HashiCorp Consul
    • mDNS
    • Kubernetes
  • Pub/sub brokers

    • Apache Kafka
    • Hazelcast
    • MQTT
    • NATS Streaming
    • In Memory
    • JetStream
    • Pulsar
    • RabbitMQ
    • Redis Streams
    • AWS SNS/SQS
    • GCP Pub/Sub
    • Azure Event Hubs
    • Azure Service Bus
  • Bindings

    • Apple Push Notifications (APN)
    • Cron (Scheduler)
    • GraphQL
    • HTTP
    • InfluxDB
    • Kafka
    • Kubernetes Events
    • Local Storage
    • MQTT
    • MySQL
    • PostgreSql
    • Postmark
    • RabbitMQ
    • Redis
    • SMTP
    • Twilio
    • Twitter
    • SendGrid
    • Alibaba Cloud DingTalk
    • Alibaba Cloud OSS
    • Alibaba Cloud Tablestore
    • AWS DynamoDB
    • AWS S3
    • AWS SES
    • AWS SNS
    • AWS SQS
    • AWS Kinesis
    • GCP Cloud Pub/Sub
    • GCP Storage Bucket
    • Azure Blob Storage
    • Azure CosmosDB
    • Azure CosmosDBGremlinAPI
    • Azure Event Grid
    • Azure Event Hubs
    • Azure Service Bus Queues
    • Azure SignalR
    • Azure Storage Queues
    • Zeebe Command
    • Zeebe Job Worker
  • Secret stores

    • Local environment variables
    • Local file
    • HashiCorp Vault
    • Kubernetes secrets
    • AWS Secrets Manager
    • AWS SSM Parameter Store
    • GCP Secret Manager
    • Azure Key Vault
    • AlibabaCloud OOS Parameter Store
  • Configuration stores

    • Redis
  • Middleware

    • Rate limit
    • OAuth2
    • OAuth2 client credentials
    • Bearer
    • Open Policy Agent
    • Uppercase

3. 开发自己的组件

https://github.com/dapr/components-contrib/blob/master/docs/developing-component.md

扫描二维码,在手机上阅读!