原标题:Python项目容器化实践(七) - lyanna的Kubernetes配置文件

接下来2篇解释我刚写出Kubernetes版本的lyanna配置文件,同时还要需要补充2个知识: DaemonSet和StatefulSet。

DaemonSet

通过资源对象的名字就能看出它的用法:用来部署Daemon(守护进程)的,DaemonSet确保在全部(或者一些)节点上运行一个需要长期运行着的Pod的副本。主要场景如日志采集、监控等。

在lyanna的项目中,执行异步arq消息的任务进程使用了它(k8s/arq.yaml):

kind: DaemonSet
apiVersion: apps/v1
metadata:
name: lyanna-arq
labels:
app.kubernetes.io/name: lyanna-arq
spec:
selector:
matchLabels:
app.kubernetes.io/name: lyanna-arq
template:
metadata:
labels:
app.kubernetes.io/name: lyanna-arq
spec:
containers:
- image: dongweiming/lyanna:latest
name: lyanna-web
command: ['sh', '-c', './arq tasks.WorkerSettings']
env:
- name: REDIS_URL
valueFrom:
configMapKeyRef:
name: lyanna-cfg
key: redis_url
- name: MEMCACHED_HOST
valueFrom:
configMapKeyRef:
name: lyanna-cfg
key: memcached_host
- name: DB_URL
valueFrom:
configMapKeyRef:
name: lyanna-cfg
key: db_url
- name: PYTHONPATH
value: $PYTHONPATH:/usr/local/src/aiomcache:/usr/local/src/tortoise:/usr/local/src/arq:/usr/local/src

简单地说就是启动一个进程执行 arq tasks.WorkerSettings,这里面有4个要说的地方

labels。lyanna项目用的Label键的名字一般都是 app.kubernetes.io/name,表示应用程序的名字,这是官方推荐使用的标签,具体的可以看延伸阅读链接1

image。由于是线上部署,所以不再使用build本地构建,而是用打包好的镜像,这里用的是 dongweiming/lyanna:latest,是我向https://hub.docker.com/注册的账号下上传的镜像,其中配置了Github集成,每次push代码会按规则自动构建镜像。

env。设置环境变量,这里用到了ConfigMap,之后会专门说,大家先略过,另外要注意使用了PYTHONPATH,预先写好的。

command。 sh -c ./arq tasks.WorkerSettings是启动的命令,参数是一个列表,要求能找到第一个参数作为可执行命令,我这里常规的是用 sh -c开头去执行,arq这个文件是修改Dockerfile添加的:

❯ cat Dockerfile
...
WORKDIR /app
COPY . /app
COPY --from=build /usr/local/bin/gunicorn /app/gunicorn
COPY --from=build /usr/local/bin/arq /app/arq

其实就是在build阶段安装包之后把生成的可执行文件拷贝到 /app下备用。

StatefulSet

先说「有状态」和「无状态」。在Deployment里面无论启动多少Pod,它们的环境和做的事情都是一样的,请求到那个Pod上都可以正常被响应。在请求过程中不会对Pod产生额外的数据,例如持久化数据。这就是「无状态」。而StatefulSet这个资源对象针对的就是有状态的应用,比如MySQL、Redis、Memcached等,因为你在Pod A上写入数据(例如添加了一个文件),如果没有数据同步,在另外一个Pod B里面是看不到这个数据的;而Pod A被销毁重建之后数据也不存在了。当然别担心,实际环境中会通过之前说的PV/PVC或者其他方法把这些需要持久化的数据存储到数据卷中,保证无论怎么操作Pod都不影响数据。

StatefulSet另外的特点是它可以控制Pod的启动顺序,还能给每个Pod的状态设置唯一标识(在Pod名字后加0,1,2这样的数字),当然对于部署、删除、滚动更新等操作也是有序的。

Memcached

在lyanna项目中Memcached和Mariadb使用了StatefulSet,先说Memcached(k8s/memcached.yaml):

