使用minikube模拟基于Kubernetes平台的容器化改造

前言

Kubernetes(K8s)脱胎于Google的Borg系统,在2015年发布以来,已经是当下最流行的容器编排工具(没有之一)。在本文中,笔者会使用上一篇文章中的demo,模拟把代码部署到K8s的过程,同时也会在这个过程中讨论一下部署方案的设计思路。

本文内容主要都是应用发布相关,为了简化步骤,使用minikube进行演示。

minikube-mydemo

Demo代码介绍

这次使用的demo代码如下,模拟一个典型的flask http应用,通过项目路径中的配置文件获取必要的业务配置,得到内网中的Redis连接信息:

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
import time
import redis
import json
from flask import Flask

config_json = json.load(open('./config/config.json', "rb"))
redis_config = config_json['redis_config']
show_message = config_json['show_message']
app = Flask(__name__)
cache = redis.Redis(host=redis_config['redis_host'], port=redis_config['redis_port'], db=redis_config['redis_db'], password=redis_config['redis_password'])

def get_hit_count():
retries = 5
while True:
try:
return cache.incr('hits')
except redis.exceptions.ConnectionError as exc:
if retries == 0:
raise exc
retries -= 1
time.sleep(0.5)

@app.route('/')
def hello():
count = get_hit_count()
return '{}! I have been seen {} times.\n'.format(show_message,count)

@app.route('/health')
def health():
return "i am fine"

if __name__ == "__main__":
app.run(host = '0.0.0.0')

其中,配置文件内容如下:

1
2
3
4
5
6
7
8
9
{
"redis_config": {
"redis_host": "192.168.0.251",
"redis_port": 6379,
"redis_db": 0,
"redis_password": "aaaa1111"
},
"show_message": "this is a python-redis demo"
}

传统部署方案的痛点

对于这种python flask应用,传统的部署方案是使用supervisor托管python进程,封装成一个服务,通过Nginx或者其他同类产品实现负载均衡,架构图如下:

nginx-supervisor

这种传统部署方案多用在IDC机房时代或者是云计算刚刚开始流行的早期,除了费钱之外,最大的问题在于不够灵活:

  • 扩缩容不灵活,就算是使用了云平台或者虚拟化,还是逃不过服务节点创建和注销的繁琐流程。

  • 部署不灵活,需要为应用量身定制一套部署流程,维护主机列表去进行批量的文件发布(如果你觉得已经比手动好很多了,那就当我没说 ;-) )。

  • 配置修改不灵活,在运行的过程中,如果需要临时对配置内容进行调整,都需要一套严谨的方案保障所有的节点使用的配置内容的一致性,避免业务故障。

  • 架构笨重,从架构图上就能看出来,为了保障服务的高可用,不可避免地需要多台服务器进行负载均衡,而单单是为了一个应用的运行就需要一个完整的操作系统,显然是一件很麻烦的事情。

总结下来,传统部署方案的诸多不便,其实是架构决定的。也正因为传统部署方案存在着诸多的不便,后来才有了容器技术。硬件的虚拟化促成了云平台的诞生,实现了硬件的复用,降低了设备成本;而内核的虚拟化又刺激了容器技术的蓬勃发展,使得系统内核可得以带着应用服务轻装上路,这就是这些年来的技术趋势。

在接下来的内容中,我们会一步一步把这个demo部署到K8s上。

容器镜像构建

在应用部署到K8s之前,我们需要先把代码封装成一个容器镜像,并上传到镜像仓库。

项目路径

项目路径的内容如下,配置文件存放在本地。

1
2
3
4
5
6
python-redis-demo
├── Dockerfile
├── app.py
├── config
│   └── config.json
└── requirements.txt

Dockerfile

1
2
3
4
5
6
7
8
9
10
11
# syntax=docker/dockerfile:1
FROM python:3.10-alpine
WORKDIR /code
ENV FLASK_APP=app.py
ENV FLASK_RUN_HOST=0.0.0.0
RUN apk add --no-cache gcc musl-dev linux-headers
COPY requirements.txt requirements.txt
RUN pip install -r requirements.txt
EXPOSE 5000
COPY . .
CMD ["flask", "run"]

使用docker build构建容器镜像

