乘风破浪:Kubernetes 笔记

乘风破浪:Kubernetes 笔记

在了解了 Kubernetes 为什么叫 K8s 之后,才明白 internationalization 为什么叫 i18n。

云原生技术原理 一文中我记录了一些 K8s 基础知识和操作,这篇笔记里就不多赘述了。文章中涉及的资源名称、镜像名称、镜像标签等均为虚构。

操作现有服务

扩缩容

1
2
k scale deploy/nginx --replicas=4 # scale up
k scale deploy/nginx --replicas=2 # scale down

滚动更新

1
k set image deploy/nginx nginx=docker.io/nginx:v2

滚动更新会重新创建 ReplicaSet 进而创建新的 Pod。关于滚动更新,有两个重要参数 maxSurgemaxUnavailable ,参见 [后文](# 滚动更新检查)。

版本回退

在创建 / 修改资源时,记得添加 --record,这样就可以留下 revision 记录并查询:

1
k rollout history deploy/nginx

随后就能回退到某个特定版本:

1
k rollout undo deploy/nginx --to-revision=1

如果不加参数,则回退到上一个版本。

基础概念

创建 Deployment 的过程

kubectl 这个 CLI 本质上就是一个 REST 客户端,主要任务是向 Kubernetes API Server 发送请求。

实际上,不少 CLI 都是这样的,例如 dockergcloud 等。

API Server 随后通知 Deployment Controller 创建 ReplicaSet,再通过 ReplicaSet 创建多个 Pod,这一点也可以从三种资源的命名方式中发现。

例如,如果 Deployment 叫做 nginx,那么 ReplicaSet 名称则形如 nginx-7848d4b86f,而 Pod 名称则形如 nginx-7848d4b86f-2ht2l

另一种验证这一点的方法是查看 describe 命令返回的 Controlled By 字段。

同一个 Pod 中的容器联系非常紧密,并且需要共享资源,比如网络和 volume。

Pod 创建后,Scheduler 将 Pod 的副本分配到不同的 Node 上运行。当我们用 kubectl 查询部署的服务信息时,也是通过请求 API Server 从 etcd 中读取服务的信息。

访问 Service 的方式

如果未指定 type,则 Service 只能从 Cluster 内部通过 ClusterIP 访问。访问 Service 的流量通过 iptables 规则轮询转发到 Pod。

除了 IP,也可以使用 Kubernetes 提供的 DNS 服务从 Cluster 内部访问 Service。例如,启动一个临时的 Pod:

1
k run busybox --rm -it --image=busybox /bin/sh

在容器内部运行 wget nginx-svc.default:8080,可以获取到网页内容;如果容器与 Service 同处于一个 Namespace(如 default),那么可以省略掉 .default 这一 Namespace 声明。

而如果要从 Cluster 外部访问 Service,就需要设置 typeNodePortLoadBalancer。前者通过 iptables 将端口映射到所在 Node 的端口上,后者则使用云服务商的 Load Balancer 对流量进行负载均衡。

DaemonSet

DaemonSet 部署的 Pod 在每个 Node 上最多只能运行一个副本,适合监控和日志收集等类似守护进程的服务。需要注意的是,DaemonSet 部署的 Pod 会绕过调度器并忽略节点的 Unschedulable 属性。

Job

Job 中配置的任务在容器中只会运行一次,完成之后容器就会停止。

常用资源 YAML

Deployment 和 Service

更常用也更方便的创建 / 修改服务的方法是使用 yaml 文件,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
apiVersion: apps/v1
kind: Deployment
metadata: # metadata for deployment
name: nginx # required metadata
spec: # specification for deployment
selector:
matchLabels:
app: nginx
replicas: 3
template: # pod template
metadata: # metadata for pod
labels:
app: nginx # at least one label required
spec: # specification for pod
containers:
- name: nginx
image: nginx
ports:
- containerPort: 80

然后运行 k apply -f nginx-deploy.yml 就可以创建 Deployment,或是修改现有的 Deployment。删除的话,把 apply 换成 delete 即可。

对于 Service 来说同理,可以编写 nginx-service.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
apiVersion: v1
kind: Service
metadata:
name: nginx-svc
spec:
type: NodePort # map to a port on the Node
selector:
app: nginx # select pods according to labels
ports:
- protocol: TCP
nodePort: 30000 # Node:30000
port: 8080 # ClusterIP:8080
targetPort: 80 # pod:80

可以发现,Service 通过 labels 来筛选 Pod。同样地,Pod 也可以通过 labels 来筛选 Node:

1
2
k label node node1 disktype=ssd
k get node --show-labels

随后在 nginx-deploy.yml 下的 .spec.template.spec 下添加:

1
2
nodeSelector:
diskType: ssd

就可以确保该 Deployment 的所有 Pod 都被分配到指定 Node 上。当然也可以删除 Node 上的 labels

1
k label node node1 disktype-

Job

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
apiVersion: batch/v1
kind: Job
metadata:
name: pi
spec:
completions: 4
parallelism: 2
template: # pod template
spec:
containers:
- name: pi
image: perl
command: ["perl", "-Mbignum=bpi", "-wle", "print bpi(2000)"]
restartPolicy: Never
backoffLimit: 4

Job 语法大同小异,parallelism 控制并行 Pod 数量,completions 控制任务总共需要完成多少次, restartPolicy 对 Job 而言只能是 NeverOnFailurebackoffLimit 限制了最大的重试次数。

restartPolicy 对 Deployment 而言还可以是 Always,此时即使容器进程返回了 0 也依然会重启容器。

如果任务运行失败,由于 restartPolicyNever,容器不会被重启,但会不断创建新的 Pod 重新运行;如果 restartPolicyOnFailure ,由于容器会被重启,因此不会创建新的 Pod。

要查看已完成 Job 的执行结果,假设 Pod 名称为 pi-trvkr,可以使用命令 k logs pi-trvkr

值得注意的是,和 Istio 一起使用时会受到 Sidecar 注入影响,Kubernetes 会认为 Job 没有执行完成,但实际上 Job 的执行并没有受到影响。为了解决这一问题,可以在 Pod 模版中关闭 Sidecar 注入,即在 .spec.template 下添加:

1
2
3
metadata:
annotations:
sidecar.istio.io/inject: 'false'

CronJob

定时任务实际上是在 Job 外面套了一层 spec,主要是为了增加 schedule 字段,其格式和 crontab 的格式相同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
apiVersion: batch/v1
kind: CronJob
metadata:
name: hello
spec:
schedule: "*/1 * * * *"
jobTemplate:
spec:
template: # pod template
spec:
containers:
- name: hello
image: busybox
imagePullPolicy: IfNotPresent
command:
- /bin/sh
- -c
- date; echo Hello from the Kubernetes cluster
restartPolicy: OnFailure

同理,CronJob 也存在和 Sidecar 冲突的问题,解决方法和上述一致,插入位置是 .spec.jobTemplate.spec.template 下。

从资源名称同样可以看出这里的层级关系:CronJob(hello)-> Job(hello-27231469)-> Pod(hello-27231469-bjbjw)。

Health Check

默认情况下,容器运行的进程返回非 0 时,Kubernetes 会认为出现了错误,此时 Health Check 不通过。然而出现错误时容器内进程未必会返回,因此我们可以自定义 Health Check 的规则。

例如,创建一个带 livenessProbe 的 Pod:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
apiVersion: v1
kind: Pod
metadata:
name: liveness
labels:
test: liveness
spec:
restartPolicy: OnFailure
containers:
- name: liveness
image: busybox
args:
- /bin/sh
- -c
- touch /tmp/healthy; sleep 30; rm -rf /tmp/healthy; sleep 600
livenessProbe:
exec:
command:
- cat
- /tmp/healthy
initialDelaySeconds: 10 # start probing after 10s
periodSeconds: 5 # probe every 5s

这里就是通过 execcat /tmp/healthy 返回值是否为 0 来判断容器是否存活,如果三次探测均失败,则会认为发生了 Failure,触发 OnFailure 重启容器。可以查看日志确认这一点:

1
2
Warning  Unhealthy  2s (x3 over 12s)  kubelet            Liveness probe failed: cat: can't open'/tmp/healthy': No such file or directory
Normal Killing 2s kubelet Container liveness failed liveness probe, will be restarted

Liveness 探测主要用来通知 Kubernetes 尝试重启容器,而另一种 Health Check 机制 Readiness 探测则用来通知 Kubernetes 容器已经可以正常提供服务了。和 Liveness 探测语法上的唯一区别就是把 livenessProbe 改成 readinessProbe。Readiness 探测,从现象上看,影响的是 Pod 的 READY 状态。

而 Startup 探测适用于启动时间较长的应用,在 READY 前以一个更低的频率进行 Readiness Check,避免高频检测影响应用启动。

这三种 Pod 都支持三种检测方法:执行命令、探测 TCP 端口以及发送 HTTP GET 请求。

扩容应用检查

一个典型的使用场景就是在扩容应用时,检查新增加的 Pod 是否能正常工作。例如对于一个 Web 服务,可以这样写探测器:

1
2
3
4
5
6
7
readinessProbe:
httpGet:
scheme: HTTP
path: /healthy
port: 8080
initialDelaySeconds: 10
periodSeconds: 5

此时 Kubernetes 会判断返回的状态码是否在 200 - 400 之间。

滚动更新检查

另一个更实用的场景是在滚动更新时,确保新上线的 Pod 能正常工作,避免更新后全线宕机不得不回滚的状况。

我们知道,滚动更新过程中会逐步增加新的 Pod,删除旧的 Pod。maxSurgemaxUnavailable 分别是对这两个过程的量化。

  • maxSurge 控制 副本总数 - 预期副本数 的最大值
    • 可以为具体数字
    • 默认为 预期副本数 的 25% 向上取整
    • 确保不会增加太多新 Pod
  • maxUnavailable 控制 不可用副本数 的最大值
    • 可以为具体数字
    • 默认为 预期副本数 的 25% 向下取整
    • 确保不会删除太多旧 Pod

这里的 Unavailable,便是通过 readinessProbe 来探测的。我们可以在 Deployment 的 .spec 下添加内容来自定义这两个值:

1
2
3
4
strategy:
rollingUpdate:
maxSurge: 30%
maxUnavailable: 30%

Volume

和 Docker 中的 Volume 类似,用于提供持久化存储。同一个 Pod 中所有容器都可以访问 Mount 到这个 Pod 上的 Volume。

emptyDir Volume

这种 Volume 在 Pod 被删除时也会被删除,不过不受容器被删除的影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
apiVersion: v1
kind: Pod
metadata:
name: producer-consumer
spec:
containers:
- name: producer
image: busybox
volumeMounts:
- mountPath: /producer_dir
name: shared-volume
args:
- /bin/sh
- -c
- echo "hello" > /producer_dir/hello; sleep 30000

- name: consumer
image: busybox
volumeMounts:
- mountPath: /consumer_dir
name: shared-volume
args:
- /bin/sh
- -c
- cat /consumer_dir/hello; sleep 30000

volumes:
- name: shared-volume
emptyDir: {}

上述 yaml 会创建一个含 producerconsumer 两个容器的 Pod,前者向 shared-volume 也就是容器内的 /producer_dir 写数据,后者从 shared-volume 也就是容器内的 /consumer_dir 读数据,最终可以通过 k logs producer-consumer consumer 查看读取到的数据。

这种 Volume 由于在 Pod 被删除后就会消失,比较适合在容器间临时共享存储。但在创建时建议设置 sizeLimit 防止占用空间过大。可以认为,emptyDir 是一种不能指定 pathtype 的 hostPath。

hostPath Volume

这个也比较好理解,就是把容器所处的宿主机 host 上的某个 path 作为 Volume 进行挂载,好处是目录并不会受到 Pod 删除的影响,并且能够更方便地访问宿主机上的文件——这也是这种方式的坏处,即增加了 Pod 和 Node 的耦合度。语法一般如下:

1
2
3
4
5
volumes:
- hostPath:
path: /etc/ssl/certs
type: DirectoryOrCreate
name: ca-certs

可以想到,如果 Pod 被调度到了其他 Node 上,那么 hostPath 很可能就失效了。

外部存储

如果需要持久化的存储,可以使用各种外部存储例如 AWS、GCP、Azure 提供的存储服务,或是使用 Ceph 等分布式存储,语法各不相同,可以参考对应的文档。

PV 和 PVC

PersistentVolume 也是持久化的存储,通过 PersistentVolumeClaim 来申请,Kubernetes 会根据条件分配适合的 PV。这实际上就是对 Volume 作了一层封装,使得用户不需要关心所获得的存储空间的底层信息。

创建 PV:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv1
spec:
capacity:
storage: 1Gi
accessModes:
- ReadWriteOnce # mount to a single node
persistentVolumeReclaimPolicy: Recycle
storageClassName: nfs # like label selector
nfs:
path: /nfs/pv1
server: 1.1.1.1 # nfs server

其中 accessModes 指定了访问模式为可读写且只能挂载到单个 Node 上,对应的还有 ReadOnlyManyReadWriteMany 等模式。persistentVolumeReclaimPolicy 指定了回收机制,Retain 需要手工回收,Recycle 会清除 PV 中所有数据,而 Delete 则会删除外部存储(一般是云平台)中的存储资源本身。

然后创建 PVC,过程类似:

1
2
3
4
5
6
7
8
9
10
11
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pvc1
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
storageClassName: nfs

成功创建后,pv1pvc1 的状态会变为 BOUND。之后如果需要在 Pod 中使用存储,只需要修改 .spec.volumes 如下:

1
2
3
4
volumes:
- name: data
persistentVolumeClaim:
claimName: pvc1

使用完毕后,用 k delete pvc pvc1 删除 PVC 来回收 PV,此时 PV 状态会变成 Released,随后 Kubernetes 会启动一个 recycler Pod 进行内容清除工作并将 PV 状态重新设为 Available。如果是 Retain 模式则不会启动 recycler,PV 始终处于不可用的 Released 状态,但即使此时删除 PV 并重新创建,PV 中的数据也依然存在。

此外,还可以使用外部的 StorageClass 以实现 PV 的动态供给,此时创建 PVC 时如未找到合适的 PV 就会自动创建。

要实现动态供给,在 PVC 的 .spec.storageClassName 中指定 StorageClass 的 .metadata.name 即可。

配置管理

Secret

说到配置管理,首先不得不提的就是如何管理配置中的敏感信息。在 GitHub 中这是通过 Repo 的 Secrets 来管理的,Kubernetes 中也同理,通过 Secret 管理。Secret 可以作为一种特殊的 Volume 并挂载到 Pod 上供读取。

首先创建 Secret:

1
2
3
4
5
6
7
apiVersion: v1
kind: Secret
metadata:
name: secret
data:
username: YWRtaW4=
password: MTIzNDU2

需要注意的是,Secret 的 key-value 中 value 必须经过 Base64 编码。

创建后就可以用 k get secretk describe secret 查看了。

之后创建对应的 Volume,就可以挂载到 Pod 了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
apiVersion: v1
kind: Pod
metadata:
name: secret-pod
spec:
containers:
- name: secret-container
image: busybox
volumeMounts:
- name: secret-volume
mountPath: /etc/secret
readOnly: true
args:
- /bin/sh
- -c
- sleep 10; touch /tmp/healthy; sleep 30000
volumes:
- name: secret-volume
secret:
secretName: secret

之后 k exec -it secret-pod -- shls /etc/secret 就可以看到每个 key 都是一个文件,内容就是 Base64 解码后的 value。当 Secret 本身更新时,文件的内容也会自动更新。

另一种方法是用 Secret 配置环境变量,只需要在 .spec.containers[i] 下添加:

1
2
3
4
5
6
env:
- name: USERNAME
valueFrom:
secretKeyRef:
name: secret
key: username

这种方式更为方便,但是不支持同步更新 Secret。

而对于剩余的不那么敏感的信息,就不需要使用 Secret 了,而是使用 ConfigMap 来配置。

ConfigMap

ConfigMap 与 Secret 的 YAML 格式非常相似,区别在于:

  • 数据不需要 Base64 编码
  • .kind 改为 ConfigMap

通过 Volume 挂载与配置环境变量的过程也完全一致:

1
2
3
4
volumes:
- name: configmap-volume
configMap:
name: configmap
1
2
3
4
5
6
env:
- name: USERNAME
valueFrom:
configMapKeyRef:
name: configmap
key: username

etcd

etcd 通过 key-value 对存储来自 API Server 的数据,其最重要的特性是可以监测数据的变更,因此可以当成消息队列来用。etcd 也可以用于服务发现和配置共享,其中 key 在经过一段时间(TTL)后可能失效,因此存在对应的续约机制,而这种机制恰好可以作为服务发现中的心跳来使用。

为保障数据一致性,etcd 采用了 Raft 协议。Raft 协议遵循 quorum 机制,也就是多数同意的规则,具体原理在 The secret lives of data 上有非常生动的解释。

在 4.2.1 版本中引入了新角色 Learner,使得新节点加入时只接收数据而不投票,因此不影响 quorum,防止过度消耗 Leader 带宽。

安装

Release 页面 有详尽且稳定的安装步骤。

启动

为了防止和 k8s 的 etcd 容器端口冲突,我们可以手动指定监听端口。其中 listen-client-urlslisten-peer-urls 是 etcd 服务器监听客户端和其他服务器请求的地址,而 advertise-client-urlsinitial-advertise-peer-urls 是 etcd 客户端和其他服务器向 etcd 服务器发起请求所使用的端口。

1
2
3
4
5
$ etcd --initial-cluster "default=http://localhost:12380" \
--listen-client-urls "http://localhost:12379" \
--listen-peer-urls "http://localhost:12380" \
--advertise-client-urls "http://localhost:12379" \
--initial-advertise-peer-urls "http://localhost:12380"

常用操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# list all members in table
etcdctl --endpoints=localhost:12379 member list --write-out=table
# put data
etcdctl --endpoints=localhost:12379 put /a b
etcdctl --endpoints=localhost:12379 put /c d
# read keys and values in /
etcdctl --endpoints=localhost:12379 get --prefix /
# read keys in /
etcdctl --endpoints=localhost:12379 get --prefix / --keys-only
# watch changes in /
etcdctl --endpoints=localhost:12379 watch --prefix /
# put new data in /a
etcdctl --endpoints=localhost:12379 put /a e
# get old data
etcdctl --endpoints=localhost:12379 get /a --rev=2

这里查询历史版本的原理涉及到 etcd 底层的存储机制。etcd 采用 kvindex 作内存索引,boltdb 进行存储。其中 kvindex 的 key 存储实际数据的 key,而 value 则存储 revision 信息。而 boltdb 中 key 存储的是 revision 信息,而 value 则是实际数据的 key-value 对。这样对数据的读写就遵循 key->kvindex->boltdb->value 的路径操作指定版本的数据。

Kubernetes 各种 API Resources 中的 resourceVersion 的值就来自于 etcd 的 revision 信息。

灾备

1
2
3
4
5
# backup
etcdctl --endpoints=localhost:12379 snapshot save snapshot.db
# restore
etcdctl --endpoints=localhost:12379 snapshot restore snapshot.db
# --initial-cluster=...

🌰:启动三节点 HTTPS 集群

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# each etcd instance name need to be unique
# x380 is for peer communication
# x379 is for client communication
# dir-data cannot be shared
nohup etcd \
--name infra0 \
--data-dir=/tmp/etcd/infra0 \
--listen-peer-urls https://127.0.0.1:3380 \
--initial-advertise-peer-urls https://127.0.0.1:3380 \
--listen-client-urls https://127.0.0.1:3379 \
--advertise-client-urls https://127.0.0.1:3379 \
--initial-cluster-token etcd-cluster-1 \
--initial-cluster infra0=https://127.0.0.1:3380,infra1=https://127.0.0.1:4380,infra2=https://127.0.0.1:5380 \
--initial-cluster-state new \
--client-cert-auth --trusted-ca-file=/tmp/etcd-certs/certs/ca.pem \
--cert-file=/tmp/etcd-certs/certs/127.0.0.1.pem \
--key-file=/tmp/etcd-certs/certs/127.0.0.1-key.pem \
--peer-client-cert-auth --peer-trusted-ca-file=/tmp/etcd-certs/certs/ca.pem \
--peer-cert-file=/tmp/etcd-certs/certs/127.0.0.1.pem \
--peer-key-file=/tmp/etcd-certs/certs/127.0.0.1-key.pem 2>&1 > /var/log/infra0.log &

nohup etcd \
--name infra1 \
--data-dir=/tmp/etcd/infra1 \
--listen-peer-urls https://127.0.0.1:4380 \
--initial-advertise-peer-urls https://127.0.0.1:4380 \
--listen-client-urls https://127.0.0.1:4379 \
--advertise-client-urls https://127.0.0.1:4379 \
--initial-cluster-token etcd-cluster-1 \
--initial-cluster infra0=https://127.0.0.1:3380,infra1=https://127.0.0.1:4380,infra2=https://127.0.0.1:5380 \
--initial-cluster-state new \
--client-cert-auth --trusted-ca-file=/tmp/etcd-certs/certs/ca.pem \
--cert-file=/tmp/etcd-certs/certs/127.0.0.1.pem \
--key-file=/tmp/etcd-certs/certs/127.0.0.1-key.pem \
--peer-client-cert-auth --peer-trusted-ca-file=/tmp/etcd-certs/certs/ca.pem \
--peer-cert-file=/tmp/etcd-certs/certs/127.0.0.1.pem \
--peer-key-file=/tmp/etcd-certs/certs/127.0.0.1-key.pem 2>&1 > /var/log/infra1.log &

nohup etcd \
--name infra2 \
--data-dir=/tmp/etcd/infra2 \
--listen-peer-urls https://127.0.0.1:5380 \
--initial-advertise-peer-urls https://127.0.0.1:5380 \
--listen-client-urls https://127.0.0.1:5379 \
--advertise-client-urls https://127.0.0.1:5379 \
--initial-cluster-token etcd-cluster-1 \
--initial-cluster infra0=https://127.0.0.1:3380,infra1=https://127.0.0.1:4380,infra2=https://127.0.0.1:5380 \
--initial-cluster-state new \
--client-cert-auth --trusted-ca-file=/tmp/etcd-certs/certs/ca.pem \
--cert-file=/tmp/etcd-certs/certs/127.0.0.1.pem \
--key-file=/tmp/etcd-certs/certs/127.0.0.1-key.pem \
--peer-client-cert-auth --peer-trusted-ca-file=/tmp/etcd-certs/certs/ca.pem \
--peer-cert-file=/tmp/etcd-certs/certs/127.0.0.1.pem \
--peer-key-file=/tmp/etcd-certs/certs/127.0.0.1-key.pem 2>&1 > /var/log/infra2.log &

API Server

任何请求到达 API Server 后首先都必须经过认证、鉴权、准入控制、限流等阶段,之后才会被接受。

认证

Kubernetes 支持多种认证方式:

  • 证书(--client-ca-file
  • 静态 token(--token-auth-file,csv 文件)
  • Bootstrap Token(kube-system 中的 Secret)
  • 静态口令(--basic-auth-file,csv 文件)
  • ServiceAccount
  • OpenID(OAuth 2.0)
  • Webhook(--authtication-token-webhook-config-file
  • 匿名访问(--anonymous-auth

其中 Webhook 需要用户自己编写认证服务,使用 TokenReview 进行认证和返回结果。

鉴权

Kubernetes 支持的鉴权方式包括:

  • ABAC(不推荐)
  • RBAC
  • Webhook
  • Node

例如,要使用 RBAC,首先要创建相应的角色,如 Role 和跨 Namespace 的 ClusterRole:

1
2
3
4
5
6
7
8
9
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: default
name: pod-reader
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "watch", "list"]
1
2
3
4
5
6
7
8
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: secret-reader
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "watch", "list"]

随后,通过 RoleBinding(或 ClusterRoleBinding) 将 Role 绑定到具体的 subject(用户、组、ServiceAccount 等) 上:

1
2
3
4
5
6
7
8
9
10
11
12
13
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
namespace: dev # only grant perm in dev namespace
name: read-secrets
subjects:
- kind: User
name: dave
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: secret-reader
apiGroup: rbac.authorization.k8s.io

准入

准入控制在授权后对请求进一步验证,粒度更细。准入控制由多个插件共同决定,通过了所有插件的检查,请求才会最终被接受。常见的插件包括:

  • AlwaysAdmit 接受任意请求
  • AlwaysPullImages 总是拉取最新镜像,无论本地是否已存在
  • SecurityContextDeny 拒绝包含非法 SecurityContext 的请求
  • ResourceQuota 用来限制 Pod 请求的配额
  • DefaultStorageClass 为 PVC 设置默认的 StorageClass

一个有用的场景就是创建一个 ResourceQuotaController,当 Namespace 创建时自动创建 ResourceQuota,使得 ResourceQuota 插件生效,从而能够限制用户的资源配额。

毫无疑问,准入插件能做到非常多的事情,因此 Kubernetes 一定会允许我们自定义这样的插件。如果我们只对准入对象进行校验而不作修改,那么可以配置 ValidatingWebhookConfiguration;反之,如果需要修改准入对象,就需要 MutatingWebhookConfiguration 了。

一个简单的 MutatingWebhook 的例子可以在 这里 找到。

限流

API Server 使用 max-requests-inflight 限制给定时间内最大 non-mutating 请求数,用 max-mutating-requests-inflight 限制给定时间内最大 mutating 请求数。这两个值会随着节点数的增加而增加。

然而,这类传统限流方式存在诸多局限性:

  • 粒度较粗,无法为不同场景设置不同限流策略
  • 单一队列,使得恶意流量能够影响正常流量
  • 容易产生饥饿问题
  • 缺少优先级,系统指令同样被限流

因此,API Server 使用 API Priority and Fairness 在更细粒度上分类请求并分别限流。每一个分类对应一种 FlowSchema,同一 FlowSchema 内的请求又会被 distinguisher 分到不同的 Flow 中。最后,APF 使用混洗分片技术将请求分配到不同队列中。这种排队机制既防止了饥饿问题,又能一定程度上应对突发流量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
apiVersion: flowcontrol.apiserver.k8s.io/v1beta1
kind: FlowSchema
metadata:
name: kube-scheduler
spec:
distinguisherMethod:
type: ByNamespace # A FlowSchema and a distinguisher identify a flow
matchingPrecedence: 800 # rule priority
priorityLevelConfiguration:
name: workload-high # queue priority
rules:
- resourceRules:
- resources:
- '*'
verbs:
- '*'
subjects:
- kind: User
user:
name: system:kube-scheduler

在另一个维度上,Priority 设置了请求的优先级。不同优先级之间的请求同样相互隔离,并且拥有独立的并发限制。对于系统指令类的流量,还可以设置为豁免流量,不受到限流的限制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
apiVersion: flowcontrol.apiserver.k8s.io/v1beta1
kind: PriorityLevelConfiguration
metadata:
name: global-default
spec:
limited:
assuredConcurrencyShares: 20 # max concurrent requests allowed
limitResponse:
queuing:
handSize: 6 # number of queues per flow
queueLengthLimit: 50 # max queue length
queues: 128 # queue number
type: Queue
type: Limited

Scheduler

资源需求

如上文所述,调度器可以使用 nodeSelector 调度 Pod 到指定的 Node 上,也可以通过 Pod 的资源需求来调度 Pod。此时,调度器关注的是 .spec.resources.requests ,而 cgroups 则使用 .spec.resources.limits 限制 Pod 中的 container 能使用的资源上限。例如,cpu: 1 意味着容器可以获得一个 CPU 的全部时间片,而 cpu: 1m 则表示一个 CPU 的全部时间片的千分之一。

根据 resources 字段的设置,container 的资源需求可以分为三种 qosClass(对应的 QoS 从高到低):

  • Guarantee:resources 下的 request 等于 limit
  • Burstable:resources 下的 request 小于 limit
  • BestEffort:不设置 resources 字段

当节点资源不足时,会按 BestEffort、Burstable、Guarantee 的顺序依次驱逐 Pod。

同时,也可以使用 LimitRange 来给所有没有设置 resources 的 container(包括 initContainers)加上默认的 resources 字段:

1
2
3
4
5
6
7
8
9
10
11
apiVersion: v1
kind: LimitRange
metadata:
name: mem-limit-range
spec:
limits:
- default:
memory: 512Mi
defaultRequest:
memory: 256Mi
type: Container

Affinity

另一种调度方式是根据 Pod 的 nodeAffinitynodeAntiAffinityrequiredDuringSchedulingIgnoredDuringExecutionpreferredDuringSchedulingIgnoredDuringExecution 分别表示强亲和性和弱亲和性,前者在找不到亲和的 Node 时不会运行。

强亲和性 Pod 语法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template:
metadata:
labels:
app: nginx
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: disktype
operator: In
values:
- ssd
containers:
- name: nginx
image: nginx

弱亲和性:

1
2
3
4
5
6
7
8
9
10
11
spec:
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 1
preference:
matchExpressions:
- key: disktype
operator: In
values:
- ssd

同理,可以用 podAffinitypodAntiAffinity 指定 Pod 间亲和性,即判断某一个范围内(由 topologyKey 指定)现有的 Pod 是否满足相应条件来进行调度。

Taint 和 Toleration

Taint 会给 Node 加污点,来防止任意 Pod 被调度到该 Node 上。同时,我们可以给部分 Pod 指定 Toleration,使得这些 Pod 能够容忍这些污点并被调度到该 Node 上。

存在三种污点类型:

  • NoSchedule 会使得新 Pod 不再调度到该 Node 上,但现有 Pod 不受影响
  • PreferNoSchedule 会使得新 Pod 尽量不调度到该 Node 上,但现有 Pod 不受影响
  • NoExecute 会使得新 Pod 不再调度到该 Node 上,且对于现有 Pod,会在 Pod 的 tolerationSeconds 秒后,驱逐对应的 Pod

例如,先给某个 Node 加污点:

1
$ k taint no node0 key=value:NoSchedule

随后创建 Pod,会发现无法调度到该 Node 上,直到我们取消这个污点:

1
$ k taint no node0 key=value:NoSchedule-

或是给新的 Pod 添加 Toleration:

1
2
3
4
5
6
7
8
9
10
11
12
13
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx
tolerations:
- key: key
operator: Equal
value: value
effect: NoSchedule

PriorityClass

最后,可以为 Pod 设置调度优先级。首先定义 PriorityClass:

1
2
3
4
5
6
7
apiVersion: v1
kind: PriorityClass
metadata:
name: high-priority
value: 1000000
globalDefault: false
description: "the greater the value, the higher the priority"

随后在 Pod 中设置该 PriorityClass:

1
2
3
4
5
6
7
8
9
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx
priorityClassName: high-priority

CRI / CNI / CSI

CRI

常见的容器运行时接口包括 Docker、containerd、CRI-O 等,采用的是主流的 runc 规范。由于 Docker 本身是一个独立的产品,用在 Kubernetes 里显得较重。而 containerd 则在实现上和使用上都更为轻量——实际上,即使使用 Docker 作为 CRI,Kubernetes 底层依然是经由 Docker,从而在 containerd 中运行容器的。

因此,可以说目前 Kubernetes 最合适的 CRI 是 containerd。

CNI

在 Kubernetes 中,网络模型是非常直观、符合直觉的:

  • 每个 Pod 都有自己的 IP 地址,但每次重建后可能发生变化
  • 同一个 Pod 中的容器共享网络 Namespace,因此可直接通过 localhost 通信
  • Pod 的 IP 对整个集群可见,集群中任意 Pod 或 Node 访问一个 Pod 都无需经过 NAT
  • 由于 Pod 的 IP 地址不稳定,可以通过 Service 来访问 Pod,同时 Service 本身具备负载均衡功能

为了让各厂商不同的网络标准和工具都能符合这种网络模型,也为了让网络能与各种 CRI 兼容,Kubernetes 采用容器网络接口规范,通过插件的形式构建网络。插件中一般会定义如何分配 IP、如何设置网卡、如何限流、如何设置防火墙、如何端口转发等等。常见的插件包括 Flannel、Calico、Cilium 等。

例如,Calico 对同网段通信采用 BGP 协议来路由数据包,此时不需要封包;对跨网段通信,则使用 IPinIP 封装 IP 数据包。此外,Calico 支持使用 ACLs 协议和 kube-proxy 来创建 iptables 过滤规则,从而隔离容器网络。

在调用任何插件前,CRI 必须先创建一个网络 Namespace。这也是为什么一个运行的 Pod 中即使只声明了一个容器,也会出现另外一个 pause 容器(这一步称为 createPodSandbox)。pause 容器只运行 sleep infinity ,几乎不占 CPU 和内存,主要作用就是为了为当前 Pod 创建好网络 Namespace 供同一 Pod 中其他容器,也就是我们声明的容器使用。

CSI

容器存储接口方面,选择就比较单一了,因为 OverlayFS 性能过于强势。Docker 和 containerd 也默认使用 OverlayFS 作为运行时存储驱动。上文提到的 emptyDir、hostPath、PV 和 PVC、外部存储等,都实现了 CSI 接口,因此可以在不同场景下为 Pod 所挂载并使用。CSI 也同样支持插件系统。

kubelet 创建 Pod 时首先会调用 CSI 接口,初始化容器存储,比如挂载 Volume 等;随后 createPodSandbox 准备好 pause 容器并运行;接着调用 CRI 的接口,启动容器、创建好 Pod 的网络 Namespace,为构建网络作好准备;最后才调用 CNI 的接口真正构建容器网络。

Pod 生命周期钩子

和 Vue 里的生命周期钩子类似,Pod 中的 container 也可以定义 postStart 钩子和 preStop 钩子。postStart 钩子在容器启动后运行,但无法保证其和容器 Entrypoint 谁先执行,运行完 postStart 后容器才会被标记为 Running。

preStop 则只有在 Pod 被删除时(而不是完成时 / 容器退出时)才会执行,执行完毕后 Kubernetes 会向容器发送 SIGTERM 信号。如果 preStop 执行时间和容器收到 SIGTERM 后花费的时间加起来超过了 terminationGracePeriodSeconds,那么容器就会收到 SIGKILL 信号强制退出。

值得注意的是,bash / sh 会忽略 SIGTERM 信号,这使得 terminationGracePeriodSeconds 失去意义。因此,用 bash / sh 作为容器 Entrypoint 时,应设置尽量小的超时时间。

服务发现与负载均衡

Endpoint

当我们创建一个 selector 不为空的 Service 时,Endpoint Controller 会监听到这一事件并创建同名的 Endpoint 对象。满足 selector 要求的所有 Pod 的 IP 都会被配置到 Endpoint 的地址列表中。例如,通过 k get po -owide 查看一个 Deployment 中一组 Pod 的 IP,分别为 10.1.1.11410.1.1.11110.1.1.112。随后查看对应 Service 所对应的 Endpoint:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ k describe ep nginx-svc
Name: nginx-svc
Namespace: default
Labels: <none>
Annotations: endpoints.kubernetes.io/last-change-trigger-time: 2021-11-26T12:37:50Z
Subsets:
Addresses: 10.1.1.111,10.1.1.112,10.1.1.114
NotReadyAddresses: <none>
Ports:
Name Port Protocol
---- ---- --------
<unset> 80 TCP

Events: <none>

可以在 Subsets 下的 Addresses 里发现这三个地址,说明实际进行流量转发的是 Service 底层的 Endpoint 对象。

如果不为 Service 定义 selector,那么就不会创建对应的 Endpoint。此时可以手动创建 Endpoint 指向指定的地址。

kube-proxy

流量的转发是 kube-proxy 的主要任务之一。kube-proxy 使用 iptables 中配置的转发规则,以 1/n 的概率转发到 n 个 IP 中的某一个上。为了解决在转发规模较大时 iptables 的性能问题、以及基于概率的伪负载均衡问题,目前可以采用 ipvs 配置更简洁的规则和更丰富的负载均衡类型,如 round-robin 等。

总的来说,外部流量从 API Server 进入,经过 Service Controller -> 外部 Load Balancer(如果存在) -> kube-proxy -> NodePort IP(如果存在) -> ClusterIP(如果存在) 的顺序最终到达 Pod IP。

CoreDNS

CoreDNS 就是 Kubernetes 内部的 DNS 服务器。对于包含 ClusterIP 的 Service,CoreDNS 都会创建 $svcName.$namespace.svc.$clusterdomain: clusterIP 的 A 记录和 PTR 记录,并为端口创建 SRV 记录。如果 Service 显式指定了 .spec.ClusterIPNone,那么 CoreDNS 会创建多条 A 记录,分别指向每个 Ready 的 Pod IP,格式类似 $podName.$svcName.$namespace.svc.$clusterdomain。而如果 Service 指定了 .spec.externalName,那么 CoreDNS 只会创建对应的 CNAME 记录。

在每个 Pod 的 /etc/resolv.conf 中,都可以看到 nameserver 地址(也就是 CoreDNS 服务的地址)和搜索规则,CoreDNS 依次尝试在要解析的域名后添加这些域名来拼凑出完整的域名。

1
2
3
4
$ cat /etc/resolv.conf
nameserver 10.96.0.10
search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5

Ingress

上面提到的 Service 设置的外部 LoadBalancer、和 kube-proxy 利用 iptables / ipvs 提供的负载均衡功能,都属于 L4 负载均衡,工作在传输层。而 Ingress 则提供了 L7 负载均衡,作为工作在应用层的代理服务来实现负载均衡的功能。Ingress 主要采用 TLS Termination 技术,我们可以在 Ingress 层面配置 HTTPS 证书来提供给内部的多个服务使用。

一个比较简单的方法是通过 Secret 配置证书:

1
2
3
4
5
6
7
8
apiVersion: v1
kind: Secret
metadata:
name: httpserver-tls
type: kubernetes.io/tls
data:
tls.crt: # Base64(PEM format file)
tls.key: # Base64(PEM format file)

随后配置 Ingress 对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: httpserver-gateway
annotations:
kubernetes.io/ingress.allow-http: 'false'
spec:
tls:
- hosts:
- sigmerc.top
secretName: httpserver-tls
rules:
- host: sigmerc.top
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: httpserver-service
port:
number: 80

注意证书和域名的匹配问题。这里可以指定多个 path,每个 path 对应一个 backend service。一些特性则可以在 .metadata.annotations 中声明,例如禁止 HTTP 等。

Helm

Kubernetes 中拥有各种各样的 API Resources,一个完整的服务可能就需要用到其中的好几种,在服务数量较多时较难管理。因此可以使用类似包管理器的 Helm 来实施对服务层面而不是 API Resources 层面的管理。

Helm chart 就像 apt 中的 package,是 Helm 部署应用的单元。Helm 客户端将 Helm chart 安装到 Kubernetes 并生成 release。

Helm 拥有相当清晰详细的文档,感觉自己写得不如文档好,具体的使用这里就不写了。

乘风破浪:Kubernetes 笔记

https://signormercurio.me/post/Kubernetes/

Author

Mercury

Posted on

2021-10-10

Licensed under

CC BY-NC-SA 4.0

Comments

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×