在这篇文章中,我们将学习如何用 Spring Cloud Kubernetes 和 Spring Boot 3 创建、测试和运行应用程序。将会学习如何在Kubernetes环境中使用Skaffold、Testcontainers、Spring Boot Admin 和 Fabric8 client 等工具。这篇文章的主要目的是介绍Spring Cloud Kubernetes项目的最新版本。
源代码
如果你想自己尝试,你可以 clone 我的 GitHub repository。然后按如下指示进行。
首先,看一下仓库的结构。它包含五个应用程序。有三个微服务(employee-service
、department-service
、organization-service
)通过REST客户端相互通信并连接到Mongo数据库。还有用Spring Cloud Gateway项目创建的API网关(gateway-service
)。最后,admin-service
目录包含用于监控所有其他应用程序的Spring Boot Admin应用程序。你可以使用一个Skaffold命令轻松地从源代码中部署所有的应用程序。如果你从 repository 根目录运行以下命令,它将用Jib Maven插件构建镜像,并将所有应用部署到Kubernetes集群上:
$ skaffold run
另一方面,你可以进入特定的应用程序目录,只使用完全相同的命令来部署它。每个应用所需的所有Kubernetes YAML清单都放在k8s目录中。在项目根k8s目录下还有一个全局配置,例如Mongo部署。下面是我们的示例 repo 的结构:
它是如何工作的
在我们的示例架构中,我们将使用Spring Cloud Kubernetes Config来通过ConfigMap和Secret注入配置,使用Spring Cloud Kubernetes Discovery与OpenFeign客户端进行服务间通信。我们所有的应用程序都在同一个命名空间内运行,但我们也可以将它们部署在几个不同的命名空间内,并通过OpenFeign处理它们之间的通信。在这种情况下,我们唯一要做的就是将 spring.cloud.kubernetes.discovery.all-namespaces
属性设置为 true。
在我们的服务前面,有一个API网关。这是一个独立的应用,但我们也可以使用本地的CRD集成将其安装在Kubernetes上。在我们的案例中,这是一个标准的Spring Boot 3应用,只是包括并使用了Spring Cloud Gateway模块。它还使用Spring Cloud Kubernetes Discovery和Spring Cloud OpenFeign来定位和调用下游服务。以下是我们的架构图。
使用 Spring Cloud Kubernetes Config
我将通过 department-service
的例子来描述实现细节。它暴露了一些REST端点,但也调用了employee-service
所暴露的端点。除了标准模块,我们还需要将Spring Cloud Kubernetes纳入Maven的依赖项。这里,我们必须决定是使用Fabric8客户端还是Kubernetes Java Client。就我个人而言,我有使用Fabric8的经验,所以我将使用spring-cloud-starter-kubernetes-fabric8-all
starter来包含配置和发现模块。
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-kubernetes-fabric8-all</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
如你所见,我们的应用程序正在连接到Mongo数据库。我们需要提供应用程序所需的连接细节和凭证。在k8s目录中,你会发现 configmap.yaml
文件。它包含了Mongo的地址和数据库的名称。这些属性被作为 application.properties
文件注入到pod中。现在是最重要的事情。ConfigMap
的名称必须与我们应用程序的名称相同。Spring Boot的名称由 spring.application.name
属性表示。
configmap.yaml
kind: ConfigMap
apiVersion: v1
metadata:
name: department
data:
application.properties: |-
spring.data.mongodb.host: mongodb
spring.data.mongodb.database: admin
spring.data.mongodb.authentication-database: admin
在目前的情况下,应用程序的名称是 department
.。这里是应用程序里面的 application.yml
文件:
application.yml
spring:
application:
name: department
同样的命名规则也适用于 Secret
。我们在下面的 Secret
里面保存敏感数据,比如Mongo数据库的用户名和密码。你也可以在 k8s
目录下的 secret.yaml
文件内找到这些内容。
secret.yaml
kind: Secret
apiVersion: v1
metadata:
name: department
data:
spring.data.mongodb.password: UGlvdF8xMjM=
spring.data.mongodb.username: cGlvdHI=
type: Opaque
现在,让我们继续讨论 Deployment
清单。我们稍后将在这里澄清前两点。Spring Cloud Kubernetes需要在Kubernetes上有特殊的权限,以便与 master API互动 (1)。我们不必为镜像提供一个标签–Skaffold会处理它 (2)。为了启用从 ConfigMap
加载属性,我们需要设置spring.config.import=kubernetes:
属性(一种新方法)或将spring.cloud.bootstrap.enabled
属性设置为true
(旧方法)。我们将不直接使用属性,而是在Deployment
(3) 上设置相应的环境变量。默认情况下,由于安全原因,通过API消费secrets的功能没有被启用。为了启用它,我们将把SPRING_CLOUD_KUBERNETES_SECRETS_ENABLEAPI
环境变量设置为true
(4) 。
deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: department
labels:
app: department
spec:
replicas: 1
selector:
matchLabels:
app: department
template:
metadata:
labels:
app: department
spec:
serviceAccountName: spring-cloud-kubernetes # (1)
containers:
- name: department
image: piomin/department # (2)
ports:
- containerPort: 8080
env:
- name: SPRING_CLOUD_BOOTSTRAP_ENABLED # (3)
value: "true"
- name: SPRING_CLOUD_KUBERNETES_SECRETS_ENABLEAPI # (4)
value: "true"
使用 Spring Cloud Kubernetes Discovery
我们已经在上一节使用 spring-cloud-starter-kubernetes-fabric8-all
starter包含了Spring Cloud Kubernetes发现模块。为了提供一个声明式REST客户端,我们还将包括Spring Cloud OpenFeign模块:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
现在,我们可以声明 @FeignClient
接口。这里重要的是一个被发现的服务的名称。它应该与为employee-service
应用程序定义的Kubernetes Service
的名称相同。
@FeignClient(name = "employee")
public interface EmployeeClient {
@GetMapping("/department/{departmentId}")
List<Employee> findByDepartment(@PathVariable("departmentId") String departmentId);
@GetMapping("/department-with-delay/{departmentId}")
List<Employee> findByDepartmentWithDelay(@PathVariable("departmentId") String departmentId);
}
下面是 employee-service
应用的Kubernetes服务清单。该服务的名称是 employee
(1)。标签spring-boot
是为Spring Boot Admin发现目的而设置的 (2)。你可以在 employee-service/k8s
目录中找到以下YAML。
employee-service/k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
name: employee # (1)
labels:
app: employee
spring-boot: "true" # (2)
spec:
ports:
- port: 8080
protocol: TCP
selector:
app: employee
type: ClusterIP
澄清一下–这里是由 OpenFeign 客户端在 department-service
中调用的employee-service
API方法的实现。
@RestController
public class EmployeeController {
private static final Logger LOGGER = LoggerFactory
.getLogger(EmployeeController.class);
@Autowired
EmployeeRepository repository;
// ... other endpoints implementation
@GetMapping("/department/{departmentId}")
public List<Employee> findByDepartment(@PathVariable("departmentId") String departmentId) {
LOGGER.info("Employee find: departmentId={}", departmentId);
return repository.findByDepartmentId(departmentId);
}
@GetMapping("/department-with-delay/{departmentId}")
public List<Employee> findByDepartmentWithDelay(@PathVariable("departmentId") String departmentId) throws InterruptedException {
LOGGER.info("Employee find: departmentId={}", departmentId);
Thread.sleep(2000);
return repository.findByDepartmentId(departmentId);
}
}
这就是我们要做的一切。现在,我们可以使用department-service
中的OpenFeign客户端调用端点。例如,在 “delayed” 端点上,我们可以使用Spring Cloud Circuit Breaker与Resilience4J。
@RestController
public class DepartmentController {
private static final Logger LOGGER = LoggerFactory
.getLogger(DepartmentController.class);
DepartmentRepository repository;
EmployeeClient employeeClient;
Resilience4JCircuitBreakerFactory circuitBreakerFactory;
public DepartmentController(
DepartmentRepository repository,
EmployeeClient employeeClient,
Resilience4JCircuitBreakerFactory circuitBreakerFactory) {
this.repository = repository;
this.employeeClient = employeeClient;
this.circuitBreakerFactory = circuitBreakerFactory;
}
@GetMapping("/{id}/with-employees-and-delay")
public Department findByIdWithEmployeesAndDelay(@PathVariable("id") String id) {
LOGGER.info("Department findByIdWithEmployees: id={}", id);
Department department = repository.findById(id).orElseThrow();
CircuitBreaker circuitBreaker = circuitBreakerFactory.create("delayed-circuit");
List<Employee> employees = circuitBreaker.run(() ->
employeeClient.findByDepartmentWithDelay(department.getId()));
department.setEmployees(employees);
return department;
}
@GetMapping("/organization/{organizationId}/with-employees")
public List<Department> findByOrganizationWithEmployees(@PathVariable("organizationId") String organizationId) {
LOGGER.info("Department find: organizationId={}", organizationId);
List<Department> departments = repository.findByOrganizationId(organizationId);
departments.forEach(d -> d.setEmployees(employeeClient.findByDepartment(d.getId())));
return departments;
}
}
使用Fabric8 Kubernetes进行测试
我们已经完成了服务的实施。所有的Kubernetes YAML清单都准备好了,可以部署。现在,问题是–在我们继续在真正的集群上进行部署之前,我们是否可以轻松地测试一切工作正常?答案是–可以。此外,我们可以在几种工具中进行选择。让我们从最简单的选项开始 - Kubernetes mock server。为了使用它,我们需要加入一个额外的Maven依赖项:
<dependency>
<groupId>io.fabric8</groupId>
<artifactId>kubernetes-server-mock</artifactId>
<version>6.7.1</version>
<scope>test</scope>
</dependency>
然后,我们可以继续进行测试。在第一步中,我们需要提供几个测试注解。在 @SpringBootTest
里面,我们应该模拟Kubernetes平台,将 spring.main.cloud-platform
属性设置为 KUBERNETES
(1)。通常情况下,Spring Boot能够自动检测它是否在Kubernetes上运行。在这种情况下,我们需要 “欺骗他”,因为我们只是在模拟API,而不是在Kubernetes上运行测试。我们还需要用spring.cloud.bootstrap.enabled=true
属性启用 ConfigMap
注入的老方法。
一旦我们用 @EnableKubernetesMockClient
(2) 来注解测试方法,我们就可以使用Fabric8 KubernetesClient
(3) 的一个自动配置的静态实例。在测试过程中,Fabric8库运行一个web服务器,模拟客户端发送的所有API请求。顺便说一下,我们正在使用Testcontainers来运行Mongo (4)。在下一步,我们将创建 ConfigMap
,将Mongo连接设置注入到Spring Boot应用中 (5)。由于Spring Cloud Kubernetes配置,它被应用自动加载,应用能够在动态生成的端口上连接Mongo数据库。
Spring Cloud Kubernetes自带自动配置的Fabric8 KubernetesClient
。我们需要强制它连接到模拟的API服务器。因此,我们应该将Fabric8 KubernetesClient
使用的 kubernetes.master
属性覆盖到从测试的 "模拟 "实例 (6) 中获取的master URL。最后,我们可以用标准方式实现测试方法。
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {
"spring.main.cloud-platform=KUBERNETES",
"spring.cloud.bootstrap.enabled=true"}) // (1)
@EnableKubernetesMockClient(crud = true) // (2)
@Testcontainers
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class EmployeeKubernetesMockTest {
private static final Logger LOG = LoggerFactory
.getLogger(EmployeeKubernetesMockTest.class);
static KubernetesClient client; // (3)
@Container // (4)
static MongoDBContainer mongodb = new MongoDBContainer("mongo:5.0");
@BeforeAll
static void setup() {
ConfigMap cm = client.configMaps()
.create(buildConfigMap(mongodb.getMappedPort(27017)));
LOG.info("!!! {}", cm); // (5)
// (6)
System.setProperty(Config.KUBERNETES_MASTER_SYSTEM_PROPERTY,
client.getConfiguration().getMasterUrl());
System.setProperty(Config.KUBERNETES_TRUST_CERT_SYSTEM_PROPERTY, "true");
System.setProperty(Config.KUBERNETES_NAMESPACE_SYSTEM_PROPERTY, "default");
}
private static ConfigMap buildConfigMap(int port) {
return new ConfigMapBuilder().withNewMetadata()
.withName("employee").withNamespace("default")
.endMetadata()
.addToData("application.properties",
"""
spring.data.mongodb.host=localhost
spring.data.mongodb.port=%d
spring.data.mongodb.database=test
spring.data.mongodb.authentication-database=test
""".formatted(port))
.build();
}
@Autowired
TestRestTemplate restTemplate;
@Test
@Order(1)
void addEmployeeTest() {
Employee employee = new Employee("1", "1", "Test", 30, "test");
employee = restTemplate.postForObject("/", employee, Employee.class);
assertNotNull(employee);
assertNotNull(employee.getId());
}
@Test
@Order(2)
void addAndThenFindEmployeeByIdTest() {
Employee employee = new Employee("1", "2", "Test2", 20, "test2");
employee = restTemplate.postForObject("/", employee, Employee.class);
assertNotNull(employee);
assertNotNull(employee.getId());
employee = restTemplate
.getForObject("/{id}", Employee.class, employee.getId());
assertNotNull(employee);
assertNotNull(employee.getId());
}
@Test
@Order(3)
void findAllEmployeesTest() {
Employee[] employees =
restTemplate.getForObject("/", Employee[].class);
assertEquals(2, employees.length);
}
@Test
@Order(3)
void findEmployeesByDepartmentTest() {
Employee[] employees =
restTemplate.getForObject("/department/1", Employee[].class);
assertEquals(1, employees.length);
}
@Test
@Order(3)
void findEmployeesByOrganizationTest() {
Employee[] employees =
restTemplate.getForObject("/organization/1", Employee[].class);
assertEquals(2, employees.length);
}
}
现在,在运行测试后,我们可以看一下日志。如你所见,我们的测试正在从 employee
ConfigMap
中加载属性。
最后,它能够成功连接动态端口上的Mongo,并针对该实例运行所有测试。
在k3s上用测试容器进行测试
正如我之前提到的,有几个工具我们可以用于Kubernetes的测试。这次我们将看到如何用Testcomntainers来做。我们已经在上一节中使用它来运行Mongo数据库。但也有用于Rancher的k3s Kubernetes发布的Testcontainers模块。目前,它处于孵化状态,但并不妨碍我们去尝试它。为了在项目中使用它,我们需要包含以下Maven依赖:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>k3s</artifactId>
<scope>test</scope>
</dependency>
下面是与上一节相同的测试的实现,但这次是用 k3s
容器。我们不需要创建任何mock。相反,我们将创建 K3sContainer
对象 (1)。在运行测试之前,我们需要创建并初始化 KubernetesClient
。测试容器 K3sContainer
提供了getKubeConfigYaml()
方法来获取kubeconfig
数据。有了Fabric8 Config
对象,我们可以从该 kubeconfig
(2) (3) 初始化客户端。之后,我们将用Mongo连接细节创建 ConfigMap
(4)。最后,我们要为Spring Cloud Kubernetes自动配置的Fabric8客户端重写master URL。与上一节相比,我们还需要设置Kubernetes客户端证书和密钥 (5)。
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {
"spring.main.cloud-platform=KUBERNETES",
"spring.cloud.bootstrap.enabled=true"})
@Testcontainers
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class EmployeeKubernetesTest {
private static final Logger LOG = LoggerFactory
.getLogger(EmployeeKubernetesTest.class);
@Container
static MongoDBContainer mongodb = new MongoDBContainer("mongo:5.0");
@Container
static K3sContainer k3s = new K3sContainer(DockerImageName
.parse("rancher/k3s:v1.21.3-k3s1")); // (1)
@BeforeAll
static void setup() {
Config config = Config
.fromKubeconfig(k3s.getKubeConfigYaml()); // (2)
DefaultKubernetesClient client = new
DefaultKubernetesClient(config); // (3)
ConfigMap cm = client.configMaps().inNamespace("default")
.create(buildConfigMap(mongodb.getMappedPort(27017)));
LOG.info("!!! {}", cm); // (4)
System.setProperty(Config.KUBERNETES_MASTER_SYSTEM_PROPERTY,
client.getConfiguration().getMasterUrl());
// (5)
System.setProperty(Config.KUBERNETES_CLIENT_CERTIFICATE_DATA_SYSTEM_PROPERTY,
client.getConfiguration().getClientCertData());
System.setProperty(Config.KUBERNETES_CA_CERTIFICATE_DATA_SYSTEM_PROPERTY,
client.getConfiguration().getCaCertData());
System.setProperty(Config.KUBERNETES_CLIENT_KEY_DATA_SYSTEM_PROPERTY,
client.getConfiguration().getClientKeyData());
System.setProperty(Config.KUBERNETES_TRUST_CERT_SYSTEM_PROPERTY,
"true");
System.setProperty(Config.KUBERNETES_NAMESPACE_SYSTEM_PROPERTY,
"default");
}
private static ConfigMap buildConfigMap(int port) {
return new ConfigMapBuilder().withNewMetadata()
.withName("employee").withNamespace("default")
.endMetadata()
.addToData("application.properties",
"""
spring.data.mongodb.host=localhost
spring.data.mongodb.port=%d
spring.data.mongodb.database=test
spring.data.mongodb.authentication-database=test
""".formatted(port))
.build();
}
@Autowired
TestRestTemplate restTemplate;
@Test
@Order(1)
void addEmployeeTest() {
Employee employee = new Employee("1", "1", "Test", 30, "test");
employee = restTemplate.postForObject("/", employee, Employee.class);
assertNotNull(employee);
assertNotNull(employee.getId());
}
@Test
@Order(2)
void addAndThenFindEmployeeByIdTest() {
Employee employee = new Employee("1", "2", "Test2", 20, "test2");
employee = restTemplate
.postForObject("/", employee, Employee.class);
assertNotNull(employee);
assertNotNull(employee.getId());
employee = restTemplate
.getForObject("/{id}", Employee.class, employee.getId());
assertNotNull(employee);
assertNotNull(employee.getId());
}
@Test
@Order(3)
void findAllEmployeesTest() {
Employee[] employees =
restTemplate.getForObject("/", Employee[].class);
assertEquals(2, employees.length);
}
@Test
@Order(3)
void findEmployeesByDepartmentTest() {
Employee[] employees =
restTemplate.getForObject("/department/1", Employee[].class);
assertEquals(1, employees.length);
}
@Test
@Order(3)
void findEmployeesByOrganizationTest() {
Employee[] employees =
restTemplate.getForObject("/organization/1", Employee[].class);
assertEquals(2, employees.length);
}
}
在Minikube上运行Spring Kubernetes应用程序
在这个练习中,我使用Minikube,但你也可以使用任何其他的发行版,如Kind或k3s。Spring Cloud Kubernetes需要在Kubernetes上有额外的权限,以便能够与master API互动。因此,在运行应用程序之前,我们将创建具有所需权限的 spring-cloud-kubernetes
ServiceAccount
。我们的角色需要拥有对configmaps
、pods
、services
、endpoints
和 secrets
的访问权。如果我们没有启用跨所有命名空间的发现(spring.cloud.kubernetes.discovery.all-namespaces
属性),可以在命名空间内进行 Role
。否则,我们应该创建一个 ClusterRole
。
k8s/privileges.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: spring-cloud-kubernetes
namespace: default
---
kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: spring-cloud-kubernetes
namespace: default
rules:
- apiGroups: [""]
resources: ["configmaps", "pods", "services", "endpoints", "secrets"]
verbs: ["get", "list", "watch"]
---
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: spring-cloud-kubernetes
namespace: default
subjects:
- kind: ServiceAccount
name: spring-cloud-kubernetes
namespace: default
roleRef:
kind: ClusterRole
name: spring-cloud-kubernetes
当然,你不需要自己去应用上面所示的清单。正如我在文章开头提到的,在 repository 根目录文件中有一个 skaffold.yaml
文件,包含了整个配置。它与所有服务一起运行带有 Mongo 部署 (1) 和带有权限 (2) 的清单。
apiVersion: skaffold/v4beta5
kind: Config
metadata:
name: sample-spring-microservices-kubernetes
build:
artifacts:
- image: piomin/admin
jib:
project: admin-service
- image: piomin/department
jib:
project: department-service
args:
- -DskipTests
- image: piomin/employee
jib:
project: employee-service
args:
- -DskipTests
- image: piomin/gateway
jib:
project: gateway-service
- image: piomin/organization
jib:
project: organization-service
args:
- -DskipTests
tagPolicy:
gitCommit: {}
manifests:
rawYaml:
- k8s/mongodb-*.yaml # (1)
- k8s/privileges.yaml # (2)
- admin-service/k8s/*.yaml
- department-service/k8s/*.yaml
- employee-service/k8s/*.yaml
- gateway-service/k8s/*.yaml
- organization-service/k8s/*.yaml
我们需要做的就是通过执行以下skaffold命令来部署所有的应用程序:
$ skaffold dev
完成后,我们就可以显示一个正在运行的 pods 的列表:
kubectl get pod
NAME READY STATUS RESTARTS AGE
admin-5f8c8498f-vtstx 1/1 Running 0 2m38s
department-746774879b-llrdn 1/1 Running 0 2m38s
employee-5bbf6b765f-7hsv7 1/1 Running 0 2m37s
gateway-578cb64558-m9n7f 1/1 Running 0 2m37s
mongodb-7f68b8b674-dbfnb 1/1 Running 0 2m38s
organization-5688c58656-bv8n6 1/1 Running 0 2m37s
我们还可以显示一个服务列表。其中一些服务,如 admin
或 gateway
,以 NodePort
的形式暴露。得益于此,我们可以在Kubernetes集群之外轻松访问它们。
kubectl get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
admin NodePort 10.101.220.141 <none> 8080:31368/TCP 3m53s
department ClusterIP 10.108.144.90 <none> 8080/TCP 3m52s
employee ClusterIP 10.99.75.2 <none> 8080/TCP 3m52s
gateway NodePort 10.96.7.237 <none> 8080:31518/TCP 3m52s
kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 38h
mongodb ClusterIP 10.108.198.233 <none> 27017/TCP 3m53s
organization ClusterIP 10.107.102.26 <none> 8080/TCP 3m52s
让我们在我们的本地机器上获得Minikube的IP地址:
$ minikube ip
现在,我们可以使用该IP地址来访问目标端口上的 Spring Boot Admin 服务器等。对我来说是 31368
。Spring Boot Admin 应该能成功地发现所有三个微服务,并连接到这些应用所暴露的 /actuator
端点。
我们可以去了解每个Spring Boot应用程序的细节。如你所见,depatment-service
正在我的本地Minikube上运行。
一旦你停止 skaffold dev
命令,所有的应用程序和配置将从你的Kubernetes集群中删除。
思考
如果你在Kubernetes集群上只运行Spring Boot应用,Spring Cloud Kubernetes是一个有趣的选择。它允许我们轻松地与Kubernetes discovery、config map和secrets集成。正因为如此,我们可以利用其他Spring Cloud组件,如负载均衡器、断路器等。然而,如果你正在运行用不同语言和框架编写的应用程序,并使用service mesh(Istio、Linkerd)等语言无关的工具,Spring Cloud Kubernetes可能不是最佳选择。
原文:Spring Cloud Kubernetes with Spring Boot 3 - Piotr's TechBlog