apiVersion: apps/v1
kind: StatefulSet
metadata:
name: lyanna-memcached
labels:
app.kubernetes.io/name: memcached
spec:
replicas: 3 # StatefulSet有3副本
revisionHistoryLimit: 10 # 只保留最新10次部署记录,再远的就退不回去了
selector:
matchLabels:
app.kubernetes.io/name: memcached
serviceName: lyanna-memcached
template:
metadata:
labels:
app.kubernetes.io/name: memcached
spec:
containers:
- command: # 容器中启动Memcached的命令,值是一个列表,按照之前部署的参数来的
- memcached
- -o
- modern
- -v
- -I
- 20m
image: memcached:latest # 使用最新的官方memcached镜像
imagePullPolicy: IfNotPresent
livenessProbe:
failureThreshold: 3
initialDelaySeconds: 10
periodSeconds: 10
successThreshold: 1
tcpSocket:
port: memcache
timeoutSeconds: 5
name: lyanna-memcached
ports:
- containerPort: 11211
name: memcache
protocol: TCP
readinessProbe:
failureThreshold: 3
initialDelaySeconds: 5
periodSeconds: 10
successThreshold: 1
tcpSocket:
port: memcache
timeoutSeconds: 1
resources: # 限定Pod使用的CPU和MEM资源
requests:
cpu: 50m # 1m = 1/1000CPU
memory: 64Mi
securityContext: # 限定运行容器的用户,默认是root
runAsUser: 1001
dnsPolicy: ClusterFirst
restartPolicy: Always
securityContext:
fsGroup: 1001
terminationGracePeriodSeconds: 30
updateStrategy:
type: RollingUpdate
---
apiVersion: v1
kind: Service
metadata:
labels:
app.kubernetes.io/name: memcached
name: lyanna-memcached
spec:
clusterIP: None
ports:
- name: memcache
port: 11211
protocol: TCP
targetPort: memcache
selector:
app.kubernetes.io/name: memcached
sessionAffinity: ClientIP

在配置文件中写了一些注释,每个服务大家可以理解他是一个「微服务」,包含一个StatefulSet/Deployment和一个Service,应用通过访问Service域名的方式访问它。在一个yaml里面能写多个配置,中间用 ---隔开即可。

Memcached是内存数据库,进程死掉缓存就丢失了,所以里面没有mount数据卷相关的配置,我使用StatefulSet它主要是考虑每个Pod内存中的数据是不一样的,另外注意服务定义中有一句 sessionAffinity:ClientIP,让请求根据客户端的IP地址做会话关联: 他每次都访问这个Pod。

再重点说一下配置文件中用到的2种探针。探针是由kubelet对容器执行的定期诊断,它是k8s提供的应用程序健康检查方案:

livenessProbe。指示容器是否正在运行。如果存活探测失败,则kubelet会杀死容器,容器将按照重启策略(restartPolicy)重启。如果容器不提供存活探针,表示容器成功通过了诊断。

readinessProbe。指示容器是否准备好服务请求。如果就绪探测失败,Service不会包含这个Pod,请求也就不会发到这个Pod上来。初始延迟之前的就绪状态默认为失败,如果容器不提供就绪探针,则默认状态为 Success。

大家理解了吧?简单地说,livenessProbe是看容器是否正常,readinessProbe是看应用是否正常。

MariaDB

接着说数据库,首先说MySQL和MariaDB的区别:

MySQL先后被Sun和Oracle收购,MySQL之父Ulf Michael Widenius离开了Sun之后,由于对这种商业公司不信任等原因,新开了分支(名字叫做MariaDB)发展MySQL。MariaDB跟MySQL在绝大多数方面是兼容的,对于开发者来说,几乎感觉不到任何不同。目前MariaDB是发展最快的MySQL分支版本,新版本发布速度已经超过了Oracle官方的MySQL版本。

MySQL和MariaDB都有各自应用大户,所以目前不需要考虑MariaDB替代MySQL的问题,我这次选择「纯」开源版本的MariaDB主要是我瓣一直在用,而我用的云服务器上面只能选择MySQL,正好借着k8s的机会使用MariaDB。

MariaDB显然是最适合用StatefulSet了,由于它要定义主从,配置文件(k8s/optional/mariadb.yaml)很长,所以分开来演示。先看一下PV部分:

kind: PersistentVolume
apiVersion: v1
metadata:
name: mariadb-master
labels:
type: local
spec:
storageClassName: lyanna-mariadb-master
capacity:
storage: 5Gi
accessModes:
- ReadWriteOnce
hostPath:
path: /var/lib/mariadb
persistentVolumeReclaimPolicy: Retain
---
kind: PersistentVolume
apiVersion: v1
metadata:
name: mariadb-slave
labels:
type: local
spec:
storageClassName: lyanna-mariadb-slave
capacity:
storage: 5Gi
accessModes:
- ReadWriteOnce
hostPath:
path: /var/lib/redis-slave
persistentVolumeReclaimPolicy: Retain

定义了2个PersistentVolume分别给Master/Slave用,它们都使用了hostPath挂载到宿主机(其实就是minikube虚拟机),空间5G,访问模式是ReadWriteOnce,表示只能被单个节点以读/写模式挂载,这也是必然的,数据库文件被多个节点同时写会让文件损坏的。通过persistentVolumeReclaimPolicy制定回收策略,默认是Delete(删除),我改成了Retain(保留): 保留数据,需要管理员手工清理。

接着是ConfigMap部分,k8s中通过ConfigMap方式配置数据库配置(my.cnf中的项):

apiVersion: v1
kind: ConfigMap
metadata:
labels:
app: mariadb
app.kubernetes.io/component: master
name: lyanna-mariadb-master
data:
my.cnf: |-
[mysqld]
skip-name-resolve
explicit_defaults_for_timestamp
basedir=/data/mariadb
port=3306
socket=/data/mariadb/tmp/mysql.sock
tmpdir=/data/mariadb/tmp
max_allowed_packet=16M
bind-address=0.0.0.0
pid-file=/data/mariadb/tmp/mysqld.pid
log-error=/data/mariadb/logs/mysqld.log
character-set-server=UTF8
collation-server=utf8_general_ci
[client]
port=3306
socket=/data/mariadb/tmp/mysql.sock
default-character-set=UTF8
[manager]
port=3306
socket=/data/mariadb/tmp/mysql.sock
pid-file=/data/mariadb/tmp/mysqld.pid
---
apiVersion: v1
kind: ConfigMap
metadata:
labels:
app.kubernetes.io/name: mariadb
app.kubernetes.io/component: slave
name: lyanna-mariadb-slave
data:
my.cnf: |-
[mysqld]
skip-name-resolve
explicit_defaults_for_timestamp
basedir=/data/mariadb
port=3306
socket=/data/mariadb/tmp/mysql.sock
tmpdir=/data/mariadb/tmp
max_allowed_packet=16M
bind-address=0.0.0.0
pid-file=/data/mariadb/tmp/mysqld.pid
log-error=/data/mariadb/logs/mysqld.log
character-set-server=UTF8
collation-server=utf8_general_ci
[client]
port=3306
socket=/data/mariadb/tmp/mysql.sock
default-character-set=UTF8
[manager]
port=3306
socket=/data/mariadb/tmp/mysql.sock
pid-file=/data/mariadb/tmp/mysqld.pid

通过配置项可以感受到Pod会发生状态变化的文件都在 /data/mariadb下。我对MariaDB配置没有什么经验,这部分主要是从helm/charts/stable/mariadb里找的。

我没有用官方MariaDB镜像,而是用了bitnami/mariadb,主要是为了容易地实现主从复制集群。先看Matser部分:

apiVersion: apps/v1
kind: StatefulSet
metadata:
labels:
app.kubernetes.io/name: mariadb
app.kubernetes.io/component: master
name: lyanna-mariadb-master
spec:
replicas: 1
revisionHistoryLimit: 10
selector:
matchLabels:
app.kubernetes.io/name: mariadb
app.kubernetes.io/component: master
serviceName: lyanna-mariadb-master
template:
metadata:
labels:
app.kubernetes.io/name: mariadb
app.kubernetes.io/component: master
spec:
containers:
- env:
- name: MARIADB_USER
valueFrom:
configMapKeyRef:
key: user
name: lyanna-cfg
- name: MARIADB_PASSWORD
valueFrom:
configMapKeyRef:
key: password
name: lyanna-cfg
- name: MARIADB_DATABASE
valueFrom:
configMapKeyRef:
key: database
name: lyanna-cfg
- name: MARIADB_REPLICATION_MODE
value: master
- name: MARIADB_REPLICATION_USER
value: replicator
- name: MARIADB_REPLICATION_PASSWORD
valueFrom:
configMapKeyRef:
key: replication-password
name: lyanna-cfg
- name: MARIADB_ROOT_PASSWORD
value: passwd
image: bitnami/mariadb:latest
imagePullPolicy: IfNotPresent
livenessProbe:
exec:
command:
- sh
- -c
- exec mysqladmin status -uroot -p$MARIADB_ROOT_PASSWORD
failureThreshold: 3
initialDelaySeconds: 120
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 1
name: mariadb
ports:
- containerPort: 3306
name: mysql
protocol: TCP
readinessProbe:
exec:
command:
- sh
- -c
- exec mysqladmin status -uroot -p$MARIADB_ROOT_PASSWORD
failureThreshold: 3
initialDelaySeconds: 30
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 1
volumeMounts:
- mountPath: /data/mariadb
name: data
restartPolicy: Always
securityContext:
fsGroup: 1001
runAsUser: 1001
terminationGracePeriodSeconds: 30
volumes:
- configMap:
defaultMode: 420
name: lyanna-mariadb-master
name: config
updateStrategy:
type: RollingUpdate
volumeClaimTemplates:
- metadata:
labels:
app.kubernetes.io/name: mariadb
app.kubernetes.io/component: master
name: data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
volumeMode: Filesystem
storageClassName: lyanna-mariadb-master

数据库主从是分别的StatefulSet,每个StatefulSet都只有一个副本,这个配置中需要着重说的有4点:

env。主从都是StatefulSet,那么Pod里面怎么知道自己要跑那种数据库实例呢?就靠环境变量,所以Master的环境变量包含 MARIADB_USER、 MARIADB_PASSWORD、 MARIADB_DATABASE、MARIADB_REPLICATION_MODE、 MARIADB_REPLICATION_USER、 MARIADB_REPLICATION_PASSWORD和 MARIADB_ROOT_PASSWORD,有些是要在我们自定义的ConfigMap中获取,有些是写死的常量

探针。livenessProbe和readinessProbe都用的是 mysqladmin status来检查数据库状态

volumeMounts。数据库就是通过volumeMounts项找挂载到哪里,mountPath表示要挂载到容器的路径,name是使用的挂载PVC名字

volumes。配置的挂载,前面配置的数据库设置项都是由于他生效的

volumeClaimTemplates。PVC的模板,基于volumeClaimTemplates数组会自动生成PVC(PersistentVolumeClaim)对象,它的名字要和volumeMounts里面的name一致才能对应上,由于访问模式是ReadWriteOnce的,所以PVC和PV是一一对应的。

接着看从(Slave),其实它就是Label、name之类的值换个名字,限于篇幅问题只展示env这不同于Master的部分:

...
spec:
containers:
- env:
- name: MARIADB_REPLICATION_MODE
value: slave
- name: MARIADB_MASTER_HOST
value: lyanna-mariadb
- name: MARIADB_MASTER_PORT_NUMBER
valueFrom:
configMapKeyRef:
key: port
name: lyanna-cfg
- name: MARIADB_MASTER_USER
valueFrom:
configMapKeyRef:
key: user
name: lyanna-cfg
- name: MARIADB_MASTER_PASSWORD
valueFrom:
configMapKeyRef:
key: password
name: lyanna-cfg
- name: MARIADB_REPLICATION_USER
value: replicator
- name: MARIADB_REPLICATION_PASSWORD
valueFrom:
configMapKeyRef:
key: replication-password
name: lyanna-cfg
- name: MARIADB_MASTER_ROOT_PASSWORD
value: passwd
...

接着看一下名字是lyanna-cfg的ConfigMap,这里面包含了数据库、Redis、Memcached相关的设置项,这些想需要通过环境变量的方式传到对应容器中(k8s/config.yaml):

apiVersion: v1
kind: ConfigMap
metadata:
name: lyanna-cfg
data:
port: "3306"
database: test
user: lyanna
password: lyanna
memcached_host: lyanna-memcached
replication-password: lyanna
redis_sentinel_host: redis-sentinel
redis_sentinel_port: "26379"
db_url: mysql://lyanna:lyanna@lyanna-mariadb:3306/test?charset=utf8Redis Sentinel

