从零放弃学习 Spring - Spring Cloud 与 Kubernetes

在 Kubernetes 中运行 Spring Boot 应用

Kubernetes (以下简称 K8s) 可以直接运行 Spring Boot 应用,这部分不需要 Spring Cloud 支持。

代码参考 spring-boot-k8s。

K8s 环境准备

使用 kind 来创建一个 K8s 集群:

1
2
kind create cluster
kubectl cluster-info --context kind-kind

构建应用镜像

创建一个简单的 Spring Boot 项目,选择 Spring Web 和 Spring Boot Actuator 依赖,然后随便写两个接口方便测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@RestController
@SpringBootApplication
public class SbkApplication {

@GetMapping("/hello")
String hello() {
return "Hello World";
}

@GetMapping("/now")
String now() {
var sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ");
return sdf.format(new Date()) + " @" + sdf.getTimeZone().getID();
}

public static void main(String[] args) {
SpringApplication.run(SbkApplication.class, args);
}

}

之所以加一个 /now 是为了后续测试时区的问题,Spring Boot Actuator 用来为应用提供探针。

Spring Boot 构建插件支持直接从项目构建出 Docker 镜像:

1
mvn spring-boot:build-image

构建容器会去下载一个 JRE 环境,非常抽象,所以我选通过 Dockerfile 来构建镜像:

1
2
3
4
5
6
7
8
9
FROM maven:3.9-eclipse-temurin-17-alpine AS builder
ADD . /app
WORKDIR /app
RUN mvn -s settings.xml -f pom.xml -Dmaven.test.skip=true package

