02 深入理解 Kubernets 的编排对象

Kubernetes 系统是一套分布式容器应用编排系统,当我们用它来承载业务负载时主要使用的编排对象有 Deployment、ReplicaSet、StatefulSet、DaemonSet 等。读者可能好奇的地方是 Kubernetes 管理的对象不是 Pod 吗?为什么不去讲讲如何灵活配置 Pod 参数。其实这些对象都是对 Pod 对象的扩展封装。并且这些对象作为核心工作负载 API 固化在 Kubernetes 系统中了。所以 ,我们有必要认真的回顾和理解这些编排对象,依据生产实践的场景需要,合理的配置这些编排对象,让 Kubernetes 系统能更好的支持我们的业务需要。本文会从实际应用发布的场景入手,分析和梳理具体场景中需要考虑的编排因素,并整理出一套可以灵活使用的编排对象使用实践。

常规业务容器部署策略

策略一:强制运行不少于 2 个容器实例副本

在应对常规业务容器的场景之下,Kubernetes 提供了 Deployment 标准编排对象,从命令上我们就可以理解它的作用就是用来部署容器应用的。Deployment 管理的是业务容器 Pod,因为容器技术具备虚拟机的大部分特性,往往让用户误解认为容器就是新一代的虚拟机。从普通用户的印象来看,虚拟机给用户的映象是稳定可靠。如果用户想当然地把业务容器 Pod 也归类为稳定可靠的实例,那就是完全错误的理解了。容器组 Pod 更多的时候是被设计为短生命周期的实例,它无法像虚拟机那样持久地保存进程状态。因为容器组 Pod 实例的脆弱性,每次发布的实例数一定是多副本,默认最少是 2 个。

部署多副本示例:

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
apiVersion: apps/v1

kind: Deployment

metadata:

name: nginx-deployment

labels:

app: nginx

spec:

replicas: 2

selector:

matchLabels:

app: nginx

template:

metadata:

labels:

app: nginx

spec:

containers:

- name: nginx

image: nginx:1.7.9

ports:

- containerPort: 80


策略二:采用节点亲和,Pod 间亲和/反亲和确保 Pod 实现高可用运行

当运维发布多个副本实例的业务容器的时候,一定需要仔细注意到一个事实。Kubernetes 的调度默认策略是选取最空闲的资源主机来部署容器应用,不考虑业务高可用的实际情况。当你的集群中部署的业务越多,你的业务风险会越大。一旦你的业务容器所在的主机出现宕机之后,带来的容器重启动风暴也会即可到来。为了实现业务容错和高可用的场景,我们需要考虑通过 Node 的亲和性和 Pod 的反亲和性来达到合理的部署。这里需要注意的地方是,Kubernetes 的调度系统接口是开放式的,你可以实现自己的业务调度策略来替换默认的调度策略。我们这里的策略是尽量采用 Kubernetes 原生能力来实现。

为了更好地理解高可用的重要性,我们深入探讨一些实际的业务场景。

首先,Kubernetes 并不是谷歌内部使用的 Borg 系统,大部分中小企业使用的 Kubernetes 部署方案都是人工扩展的私有资源池。当你发布容器到集群中,集群不会因为资源不够能自动扩展主机并自动负载部署容器 Pod。即使是在公有云上的 Kubernetes 服务,只有当你选择 Serverlesss Kubernetes 类型时才能实现资源的弹性伸缩。很多传统企业在落地 Kubernetes 技术时比较关心的弹性伸缩能力,目前只能折中满足于在有限静态资源的限制内动态启停容器组 Pod,实现类似的业务容器的弹性。用一个不太恰当的比喻就是房屋中介中,从独立公寓变成了格子间公寓,空间并没有实质性扩大。在实际有限资源的情况下,Kubernetes 提供了打标签的功能,你可以给主机、容器组 Pod 打上各种标签,这些标签的灵活运用,可以帮你快速实现业务的高可用运行。

**其次,实践中你会发现,为了高效有效的控制业务容器,你是需要资源主机的。**你不能任由 Kubernetes 调度来分配主机启动容器,这个在早期资源充裕的情况下看不到问题。当你的业务复杂之后,你会部署更多的容器到资源池中,这个时间你的业务运行的潜在危机就会出现。因为你没有管理调度资源,导致很多关键业务是运行在同一台服务器上,当主机宕机发生时,让你很难处理这种灾难。所以在实际的业务场景中,业务之间的关系需要梳理清楚,设计单元化主机资源模块,比如 2 台主机为一个单元,部署固定的业务容器组 Pod,并且让容器组 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
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
55
56
57
pods/pod-with-node-affinity.yaml 



apiVersion: v1

kind: Pod

metadata:

name: with-node-affinity

spec:

affinity:

