使用Spring Boot 3的Spring Cloud Kubernetes教程


在这篇文章中,你将学习如何用Spring Cloud Kubernetes和Spring Boot 3创建、测试和运行应用程序。

你将看到如何在Kubernetes环境中使用Skaffold、Testcontainers、Spring Boot Admin和Fabric8客户端等工具。

这篇文章的主要目的是向你介绍Spring Cloud Kubernetes项目的最新版本。

源码:GitHub repository

首先,让我们介绍一下这个Github资源库:

  • 它包含五个应用程序。
  • 有三个微服务(雇员服务、部门服务、组织服务)
  • 通过REST客户端相互通信并连接到Mongo数据库。
  • 还有用Spring Cloud Gateway项目创建的API网关(gateway-service)。
  • 最后,admin-service目录包含用于监控所有其他应用程序的Spring Boot Admin应用程序。

你可以使用一个Skaffold命令轻松地从源代码中部署所有的应用程序。

如果你从版本库根目录运行以下命令,它将用Jib Maven插件构建镜像,并将所有应用部署到Kubernetes集群上:

$ skaffold run

另一方面,你可以进入特定的应用程序目录,只使用完全相同的命令来部署它。
每个应用所需的所有Kubernetes YAML清单都放在k8s目录中。
在项目根k8s目录下还有一个全局配置,例如Mongo部署。

它是如何工作的
在我们的示例架构中,我们将使用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配置
我将通过部门服务的例子来描述实施细节。它暴露了一些REST端点,但也调用了雇员服务所暴露的端点。

除了标准模块,我们还需要将Spring Cloud Kubernetes纳入Maven的依赖项。

这里,我们必须决定是使用Fabric8客户端还是Kubernetes Java客户端。
就我个人而言,我有使用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属性表示。

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文件:

spring:
  application:
    name: department


同样的命名规则也适用于Secret。我们在下面的Secret里面保存敏感数据,比如Mongo数据库的用户名和密码。你也可以在k8s目录下的secret.yaml文件内找到这些内容。

kind: Secret
apiVersion: v1
metadata:
  name: department
data:
  spring.data.mongodb.password: UGlvdF8xMjM=
  spring.data.mongodb.username: cGlvdHI=
type: Opaque


现在,让我们继续讨论部署清单。我们稍后将在这里澄清前两点。

Spring Cloud Kubernetes需要在Kubernetes上有特殊的权限,以便与主API互动
(1)我们不必为镜像提供一个标签--Skaffold会处理它
(2)为了启用从ConfigMap加载属性,我们需要设置spring.config.import=kubernetes: 属性(一种新方法)或将spring.cloud.bootstrap.enabled属性设置为true(旧方法)。
(3) 我们将不直接使用属性,而是在部署上设置相应的环境变量。
(4)默认情况下,由于安全原因,通过API消耗secrets的功能没有被启用。为了启用它,我们将把SPRING_CLOUD_KUBERNETES_SECRETS_ENABLEAPI环境变量设置为true。

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 Discovery模块。为了提供一个声明式REST客户端,我们还将包括Spring Cloud OpenFeign模块:

<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>

现在,我们可以声明@FeignClient接口。这里重要的是一个被发现的服务的名称。它应该与为雇员服务应用程序定义的Kubernetes服务的名称相同。

@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);
}

下面是雇员服务应用的Kubernetes服务清单。该服务的名称是employee (1)。标签spring-boot是为Spring Boot Admin发现目的而设置的(2)。你可以在employee-service/k8s目录中找到以下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客户端在部门服务中调用的雇员服务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);
    }
    
}

这就是我们要做的一切。

现在,我们可以使用部门服务中的OpenFeign客户端调用端点。
例如,在 "延迟 "端点上,我们可以使用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;
    }

}

在 k3s 上使用 Testcontainer 进行测试

正如我之前提到的,我们可以使用多种工具对 Kubernetes 进行测试。这次我们将看到如何使用 Testcomntainers 来完成它。我们已经在上一节中使用它来运行 Mongo 数据库。但是还有用于 Rancher 的 k3s Kubernetes 发行版的 Testcontainers 模块。目前,它处于孵化状态,不过我们也懒得去尝试。为了在项目中使用它,我们需要包含以下 Maven 依赖项:

<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>k3s</artifactId>
  <scope>test</scope>
</dependency>

代码:

@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);
    }

}


我们不需要创建任何模拟。相反,我们将创建K3sContainer对象(1)。在运行测试之前,我们需要创建并初始化KubernetesClient。测试容器 K3sContainer提供了getKubeConfigYaml()方法来获取kubeconfig数据。有了Fabric8配置对象,我们可以从该kubeconfig(2)(3)初始化客户端。之后,我们将用Mongo连接细节创建ConfigMap(4)。最后,我们要为Spring Cloud Kubernetes自动配置的Fabric8客户端重写主URL。与上一节相比,我们还需要设置Kubernetes客户端证书和密钥(5)。

在Minikube上运行Spring Kubernetes应用程序
在这个练习中,我使用Minikube,但你也可以使用任何其他的发行版,如Kind或k3s。

Spring Cloud Kubernetes需要在Kubernetes上有额外的权限,以便能够与主API互动。
因此,在运行应用程序之前,我们将创建具有所需权限的spring-cloud-kubernetes ServiceAccount。我们的角色需要拥有对配置图、pods、服务、端点和机密的访问权。
如果我们没有启用跨所有命名空间的发现(spring.cloud.kubernetes.discovery.all-namespaces 属性),可以在命名空间内进行角色。否则,我们应该创建一个ClusterRole。

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


当然,你不需要自己去应用上面可见的清单。正如我在文章开头提到的,在版本库根目录文件中有一个 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

最后的思考
如果你在Kubernetes集群上只运行Spring Boot应用,Spring Cloud Kubernetes是一个有趣的选择。它允许我们轻松地与Kubernetes发现、配置图和秘密集成。

正因为如此,我们可以利用其他Spring Cloud组件,如负载平衡器、断路器等。

然而,如果你正在运行用不同语言和框架编写的应用程序,并使用服务网(Istio、Linkerd)等语言无关的工具,Spring Cloud Kubernetes可能不是最佳选择。