FROM eclipse-temurin:17-jre-alpine
COPY --from=builder /app/target/*.jar /app/
WORKDIR /app
CMD java -jar app.jar --spring.config.location=./config/application.yml

这里我用了 alpine 镜像,纯粹是个人爱好。然后就是构建 Docker 镜像和在 Docker 中运行的命令:

1
2
3
4
5
6
7
8
9
10
cd path/to/project

# 构建镜像
docker build -t app .

# 运行容器
docker run -v $(pwd)/src/main/resources/application.yml:/app/config/application.yml --network=host app:latest

# 测试访问
curl localhost:8080/hello

部署

在 K8s 部署应用是常规操作,除了内存开大一点以外没有什么区别,我在 Demo 项目里给了对应文件,说起来这步要上传镜像到 Registry,不能直接使用本地镜像,我没有深究是不是 namespace 的原因。

Spring Boot Actuator 提供了开箱即用的 Health Endpoint:/actuator/health/liveness 和 /actuator/health/readiness,作为应用部署在 K8s 需要的健康检测探针,如果有更复杂需求也可以实现 HealthIndicator。

NOTE: 当 Spring 应用运行在 K8s 中时,Spring Boot Actuator 会自动启用这两个探针,你可以通过 management.health.probes.enabled=true 主动启用探针

Java 时区问题

在 K8s 中给一个容器配置时区最简单的方法是用 hostPath 把 /etc/localtime 到容器中 /etc/localtime,绝大部分应用有这个文件就正确显示时区了,但是 Java 应用不行。Java 应用的时区配置还需要 /etc/timezone 或者环境变量 TZ 设为对应时区,例如 Asia/Shanghai。

Spring Cloud Kubernetes

Spring Cloud 本身是一个微服务框架,在云原生大趋势下也对 K8s 做了支持。Java 有两个 K8s 客户端实现 Fabric8 Kubernetes Java Client 和 Kubernetes Java Client,它们有各自对应的 Spring Cloud Kubernetes 生态,我这边示例用到的是后者。

新建一个项目,添加依赖 Spring Web、Spring Boot Actuator、OpenFeign。不知道为什么 Spring Initializr 上不提供添加 K8s 有关的依赖,手动添加 spring-cloud-starter-kubernetes-client-all:

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-kubernetes-client-all</artifactId>
</dependency>

spring-cloud-starter-kubernetes-client-all 依赖包含 discovery、loadbalancer 和 config,引入后 Spring 框架会自动激活 kubernetes profile。

代码参考 spring-cloud-k8s。

Service Account

K8s 通过 Service Account 为 Pod 访问 K8s API 提供身份信息,如果没有指定,默认是 default。

因为 Spring Cloud Kubernetes 需要访问 API,所以我们要给 Service Account 配置一些权限,这里直接用了 Spring Cloud Kubernetes 文档中的配置,它为 default Account 增加了几个资源的的 get、list、watch 权限:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
kind: Role
apiVersion: rbac.authorization.k8s.io/v1
metadata:
namespace: default
name: namespace-reader
rules:
- apiGroups: [""]
resources: ["configmaps", "pods", "services", "endpoints", "secrets"]
verbs: ["get", "list", "watch"]

---

kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: namespace-reader-binding
namespace: default
subjects:
- kind: ServiceAccount
name: default
apiGroup: ""
roleRef:
kind: Role
name: namespace-reader
apiGroup: ""

这么配置只是为了方便使用,不是一个正规的配置,如果在生产环境用还是要按需配置的。

服务发现和 LoadBalancer

K8s 本身提供了 Service 管理网络流量,Service 通过 Selector 关联 Endpoint。Spring Cloud 框架也自带了服务发现功能,所以为了结合 K8s,Spring Cloud Kubernetes 服务发现通过调用 K8s API 获得 Service 的 Endpoint,如果 Endpoint 有多个端口,默认选择第一个。

对服务的访问和其他 Spring Cloud 的区别不大,注意注解 @FeignClient 要写成服务名,而不是应用名称,例如我们使用 OpenFeign 去调用上一节提到的 Spring Boot 应用。

接口代码:

1
2
3
4
5
6
@FeignClient("sbk-app-svc")
public interface SbkClient {

@GetMapping("/hello")
String hello();
}

调用代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Autowired
SbkClient sbkClient;

@GetMapping("/hello")
String hello() {
log.info("call by OpenFeign");
return sbkClient.hello();
}

@Bean
RestTemplate restTemplate() {
return new RestTemplate();
}

@GetMapping("/now")
String now() {
log.info("call by RestTemplate");
return restTemplate().getForObject("http://sbk-app-svc:8080/now", String.class);
}

代码还展示了用 RestTemplate 访问其他服务的方式,这种方式下负载均衡只会提供 K8s Service 完成。

默认情况下,负载均衡使用 POD 模式,Spring Cloud Kubernetes Discovery 通过服务名获取的 Pod IP 列表,服务调用直接访问 Pod(类似 Headless Service 的作法),这是兼容 Spring Cloud 机制的,如果想要直接使用 K8s Service 自带的流量管理,可以把模式改为 SERVICE:

1
2
3
4
5
spring:
cloud:
kubernetes:
loadbalancer:
mode: SERVICE

服务发现默认工作在当前命名空间,可以通过配置指定命名空间跨命名空间服务发现:

1
2
3
4
5
6
7
spring:
cloud:
kubernetes:
discovery:
all-namespaces: false
namespaces:
- default

另外,因为 Service 的 Endpoint 的注册和取消注册由 Pod 生命周期控制,所以 Spring Cloud Kubernetes 不再需要注册服务,这部分机制会被自动屏蔽。

配置

之前已经演示过了从 spring.config.location 加载配置文件,接入 K8s 后,框架可以从 ConfigMap 获取应用配置(我这里只谈 ConfigMap,Secret 基本相同)。

通过设置 spring.config.import=kubernetes: 让应用从 Spring Cloud Kubernetes Config 获得配置,这部分和用其他配置管理中间件很像。加入配置中设置了 spring.application.name,配置客户端会去获取同名的 ConfigMap,如果没有设置,客户端获取 application ConfigMap,如果你希望指定其他名字,可以通过 spring.cloud.kubernetes.config.name 直接指定 ConfigMap 名称。

创建两个 ConfigMap,一个用于启动配置(这一步不是必须的),一个用会被配置客户端自动读取。

startup-cm.yaml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
apiVersion: v1
kind: ConfigMap
metadata:
name: sck-app-conf-cm
namespace: default
data:
application.yml: |-
spring:
application:
name: 'sck'
config:
import: 'kubernetes:'
cloud:
kubernetes:
config:
name: sck-ext-conf-cm

app-cm.yaml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
apiVersion: v1
kind: ConfigMap
metadata:
name: sck-ext-conf-cm
namespace: default
data:
application.yaml: |-
app:
version: '1.0'
logging:
level:
root: info
management:
endpoints:
web:
exposure:
include: '*'
server:
port: 8080
spring:
cloud:
kubernetes:
discovery:
all-namespaces: false
namespaces:
- default
loadbalancer:
mode: SERVICE
reload:
enabled: true

通过执行命令查看配置生效情况:

1
2
$ kubectl exec -it pods/sck-app-7b9d868847-6g4zn -- /bin/sh
/app # wget -O - localhost:8080/actuator/env && cat env

虽然 K8s 会 Pod 挂载的 ConfigMap 文件同步改动,但是 Spring 应用并不监控本地文件,而是使用了 API 获取 configmap。如果应用需要及时配置变更就需要配置 spring.cloud.kubernetes.reload.enabled=true,这样配置客户端就回去 watch configmap,别忘了代码中加上注解 @RefreshScope。此外,配置客户端也支持通过配置 spring.cloud.kubernetes.reload.period 周期性从配置检查变更,文档提到这个功能在文件挂载时是无需权限的。

discovery-server 和 config-server

除了框架本身的组件外,Spring Cloud Kubernetes 生态还提供配置服务 spring-cloud-kubernetes-configserver 和发现服务 spring-cloud-kubernetes-discoveryserver。

spring-cloud-kubernetes-configserver 在 Config Server 基础上增加了对 K8s ConfigMap 和 Secret 支持。有一点比较令人迷惑,Kubernetes Config Server 不支持配置的 watch,有这方面需求的话就要结合 Kubernetes Configuration Watcher 一起使用。

spring-cloud-kubernetes-discoveryserver 封装了对 K8s Service 访问,你可以直接访问 HTTP API 获取服务 Endpoint,也能使用对应的客户端 spring-cloud-starter-kubernetes-discoveryclient 把服务集成到 Spring Cloud Discovery 能力。

这两个服务虽然可选,但是我还是比较推荐使用,因为抛开功能还有一点很重要,就是它可以减少对 K8s API Server 的压力。

最后

Spring Cloud 到底是一个微服务框架,它和 K8s 结合的方式是通过 K8s 机制的 API 和调整了自身部分的机制,而一个普通的云原生应用应该是在低状态的前提下,也避免和 API 有所交流,所以这是一件很违和的事情。

参考