在开发任务结束并且确认功能正常后,我们就可以把代码封装成一个容器镜像了,步骤和上一篇文章类似:

1
2
3
4
5
6
7
8
9
10
# 构建docker 镜像

cd python-redis-demo
docker build -t rondochen/python-redis-demo:v1.2 .

# 如果因为网络问题速度很慢,可以使用`--build-arg`参数,在build的时候增加http代理设置:
docker build --build-arg "HTTPS_PROXY=http://xxx.xxx.xxx.xxx:xxxx" -t rondochen/python-redis-demo:v1.2 .

# 把镜像上传到镜像仓库
docker push rondochen/python-redis-demo:v1.2

笔者已把这个镜像上传到Dockerhub,供大家参考:

1
docker push rondochen/python-redis-demo:v1.2

(顺带一提)关于K8s不再支持Docker的新闻

在K8s 1.20版本的changelog里面,有提过说未来将会放弃对docker的支持,当时还引起了广泛的讨论,甚至很多人都以为后面就不能在K8s里面使用docker镜像了。

后来在K8s的官方博客里面还专门重新解释了一下这个变更的原因。简单来说,K8s只是在未来不再支持docker的运行环境,而我们通过docker build生成的镜像并不是只能运行在docker环境中,只要镜像文件是符合OCI(Open Container Initiative)标准的,就可以在K8s中运行,哪怕是使用不同的容器技术(如containerd或CRI-O)。

也就是说,我们可以继续使用docker build去构建容器镜像并部署到K8s中,但是如果是有一些docker运行环境专属的功能,比如说一些依赖/var/run/docker.sock的场景,就不再支持。

部署到minikube(Kubernetes)

如果你刚刚接触K8s,或者是希望有一个K8s环境可以调试应用的开发者,又或者是一个懒得去部署一整套K8s集群但又想介绍一个K8s方案的撰稿人, Minikube就是你的好朋友。

Minikube可以很轻易地帮你生成出一个足够迷你但是功能又刚好够用的K8s集群,以支持你进行常用的K8s功能测试。

https://kubernetes.io/docs/tutorials/hello-minikube/

安装minikube

可以参考官方文档完成Minikube的安装:https://minikube.sigs.k8s.io/docs/start/

如果是使用Linux系统安装,建议选择带有图形界面的版本,这样在使用minikube dashboard的时候可以方便一点。

另外,如果因为网络问题无法使用Google的镜像库,可以设置国家或者地区,从而在启动的时候就指定镜像的下载地址:

1
minikube start --image-mirror-country='cn'

在接下来的演示中,我们都在dev这个命名空间下进行。

1
2
# 创建命名空间
kubectl create namespace dev

通过deployment部署pod

集群启动好了之后,我们就可以把pod部署起来了。尽管我们可以直接用kubectl以命令行的方式创建pod,比如说:

1
kubectl run nginx --image=nginx:latest --port=80 --namespace dev

但我们一般不会这样做。

在生产环境中,对于本文demo的这种无状态服务,更常见和规范的做法,是通过deployment完成。
在下面的yaml文件中,对通过deployment这个pod控制器,对pod进行了如命名空间、pod节点数以及使用的镜像等配置,并赋予了app: mydemo这个标签属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# deploy-mydemo.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: mydemo
namespace: dev
spec:
replicas: 2
selector:
matchLabels:
app: mydemo
template:
metadata:
labels:
app: mydemo
spec:
containers:
- image: rondochen/python-redis-demo:v1.2
name: mydemo
ports:
- name: mydemo-port
containerPort: 5000
protocol: TCP

把配置文件提交到k8s集群后,就可以自动完成pod的创建:

1
2
3
4
5
6
7
8
9
# 提交配置文件
kubectl apply -f deploy-mydemo.yaml

# 查看deployment的运行结果

[root@minikube yaml]# kubectl get deployment -n dev mydemo -o wide
NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR
mydemo 2/2 2 2 4d1h mydemo rondochen/python-redis-demo:v1.2 app=mydemo

查看pod状态

我们可以看到,有两个pod节点已经正常运行:

1
2
3
4
5
6
7
8
9
[root@minikube yaml]# kubectl get pods -n dev -l app=mydemo
NAME READY STATUS RESTARTS AGE
mydemo-59d5b8c44c-95jbt 1/1 Running 0 46h
mydemo-59d5b8c44c-gpssx 1/1 Running 0 46h

[root@minikube yaml]# kubectl get pods -n dev -l app=mydemo -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
mydemo-59d5b8c44c-95jbt 1/1 Running 0 46h 172.17.0.4 minikube <none> <none>
mydemo-59d5b8c44c-gpssx 1/1 Running 0 46h 172.17.0.3 minikube <none> <none>

再回看前文提到的传统部署方案中对于扩容的不便,这里就可以产生明显的对比了。在K8s中,只需要简单的replicas配置,即可快速完成服务的扩缩容操作。

pod间访问

在K8s的设计网络设计理念中,最特别的一点是,所有的pod都有一个唯一的IP地址,并且所有的pod都可以通过IP地址互访。

在上面的输出中,我们已知pod运行在172.17.0.3172.17.0.4两个IP下,我们可以使用kubectl debug命令创建一个临时的pod请求一下mydemo所在的IP地址,检查pod是否在正常运行。

1
2
3
4
5
6
7
8
9
10
[root@minikube yaml]# kubectl debug -n dev node/minikube -it --image=centos     
Creating debugging pod node-debugger-minikube-zb8s8 with container debugger on node minikube.
If you don't see a command prompt, try pressing enter.
[root@minikube /]# curl http://172.17.0.3:5000
this is a python-redis demo! I have been seen 1047 times.
[root@minikube /]# curl http://172.17.0.3:5000
this is a python-redis demo! I have been seen 1048 times.
[root@minikube /]# curl http://172.17.0.3:5000/health
i am fine[root@minikube /]# exit
exit

创建service

显然,光有pod是不够的,我们还需要一个类似传统部署方案中的Nginx那样的入口,同时肩负起负载均衡和服务暴露的功能。在K8s集群中,我们通过Service实例实现这个需求。

在service的配置文件中,通过selector选择了带有app: mydemo标签的pod作为自己的后端服务,并且完成了80:5000的端口映射:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# service-mydemo.yaml
apiVersion: v1
kind: Service
metadata:
name: svc-mydemo
namespace: dev
labels:
app: mydemo
spec:
selector:
app: mydemo
ports:
- protocol: TCP
port: 80
targetPort: 5000

提交配置到K8s后,service即可创建成功:

1
kubectl apply -f service-mydemo.yaml

集群内访问service

我们可以看到,在默认情况下,创建出来的service是ClusterIP类型,有CLUSTER-IP,但没有EXTERNAL-IP,所以只能在集群内访问。

1
2
3
4
5
6
7
8
9
[root@minikube yaml]# kubectl get service -n dev -o wide
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR
svc-mydemo ClusterIP 10.100.164.207 <none> 80/TCP 4d app=mydemo

[root@minikube yaml]# kubectl debug -n dev node/minikube -it --image=centos Creating debugging pod node-debugger-minikube-hzlrm with container debugger on node minikube.
If you don't see a command prompt, try pressing enter.
[root@minikube /]#
[root@minikube /]# curl http://10.100.164.207/
this is a python-redis demo! I have been seen 1051 times.

集群外访问service

如果想要在集群外都能访问Servcie,需要把Service设置成NodePort类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# service-mydemo-NodePort.yaml
apiVersion: v1
kind: Service
metadata:
name: svc-mydemo
namespace: dev
labels:
app: mydemo
spec:
selector:
app: mydemo
ports:
- protocol: TCP
port: 80
targetPort: 5000
type: NodePort

完成修改后,再看service的信息,我们会发现,类型已经更改成NodePort,而且可以在集群外被访问:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[root@minikube yaml]# kubectl get service -n dev  
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
apigateway ExternalName <none> apigateway.dev.svc.cluster.local <none> 47h
svc-mydemo NodePort 10.100.164.207 <none> 80:32098/TCP 4d4h
[root@minikube yaml]# minikube service list
|---------------|------------------------------------|--------------|---------------------------|
| NAMESPACE | NAME | TARGET PORT | URL |
|---------------|------------------------------------|--------------|---------------------------|
| default | kubernetes | No node port |
| dev | svc-mydemo | 80 | http://192.168.49.2:32098 |
| kube-system | apigateway | No node port |
|---------------|------------------------------------|--------------|---------------------------|
[root@minikube yaml]# curl http://192.168.49.2:32098
this is a python-redis demo! I have been seen 1052 times.