类似部署MariaDB用的主从方案最大的问题是Master宕机了,不能实现自动主从切换,所有在实际的应用中还是直接连接的主服务器,从服务器更多的是数据备份的作用,如果真的Master出错了能手动调整ConfigMap让应用直接使用从服务器的数据。当然这部分可以优化,但我的博客实际上用的是云数据库,所以先跑起来再说。

而用Redis做Master-Slave也有这个问题,所以官方推荐Redis Sentinel这种高可用性(HA)解决方案: Sentinel监控集群状态并能够实现自动切换,我们只要不断地从Sentinel哪里获得现在的Master是谁就可以了。

在学习k8s过程里面我发现k8s世界更多的是做基础支持,对于高可用、备份方案这类现实世界更真实的需求没什么官方成熟、完善的支持。我现在使用的是k8s官方例子中的Redis Sentinel集群用法,具体的可以看延伸阅读链接2: 《Reliable, Scalable Redis on Kubernetes》,不过它的文档写的很简陋且不符合国情(你懂得),且这个例子看起来比较古老,我对其做了一些调整。

构建镜像

例子中使用的镜像是 k8s.gcr.io/redis:v1,但其实这个镜像是通过例子的image目录下的代码构建出来的,所以我针对国内源的问题修改了下具体的可以看lyanna项目下的k8s/sentinel目录下的内容,为此,我需要构建一个新的镜像(dongweiming/redis-sentinel)并上传到hub.docker.com:

❯ docker build -t dongweiming/redis-sentinel:latest .

❯ docker push dongweiming/redis-sentinel用ReplicaSet替代ReplicationController

官方都这么推荐好久,可以这个例子还是使用RC,所以为此我改进成了ReplicaSet,不过为了省事我没有改成StatefulSet,未来有时间再搞吧。

让lyanna支持Redis Sentinel

原来在lyanna的代码中使用 DB_URL、 REDIS_URL这样的设置项,而现在迁到容器里面,我的思路是用上面那个叫lyanna-cfg的ConfigMap把设置项通过环境变量传进容器,启动应用时会读取这些环境变量,另外也要支持Redis Sentinel,所以改成这样(config.py):

DB_URL = os.getenv('DB_URL', 'mysql://root:@localhost:3306/test?charset=utf8')
REDIS_URL = os.getenv('REDIS_URL', 'redis://localhost:6379')
DEBUG = os.getenv('DEBUG', '').lower in ('true', 'y', 'yes', '1')
MEMCACHED_HOST = os.getenv('MEMCACHED_HOST', '127.0.0.1')
# Redis sentinel
REDIS_SENTINEL_SERVICE_HOST = None
REDIS_SENTINEL_SERVICE_PORT = 26379
try:
from local_settings import * # noqa
except ImportError:
pass
# 这部分要加在`from local_settings import *`之后
redis_sentinel_host = os.getenv('REDIS_SENTINEL_SVC_HOST') or REDIS_SENTINEL_SERVICE_HOST # noqa
if redis_sentinel_host:
redis_sentinel_port = os.getenv('REDIS_SENTINEL_SVC_PORT',
REDIS_SENTINEL_SERVICE_PORT)
from redis.sentinel import Sentinel
sentinel = Sentinel([(redis_sentinel_host, redis_sentinel_port)],
socket_timeout=0.1)
redis_host, redis_port = sentinel.discover_master('mymaster')
REDIS_URL = f'redis://{redis_host}:{redis_port}'

另外,lyanna是一个aio项目,redis驱动用的是aioredis,它底层用的是hiredis(Redis C客户端的Python封装),它是不支持sentinel的,所以需要额外引入redis-py这个库(requirements.txt)

看看代码

说了这么多,看看具体代码吧。架构分三步,首先是一个Pod,里面有2个容器: 一个Master和一个Sentinel:

apiVersion: v1
kind: Pod
metadata:
labels:
name: redis
redis-sentinel: "true"
role: master
name: redis-master
spec:
containers:
- name: master
image: dongweiming/redis-sentinel:latest
env:
- name: MASTER
value: "true"
ports:
- containerPort: 6379
resources:
limits:
cpu: "0.1"
volumeMounts:
- mountPath: /redis-master-data
name: data
- name: sentinel
image: dongweiming/redis-sentinel:latest
env:
- name: SENTINEL
value: "true"
ports:
- containerPort: 26379
volumes:
- name: data
hostPath:
path: /var/lib/redis

