Spring Cloud Alibaba+K8S 无感升级架构方案

13 阅读12分钟

第一部分:Spring Cloud Alibaba 微服务项目 (sc-alibaba-k8s-demo)

1. 项目结构

创建一个 Maven 项目,例如命名为 sc-alibaba-k8s-demo

sc-alibaba-k8s-demo/
├── pom.xml
└── src/
    └── main/
        ├── java/
        │   └── com/
        │       └── example/
        │           └── scalibaba/
        │               ├── ScAlibabaK8sDemoApplication.java
        │               └── controller/
        │                   └── DemoController.java
        └── resources/
            └── application.yml

2. pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.1.5</version> <!-- 使用 Spring Boot 3.x 以获得最新的优雅停机支持 -->
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>sc-alibaba-k8s-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>sc-alibaba-k8s-demo</name>
    <description>Demo project for Spring Cloud Alibaba K8s Graceful Shutdown</description>

    <properties>
        <java.version>17</java.version>
        <spring-cloud.version>2022.0.4</spring-cloud.version> <!-- 对应 Spring Boot 3.1.x -->
        <spring-cloud-alibaba.version>2022.0.0.0</spring-cloud-alibaba.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-alibaba-dependencies</artifactId>
                <version>${spring-cloud-alibaba.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

3. src/main/java/com/example/scalibaba/ScAlibabaK8sDemoApplication.java

package com.example.scalibaba;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
@EnableDiscoveryClient
@RestController // 添加RestController注解,这样可以直接在这里定义简单的接口
public class ScAlibabaK8sDemoApplication {

    private final String instanceId = java.util.UUID.randomUUID().toString().substring(0, 8); // 用于区分不同的Pod实例

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

    @GetMapping("/hello")
    public String hello() {
        System.out.println("Instance " + instanceId + " received /hello request.");
        return "Hello from Spring Cloud Alibaba K8s Demo (Instance: " + instanceId + ")!";
    }

    @GetMapping("/slow-hello")
    public String slowHello() {
        System.out.println("Instance " + instanceId + " received /slow-hello request. Starting 20s processing...");
        try {
            // 模拟一个耗时操作,例如数据库查询、RPC调用等
            Thread.sleep(20 * 1000); // 20秒
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.out.println("Instance " + instanceId + " /slow-hello interrupted.");
            return "Slow hello interrupted by instance " + instanceId;
        }
        System.out.println("Instance " + instanceId + " finished /slow-hello request.");
        return "Slow Hello finished from Spring Cloud Alibaba K8s Demo (Instance: " + instanceId + ")!";
    }
}

4. src/main/resources/application.yml

server:
  port: 8080
  shutdown: graceful # 启用优雅停机
spring:
  application:
    name: sc-alibaba-k8s-demo
  cloud:
    nacos:
      discovery:
        server-addr: nacos-service:8848 # 指定 Nacos Server 地址,使用 K8s Service Name
        # 更多Nacos配置可以按需添加,例如命名空间等
  lifecycle:
    timeout-per-shutdown-phase: 60s # 优雅停机阶段的超时时间,建议比最长请求时间长
  profiles:
    active: dev # 明确激活一个profile
management:
  endpoints:
    web:
      exposure:
        include: "*" # 暴露所有Actuator端点
  endpoint:
    health:
      show-details: always # 在健康检查中显示详细信息
      livenessstate:
        enabled: true
      readinessstate:
        enabled: true
logging:
  level:
    root: INFO
    org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext: INFO
    # 增加日志,查看优雅停机过程中的连接关闭信息
    org.apache.coyote.AbstractProtocol: DEBUG
    org.springframework.web.servlet.DispatcherServlet: DEBUG

这个 application.yml 文件开启了优雅停机,设置了较长的停机超时时间以确保慢请求可以完成。nacos.discovery.server-addr 配置指向了我们稍后将在 Kubernetes 中部署的 Nacos Service。

第二部分:Docker 配置

1. Dockerfile

在项目根目录创建 Dockerfile 文件:

# 使用官方的 Java 镜像作为基础镜像
FROM openjdk:17-jdk-slim

# 设置工作目录
WORKDIR /app

# 将 Maven 构建的 JAR 文件复制到容器中
# 注意:你需要先执行 `mvn clean package` 来生成这个 JAR 文件
COPY target/sc-alibaba-k8s-demo-0.0.1-SNAPSHOT.jar app.jar

# 暴露应用程序的端口
EXPOSE 8080

# 定义容器启动时执行的命令
# `-Djava.security.egd=file:/dev/./urandom` 用于提高随机数生成速度,避免启动慢
# `-Dserver.port=8080` 明确指定端口,Dockerfile EXPOSE 只是声明,但实际使用还是配置决定
ENTRYPOINT ["java", "-Djava.security.egd=file:/dev/./urandom", "-jar", "app.jar"]

第三部分:Kubernetes 部署文件

为了在 Kubernetes 中运行我们的应用,我们需要:

  1. 部署 Nacos Server: Spring Cloud Alibaba 应用需要 Nacos 作为服务注册中心。
  2. 部署我们的应用: 包含 Deployment 和 Service。

1. Nacos Kubernetes 部署 (nacos-kubernetes.yaml)

(为了测试的便捷性,这里部署单实例 Nacos。生产环境请部署高可用 Nacos 集群。)

将以下内容保存为 nacos-kubernetes.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nacos-server
  labels:
    app: nacos
spec:
  selector:
    matchLabels:
      app: nacos
  replicas: 1
  template:
    metadata:
      labels:
        app: nacos
    spec:
      containers:
      - name: nacos
        image: nacos/nacos-server:2.2.0 # 使用 Nacos 官方镜像
        ports:
        - containerPort: 8848 # client port
        - containerPort: 9848 # raft port
        - containerPort: 9849 # raft port
        env:
        - name: MODE
          value: "standalone" # 单机模式
        - name: JVM_XMS # 调整 JVM 内存,如果资源不足可以稍微调小
          value: "256m"
        - name: JVM_XMX
          value: "256m"
        # 确保 Nacos 启动后可以响应健康检查
        livenessProbe:
          httpGet:
            path: /nacos/actuator/health
            port: 8848
          initialDelaySeconds: 30
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /nacos/actuator/health
            port: 8848
          initialDelaySeconds: 20
          periodSeconds: 10
        volumeMounts: # 持久化 Nacos 数据
        - name: nacos-storage
          mountPath: /home/nacos/data
      volumes:
      - name: nacos-storage
        emptyDir: {} # 简单起见使用 emptyDir,生产环境请使用 PersistentVolume/PersistentVolumeClaim

---
apiVersion: v1
kind: Service
metadata:
  name: nacos-service # 这个名字要和 application.yml 中的 server-addr 匹配
  labels:
    app: nacos
spec:
  ports:
  - port: 8848
    targetPort: 8848
    name: client
  - port: 9848
    targetPort: 9848
    name: raft-http
  - port: 9849
    targetPort: 9849
    name: raft-grpc
  selector:
    app: nacos
  type: ClusterIP # 内部服务,集群内访问

2. 微服务 Kubernetes 部署 (app-kubernetes.yaml)

将以下内容保存为 app-kubernetes.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: sc-alibaba-k8s-demo-deployment
  labels:
    app: sc-alibaba-k8s-demo
spec:
  replicas: 3 # 部署3个实例,方便观察滚动升级
  selector:
    matchLabels:
      app: sc-alibaba-k8s-demo
  strategy:
    type: RollingUpdate # 默认就是RollingUpdate,这里显式声明
    rollingUpdate:
      maxSurge: 1 # 最多允许比期望数量多一个Pod
      maxUnavailable: 0 # 最多允许有0个Pod不可用 (这是确保零停机的关键)
  template:
    metadata:
      labels:
        app: sc-alibaba-k8s-demo
        version: v1.0.0 # 用于区分版本,方便升级
    spec:
      terminationGracePeriodSeconds: 120 # 允许Pod最长120秒关闭,要大于 preStop hook + graceful shutdown timeout
      containers:
      - name: sc-alibaba-k8s-demo
        image: sc-alibaba-k8s-demo:v1.0.0 # 初始镜像版本,需要本地构建
        imagePullPolicy: IfNotPresent # 如果本地有就不拉取
        ports:
        - containerPort: 8080
        livenessProbe: # 存活探针,检测应用是否还活着,失败则重启Pod
          httpGet:
            path: /actuator/health/liveness
            port: 8080
          initialDelaySeconds: 15 # 初始延迟
          periodSeconds: 10 # 每10秒检查一次
          timeoutSeconds: 3
          failureThreshold: 3
        readinessProbe: # 就绪探针,检测应用是否可以接收流量,失败则从Service中移除
          httpGet:
            path: /actuator/health/readiness
            port: 8080
          initialDelaySeconds: 30 # 初始延迟,确保应用完全启动并注册Nacos后才Ready
          periodSeconds: 10
          timeoutSeconds: 3
          failureThreshold: 3
        lifecycle:
          preStop: # 在发送 TERM 信号前执行,给 K8s 和负载均衡器足够时间移除 Pod
            exec:
              command: ["/bin/sh", "-c", "echo 'PreStop hook: Sleeping for 60 seconds to allow clients to drain and K8s to update endpoints.' && sleep 60"]
              # 这个 sleep 时间应该足够长,以确保:
              # 1. K8s 将此 Pod 从 Service Endpoint 列表移除。
              # 2. 如果使用外部负载均衡 (如 Ingress Controller, LoadBalancer),它们有时间更新路由。
              # 3. Nacos 上的本实例有足够时间被Service Registry移除 (通常是自动的,但延迟可以辅助)。

---
apiVersion: v1
kind: Service
metadata:
  name: sc-alibaba-k8s-demo-service # 外部访问的服务名
  labels:
    app: sc-alibaba-k8s-demo
spec:
  type: LoadBalancer # 或者 NodePort,具体取决于你的本地 K8s 环境支持
  ports:
  - port: 80
    targetPort: 8080
  selector:
    app: sc-alibaba-k8s-demo

maxUnavailable: 0maxSurge: 1 策略解释:

  • maxUnavailable: 0:在升级过程中,任何时候都不能有旧版本的 Pod 不可用。这意味着 Kubernetes 在启动并使一个新 Pod 就绪之前,不会停止任何旧 Pod。
  • maxSurge: 1:允许的 Pod 数量比期望的 replicas 数量多一个。这意味着在升级开始时,Kubernetes 会先创建一个新 Pod,当这个新 Pod 成功启动并就绪后,它才会移除一个旧 Pod。

这个组合确保了在升级的任何阶段,服务实例数量都不会低于期望值,从而实现零停机。

第四部分:测试验证步骤

请确保您的 Docker Desktop/Minikube Kubernetes 环境已启动。

1. 部署 Nacos Server

kubectl apply -f nacos-kubernetes.yaml

等待 Nacos Pod 启动并运行:

kubectl get pods -l app=nacos -w
# 看到 nacos-server-xxxxx-yyyyy   1/1     Running 就绪

2. 构建并推送 Docker 镜像

进入 sc-alibaba-k8s-demo 项目根目录:

# 1. 清理并打包 Spring Boot 应用为 JAR
mvn clean package

# 2. 构建 Docker 镜像 (版本命名为 v1.0.0)
docker build -t sc-alibaba-k8s-demo:v1.0.0 .

# 3. 验证镜像是否生成
docker images | grep sc-alibaba-k8s-demo

如果您的 Kubernetes 是 Docker Desktop 内置的,那么这个镜像就会自动在 K8s 环境中可用。如果是 Minikube,您可能需要:minikube docker-env 然后 eval $(minikube docker-env),再进行 docker build

3. 部署微服务应用 v1.0.0

kubectl apply -f app-kubernetes.yaml

等待应用 Pod 启动并运行:

kubectl get pods -l app=sc-alibaba-k8s-demo -w
# 看到所有 Pod 都是 Running 状态 (3/3)

获取 Service 的外部 IP/端口:

kubectl get svc sc-alibaba-k8s-demo-service
# 查找 EXTERNAL-IP 和 PORT
# 如果是 Docker Desktop,通常 EXTERNAL-IP 是 localhost,NodePort 也会有
# 例如:localhost:30000 这样的,或者直接是 LoadBalancer 暴露的端口

假设 Service 暴露在 localhost:80 (如果是 NodePort,则可能是 <minikube-ip>:<node-port>)。

访问 /hello 接口验证:

curl http://localhost:80/hello
# 应该会看到来自不同实例的 "Hello..." 响应

4. 准备升级版本 v2.0.0

我们将模拟对应用代码的修改,然后重新打包和部署。

修改代码:

简单修改 ScAlibabaK8sDemoApplication.java/hello 接口,例如:

    @GetMapping("/hello")
    public String hello() {
        System.out.println("Instance " + instanceId + " received /hello request. (v2.0.0)"); // 添加版本信息
        return "Hello from Spring Cloud Alibaba K8s Demo (v2.0.0, Instance: " + instanceId + ")!"; // 添加版本信息
    }
重新构建 JAR 和 Docker 镜像:
# 1. 清理并重新打包 Spring Boot 应用为 JAR
mvn clean package

# 2. 构建 Docker 镜像 (版本命名为 v2.0.0)
docker build -t sc-alibaba-k8s-demo:v2.0.0 .

5. 执行滚动升级并验证无感

现在是关键步骤。

5.1. 持续发送请求(模拟前端访问)

打开两个终端窗口:

终端 1 (发送慢请求):

# 循环发送慢请求,观察哪个实例处理了请求
# 注意:第一次请求需要等待20秒才能看到结果
while true; do curl http://localhost:80/slow-hello; echo; sleep 1; done

这个命令会持续向服务发送慢请求。当一个 Pod 接收到 TERM 信号准备关闭时,如果它正在处理这样的慢请求,它应该完成这个请求,而不是中断。

终端 2 (发送快请求):

# 循环发送快请求,观察服务是否持续可用,以及版本切换情况
while true; do curl http://localhost:80/hello; echo; sleep 0.1; done

这个命令会高频次地向服务发送快请求。在滚动升级过程中,您应该始终看到成功响应,并且逐渐地,响应中的版本信息会从 v1.0.0 变为 v2.0.0,而不会出现连接错误或服务中断。

5.2. 在 Kubernetes 中执行升级

在另一个终端修改 app-kubernetes.yaml,将 image: sc-alibaba-k8s-demo:v1.0.0 改为 image: sc-alibaba-k8s-demo:v2.0.0,并将 template.metadata.labels.version 也改为 v2.0.0

# ... (app-kubernetes.yaml 部分内容)
  template:
    metadata:
      labels:
        app: sc-alibaba-k8s-demo
        version: v2.0.0 # <--- 修改为 v2.0.0
    spec:
      terminationGracePeriodSeconds: 120
      containers:
      - name: sc-alibaba-k8s-demo
        image: sc-alibaba-k8s-demo:v2.0.0 # <--- 修改为 v2.0.0
# ...

然后应用更新:

kubectl apply -f app-kubernetes.yaml
5.3. 观察和验证

打开一个新的终端窗口:

终端 3 (观察 Pod 生命周期):

kubectl get pods -w -l app=sc-alibaba-k8s-demo

您将看到以下现象:

  1. 一个新的 v2.0.0 Pod 会被创建。
  2. 这个新 Pod 会经历 Pending -> ContainerCreating -> Running,但此时它的 READY 状态是 0/1
  3. 只有当 readinessProbe 通过后,新 Pod 的 READY 状态才会变为 1/1
  4. 此时,Kubernetes 会将这个新 Pod 添加到 Service 的 Endpoints 列表中。
  5. 然后,Kubernetes 会选择一个 v1.0.0 的旧 Pod 进行终止。
  6. 旧 Pod 进入 Terminating 状态。此时:
    • Kubernetes 会执行 preStop hook (sleep 60秒)。 在这 60 秒内,Pod 会继续处理已接收的请求,但 Service 不会再向它发送新请求。
    • 同时,kubectl get pods 会显示 Terminating 状态。 READY 状态可能会保持 1/1 或变成 0/1,具体取决于 readinessProbepreStop hook 期间的状态。由于我们只有一个 sleepreadinessProbe 可能会在 sleep 期间仍是 UP,但 K8s 已经将其标记为 Terminating 并从 Service Endpoints 移除。
    • preStop hook 结束后,Kubernetes 发送 TERM 信号。
    • Spring Boot 应用启动优雅停机: 它会停止接受新请求,等待正在进行中的慢请求(20秒)完成。
    • 应用程序日志中会打印 Spring IoC 容器关闭、Web 服务器关闭等信息。
    • 当所有请求完成且应用程序关闭后,旧 Pod 会被完全移除。
    • 这个过程会一直重复,直到所有 3 个 v1.0.0 Pod 都被 3 个 v2.0.0 Pod 替换。

您还会观察到:

  • 终端 1 (慢请求): 即使旧 Pod 处于 Terminating 状态,它正在处理的慢请求也应该能够顺利完成,不会出现错误。当该 Pod 完全关闭后,新的慢请求会被路由到新的 v2.0.0 实例。
  • 终端 2 (快请求): 始终能得到响应。随着升级进行,您会看到响应逐渐从 v1.0.0 切换到 v2.0.0,中间不会有任何中断或错误。

终端 4 (观察 Pod 日志,特别是 Terminating 状态的 Pod):

# 找出一个正在 Terminating 的旧 Pod 的名字,例如 sc-alibaba-k8s-demo-deployment-xxxxx-abcde
kubectl logs -f sc-alibaba-k8s-demo-deployment-xxxxx-abcde

在旧 Pod 的日志中,您应该能看到:

  • 处理慢请求的 "Instance ... received /slow-hello request...""Instance ... finished /slow-hello request." 正常完成。
  • Spring Boot 捕获 TERM 信号并启动优雅停机的日志(例如,Tomcat 停止接收连接,关闭线程池等)。
  • Nacos 实例注销的日志。

总结和关键点回顾

  • maxUnavailable: 0maxSurge: 1 滚动升级策略:这是 Kubernetes 层面实现零停机的根本。它保证了始终有足够多的可用 Pod 处理流量。
  • readinessProbe (就绪探针)
    • 新 Pod 启动时:确保只有当应用完全初始化(如连接 DB, 注册 Nacos)并真正准备好处理请求时,才会被添加到 Service 的 Endpoints。
    • 旧 Pod 终止前:Kubernetes 会在旧 Pod 被标记为 Terminating 后,等待 readinessProbe 最终失败(通常在 preStop hook 之后或 Spring Boot 停止后),才会将其从 Service Endpoints 彻底移除。
  • preStop hook:提供了一个在 TERM 信号发送之前的预处理阶段。
    • 我们使用 sleep 60 来给 Kubernetes 和所有中间件(如服务网格、负载均衡器)足够的时间来更新路由配置,确保旧 Pod 在开始优雅停机前,已经不再接收任何新请求。
    • terminationGracePeriodSeconds 必须足够长,以覆盖 preStop hook 的执行时间,以及 Spring Boot 优雅停机处理最长请求的时间。
  • Spring Boot server.shutdown: graceful:确保应用程序在收到 TERM 信号后,停止接受新请求,但会等待所有正在进行中的请求完成,从而避免客户端错误。
  • Spring Cloud Alibaba Nacos 注销:当 Spring Application Context 关闭时,Spring Cloud Nacos 会自动将该实例从 Nacos 中注销,确保 Nacos Server 不会将流量路由到已关闭的实例。

码字不易,点赞支持~~