nodeAffinity:

requiredDuringSchedulingIgnoredDuringExecution:

nodeSelectorTerms:

- matchExpressions:

- key: kubernetes.io/e2e-az-name

operator: In

values:

- e2e-az1

- e2e-az2

preferredDuringSchedulingIgnoredDuringExecution:

- weight: 1

preference:

matchExpressions:

- key: another-node-label-key

operator: In

values:

- another-node-label-value

containers:

- name: with-node-affinity

image: gcr.azk8s.cn/google-samples/node-hello:1.0


目前有两种类型的节点亲和,分别为 requiredDuringSchedulingIgnoredDuringExecution 和 preferredDuringSchedulingIgnoredDuringExecution。你可以视它们为“硬”和“软”,意思是,前者指定了将 pod 调度到一个节点上必须满足的规则,后者指定调度器将尝试执行但不能保证的偏好。

Pod 间亲和与反亲和示例

使用反亲和性避免单点故障例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
affinity:

podAntiAffinity:

requiredDuringSchedulingIgnoredDuringExecution:

- labelSelector:

matchExpressions:

- key: "app"

operator: In

values:

- zk

topologyKey: "kubernetes.io/hostname"


意思是以主机的 hostname 命名空间来调度,运行打了标签键 "app" 并含有 "zk" 值的 Pod 在不同节点上部署。

策略三:使用 preStop Hook 和 readinessProbe 保证服务平滑更新不中断

我们部署应用之后,接下来会做的工作就是服务更新的操作。如何保证容器更新的时候,业务不中断是最重要的关心事项。参考示例:

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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
apiVersion: apps/v1

kind: Deployment

metadata:

name: nginx

spec:

replicas: 1

selector:

matchLabels:

component: nginx

template:

metadata:

labels:

component: nginx

spec:

containers:

- name: nginx

image: xds2000/nginx-hostname

ports:

- name: http

hostPort: 80

containerPort: 80

protocol: TCP

readinessProbe:

httpGet:

path: /

port: 80

httpHeaders:

- name: X-Custom-Header

value: Awesome

initialDelaySeconds: 15

timeoutSeconds: 1

lifecycle:

preStop:

exec:

command: ["/bin/bash", "-c", "sleep 30"]


给 Pod 里的 container 加 readinessProbe(就绪检查),通常是容器完全启动后监听一个 HTTP 端口,kubelet 发送就绪检查探测包,正常响应说明容器已经就绪,然后修改容器状态为 Ready,当 Pod 中所有容器都 Ready 之后这个 Pod 才会被 Endpoint Controller 加进 Service 对应 Endpoint IP:Port 列表,然后 kube-proxy 再更新节点转发规则,更新完了即便立即有请求被转发到新的 Pod 也能保证能够正常处理连接,避免了连接异常。

给 Pod 里的 container 加 preStop hook,让 Pod 真正销毁前先 sleep 等待一段时间,留点时间给 Endpoint controller 和 kube-proxy 更新 Endpoint 和转发规则,这段时间 Pod 处于 Terminating 状态,即便在转发规则更新完全之前有请求被转发到这个 Terminating 的 Pod,依然可以被正常处理,因为它还在 sleep,没有被真正销毁。

策略四:通过泛域名转发南北向流量范式

常规集群对外暴露一个公网 IP 作为流量入口(可以是 Ingress 或 Service),再通过 DNS 解析配置一个泛域名指向该 IP(比如 *.test.foo.io),现希望根据请求中不同 Host 转发到不同的后端 Service。比如 a.test.foo.io 的请求被转发到 my-svc-a,b.test.foo.io 的请求转发到 my-svc-b。当前 Kubernetes 的 Ingress 并不原生支持这种泛域名转发规则,我们需要借助 Nginx 的 Lua 编程能力解决实现泛域名转发。

Nginx proxy 示例(proxy.yaml):

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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
apiVersion: apps/v1

kind: Deployment

metadata:

labels:

component: nginx

name: proxy

spec:

replicas: 1

selector:

matchLabels:

component: nginx

template:

metadata:

labels:

component: nginx

spec:

containers:

- name: nginx

image: "openresty/openresty:centos"

ports:

- name: http

containerPort: 80

protocol: TCP

volumeMounts:

- mountPath: /usr/local/openresty/nginx/conf/nginx.conf

name: config

subPath: nginx.conf

- name: dnsmasq

image: "janeczku/go-dnsmasq:release-1.0.7"

args:

- --listen

- "127.0.0.1:53"

- --default-resolver

- --append-search-domains

- --hostsfile=/etc/hosts

- --verbose

volumes:

- name: config

configMap:

name: configmap-nginx



---



apiVersion: v1

kind: ConfigMap

metadata:

labels:

component: nginx

name: configmap-nginx

