Skip to main content

k8s 无状态服务

前面我们已经从实践的角度讲解了 k8s 的无状态服务的基本部署,以及如何打包应用,如何配置网关。这一节,我们会介绍 k8s 中的无状态服务的生产级部署。

k8s 中,服务可以根据其是否有状态,即是否需要持久化存储,分为无状态服务和有状态服务。无状态服务是指服务不需要持久化存储,可以随时重启,而有状态服务则需要持久化存储,不能随时重启。无状态服务的部署相对简单,因为无状态服务可以随时重启,而有状态服务则需要保证数据的一致性。

k8s 对象

前面我们一直在编写一些配置文件,这些配置文件其实是 k8s 对象的定义。k8s 中有很多对象,每个对象都有自己的作用。我们可以通过配置文件来定义这些对象,然后通过 k8s 的 API 来创建这些对象。

要查看目前已经注册的对象,可以使用命令,

kubectl api-resources

必须字段

一个 k8s 对象包含,

  • API 版本,apiVersion,指定对象的 API 版本。
  • 类型,kind,指定对象的类型。
  • 元数据,metadata,指定对象的元数据,例如名称、标签等。
  • 规范,通常是spec,指定对象的规范,例如容器的镜像、端口等。

这些字段是 k8s 对象的基本字段,每个对象都有这些字段。

对象元数据

之前我们在使用 Service 时发现,Service 是一个负载均衡到达若干 Pod 请求的对象,我们需要选中一些 Pod。当时我们使用的是,

selector:
app: producer

这是因为我们在创建 deployment 时,为 pod 指定的模版为,

metadata:
labels:
app: producer

这就是一个选择器,Service 会根据这个选择器选择对应的 Pod。这里的 label 就是对象的元数据。

k8s 中,首先按命名空间划分逻辑集群。每个逻辑集群包含若干对象。每个对象都有一个唯一的 UID。同时,每个对象还可以带有 metadata,包括名称、命名空间、标签、注解。

名称通常是用户为了方便指定的唯一标识符,与 UID 功能相同,但是 UID 是 k8s 自动生成的,名称是用户指定的。这是 metadata 的 name 字段。

labels 是一个字典,包含了一些键值对。这些键值对可以用来选择对象。例如,Service 会根据 selector 选择对应的 Pod。这是 metadata 的 labels 字段。常用的一个例子是app: producer,表示这个对象是一个 producer 服务。此外,有的服务网格(后面会介绍)会根据version: v1选择对应的版本。

如果需要为对象指明命名空间,可以使用 metadata 的 namespace 字段。如果不指定,那么默认为 default 命名空间。

注解我们之前没有使用过,它是 metadata 的 annotations 字段。注解是一些键值对。与 labels 不同,注解不是单纯的标记,而是有功能的,例如,可以通过imagerregistory注解指定 Pod 镜像的仓库。

无状态服务对象

首先我们介绍需要使用到的对象,前面介绍到的会一笔带过。

Pod

Pod 是 k8s 的最小部署单元,是一个或多个容器的集合。Pod 中的容器共享网络和存储,可以通过 localhost 直接通信。Pod 是无状态服务的基本部署单元。

ReplicaSet

ReplicaSet 是 Pod 的控制器,用于保证 Pod 的数量。ReplicaSet 会根据用户定义的 Pod 模板,保证 Pod 的数量。如果 Pod 挂掉,ReplicaSet 会自动重启 Pod。

Deployment

Deployment 是 ReplicaSet 的控制器,用于保证 ReplicaSet 的数量。Deployment 会根据用户定义的 ReplicaSet 模板,保证 ReplicaSet 的数量。如果 ReplicaSet 挂掉,Deployment 会自动重启 ReplicaSet。

因此,Deployment 只能用于部署无状态服务,因为无状态服务可以随时重启。如果服务有状态,那么重启后数据会丢失。

Deployment 可以指定 Resources,例如 CPU 和内存。

resources:
requests:
memory: "64Mi"
cpu: "250m"
limits:
memory: "128Mi"
cpu: "500m"

这里m表示 milli,即 1/1000,CPU 的单位是核心数。requests表示请求的资源,limits表示限制的资源。

Service

Service 是 Pod 的负载均衡器,用于将请求转发到 Pod。Service 会根据用户定义的 Pod Selector,将请求转发到对应的 Pod。Service 有四种类型,分别是 ClusterIP,NodePort,LoadBalancer 和 ExternalName。

ConfigMap

前面我们使用 helm 模版实现了用户自定义,但是如此操作,每次都需要重新部署。ConfigMap 可以将配置文件独立出来,然后挂载到 Pod 中。这样,用户可以通过修改 ConfigMap 来修改配置文件,而不需要重新部署 Deployment。

ConfigMap 有一个额外的字段,data,用于存放配置信息。

apiVersion: v1
kind: ConfigMap
metadata:
name: username-password-config
data:
username: admin
password: admin

这里的 data 就是普通的 yaml,可以存放任意配置信息。

此外,如果你确定你的 ConfigMap 不会在重新部署前改变,可以使用immutable字段来指定 ConfigMap 是不可变的。