这2个容器都有对应的环境变量MASTER和SENTINEL,但是注意监听端口不同(master 6379/sentinel 26379),而且Master会把容器的/redis-master-data(Redis数据存储目录,具体逻辑可以看k8s/sentinel目录下的代码)挂载到本地/var/lib/redis,让数据持久化。

然后是Sentinel服务:

apiVersion: v1
kind: Service
metadata:
labels:
name: sentinel
role: service
name: redis-sentinel
spec:
ports:
- port: 26379
targetPort: 26379
selector:
redis-sentinel: "true"

服务并不直接提供Redis服务,这是一个Sentinel服务,lyanna请求它获得现在的Master IP和端口,然后拼 REDIS_URL访问,具体的可以看前面提的config.py中的改动。

然后是2个ReplicaSet,先看Master的:

apiVersion: apps/v1
kind: ReplicaSet
metadata:
name: redis
spec:
replicas: 2
selector:
matchLabels:
name: redis
template:
metadata:
labels:
name: redis
role: master
spec:
containers:
- name: redis
image: dongweiming/redis-sentinel:latest
ports:
- containerPort: 6379
volumeMounts:
- mountPath: /redis-master-data
name: data
volumes:
- name: data
hostPath:
path: /var/lib/redis

总体和前面的name为redis-master的Pod中master部分一样,唯一不同的是: 这个ReplicaSet中的2个副本都没有环境变量MASTER,所以可以理解它们是Slave!

再看Sentinel的ReplicaSet:

apiVersion: apps/v1
kind: ReplicaSet
metadata:
name: redis-sentinel
spec:
replicas: 2
selector:
matchLabels:
redis-sentinel: "true"
template:
metadata:
labels:
name: redis-sentinel
redis-sentinel: "true"
role: sentinel
spec:
containers:
- name: sentinel
image: dongweiming/redis-sentinel:latest
env:
- name: SENTINEL
value: "true"
ports:
- containerPort: 26379

Service的后端Pod(服务的selector为redis-sentinel: "true")包含这个ReplicaSet里面2个Pod,以及前面的name为redis-master的Pod中的sentinel,这三个Pod都有SENTINEL变量但是没有放在同一个ReplicaSet的设计是为了在初始化时让Sentinel服务先生效再启动ReplicaSet里面的2个Pod(这部分逻辑在k8s/sentinel/run.sh里面)。

我再深入的解释下这个问题吧。Replica里的2个Pod是靠svc/redis-sentinel获取IP和端口的,但问题是这个服务就是靠这些Pod才能接受请求,这就有了「没有鸡就下不了蛋,没有蛋生不了鸡」的问题。那么svc中久需要有一(多)个用另外的方法获得IP和端口才可以。svc是Pod之间的通信,另外一种方法就是让Pod内部2个容器内部直接通信,所以在run.sh里面会尝试 redis-cli-h $(hostname-i)INFO,那么name为redis-master的Pod中的sentinel就能和Master容器直接通信了。其实看Sentinel Pod日志也能看到这个过程:

❯ kubectl logs redis-sentinel-5p84q |head -5
Could not connect to Redis at 10.101.31.21:26379: Connection refused
Could not connect to Redis at 172.17.0.7:6379: Connection refused
Connecting to master failed. Waiting...
# Server
redis_version:4.0.14

# 👆 首先尝试从服务10.101.31.21:26379获取失败,由于容器所在的Pod的网络是共享的,所以尝试了访问自己这个IP的6379端口也失败

# 👇先从服务10.101.31.21:26379获取失败,再连自己连成功了,就没第二个Connection refused

❯ kubectl logs redis-master -c sentinel |head -5
Could not connect to Redis at 10.101.31.21:26379: Connection refused
# Server
redis_version:4.0.14

现在为什么这么搞了吧?

后记

贴了好长的配置,大家慢慢理解吧~

全部k8s配置可以看lyanna项目下的k8s目录