data:

nginx.conf: |-

worker_processes 1;



error_log /error.log;



events {

accept_mutex on;

multi_accept on;

use epoll;

worker_connections 1024;

}



http {

include mime.types;

default_type application/octet-stream;

log_format main '$time_local $remote_user $remote_addr $host $request_uri $request_method $http_cookie '

'$status $body_bytes_sent "$http_referer" '

'"$http_user_agent" "$http_x_forwarded_for" '

'$request_time $upstream_response_time "$upstream_cache_status"';



log_format browser '$time_iso8601 $cookie_km_uid $remote_addr $host $request_uri $request_method '

'$status $body_bytes_sent "$http_referer" '

'"$http_user_agent" "$http_x_forwarded_for" '

'$request_time $upstream_response_time "$upstream_cache_status" $http_x_requested_with $http_x_real_ip $upstream_addr $request_body';



log_format client '{"@timestamp":"$time_iso8601",'

'"time_local":"$time_local",'

'"remote_user":"$remote_user",'

'"http_x_forwarded_for":"$http_x_forwarded_for",'

'"host":"$server_addr",'

'"remote_addr":"$remote_addr",'

'"http_x_real_ip":"$http_x_real_ip",'

'"body_bytes_sent":$body_bytes_sent,'

'"request_time":$request_time,'

'"status":$status,'

'"upstream_response_time":"$upstream_response_time",'

'"upstream_response_status":"$upstream_status",'

'"request":"$request",'

'"http_referer":"$http_referer",'

'"http_user_agent":"$http_user_agent"}';



access_log /access.log main;



sendfile on;



keepalive_timeout 120s 100s;

keepalive_requests 500;

send_timeout 60000s;

client_header_buffer_size 4k;

proxy_ignore_client_abort on;

proxy_buffers 16 32k;

proxy_buffer_size 64k;



proxy_busy_buffers_size 64k;



proxy_send_timeout 60000;

proxy_read_timeout 60000;

proxy_connect_timeout 60000;

proxy_cache_valid 200 304 2h;

proxy_cache_valid 500 404 2s;

proxy_cache_key $host$request_uri$cookie_user;

proxy_cache_methods GET HEAD POST;



proxy_redirect off;

proxy_http_version 1.1;

proxy_set_header Host $http_host;

proxy_set_header X-Real-IP $remote_addr;

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

proxy_set_header X-Forwarded-Proto $scheme;

proxy_set_header X-Frame-Options SAMEORIGIN;



server_tokens off;

client_max_body_size 50G;

add_header X-Cache $upstream_cache_status;

autoindex off;



resolver 127.0.0.1:53 ipv6=off;



server {

listen 80;



location / {

set $service '';

rewrite_by_lua '

local host = ngx.var.host

local m = ngx.re.match(host, "(.+).test.foo.io")

if m then

ngx.var.service = "my-svc-" .. m[1]

end

';

proxy_pass http://$service;

}

}

}


用 Service 的示例(service.yaml):

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: Service

metadata:

labels:

component: nginx

name: service-nginx

spec:

type: LoadBalancer

ports:

- name: http

port: 80

targetPort: http

selector:

component: nginx


用 Ingress 的示例(ingress.yaml):

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
apiVersion: extensions/v1beta1

kind: Ingress

metadata:

name: ingress-nginx

spec:

rules:

- host: "*.test.foo.io"

http:

paths:

- backend:

serviceName: service-nginx

servicePort: 80

path: /


有状态业务容器部署策略

StatefulSet 旨在与有状态的应用及分布式系统一起使用。为了理解 StatefulSet 的基本特性,我们使用 StatefulSet 部署一个简单的 Web 应用。

创建一个 StatefulSet 示例(web.yaml):

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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
apiVersion: v1

kind: Service

metadata:

name: nginx

labels:

app: nginx

spec:

ports:

- port: 80

name: web

clusterIP: None

selector:

app: nginx

---

apiVersion: apps/v1

kind: StatefulSet

metadata:

name: web

spec:

serviceName: "nginx"

replicas: 2

selector:

matchLabels:

app: nginx

template:

metadata:

labels:

app: nginx

spec:

containers:

- name: nginx

image: gcr.azk8s.cn/google_containers/nginx-slim:0.8

ports:

- containerPort: 80

name: web

volumeMounts:

- name: www

mountPath: /usr/share/nginx/html

volumeClaimTemplates:

- metadata:

name: www

spec:

accessModes: [ "ReadWriteOnce" ]

resources:

requests:

storage: 1Gi


注意 StatefulSet 对象运行的特点之一就是,StatefulSet 中的 Pod 拥有一个唯一的顺序索引和稳定的网络身份标识。这个输出最终将看起来像如下样子:

1
2
3
4
5
6
7
8
9
kubectl get pods -l app=nginx

NAME READY STATUS RESTARTS AGE

web-0 1/1 Running 0 1m

web-1 1/1 Running 0 1m


很多文档在提及 Statefulset 对象的概念时,用户容易望文生义,常常把挂盘的容器实例当成有状态实例。这是不准确的解释。在 Kubernetes 的世界里,有稳定的网络身份标识的容器组才是有状态的应用。例如:

1
2
3
4
5
6
7
for i in 0 1; do kubectl exec web-$i -- sh -c 'hostname'; done

web-0

web-1


另外,我们使用 kubectl run 运行一个提供 nslookup 命令的容器。通过对 Pod 的主机名执行 nslookup,你可以检查他们在集群内部的 DNS 地址。示例如下:

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
kubectl run -i --tty --image busybox:1.28 dns-test --restart=Never --rm   

nslookup web-0.nginx

Server: 10.0.0.10

Address 1: 10.0.0.10 kube-dns.kube-system.svc.cluster.local



Name: web-0.nginx

Address 1: 10.244.1.6



nslookup web-1.nginx

Server: 10.0.0.10

Address 1: 10.0.0.10 kube-dns.kube-system.svc.cluster.local



Name: web-1.nginx

Address 1: 10.244.2.6


策略五:灵活运用 StatefulSet 的 Pod 管理策略

通常对于常规分布式微服务业务系统来说,StatefulSet 的顺序性保证是不必要的。这些系统仅仅要求唯一性和身份标志。为了加快这个部署策略,我们通过引入 .spec.podManagementPolicy 解决。

Parallel pod 管理策略告诉 StatefulSet 控制器并行的终止所有 Pod,在启动或终止另一个 Pod 前,不必等待这些 Pod 变成 Running 和 Ready 或者完全终止状态。示例如下:

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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
apiVersion: v1

kind: Service

metadata:

name: nginx

labels:

app: nginx

spec:

ports:

- port: 80

name: web

clusterIP: None

selector:

app: nginx

---

apiVersion: apps/v1

kind: StatefulSet

metadata:

name: web

spec:

serviceName: "nginx"

podManagementPolicy: "Parallel"

replicas: 2

selector:

matchLabels:

app: nginx

template:

metadata:

labels:

app: nginx

spec:

containers:

- name: nginx

image: gcr.azk8s.cn/google_containers/nginx-slim:0.8

ports:

- containerPort: 80

name: web

volumeMounts:

- name: www

mountPath: /usr/share/nginx/html

volumeClaimTemplates:

- metadata:

name: www

spec:

accessModes: [ "ReadWriteOnce" ]

resources:

requests:

storage: 1Gi


业务运维类容器部署策略

在我们部署 Kubernetes 扩展 DNS、Ingress、Calico 能力时需要在每个工作节点部署守护进程的程序,这个时候需要采用 DaemonSet 来部署系统业务容器。默认 DaemonSet 采用滚动更新策略来更新容器,可以通过执行如下命令确认:

1
2
3
4
5
6
7
kubectl get ds/<daemonset-name> -o go-template='{{.spec.updateStrategy.type}}{{"\n"}}'



RollingUpdate


在日常工作中,我们对守护进程只需要执行更换镜像的操作:

1
2
3
kubectl set image ds/<daemonset-name> <container-name>=<container-new-image>


查看滚动更新状态确认当前进度:

1
2
3
kubectl rollout status ds/<daemonset-name>


当滚动更新完成时,输出结果如下:

1
2
3
daemonset "<daemonset-name>" successfully rolled out


此外,我们还有一些定期执行脚本任务的需求,这些需求可以通过 Kubernetes 提供的 CronJob 对象来管理,示例如下:

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
apiVersion: batch/v1beta1

kind: CronJob

metadata:

name: hello

spec:

schedule: "*/1 * * * *"

successfulJobsHistoryLimit: 0

failedJobsHistoryLimit: 0

jobTemplate:

spec:

template:

spec:

containers:

- name: hello

image: busybox

args:

- /bin/sh

- -c

- date; echo Hello from the Kubernetes cluster

restartPolicy: OnFailure


总结

本文从实际业务需求出发,带着读者一起梳理了 Kubernetes 的工作负载定义的稳定版本的编排对象 Deployment、StatefulSet、DaemonSet、CronJob。所有提供的资料都是来自行业分享的实践经验的总结,去掉了很多文档的繁琐或者不正确的介绍,帮助读者正确建立合理的编排对象的使用策略。当然,除了这些核心编排对象之外,Kubernetes 还提供了扩展接口,我们通过 Operator 编程框架就可以自定义需要的编排对象,把自己的运维经验用代码规范起来,让你的持续发布的流程更加方便快捷。