# ...
metadata:
name: username-password-config
# ...
immutable: true

如果要让 Pod 读取到 ConfigMap,常用的是以下两种方法。

将 ConfigMap 作为文件

Pod 本身就是 Docker 容器,而 Docker 容器有 Volume,用于挂载外部文件。ConfigMap 自身可以作为一个文件,被挂载到 Pod 中。

apiVersion: v1
kind: Pod
metadata:
name: producer
spec:
containers:
- name: producer
image: producer
volumeMounts:
- name: username-password-volume
mountPath: "/conf"
readOnly: true
volumes:
- name: username-password-volume
configMap:
name: username-password-config

在上面的 Pod 配置中,我们首先在 volumes 中将 ConfigMap 定义成了一个 Volume。然后在 containers 中将这个 Volume 挂载到了/conf路径。

然后我们就可以读取/conf路径下的文件,即 ConfigMap。例如,使用 Javascript。

import * as fs from 'fs';

const username = fs.readFileSync('/conf/username', 'utf8');
const password = fs.readFileSync('/conf/password', 'utf8');

注意,在除了环境变量模式以外的模式下,ConfigMap 更新时,其被映射的内容(例如这里的文件),会被一并刷新。但是,程序需要重新读取才能获取到新的内容。

将 ConfigMap 作为环境变量

使用以下的配置方式来把 ConfigMap 加载成环境变量,

apiVersion: v1
kind: Pod
metadata:
name: producer
spec:
containers:
- name: producer
image: producer
env:
- name: CONFIG_MAP_USERNAME
valueFrom:
configMapKeyRef:
name: username-password-config
key: username
- name: CONFIG_MAP_PASSWORD
valueFrom:
configMapKeyRef:
name: username-password-config
key: password

但是,注意,Pod 中环境变量名称允许的字符范围是有限的。如果某些变量名称不满足这些规则,则即使 Pod 可以被启动,你的容器也无法访问这些环境变量。

Secret

如果你有一些敏感信息,例如密码,那么你可以使用 Secret 来存储这些信息。Secret 与 ConfigMap 类似,但是 Secret 会被加密存储。

Secret 也有 data 字段,与 ConfigMap 行为基本一致。

apiVersion: v1
kind: Secret
metadata:
name: password-secret
type: Opaque
data:
password: password

这里 type 字段是 Opaque,即用户自定义加密方式。type 还有许多类型,有不同的加密方式,具体可以查看文档

注意,k8s 只是将 Secret 加密存储,而不是加密传输。即这里我们的文件里写的是明文密码,但是 k8s 会将其加密存储。如果你不希望明文密码出现在文件中,手动进行对称或非对称加密,然后将加密后的密码存储在 Secret 中,公钥公开在 ConfigMap,私钥被对应的 Pod 持有。

如果要加载 Secret,把上文中 ConfigMap 的对象名改为 Secret 即可,例如,

apiVersion: v1
kind: Pod
metadata:
name: secret-dotfiles-pod
spec:
volumes:
- name: secret-volume
secret:
secretName: dotfile-secret
containers:
- name: dotfile-test-container
image: registry.k8s.io/busybox
command:
- ls
- "-l"
- "/etc/secret-volume"
volumeMounts:
- name: secret-volume
readOnly: true
mountPath: "/etc/secret-volume"

注意,Secret 对所有挂载的容器都是明文的。如果需要更安全的方式,可以手动实现非对称加密,并把私钥储存在唯一允许访问的 Pod 中。

HorizontalPodAutoscaler

前面我们已经实现了服务的部署。但是,使用 Deployment 只能给定一个 Pod 的数量。但在现实中,大部分服务下,我们都希望能自动扩缩,以节省计算资源。例如,在购物淡季,我们希望减少服务器数量来缩减成本,而在购物旺季,我们希望增加服务器数量来提高性能。

容器的扩缩容是 k8s 的核心功能之一。扩容有两个维度,一个是水平扩容,即增加 Pod 的数量,另一个是垂直扩容,即增加 Pod 的资源。

扩缩是十分复杂的,我们只介绍最简单的自动水平扩容。HorizontalPodAutoscaler 是 k8s 的水平扩容器。它会根据用户定义的 CPU 使用率和内存使用率,自动调整 Pod 的数量。

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: producer-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: producer
minReplicas: 1
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 50

这里的minReplicasmaxReplicas分别是最小和最大 Pod 数量。metrics是水平扩容的指标,这里使用了 CPU 使用率。averageUtilization是 CPU 使用率的目标值。

上面的配置方式下,如果 CPU 的平均使用率超过 50%,那么就会增加 Pod 的数量。如果 CPU 的平均使用率低于 50%,那么就会减少 Pod 的数量。但是无论如何,最多只会增加到 10 个 Pod,最少只会减少到 1 个 Pod。

此外,常用的指标还有内存,使用,

metrics:
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 50

注意,这里的算法是滑动窗口算法。如果要配置算法的参数,使用behavior字段。

behavior:
scaleDown:
stabilizationWindowSeconds: 300
scaleUp:
stabilizationWindowSeconds: 300

