Python项目容器化实践(七) - lyanna的Kubernetes配置文件
/ / / 阅读数:6282接下来 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=utf8 |
Redis 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 目录