因为这个K8s集群是建立在一个内网服务器上的minikube,没有公网地址或者是向云平台上的LoadBalance,所以最后我们就算是把服务暴露到集群外了,也无法模拟完整的使用场景。假如是在功能完整的K8s集群中,一个类型为NodePort的Service,会绑定到一个Node服务器的IP地址上,我们从内网或者公网都可以通过Node的IP:Port访问服务。

Kubernetes的高级应用

经过上面的步骤,我们已经初步完成了这个demo应用的容器化改造,也通过了deployment和service功能成功打通了访问路径,可以说,这个应用已经上线了。但是从运维的角度来看,我们不会只满足于把一段代码部署到线上,我们还需要考虑其他的运维需求。

配置分离

在真实的项目中,容器镜像里面的配置文件往往是开发环境或者是测试环境的内容,在我们要给一个容器镜像部署到生产环境之前,都需要重新修改配置内容。在Docker Host方案中,我们可以使用挂载Volume的方式替换掉容器里面的配置文件,但如果再遇到多节点负载均衡的情况,我们又要花额外的精力去保障所有DockerHost中配置文件的一致性,显然不是一个优雅的方案。

在K8s,我们可以使用K8s的配置管理工具ConfigMap完成配置分离。

创建ConfigMap

我们可以把ConfigMap简单理解为一个特殊的Volume,也是通过一个yaml文件定义ConfigMap的属性和配置内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# configmap-mydemo.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: mydemo-config
namespace: dev
labels:
app: mydemo
data:
config.json: |
{
"redis_config": {
"redis_host": "192.168.0.251",
"redis_port": 6379,
"redis_db": 0,
"redis_password": "aaaa1111"
},
"show_message": "this is a python-redis demo with configmap"
}

最后使用命令kubectl apply -f configmap-mydemo.yaml 提交更改。

Pod挂载ConfigMap

正如前面所说,我们把ConfigMap看做一个特殊的Volume,创建ConfigMap之后,还需要把ConfigMap挂载到Pod中,deployment的yaml文件需要添加如下的内容:

1
2
3
4
5
6
7
8
9
10
11
12
...
containers:
- image: rondochen/python-redis-demo:v1.2
name: mydemo
volumeMounts:
- name: config-volumn
mountPath: /code/config
volumes:
- name: config-volumn
configMap:
name: mydemo-config
...

(顺带一提)配置文件动态加载功能

在腾讯游戏的《可运营规范》里面,有要求服务必须支持动态加载配置的功能,即配置文件变更后,不需要重启服务进程就能生效。在ConfigMap乃至K8s其他的配置管理工具中,当我们成功提交了配置内容变更的指令后,Pod中的配置都是无需重启就能更新的。至于Pod中的服务进程是否有做到动态加载变化的配置文件,还是需要在业务代码中实现。

之所以突然想到这个,是想提醒大家,业务上的功能不能依赖操作系统或者运维工具实现 ;-)。

资源管理

在K8s集群中,一个Node节点的CPU/内存或者其他硬件资源是有限的,我们要怎么给里面的各个容器分配资源呢?

同样,我们在deployment中可以给Pod中的每个容器分配资源。举例说,我要为这个容器分配0.5核的CPU和0.5G的内存空间,具体yaml示例如下:

1
2
3
4
5
6
7
8
9
10
containers:
- image: rondochen/python-redis-demo:v1.2
name: mydemo
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 500m
memory: 512Mi

健康检查

使用K8s的探针功能,定期请求demo中的健康检查URI(/health),判断服务是否处在就绪状态:

1
2
3
4
5
6
7
8
9
containers:
- image: rondochen/python-redis-demo:v1.2
name: mydemo
livenessProbe:
httpGet:
path: /health
port: 5000
initialDelaySeconds: 10
periodSeconds: 10