上面的 behavior 中,scaleDown 和 scaleUp 分别是缩容和扩容的配置。stabilizationWindowSeconds是稳定窗口的时间,即上文平均值计算的时间窗口。当然,还有更复杂的配置,这里不再赘述。

生产级无状态服务部署

现在,我们重新部署我们之前的简单项目。有一个 producer 服务,一个内容随时可以丢弃的消息队列,一个 consumer 服务。我们希望所有的服务都是无状态服务,可以随时重启,且能自动扩容。此外,配置信息使用 ConfigMap 存储,敏感信息使用 Secret 存储,还要支持自动扩容。

此外,我们使用 helm 来打包,并实现模版。

配置参数

我们希望能使用 ConfigMap 存储 RabbitMQ 的用户名以及队列名称。

apiVersion: v1
kind: ConfigMap
metadata:
name: rabbitmq-config
data:
username: guest
queue: food

然后还有一个 Secret,存储 RabbitMQ 的密码。

apiVersion: v1
kind: Secret
metadata:
name: rabbitmq-secret
type: Opaque
data:
password: password

服务部署

我们要修改一下我们的 Producer 与 Consumer 的连接部分,

import { Hono } from 'hono'
import { connect } from 'amqplib'
import { config } from 'dotenv'

config()
const mq = `amqp://${process.env.RABBITMQ_USERNAME}:${process.env.RABBITMQ_PASSWORD}@message-queue:5672`


async function connectMq() {
const connection = await connect(mq)
const channel = await connection.createChannel()
await channel.assertQueue(process.env.RABBITMQ_QUEUE)
return channel
}

const channel = connectMq()

然后修改 deployment 部分,

apiVersion: apps/v1
kind: Deployment
metadata:
name: producer
spec:
replicas: 1
selector:
matchLabels:
app: producer
template:
metadata:
labels:
app: producer
spec:
resources:
requests:
memory: "64Mi"
cpu: "100m"
limits:
memory: "128Mi"
cpu: "100m"
containers:
- name: producer
image: producer
env:
- name: RABBITMQ_USERNAME
valueFrom:
configMapKeyRef:
name: rabbitmq-config
key: username
- name: RABBITMQ_QUEUE
valueFrom:
configMapKeyRef:
name: rabbitmq-config
key: queue
- name: RABBITMQ_PASSWORD
valueFrom:
secretKeyRef:
name: rabbitmq-secret
key: password

Consumer 部分类似。

此外,对于 RabbitMQ,我们同样进行配置,

apiVersion: apps/v1
kind: Deployment
metadata:
name: message-queue
spec:
replicas: 1
selector:
matchLabels:
app: message-queue
template:
metadata:
labels:
app: message-queue
spec:
resources:
requests:
memory: "64Mi"
cpu: "100m"
limits:
memory: "128Mi"
cpu: "100m"
containers:
- name: message-queue
image: rabbitmq
ports:
- containerPort: 5672
env:
- name: RABBITMQ_DEFAULT_USER
valueFrom:
configMapKeyRef:
name: rabbitmq-config
key: username
- name: RABBITMQ_DEFAULT_PASS
valueFrom:
secretKeyRef:
name: rabbitmq-secret
key: password

Service 对象不变。

流量监控

我们继续使用之前的网关即可,配置过程省略。

不过,我们前面没有介绍对调试很重要的服务链路追踪。服务链路追踪是一种监控手段,用于监控服务之间的调用。kiali 是 Istio 的监控工具。

我们使用下面的命令添加 Istio。

kubectl apply -f https://raw.githubusercontent.com/istio/istio/release-1.23/samples/addons/kiali.yaml

注意,就像我们之前学习 MicroMeter 一样,kiali 只是一个 dashboard。它需要从 Prometheus 和 Grafana 中获取数据。我们需要先安装 Prometheus 和 Grafana。

kubectl apply -f https://raw.githubusercontent.com/istio/istio/release-1.23/samples/addons/grafana.yaml

以及,

kubectl apply -f https://raw.githubusercontent.com/istio/istio/release-1.23/samples/addons/prometheus.yaml

注意,后者 prometheus 的默认配置适合小集群而非大集群。如果集群变得太大,我们需要使用 Service Mesh 来为集群提供基础设施,我们会在后面介绍 Service Mesh。

现在,使用,istioctl dashboard kiali来打开 kiali。我们可以看到服务之间的调用关系,调用图,流量等。

注意,如果我们使用 helm,应该把上面的文件放入 template 中,然后使用helm install来安装。

不过,我们会发现消息队列的流量没有显示出来。我们需要给 RabbitMQ 添加插件,

rabbitmq-plugins enable rabbitmq_prometheus

这需要我们使用 dockerfile 启动 RabbitMQ 时添加插件。

FROM rabbitmq:4.0-rc-management

RUN rabbitmq-plugins enable rabbitmq_prometheus

然后使用这个 dockerfile 来构建镜像即可。重启消息队列后,记得重启 Consumer 和 Producer。

自动扩容

我们使用 HorizontalPodAutoscaler 来实现自动扩容。

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: producer-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: producer
minReplicas: 1
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 50

consumer 同理。