Skip to main content

k8s 无状态服务基本部署

终于来到了本系列第二个重点,kubernetes,简称 k8s。

通过我们前面的学习,我们了解了微服务架构的基本概念。但是,每个微服务都是一个独立的服务,它们需要独立部署、独立扩展、独立监控。

容器化技术解决了服务的独立部署问题,而 k8s 则解决了服务的独立扩展、独立监控问题。k8s 就是一个容器的管理平台,它可以帮助我们管理大量的容器,让我们可以更加方便地部署、扩展、监控我们的服务。

minikube

k8s 有许多个实现,生产环境中就是使用的 k8s,但是在开发环境中,我们可以使用 minikube 来模拟一个 k8s 集群。k3s 是另一个轻量级的 k8s 实现,但是它需要依赖于 linux 虚拟机。

不过,minikube 只能建立一个单节点的 k8s 集群,所以它只适合用来学习和开发。如果你要进入生产环境,请自行配置 k8s 集群。但是注意,这里我们学的所有内容都不会改变- 除了 minikube 限定的。最大的区别只不过是登陆集群时,你要使用 ssh 而不是 minikube ssh。此外,在访问服务时,你也可以直接访问,不需要 minikube 的一些特殊操作。总而言之,在生产环境中的使用其实更简单一些,而且被我们的所有内容包含。

参考文档安装即可。

使用minikube start即可启动一个 k8s 集群。minikube stop可以停止集群。minikube pause可以暂停集群。minikube unpause可以恢复集群。minikube delete可以删除集群。

minikube dashboard可以打开 k8s 的 dashboard,可以在浏览器中查看集群的状态。这个命令行要保持运行,否则 dashboard 会关闭。

minikube 本身也是一个 docker 容器,在 docker 中会显示出来。

Kubectl

minikube 只是启动了一个容器集群,管理这个集群的工具是 kubectl。kubectl 是 k8s 的命令行工具,可以用来管理 k8s 集群。

你可以使用kubectl config current-context来查看当前 kubectl 操作的集群。

k8s 微服务项目的实现

之前在 Spring Cloud 部分,我们要使用许多中间件,例如 Consul 等。这些中间件的功能很多都在 k8s 内置了。例如,k8s 有自己的服务发现机制,有自己的配置中心,有自己的负载均衡等等。

现在我们先实现一个简单的微服务项目。这里我们用一个简单的带消息队列的项目来演示。注意,我们所有的项目都要变成 docker 容器。

Producer

首先,我们创建一个生产者项目。这个项目会向消息队列发送消息。

在 Javascript 中,使用amqplib库来操作 RabbitMQ。

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

const mq = "amqp://user:password@message-queue:5672"

async function connectMq() {
const connection = await connect(mq)
const channel = await connection.createChannel()
await channel.assertQueue('food')
return channel
}

const channel = connectMq()

const app = new Hono()

app.get('/apple', async (c) => {
const ch = await channel
ch.sendToQueue('food', Buffer.from('apple'))
return c.json({ message: 'apple sent' })
})

app.get('/strawberry/:count?', async (c) => {
const ch = await channel
const count = Number.parseInt(c.req.param('count') || '1')
Array.from({
length: count
}).forEach(() => {
ch.sendToQueue('food', Buffer.from('strawberry'))
})
return c.json({ message: 'strawberry sent', count })
})

export default app

这里:count?表示 count 是可选的。我们可以访问/strawberry或者/strawberry/3

URL 里地址使用的message-queue,这个将是我们 k8s 集群中的消息队列的地址。我们会在后面创建这个消息队列。

然后我们需要编写 Dockerfile。

FROM oven/bun:slim

COPY . /app
WORKDIR /app
RUN bun install
CMD bun run ./src/index.ts

Consumer

然后我们创建一个消费者项目。这个项目会从消息队列中接收消息。

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

const mq = "amqp://user:password@message-queue:5672"

