第一部分: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 中运行我们的应用,我们需要:
- 部署 Nacos Server: Spring Cloud Alibaba 应用需要 Nacos 作为服务注册中心。
- 部署我们的应用: 包含 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: 0 和 maxSurge: 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
您将看到以下现象:
- 一个新的
v2.0.0Pod 会被创建。 - 这个新 Pod 会经历
Pending->ContainerCreating->Running,但此时它的READY状态是0/1。 - 只有当
readinessProbe通过后,新 Pod 的READY状态才会变为1/1。 - 此时,Kubernetes 会将这个新 Pod 添加到 Service 的 Endpoints 列表中。
- 然后,Kubernetes 会选择一个
v1.0.0的旧 Pod 进行终止。 - 旧 Pod 进入
Terminating状态。此时:- Kubernetes 会执行
preStophook (sleep 60秒)。 在这 60 秒内,Pod 会继续处理已接收的请求,但 Service 不会再向它发送新请求。 - 同时,
kubectl get pods会显示Terminating状态。READY状态可能会保持1/1或变成0/1,具体取决于readinessProbe在preStophook 期间的状态。由于我们只有一个sleep,readinessProbe可能会在sleep期间仍是UP,但 K8s 已经将其标记为Terminating并从 Service Endpoints 移除。 preStophook 结束后,Kubernetes 发送TERM信号。- Spring Boot 应用启动优雅停机: 它会停止接受新请求,等待正在进行中的慢请求(20秒)完成。
- 应用程序日志中会打印 Spring IoC 容器关闭、Web 服务器关闭等信息。
- 当所有请求完成且应用程序关闭后,旧 Pod 会被完全移除。
- 这个过程会一直重复,直到所有 3 个
v1.0.0Pod 都被 3 个v2.0.0Pod 替换。
- Kubernetes 会执行
您还会观察到:
- 终端 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: 0和maxSurge: 1滚动升级策略:这是 Kubernetes 层面实现零停机的根本。它保证了始终有足够多的可用 Pod 处理流量。readinessProbe(就绪探针):- 新 Pod 启动时:确保只有当应用完全初始化(如连接 DB, 注册 Nacos)并真正准备好处理请求时,才会被添加到 Service 的 Endpoints。
- 旧 Pod 终止前:Kubernetes 会在旧 Pod 被标记为
Terminating后,等待readinessProbe最终失败(通常在preStophook 之后或 Spring Boot 停止后),才会将其从 Service Endpoints 彻底移除。
preStophook:提供了一个在TERM信号发送之前的预处理阶段。- 我们使用
sleep 60来给 Kubernetes 和所有中间件(如服务网格、负载均衡器)足够的时间来更新路由配置,确保旧 Pod 在开始优雅停机前,已经不再接收任何新请求。 terminationGracePeriodSeconds必须足够长,以覆盖preStophook 的执行时间,以及 Spring Boot 优雅停机处理最长请求的时间。
- 我们使用
- Spring Boot
server.shutdown: graceful:确保应用程序在收到TERM信号后,停止接受新请求,但会等待所有正在进行中的请求完成,从而避免客户端错误。 - Spring Cloud Alibaba Nacos 注销:当 Spring Application Context 关闭时,Spring Cloud Nacos 会自动将该实例从 Nacos 中注销,确保 Nacos Server 不会将流量路由到已关闭的实例。
码字不易,点赞支持~~