引言
在开发Kubernetes服务时,我通常会从本地的Kind Kubernetes集群着手进行开发。我之前曾撰写过如何设置Kind 一文,并且在与本文相关的GitHub仓库 中,包含了完成这项工作所需的配置文件。
你可以通过以下命令克隆该仓库:
git clone [email protected]:MartinHodges/aquarium-with-mongo-db.git
为什么选择MongoDB?
我之前的文章探讨过究竟该选择SQL还是No-SQL的问题。如果你正在阅读本文,我假定你已经决定采用No-SQL。
一旦做出这个决定,紧接着就要考虑选择使用哪种No-SQL数据库。MongoDB的市场份额是其最接近的竞争对手的两倍,功能十分强大,同时拥有社区版和企业版。一般来说,它是大家首选的No-SQL数据库。
对MongoDB与其他数据库进行技术层面的比较,并非本文的重点。选择MongoDB,主要是基于它极高的人气,以及它完全能够出色地完成任务!
安装MongoDB
在我们的Kubernetes集群中安装MongoDB,和安装其他应用程序的方式类似,都是借助一个Operator
来完成。
Kubernetes的Operator
负责替你管理应用程序。它不仅能够安装和管控应用程序的生命周期,还能实时监控应用程序,在必要时采取相应措施。
就数据库而言,Operator
可以创建数据库集群、实现扩展、执行备份等操作。一般来说,操作器依赖于安装自定义资源定义(CRDs),这些定义为它提供了专属的“Kubernetes配置语言”。Operator
会监听将这些自定义资源添加到集群的请求,然后代你执行相应操作。
创建开发Kubernetes集群
假设你已经安装好了Kind,现在就可以使用以下配置来创建一个Kind集群:
kind/kind-config.yml
apiVersion: kind.x-k8s.io/v1alpha4
kind: Cluster
nodes:
- role: control-plane
extraPortMappings:
# apis
- containerPort: 30080
hostPort: 30080
- role: worker
- role: worker
- role: worker
上述配置会创建一个包含4个节点的集群,其中有1个控制节点和3个工作节点。同时,它还会在开发机器上开放30080端口。你可以使用以下命令创建本地Kubernetes集群:
kind create cluster --config kind/kind-config.yml
安装Operator
你可以通过Helm来安装一个受社区支持的Operator
。
首先,将Helm链接添加到本地仓库:
helm repo add mongodb https://mongodb.github.io/helm-charts
接着,查看该链接添加了哪些图表(chart):
helm search repo mongo
在列出的结果中,你会看到我们即将使用的社区Operator
。
我们打算将Operator
和数据库放在一个名为mongo
的独立命名空间中,先创建这个命名空间:
kubectl create namespace mongo
现在就可以安装Operator
了:
helm install community-operator mongodb/community-operator -n mongo
如果你希望
Operator
监控在其他不同命名空间中创建的资源,可以在上述命令中添加--set operator.watchNamespace="<other namespace>"
。
安装完成后,你可以通过以下命令检查Operator
的就绪状态是否为1/1 Running
:
kubectl get pods -n mongo
现在Operator
已经启动并正常运行,我们可以查看它安装的自定义资源定义(CRDs):
kubectl get crds
kubectl describe crd mongodbcommunity.mongodbcommunity.mongodb.com
至此,我们已经准备好创建第一个MongoDB集群了。
创建集群
安装operator之后,它会持续监听任何创建MongoDB数据库的请求。我们可以借助operator加载的CRDs,通过将MongoDB清单应用到Kubernetes集群来发出创建请求。
在此之前,我们得为数据库用户设置一个以Kubernetes secret形式存在的密码。
按照下面的命令创建secret(记得把<…>替换成你自己选定的密码):
kubectl create secret generic my-user-password -n mongo --from-literal="password=<your password>"
你可以使用以下命令检查这个secret:
kubectl get secrets -n mongo my-user-password -o jsonpath={.data.password} | base64 -d; echo
你会发现,我使用了base64 -d
来解码密码,这是因为所有Kubernetes的secret都是以base64编码存储的。由于我们使用了--from-literal
参数,create secret
命令会自动将密码进行base64编码。
现在有了密码,就可以创建一个MongoDB集群以及一个带有此密码的管理员用户的数据库了。
创建清单文件:
k8s/my-mongo-db.yml
apiVersion: mongodbcommunity.mongodb.com/v1
kind: MongoDBCommunity
metadata:
name: my-mongo-db
namespace: mongo
spec:
members: 3
type: ReplicaSet
version: "7.0.11"
security:
authentication:
modes: ["SCRAM"]
users:
- name: my-user
db: admin
passwordSecretRef: # 引用用于生成用户密码的secret
name: my-user-password
key: password
roles:
- name: clusterAdmin
db: admin
- name: userAdminAnyDatabase
db: admin
scramCredentialsSecretName: my-user-scram
additionalMongodConfig:
storage.wiredTiger.engineConfig.journalCompressor: zlib
需要注意的是,这会在
standard
的storageClass
下创建持久卷声明(PVCs)。你必须确保你的持久卷(PV)operator能够正确地为这个请求的类创建PV。在Kind环境中这是可行的,但在其他环境中可能需要像nfs-client
这类工具。如果这些PV不可用,你的集群将无法启动。
现在可以应用这个文件:
kubectl apply -f k8s/my-mongo-db.yml
并通过以下命令检查创建进度:
kubectl get pods -n mongo
你需要等待3个实例创建完成。在我的MacBook Pro(M2 Max Apple silicon)上,使用4节点的Kind集群,启动这3个实例大约需要5分钟。
一旦集群启动并运行,就可以检查服务是否已启动:
kubectl get svc -n mongo
返回结果应该类似这样:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
my-mongo-db-svc ClusterIP None <none> 27017/TCP 6m
测试数据库
在我们的应用程序里,会直接从Kubernetes内部通过其服务的DNS名称连接到数据库。但为了进行测试,我们希望能从本地开发机器连接到它。
我第一次测试时,采用端口转发的方式,将其中一个MongoDB pod转发到本地开发机器上,结果在尝试进行任何更改时,收到了这样的错误信息:
MongoServerError[NotWriteablePrimary]: not primary
这是因为我选择进行端口转发的pod并非集群的主节点。从节点只是只读副本,因此所有写操作都必须通过主节点来执行。
为了避免这个问题,我们需要连接到主节点。
由于我们处于集群外部,要是使用像Compass这样的MongoDB客户端,它会尝试借助Kubernetes pod名称(例如:
my-mongo-db-1.my-mongo-db-svc.mongo.svc.cluster.local
)来查找主节点pod,但这肯定会失败,因为这些名称在外部是无法访问的。
要找出哪个节点是主节点,你可以查看任意节点的日志:
kubectl logs my-mongo-db-0 -n mongo -c mongod | grep "\"primary\":"
要是没有返回结果,那就说明你查看的这个节点就是主节点。
如果你得到了结果,即便只提取出几行,内容也会很长且难以阅读。要是你有像jq这样的JSON格式化工具,就可以使用:
kubectl logs my-mongo-db-0 -n mongo -c mongod | grep "\"primary\":" | jq
你会看到类似这样的一行:
...
"primary": "my-mongo-db-1.my-mongo-db-svc.mongo.svc.cluster.local:27017",
...
这就告诉你需要连接到哪个pod(在我的例子中是:my-mongo-db-1
)。现在,你可以通过以下命令转发这个pod的端口:
kubectl port-forward my-mongo-db-1 -n mongo 27017:27017
需要注意的是,你也可以通过其他pod进行连接,但会被限制只能执行只读命令。此外,主节点可能会发生变更。
一旦端口转发完成,我们就需要连接到数据库。为此,我们可以使用MongoDB Compass客户端。你可以从 https://www.mongodb.com/try/download/compass
下载这个客户端。
安装完成后,应该就能连接到你的数据库了。它会给出一个连接字符串(mongodb://localhost:27017
),但我们需要更改一些设置。
点击“Advanced Connection Options
”,选择“Direct connection
”(否则会出现找不到地址的错误,因为客户端会尝试使用Kubernetes内部地址)。
点击“Authentication
”选项卡。选择“Username/Password
”,输入之前设置的用户名(my-user
)和密码。将数据库添加为Admin,然后选择SCRAM-SHA-256
认证机制(如有必要,向下滚动查找)。
点击“Save and Connect
”,为你的连接命名,之后你应该就能看到Compass控制台已连接到你的数据库。
你会看到它在我们的集群中创建了admin
、config
和local
数据库。
要是你顺利完成了这些步骤,那就说明你的MongoDB集群已经成功启动并运行了。
创建应用程序用户
你或许会觉得,任何要连接到我们MongoDB的应用程序,都能使用我们之前创建的my-user
。但遗憾的是,并非如此,因为这个用户实际上是用于数据库维护的。
要让应用程序能够使用我们的数据库集群,我们得创建一个数据库以及一个用于访问该数据库的用户。
在Compass窗口底部,你会看到一个“>_MONGOSH”的提示。点击它,就能进入命令行界面。
接下来,我们通过以下命令来创建用户:
use aquarium
db.createUser({
user: "my-app-user",
pwd: "<password>",
roles: [{ db: "aquarium", role: "dbOwner" }]
})
这里有几点需要留意。首先,在数据库(aquarium
)尚未创建时,我们就切换到了这个不存在的数据库。这遵循了MongoDB的规则,即数据库在使用前无需预先定义,数据库和任何集合都是在你首次添加文档时才创建的。
其次是分配给新用户的角色。MongoDB有一组少量的内置角色可供分配给用户。在这种情形下,dbOwner
角色允许用户读取、写入以及管理数据库。不过要是在生产环境中,你得根据实际需求合理限制用户权限。
执行上述命令后,你会得到一段较长的响应,在开头部分会包含以下内容:
...
ok: 1,
...
为了检查这个新用户,我们打开一个新的Compass连接。你可以通过菜单操作,在Mac上也可以按Cmd+N
。注意,可能会有几秒钟没有任何反应,所以只按一次就好,别重复操作!
新连接窗口出现后,我觉得复制我们之前保存的连接会更简便(使用连接旁边的“…
”菜单)。
修改用户名和密码,还需要把“Authentication Database
”改为“aquarium
”,然后进行连接。
现在,你应该就能看到新创建的“aquarium
”数据库了。你可以在数据库名称旁边点击“+
”来创建一个“fishes
”集合用于测试。接着,就可以以文档的形式向数据库添加数据,比如:
{
"_id": 123,
"fish": "Guppy"
}
到目前为止,我们的MongoDB已经准备就绪,可以和Spring Boot应用程序配合使用了。
创建Spring Boot应用程序
在创建基于数据库的简单示例时,我通常从“aquarium
”应用程序入手。这个应用程序的思路是,通过REST API,你能够创建和管理鱼类以及鱼缸,随后还可以把鱼添加到鱼缸里。
在本文中,我不会涉及将鱼添加到鱼缸的功能,因为我打算在另一篇文章里专门讨论这一关系相关的内容。
代码
我不打算在此处展示代码,不过在相关的GitHub仓库中可以找到。
依赖
借助Spring Initializr(https://start.spring.io/ )来启动Spring Boot应用程序通常会更简便。我假定你知道如何使用它。
针对这个项目,添加Spring Web和Spring Data MongoDB作为依赖,然后创建项目。
我还引入了Lombok,以此减少重复代码的编写量。
包结构
依据我正在构建的应用程序的特性,我可能会选择基于组件类型(例如controllers
、services
和repositories
)或者业务领域来构建包结构。
由于这是一个仅包含两个业务领域(鱼类和鱼缸)的小型应用程序,我将基于这些领域创建如下结构:
fishes
FishController
FishService
FishRepository
fishtanks
FishTankController
FishTankService
FishTankRepository
可以看出,我采用了标准的分层方式,包含控制器层、服务层和仓储层。
API端点
控制器为各自的API提供创建(Create)、读取(Read)、更新(Update)和删除(Delete),即CRUD端点。
Entities和Documents
如果你熟悉JPA和像Postgres这样的SQL数据库,那么对于Entities
和repositories
就不会陌生。
在No-SQL数据库中,表被集合所取代,表中的行被Documents
所取代。
这就意味着,我们在No-SQL数据库中的存储库与SQL数据库中的略有不同。
由于No-SQL数据库能够处理任何结构,Entities
(或者Documents
)就变成了简单的普通Java对象(POJOs)。这意味着在我们的示例应用程序中,可以这样创建Entities
:
aquarium/fishes/Fish.java
@Setter
@Getter
@Document("fishes")
@NoArgsConstructor
public class Fish {
@Id
public UUID id;
public String type;
public Fish(String type) {
this.id = UUID.randomUUID();
this.type = type;
}
...
}
这里有几点需要留意:
- 我们定义的是
@Document
而非@Entity
,它接收集合的名称。 - 我使用
@Id
(这是可选的,因为如果不提供,MongoDB会自行添加)而不是@mongoId
,这样我就能自行管理UUID类型的Id。 - 我倾向于使用Lombok(例如
@Getter
)来减少样板代码。
我们现在可以用类似的方式创建鱼缸类:
aquarium/fishtanks/FishTank.java
@Setter
@Getter
@Document("fish tanks")
@NoArgsConstructor
public class FishTank {
@Id
public UUID id;
public String name;
public FishTank(String name) {
this.id = UUID.randomUUID();
this.name = name;
}
@Override
public String toString() {
return String.format(
"FishTank[id=%s, type='%s']",
id.toString(), name);
}
}
Repositories
现在我们已经有了文档,那么该如何访问它们呢?
我来展示一下我们的存储库的变化。以鱼类的Repositories
为例:
public interface FishRepository extends MongoRepository<Fish, UUID> {
public List<Fish> findAll();
public Optional<Fish> findFirstById(UUID id);
public Optional<Fish> findFirstByType(String type);
}
可以看到,它几乎和我们在SQL数据库中看到的Repositories
类型一样。唯一的区别在于,这个接口扩展的是MongoRepository
,而不是CrudRepository
。
我会把一对多和其他映射相关的内容留到另一篇文章中讲解,所以目前我们只能进行鱼类和鱼缸的创建与管理。
Application properties
当然,在使用任何数据库时,你必须告诉应用程序如何连接到它。我们通过应用程序属性来实现这一点,就像在使用SQL数据库时一样。
我更喜欢使用YAML文件作为Spring Boot的属性文件,因此我的配置如下所示(记得用你自己的值替换< >字段):
resources/application.yml
spring:
application:
name: aquarium-with-mongo-db
data:
mongodb:
host: localhost
port: 27017
database: aquarium
username: my-app-user
password: <password>
稍后我会在讲到配置文件时再回到这个问题。
Controllers 和 services
现在我们可以像在SQL数据库中一样添加我们的Controllers
和services
。我不打算在这里展示它们,因为它们在GitHub仓库中可用。
单测
完成代码(或克隆我的仓库)然后在你的IDE中运行应用程序。如果你仍然在对primary进行端口转发,应用程序应该能够启动。
然后你可以使用这些curl命令进行测试:
curl localhost:8080/api/v1/fishes -H "Content-Type: application/json" -d '{"type": "guppy2"}'
curl localhost:8080/api/v1/fish-tanks -H "Content-Type: application/json" -d '{"name": "big one"}'
curl localhost:8080/api/v1/fishes
curl localhost:8080/api/v1/fish-tanks
现在,如果你打开你的Compass客户端并刷新aquarium
数据库,你应该会看到两个集合:fishes
和fish tanks
。在这些集合中,你会看到你创建的fishes
和fish tanks
。
最后一步
目前,我们已经有一个Spring Boot应用程序连接到了运行在Kubernetes集群中的MongoDB。现在,还需要完成最后一步,也就是把Spring Boot应用程序也部署到Kubernetes集群中。
要达成这个目标,我们需要完成以下几个步骤:
- 创建一个包含所有依赖项的可执行JAR文件。
- 基于这个JAR文件创建一个Docker镜像。
- 将镜像上传到我们的Docker仓库。
- 创建一个部署清单文件。
- 将部署清单应用到Kubernetes集群。
因为我使用的是Kind,所以第3步可以用一个简单的加载步骤替代,这样就不需要使用Docker仓库了。
配置文件
在创建JAR文件之前,创建两个Spring Boot配置文件是很有帮助的,这样我们就能在不同的模式下运行应用程序:一种是当前的连接模式(在集群外运行),另一种是在Kubernetes集群中运行。我们要创建的两个Spring Boot配置文件如下:
connected
:用于在集群外运行应用程序时。local-cluster
:用于在集群内运行应用程序时。
第一种connected
模式就是我们目前正在使用的运行模式。这意味着我们可以直接把application.yml
(或者application.properties
)文件复制一份,重命名为application-connected.yml
。然后,在JVM命令行中添加一个JVM参数:
-Dspring.profiles.active=connected
对于local-cluster
配置文件,我们也采取类似的复制操作,不过这次需要做一些修改。
...
data:
mongodb:
host: my-mongo-db-svc.mongo.svc.cluster.local
port: 27017
...
通过使用DNS名称,我们可以连接到正确的pod。需要注意的是,由于在pod中设置的DNS搜索规则,my-mongo-db-svc.mongo.svc.cluster.local
中的部分名称(例如my-mongo-db-svc.mongo.svc
)可以省略。这使得我们能够将应用程序部署到不同的集群中,并且依然保证其正常可用。
创建镜像
现在,我们来看看如何创建镜像。由于我在GitHub上的项目是基于Gradle构建的,在根项目文件夹中,使用以下命令就可以创建一个JAR文件:
gradle build
需要注意,gradle.build
文件中添加了以下内容,以确保清单指向主应用程序文件:
gradle.build
jar {
manifest {
attributes "Main-Class": "com.requillion_solutions.aquarium.AquariumWithMongoDbApplication"
}
}
执行上述命令后,会生成一个JAR文件:build/libs/aquarium-with-mongo-db-0.0.1-SNAPSHOT.jar
。
要创建Docker镜像,我们还需要一个Dockerfile文件,内容如下:
Dockerfile
FROM openjdk:17.0.2-slim-buster
RUN addgroup --system spring && useradd --system spring -g spring
USER spring:spring
ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]
EXPOSE 8080
这个Dockerfile从Java 17基础镜像开始构建(这样做是为了避免与Lombok相关的问题),接着添加一个新用户(spring),这样我们的应用程序就不会以root身份运行。然后将JAR文件复制到镜像中,并设置入口点以运行应用程序。
使用以下命令来创建Docker镜像:
docker build -t aquarium.
如果你使用的是Kind环境,可以直接将镜像加载到Kubernetes集群中:
kind load docker-image aquarium
完成上述步骤后,我们就可以准备创建部署清单,以便在集群中运行应用程序了。
部署清单
现在,我们已经将Docker镜像加载到Kubernetes集群中,接下来就可以使用部署清单进行部署。创建以下文件:
k8s/deployment.yml
apiVersion: apps/v1
kind: Deployment
metadata:
name: aquarium
namespace: default
spec:
replicas: 1
selector:
matchLabels:
app: aquarium
template:
metadata:
labels:
app: aquarium
spec:
containers:
- name: aquarium
image: aquarium
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
env:
# 注意,以下环境变量在Spring读取时会转换为名为spring.profiles.active的属性覆盖
- name: SPRING_PROFILES_ACTIVE
value: local-cluster
---
apiVersion: v1
kind: Service
metadata:
name: aquarium
namespace: default
spec:
selector:
app: aquarium
type: NodePort
ports:
- port: 8080
targetPort: 8080
nodePort: 30080
这里有几点需要留意:
- 应用程序部署在默认命名空间(当未指定命名空间时,会使用该命名空间)。
- 副本数量设置为1个。
- 只有在镜像不存在的情况下才会拉取镜像(因为我们之前已经将镜像加载到集群中)。
- 配置文件设置为
local-cluster
。 - 创建了一个服务,将应用程序的8080端口映射到开发机器上的30080端口。
现在,可以通过以下命令进行部署:
kubectl apply -f k8s/deployment.yml
部署完成后,使用以下命令检查是否成功启动:
kubectl get pods
部署成功后,我们可以使用之前测试API时用过的curl
命令,只是将端口改为30080:
curl localhost:30080/api/v1/fishes -H "Content-Type: application/json" -d '{"type": "guppy2"}'
curl localhost:30080/api/v1/fish-tanks -H "Content-Type: application/json" -d '{"name": "big one"}'
curl localhost:30080/api/v1/fishes
curl localhost:30080/api/v1/fish-tanks
你还可以在Compass UI中查看新生成的文档(记得要确保端口转发仍然有效)。
总结
在本文中,我详细分享了如何在Kind Kubernetes集群中安装MongoDB,并将其集成到Spring Boot应用程序里的实操经验。
这个示例操作相对基础,主要是为了给大家演示整个流程。在实际应用场景中,还有许多关键因素需要着重考虑,比如安全性如何保障、怎样进行数据备份以及故障切换机制该如何设置等。
在后续的文章里,我还会进一步展示如何处理文档之间的关系,帮助大家更深入地掌握相关知识。
通过这个示范,我希望能让大家直观地看到,No-SQL数据库与Kubernetes和Spring Boot结合使用是非常便捷的。
衷心希望你喜欢这篇文章,并且在阅读过程中,哪怕只是收获了一点点新的知识,从而让自己的技能有所提升,我也会感到十分欣慰。
如果你觉得这篇文章对你有所帮助,不妨点个赞。这不仅能让我清楚哪些内容真正对大家有价值,也能为我今后确定写作主题提供方向。要是你有任何建议或者想法,欢迎在评论区畅所欲言,期待与你的交流!