async function connectMq() {
const connection = await connect(mq)
const channel = await connection.createChannel()
await channel.assertQueue('food')
return channel
}

const channelPromise = connectMq()
const messageQueue: string[] = []

const app = new Hono()

app.get('/ping', async (c) => {
return c.json({ message: 'pong' })
})

channelPromise.then((ch) => {
// adds to the messageQueue array endlessly
ch.consume('food', (msg) => {
if (msg) {
messageQueue.push(msg.content.toString())
}
}, { noAck: true })
})

app.get('/food', async (c) => {
return c.json({ messageQueue })
})

export default app

这里的ack表示确认收到消息。这个步骤之前在 Spring 中被自动处理了。我们在这里开启noAck,代表不确认,直接接收。

Dockerfile 与生产者一样。

k8s 基础无状态组件

上面我们完成了 Producer 和 Consumer 两个项目。我们需要将这两个项目组织到 k8s 中,并添加消息队列。在部署之前,我们需要了解 k8s 的一些基础无状态组件。各个组件的配置文件可见官方文档

Pod

Pod 是 k8s 的最小单元。一个 Pod 可以包含一个或多个容器。在我们的例子中,Producer 和 Consumer 都是一个容器,所以我们可以将它们放在一个 Pod 中。

k8s 的配置文件是 yaml 格式的。我们可以创建一个pod.yaml文件。

apiVersion: v1
kind: Pod
metadata:
name: producer-consumer
spec:
containers:
- name: producer
image: producer
ports:
- containerPort: 3000
resources:
requests:
memory: "64Mi"
cpu: "250m"
limits:
memory: "128Mi"
cpu: "500m"
- name: consumer
image: consumer
ports:
- containerPort: 3000
resources:
requests:
memory: "64Mi"
cpu: "250m"
limits:
memory: "128Mi"
cpu: "500m"

对于 k8s 配置的语法,我们可以参考文档。具体而言,一个 Pod 配置文件包含了 apiVersion, kind, metadata, spec 四个部分。

  • apiVersion 表示 k8s 的 api 版本。
  • kind 表示这个配置文件的类型,这里是 Pod。
  • metadata 包含了一些元数据,例如 Pod 的名字。
  • spec 包含了 Pod 的配置,例如容器的配置。

对于不同的组件,我们要学习的就是 spec 部分的配置。在这里,我们配置了两个容器,一个是 Producer,一个是 Consumer。

Pod 的 spec 如下,

  • containers 表示容器的配置。这里我们配置了两个容器。
  • name 表示容器的名字。
  • image 表示容器的镜像。
  • ports 表示容器使用的 port,这里只是进行说明,并不会自动打开端口。ports 是一个列表,每个元素可以是一个对象,包含 containerPort 和 protocol 两个属性。一般而言,我们只需要配置 containerPort。
  • resources 表示容器的资源配置。requests 表示容器的最小资源,limits 表示容器的最大资源。这里我们配置了内存和 CPU 的资源。因为 k8s 支持自动扩展,所以我们需要配置资源,以避免资源耗尽。

一般而言,我们不会直接使用 Pod,而是使用其它组件。其它组件会自动创建 Pod。

注意,默认情况下,k8s 是从 registry 获取镜像,如果要使用本地镜像,需要使用imagePullPolicy: Never。并且手动使用minikube image load im1 im2 im3命令加载镜像。

即,

apiVersion: v1
# ...
containers:
- name: producer
image: producer
imagePullPolicy: Never
# ...
- name: consumer
image: consumer
imagePullPolicy: Never
# ...

且要运行,minikube image load producer:latest consumer:latest,来加载镜像到 minikube 中。

或者,使用minikube image build -t producer .来使用 minikube 的 docker 构建镜像。这样就不需要手动加载镜像了。

如果要删除镜像,使用minikube ssh进入集群,然后用 docker 删除镜像即可。

ReplicaSet