在这个例子中,假如其中一个Pod不在就绪状态,Service就会把流量分配到其他正常的Pod。

(扩展内容)使用Ingress实现URI的rewrite

在微服务的设计理念中,所有的服务都通过一个统一的入口接入,再根据不同的URI进入到不同的服务中,所以就有了前面题图里面的Ingress模块。

minikube-mydemo

为了便于理解,可以把Ingress理解为一个Nginx(实际上就是Nginx),通过location匹配把不同的URI分类反向代理到不同的服务中。为了实现准确的反向代理,Ingress还需要使用一种类似DNS的功能,在反向代理的时候能准确找到各个服务的IP。

需要注意的是,下面的操作Ingress部分只是在Minikube环境中特有的。在完整的K8s集群中,并不适用下面的步骤。

官方文档: https://minikube.sigs.k8s.io/docs/handbook/addons/ingress-dns/

创建ingress实例

在本例中,笔者在集群中创建了一个域名为apigateway.test的服务,通过URI路径反向代理到flask demo, 使用的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: networking.k8s.io/v1
kind: Ingress
metadata:
name: apigateway
namespace: dev
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /$2
spec:
ingressClassName: nginx
rules:
- host: apigateway.test
http:
paths:
- path: /mydemo(/|$)(.*)
pathType: Prefix
backend:
service:
name: svc-mydemo
port:
number: 80
---
apiVersion: v1
kind: Service
metadata:
name: apigateway
namespace: dev
spec:
type: ExternalName
externalName: apigateway.dev.svc.cluster.local

集群外访问

请求示例如下:

1
2
3
4
[root@minikube yaml]# curl http://apigateway.test/mydemo
this is a python-redis demo with configmap! I have been seen 1058 times.
[root@minikube yaml]# curl http://apigateway.test/mydemo/health
i am fine[root@minikube yaml]#

配置汇总

最后来汇总一下deployment中的内容:

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
apiVersion: apps/v1
kind: Deployment
metadata:
name: mydemo
namespace: dev
labels:
app: mydemo
spec:
replicas: 2
selector:
matchLabels:
app: mydemo
template:
metadata:
labels:
app: mydemo
spec:
containers:
- image: rondochen/python-redis-demo:v1.2
name: mydemo
ports:
- name: mydemo-port
containerPort: 5000
protocol: TCP
livenessProbe:
httpGet:
path: /health
port: 5000
initialDelaySeconds: 10
periodSeconds: 10
volumeMounts:
- name: config-volumn
mountPath: /code/config
resources:
limits:
cpu: 500m
memory: 500Mi
requests:
cpu: 500m
memory: 500Mi
volumes:
- name: config-volumn
configMap:
name: mydemo-config

总结

最初笔者是想通过一个demo去介绍K8s的实现原理和大概的编排思路,但是在撰文的过程中却反过来觉得被这个demo局限了思路。到了总结阶段,回头发现一个小小的demo已经引出了这么多的思考。

尽管笔者已经尽可能地把大部分常见运维场景都覆盖到,但一个小小的demo显然撑不起这个野心,现在能想到的就还有镜像瘦身、性能监控、日志收集和Pod调度这些内容没有提到。(吐槽again,总不能把公司的项目拿上来说吧)

诚然,K8s里面的功能可远远不止这些,单单说是服务管理,除了Deployment还有Job和DaemonSet;在应用配置管理,除了ConfigMap还有Secret和ServiceAccount。想要对这诸多功能和feature一一详细介绍显然是不可能的,笔者也无意照搬官方文档对K8s进行面面俱到的指引,只是希望可以通过这个基于http的无状态服务部署到K8s过程,为读者带来一些思考和启发。

后面会再抽时间介绍一下Helm或者是容器编排的一些场景,拭目以待。

扩展阅读

https://github.com/kubernetes/ingress-nginx/blob/main/docs/examples/rewrite/README.md
https://minikube.sigs.k8s.io/docs/handbook/addons/ingress-dns/
https://kubernetes.io/docs/tasks/debug-application-cluster/
https://kubernetes.io/docs/tasks/configure-pod-container/assign-memory-resource/
https://kubernetes.io/blog/2020/12/02/dont-panic-kubernetes-and-docker/