ReplicaSet 一般不会独立使用,而是和 Deployment 一起使用。ReplicaSet 会自动创建 Pod,并且可以自动扩展 Pod 的数量。创建 Deployment 时,ReplicaSet 会自动创建。

因为我们不会直接使用 ReplicaSet,所以这里不再赘述其配置文件。

Deployment

Deployment 是 k8s 的一个控制器,它是一组 Pod 的抽象。Deployment 会自动创建 Pod,并且可以自动扩展 Pod 的数量。此外,Deployment 还有自动重试,回滚,热更新等功能。

现在,我们为 Producer 和 Consumer 分别创建一个 Deployment。

apiVersion: apps/v1
kind: Deployment
metadata:
name: producer
spec:
selector:
matchLabels:
app: producer
template:
metadata:
labels:
app: producer
spec:
containers:
- name: producer
image: producer
ports:
- containerPort: 3000
resources:
limits:
cpu: "1"
memory: "512Mi"

对于 Deployment,spec 有三个部分,

  • replicas 表示 Pod 的数量。
  • selector 表示选择器,用来选择哪些 Pod 属于这个 Deployment。这里我们选择 label 中 app 值为 producer 的 Pod。
  • template 表示 Pod 的配置文件模版。这部分的配置和 Pod 配置一样。

此外,我们还需要配置 RabbitMQ 和 consumer。

apiVersion: apps/v1
kind: Deployment
metadata:
name: mq
spec:
selector:
matchLabels:
app: mq
template:
metadata:
labels:
app: mq
spec:
containers:
- name: mq
image: rabbitmq:4.0-rc-management
resources:
limits:
memory: "128Mi"
cpu: "500m"
ports:
- containerPort: 5672
apiVersion: apps/v1
kind: Deployment
metadata:
name: mq
spec:
selector:
matchLabels:
app: mq
template:
metadata:
labels:
app: mq
spec:
containers:
- name: mq
image: rabbitmq:4.0-rc-management
resources:
limits:
memory: "128Mi"
cpu: "500m"
env:
- name: RABBITMQ_DEFAULT_USER
value: user
- name: RABBITMQ_DEFAULT_PASS
value: password
ports:
- containerPort: 5672

注意,这里的语法与 docker-compose 类似但不完全一样。

Service

此前我们在 Spring Cloud 中,服务发现使用的是 Consul。Service 组件也有类似的功能。Service 是 k8s 的一个服务发现机制。Service 会自动创建一个虚拟 IP,用来代理一组 Pod。这样,我们就可以通过这个虚拟 IP 来访问这组 Pod。

上文中,我们的 producer 和 consumer 都使用amqp://user:password@message-queue:5672来访问消息队列。因此我们创建一个名为message-queue的 Service。

apiVersion: v1
kind: Service
metadata:
name: message-queue
spec:
selector:
app: mq
ports:
- port: 5672
targetPort: 5672

这里 selector 选择了 label 中 app 值为 mq 的 Pod。ports 配置了端口映射。这样,通过访问message-queue:5672就可以访问到 mq Pod 的 5672 端口。如果我们有多个 mq Pod,k8s 会自动负载均衡。

此外,如果你想暴露多个端口,每个端口都要有一个独特的 name。

# ...
ports:
- name: amqp
port: 5672
targetPort: 5672
- name: management
port: 15672
targetPort: 15672

注意,所有的 deployment,如果需要访问,无论是内部还是外部,都需要创建 service。因此我们还需要为 producer 和 consumer 创建 service。

Service 有一个 type 参数,可以加在 spec 中。type 有四个值,

  • ClusterIP:默认值,创建一个虚拟 IP,只能在集群内部访问。
  • NodePort:将集群的端口映射到 Node 的端口上,可以在集群外部访问。一般用于测试。
  • LoadBalancer:创建一个负载均衡器,可以在集群外部访问。具体配置取决于云服务商。Minikube 支持这个类型,它的行为即可以通过 minikube 命令暴露端口。
  • ExternalName:将 Service 映射到一个外部域名。

之后我们会在部署时介绍它们的使用。现在我们先保留默认值。

注意,我们在下文中修改 message queue 时,很可能不会修改 producer 和 consume。这时,程序里的 channel 会保持连接,但事实上已经失效。这会导致 producer 和 consumer 无法访问到新的 message queue。这时,我们需要重启 producer 和 consumer。重启方法是使用命令kubectl rollout restart deployment producer

k8s 独立外部访问

现在,我们有了 6 个配置文件,分别是 producer, consumer, mq, producer-service, consumer-service, mq-service。我们可以使用kubectl apply -f命令来部署这些配置文件。该命令可在后面加文件夹名,会自动部署文件夹下的所有配置文件。或者加文件名,会部署单个文件。

kubectl apply -f .

现在,就可以在 dashboard 中看到 pod 和之前我们定义的所有的 service 等。

但是,还有个重要问题需要解决:尽管 k8s 整个集群都在正常工作,我们却没有办法访问到 producer 和 consumer。这是因为我们的 service 是 ClusterIP 类型的,只能在集群内部访问。

使用下面的方法,你可以检查出你的微服务是否正常工作。注意,反代,NodePort 只会用于测试环境,生产环境中,如果节点很少,可以使用 LoadBalancer,但是一般都应该使用网关。网关属于统一访问。

反代

在开发模式中,可以使用minikube service --all来进行转发,这样就可以在本地访问到 k8s 中的服务。命令行上会显示出所有反向代理到本机的服务。或者,也可以使用minikube service producer-service --url来单独转发。

NodePort

NodePort 类型的 service 可以将集群的端口映射到 Node 的端口上,可以在集群外部访问。一般用于测试。

我们可以修改 service 的配置文件,将 type 改为 NodePort。

apiVersion: v1
kind: Service
metadata:
name: producer-service
spec:
selector:
app: producer
ports:
- port: 3000
targetPort: 3000
nodePort: 30001
type: NodePort

理论上,只要访问 minikube 的 IP 地址(使用minikube ip命令)和 nodePort,就可以访问到 producer 服务。

很可惜,在 MacOS 或 Windows 上,事情没这么简单。这是因为,minikube 本身就是一个容器,而在 MacOS 或 Windows 上,docker 是运行在虚拟机中的。

现在,你可以使用minikube ssh进入 minikube 的虚拟机,然后使用curl命令访问http://localhost:30001/apple,这样是可以访问到 producer 服务的。

minikube官方是希望用户使用minikube service producer-service --url来访问的。但是正如我们前面所说的,开发环境中,ClusterIP 模式也能访问,只是会有 Warning。

如果不使用 docker,而是使用其它的无隔离的,支持直通的虚拟机或容器,也可以直接访问。

LoadBalancer

LoadBalancer 是 k8s 的标准暴露方式,可以在集群外部访问。可以用于生产环境。具体配置取决于云服务商。Minikube 支持这个类型。

使用 LoadBalancer 时,服务提供者会自动创建一个负载均衡器,这个负载均衡器会将请求转发到集群中的 Pod。

我们可以修改 service 的配置文件,将 type 改为 LoadBalancer。移除 NodePort。

apiVersion: v1
kind: Service
metadata:
name: producer-service
spec:
selector:
app: producer
ports:
- port: 3000
targetPort: 3000
type: LoadBalancer

然后,我们必须开启 tunnel,这和上面的 NodePort 原因一样,但是 minikube 为 load balancer 提供了 tunnel 功能,可以直接把虚拟机的端口映射到本地。

minikube tunnel

此时可以使用kubectl get svc来查看服务的状态,当对应服务 EXTERNAL-IP 变为一个 IP 地址时,就可以访问这个 IP 地址了。使用{EXTERNAL-IP}:{PORT}/apple即可访问。这里的端口是上文配置文件中 port 对应的端口。如果没有开启 tunnel,EXTERNAL-IP 会一直是 pending。