5 台服务器(五):Spring Cloud 上线与验收
5 台服务器系列最终篇:MySQL/Redis/Nacos、Spring Cloud 微服务上线、Argo CD 发布与回滚、灰度,以及整条链路的验收清单。
系列导航:系列总览 · 上一篇:5 台服务器(四):DevOps 工具链
| 项目 | 说明 |
|---|---|
| 系列 | 5 台服务器二进制 K8s(第 5/5 篇) |
| 本篇 | 中间件、微服务上线、发布/回滚/灰度、验收 |
| 前置 | 建议先读 第 4 篇:DevOps 工具链 |
| 版本 | Kubernetes v1.36.4 / etcd v3.6.13 / containerd v2.3.3 LTS / Calico v3.32.2 |
#一、中间件与 Spring Cloud 上线
以一个拆得比较标准的电商 demo 为例,五个服务:shop-gateway(Spring Cloud Gateway,统一入口、鉴权、限流)、shop-auth(认证授权,发 JWT)、shop-user(用户、余额)、shop-product(商品、库存)、shop-order(订单,Feign 调 user 和 product),全部监听 8080。版本组合(2026 年主流稳定线):
JDK 21
Spring Boot 3.5.x
Spring Cloud 2025.0.x
Spring Cloud Alibaba 2025.0.0.0
Nacos Server/Client 3.2.4父工程用三个 BOM 锁版本,子模块不许单独写版本号——这是避免"能编译但运行时炸"的最有效手段:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.5.6</version>
<type>pom</type><scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2025.0.2</version>
<type>pom</type><scope>import</scope>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2025.0.0.0</version>
<type>pom</type><scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>中间件三个都放 middleware 命名空间,单实例 StatefulSet,生产环境要主从/哨兵,逻辑一样,副本和探针改一改。MySQL 8.4:headless Service + StatefulSet,注意 clusterIP: None 的服务名、utf8mb4 参数和 innodb_buffer_pool_size=2G;起来后建库,Nacos 和业务各一个:
kubectl -n middleware exec -it mysql-0 -- mysql -uroot -p'Mysql@2026!ChangeMe' -e "
CREATE DATABASE IF NOT EXISTS nacos_config DEFAULT CHARSET utf8mb4;
CREATE DATABASE IF NOT EXISTS shop DEFAULT CHARSET utf8mb4;
"
# Nacos 建表语句从这个地址拿,对应版本 3.2.4
# https://github.com/alibaba/nacos/blob/3.2.4/distribution/conf/mysql-schema.sql
kubectl -n middleware exec -i mysql-0 -- mysql -uroot -p'Mysql@2026!ChangeMe' nacos_config < mysql-schema.sqlRedis 7 单实例,--appendonly yes --maxmemory 2gb --maxmemory-policy allkeys-lru。Nacos 3.2.4 是 3 副本 StatefulSet + headless Service,Nacos 3.x 靠 headless 域名互相发现,比 2.x 的 NACOS_SERVERS 列表可靠;注意 3.x 的健康检查路径是 /nacos/v3/admin/core/state/health,探针一直失败先 kubectl exec 进去 curl 确认真实路径:
# /root/nacos.yaml 关键片段
apiVersion: v1
kind: Service
metadata:
name: nacos-headless
namespace: middleware
spec:
clusterIP: None
selector: { app: nacos }
ports:
- { name: http, port: 8848, targetPort: 8848 }
- { name: grpc, port: 9848, targetPort: 9848 }
- { name: jraft, port: 9849, targetPort: 9849 }
---
apiVersion: v1
kind: Service
metadata:
name: nacos
namespace: middleware
spec:
selector: { app: nacos }
ports:
- { name: http, port: 8848, targetPort: 8848 }
- { name: grpc, port: 9848, targetPort: 9848 }
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: nacos
namespace: middleware
spec:
serviceName: nacos-headless
replicas: 3
podManagementPolicy: Parallel
selector:
matchLabels: { app: nacos }
template:
metadata:
labels: { app: nacos }
spec:
containers:
- name: nacos
image: nacos/nacos-server:3.2.4
env:
- name: MODE
value: cluster
- name: NACOS_SERVERS
value: "nacos-0.nacos-headless.middleware.svc.cluster.local:8848 nacos-1.nacos-headless.middleware.svc.cluster.local:8848 nacos-2.nacos-headless.middleware.svc.cluster.local:8848"
- name: SPRING_DATASOURCE_PLATFORM
value: mysql
- name: MYSQL_SERVICE_HOST
value: mysql.middleware.svc.cluster.local
- name: MYSQL_SERVICE_DB_NAME
value: nacos_config
- name: MYSQL_SERVICE_USER
value: root
- name: MYSQL_SERVICE_PASSWORD
valueFrom:
secretKeyRef: { name: nacos-secret, key: mysql-password }
- name: NACOS_AUTH_ENABLE
value: "true"
ports:
- { containerPort: 8848, name: http }
- { containerPort: 9848, name: grpc }
- { containerPort: 9849, name: jraft }
readinessProbe:
httpGet: { path: /nacos/v3/admin/core/state/health, port: 8080 }
initialDelaySeconds: 40
periodSeconds: 10
failureThreshold: 15
livenessProbe:
httpGet: { path: /nacos/v3/admin/core/state/health, port: 8080 }
initialDelaySeconds: 90
periodSeconds: 20
failureThreshold: 10
resources:
requests: { cpu: 500m, memory: 2Gi }
limits: { cpu: 2, memory: 3Gi }
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: nfs-client
resources:
requests:
storage: 20GiYAML 里这种一行写完的 map({ cpu: 500m, memory: 2Gi })看着紧凑,但漏一个右括号很难一眼看出来,所有清单落地前先过一遍 kubectl apply --dry-run=client -f nacos.yaml。
镜像构建。 用分层 JAR,改动代码时只有应用层重建,CI 里能省一半时间:
# Dockerfile(分层版)
FROM eclipse-temurin:21-jre-alpine AS extract
WORKDIR /build
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} app.jar
RUN java -Djarmode=layertools -jar app.jar extract
FROM eclipse-temurin:21-jre-alpine
RUN apk add --no-cache tzdata curl \
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone
WORKDIR /app
COPY --from=extract /build/dependencies/ ./
COPY --from=extract /build/spring-boot-loader/ ./
COPY --from=extract /build/snapshot-dependencies/ ./
COPY --from=extract /build/application/ ./
ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=70.0 \
-XX:+UseG1GC -XX:MaxGCPauseMillis=200 \
-XX:+ExitOnOutOfMemoryError \
-Djava.security.egd=file:/dev/./urandom \
-Dfile.encoding=UTF-8"
EXPOSE 8080
ENTRYPOINT ["sh","-c","exec java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"]# Dockerfile(简单版,本地调试用)
FROM eclipse-temurin:21-jre-alpine
RUN apk add --no-cache tzdata curl \
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
WORKDIR /app
COPY target/*.jar app.jar
ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=70.0 -XX:+UseG1GC -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8"
EXPOSE 8080
ENTRYPOINT ["sh","-c","exec java $JAVA_OPTS -jar /app/app.jar"]分层镜像最容易写错的是最后的 Entrypoint:目录已被 extract 拆开,不能再 -jar app.jar;而且 Spring Boot 3.2 之后 launcher 主类从 org.springframework.boot.loader.JarLauncher 挪到了 org.springframework.boot.loader.launch.JarLauncher,少一个 launch 包路径就会报 ClassNotFoundException。MaxRAMPercentage=70 这条必须写:不写的话 JVM 按宿主机内存算堆,Pod limits 给 2G 但宿主机 64G,JVM 会以为自己能用到 16G,然后被 cgroup 直接 OOMKill,Kubernetes 事件里只看到 OOMKilled,JVM 日志里什么都不留。
部署清单。 以 shop-order 为例(其余四个照抄改名字),关键点都在模板里:
# shop-order.yaml 关键片段
apiVersion: v1
kind: ConfigMap
metadata:
name: shop-common-env
namespace: prod
data:
SPRING_PROFILES_ACTIVE: "prod"
SPRING_CLOUD_NACOS_SERVER_ADDR: "nacos.middleware.svc.cluster.local:8848"
SPRING_CLOUD_NACOS_DISCOVERY_NAMESPACE: "prod"
SPRING_CLOUD_NACOS_CONFIG_NAMESPACE: "prod"
SPRING_DATASOURCE_URL: "jdbc:mysql://mysql.middleware.svc.cluster.local:3306/shop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false"
SPRING_DATA_REDIS_HOST: "redis.middleware.svc.cluster.local"
SPRING_DATA_REDIS_PORT: "6379"
SERVER_SHUTDOWN: "graceful"
SPRING_LIFECYCLE_TIMEOUT_PER_SHUTDOWN_PHASE: "30s"
MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: "health,info,prometheus"
MANAGEMENT_ENDPOINT_HEALTH_PROBES_ENABLED: "true"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: shop-order
namespace: prod
spec:
replicas: 2
revisionHistoryLimit: 5
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels: { app: shop-order }
template:
metadata:
labels:
app: shop-order
annotations:
prometheus.io/scrape: "true"
prometheus.io/path: /actuator/prometheus
prometheus.io/port: "8080"
spec:
terminationGracePeriodSeconds: 60
imagePullSecrets:
- name: harbor-pull
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels: { app: shop-order }
containers:
- name: shop-order
image: harbor.apps.lxhhub.top/shop/shop-order:20260909-a1b2c3d
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
envFrom:
- configMapRef: { name: shop-common-env }
- secretRef: { name: shop-secret }
env:
- name: POD_IP
valueFrom:
fieldRef: { fieldPath: status.podIP }
- name: SPRING_CLOUD_NACOS_DISCOVERY_IP
value: $(POD_IP)
startupProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
failureThreshold: 60
periodSeconds: 5
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 8080 }
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
periodSeconds: 15
failureThreshold: 3
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 25"]
resources:
requests: { cpu: 500m, memory: 1Gi }
limits: { cpu: 2, memory: 2Gi }
---
apiVersion: v1
kind: Service
metadata:
name: shop-order
namespace: prod
spec:
selector: { app: shop-order }
ports:
- port: 8080
targetPort: 8080再补 HPA(min 2 max 8,CPU 70%,stabilizationWindowSeconds: 300 防抖)和 PDB(minAvailable: 1)。四个不能省的逻辑:
- startupProbe:Spring Boot 冷启动 40~90 秒很正常,没有它 livenessProbe 会在启动过程中失败并杀容器,Pod 陷入
CrashLoopBackOff死循环。startupProbe 给了 60×5=300 秒窗口,它成功之前 liveness/readiness 都不介入。 - preStop sleep 25:K8s 发 SIGTERM 和删除 Endpoint 是并发的,Endpoint 传播到 kube-proxy、Ingress 需要几秒,期间还在往 Pod 上打流量,JVM 立刻关闭用户就会看到 502。sleep 25 让容器先"装死":不再接新请求,但已有请求能跑完,同时 Nacos 心跳在这段时间里超时、服务端完成反注册。这个值要大于"Endpoint 传播时间 + Nacos 心跳周期(默认 5s)× 2",小于
terminationGracePeriodSeconds。 - 注册 IP:
SPRING_CLOUD_NACOS_DISCOVERY_IP=$(POD_IP)让 Nacos 里注册的是 Pod IP;不配的话 Nacos 拿到容器 hostname 或错误网卡 IP,网关转发时连接不上。 - topologySpreadConstraints:只有 2 个 worker,不打散的话滚动更新完两个副本可能挤在一台上,那台一挂服务全断。
网关单独暴露:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: shop-gateway
namespace: prod
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "20m"
nginx.ingress.kubernetes.io/proxy-connect-timeout: "10"
nginx.ingress.kubernetes.io/proxy-read-timeout: "120"
spec:
ingressClassName: nginx
rules:
- host: shop.apps.lxhhub.top
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: shop-gateway
port: { number: 8080 }Jenkinsfile。 用 Kaniko 构建镜像,不挂 docker.sock——挂 docker socket 等于把宿主机 root 交给构建容器,CI 里跑什么代码都能拿下节点。构建在 Kubernetes 云上的 maven + kaniko 双容器 Pod 里跑,镜像标签用 BUILD_NUMBER-GITSHA,可追溯也便于回滚;latest 同时推一份只为本地调试,生产清单永远钉死具体 tag。核心流程:Checkout → Unit Test → Package → Build & Push Images(五个服务并行)→ Update GitOps Repo(kustomize edit set image 改 overlays/prod 里的 tag 并 push)。SonarQube 阶段默认关闭,装了 Sonar 再打开。Jenkins 改完 tag 一 push,Argo CD 默认 3 分钟内检测到并同步;想立刻生效就在 Gitea 上配 webhook 打到 https://argocd.apps.lxhhub.top/api/webhook。
GitOps 仓库 shop-gitops 的结构:
shop-gitops/
├── base/
│ ├── kustomization.yaml
│ ├── configmap.yaml
│ ├── secret.yaml
│ ├── shop-gateway.yaml
│ └── ...
└── overlays/
├── prod/
│ ├── kustomization.yaml # kustomize edit set image 改的就是这里
│ └── replica-patch.yaml
└── test/
└── kustomization.yamlArgo CD 里建 Application,automated.prune + selfHeal,ApplyOutOfSyncOnly=true:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: shop-prod
namespace: argocd
spec:
project: default
source:
repoURL: http://git.apps.lxhhub.top/gitea-admin/shop-gitops.git
targetRevision: main
path: overlays/prod
destination:
server: https://kubernetes.default.svc
namespace: prod
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- ApplyOutOfSyncOnly=true
retry:
limit: 5
backoff:
duration: 5s
factor: 2
maxDuration: 3m回滚。 两种方式,更推荐 Git revert,因为 Git 才是事实来源:
# 方式一:Argo CD 回滚到上一版
argocd app history shop-prod
argocd app rollback shop-prod <REVISION>
# 方式二:Git revert
git revert HEAD && git push灰度。 不想引入 Argo Rollouts 的话,用 Ingress-NGINX 的 canary 注解最省事:再部署一份 shop-gateway-canary(新版本,1 副本),然后 nginx.ingress.kubernetes.io/canary: "true" + canary-weight: "10",10% 流量进新版本,观察 Grafana 里的错误率和 SkyWalking 里的链路,没问题就把稳定版的镜像 tag 改成新版本、删掉 canary Ingress。
#二、验证层:怎么确认做对了
集群层面:
kubectl get nodes -o wide # 5 台 Ready
kubectl get pod -A # 无 Pending / CrashLoopBackOff
kubectl get --raw='/readyz?verbose' | tail -20
etcdctl-prod endpoint health --write-out=table # 3 个 true
kubectl -n kube-system logs -l k8s-app=kube-dns --tail=20
kubectl top nodes # metrics-server 正常高可用:
# 停掉持有 VIP 的 master 上的 apiserver,VIP 应漂走,kubectl 仍可用
systemctl stop kube-apiserver
kubectl get nodes
# 停掉一个 master 整机,etcd 应仍可写(3 节点坏 1 个仍然 quorum)
kubectl create sa test-ha && kubectl delete sa test-ha
# 恢复后 etcd 成员应自动追平
etcdctl-prod endpoint status --write-out=table网络与 DNS:
kubectl run dns-test --image=busybox:1.37 -it --rm --restart=Never -- \
nslookup kubernetes.default.svc.cluster.local
kubectl run dns-test2 --image=busybox:1.37 -it --rm --restart=Never -- \
nslookup shop-order.prod.svc.cluster.local
# Pod 之间跨节点互通
kubectl -n prod exec -it <pod1> -- curl -s http://shop-order:8080/actuator/health业务层面:
kubectl -n prod get pod -o wide
kubectl -n prod get hpa
curl -H "Host: shop.apps.lxhhub.top" http://10.0.0.14/actuator/health
# 压测触发 HPA
kubectl run -i --tty load --rm --image=busybox:1.37 --restart=Never -- \
/bin/sh -c "while true; do wget -q -O- http://shop-order.prod:8080/api/order/list; done"
kubectl -n prod get hpa -w数据备份演练别等出事才做——etcd 是集群的真相,定时快照:
etcdctl-prod snapshot save /backup/etcd-$(date +%Y%m%d).db
etcdctl-prod snapshot status /backup/etcd-$(date +%Y%m%d).db --write-out=table
cat > /etc/systemd/system/etcd-backup.service <<'EOF'
[Unit]
Description=etcd snapshot backup
[Service]
Type=oneshot
ExecStart=/bin/bash -c 'etcdctl-prod snapshot save /backup/etcd-$(date +%%Y%%m%%d-%%H%%M).db'
EOF
cat > /etc/systemd/system/etcd-backup.timer <<'EOF'
[Unit]
Description=Daily etcd backup
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl enable --now etcd-backup.timer#三、边界与坑
按实际遇到频率排序:
kubelet 一直 NotReady,报 cgroup driver "cgroupfs" is different。 containerd 的 SystemdCgroup 和 kubelet 的 cgroupDriver 必须都是 systemd;改完重启 containerd 和 kubelet,kubeadm 那种改法不适用,直接改 /etc/containerd/config.toml。
CSR 一直 Pending。 三连查:bootstrap token 是不是和 token.csv 里的一致;apiserver 有没有 --enable-bootstrap-token-auth=true;ClusterRoleBinding 里的 group 名是不是 system:kubelet-bootstrap。
Pod 一直 ContainerCreating,failed to allocate for range 0。 通常是 kube-controller-manager 的 --cluster-cidr 和 Calico 的 CALICO_IPV4POOL_CIDR 不一致,或者 master 上没跑 kube-controller-manager。
CoreDNS 起不来,no endpoints available for service "kube-dns"。 集群还没有 CNI 时 CoreDNS 的 Pod 拿不到 IP,会一直重启。正确顺序:先 Calico,等 calico-node 全部 Running,再上 CoreDNS。
kubectl exec/logs 报 Unauthorized。 缺 system:kube-apiserver-to-kubelet ClusterRole/ClusterRoleBinding,二进制集群不会自动创建。
kubectl top 报 metrics not available yet。 要么 metrics-server 没装,要么 kubelet 的 serving 证书是自签的(开了 serverTLSBootstrap 且 CSR 没批准)。kubectl get csr 看有没有 pending 的 selfnodeserver 请求。
Harbor 镜像推得上但 K8s 拉不下来。 containerd 的 hosts.toml 是每个节点的配置,只在 m1 上配了没用,5 台都要配,改完 systemctl restart containerd。
Jenkins 构建镜像时报 Cannot connect to the Docker daemon。 用 Kaniko,别挂 docker.sock;真要用 Docker 就跑 DinD sidecar,但那需要 privileged,风险自担。
Spring Boot Pod 反复 OOMKilled,本地跑得好好的。 JVM 没设 MaxRAMPercentage,按宿主机内存算堆;limits 给 2G 就配 MaxRAMPercentage=70,堆上限约 1.4G,剩下的留给元空间、线程栈、直接内存。
滚动更新期间短暂 502。 preStop 没配或 sleep 太短,25 秒配合 terminationGracePeriodSeconds: 60 用;另外 readinessProbe 的 periodSeconds 别设太大,否则摘除不及时。
Nacos 里服务实例数是 Pod 数的好几倍。 服务名写错或命名空间不一致会导致重复注册;也可能是旧 Pod 没走完优雅下线就消失。查 Nacos 控制台的实例 IP 和 Pod IP 是否一一对应。
NFS 挂了之后 PVC 创建不出来。 nfs-subdir-external-provisioner 是单点,监控上给 m1 的 nfs-server 加存活告警,比事后排查有用。
etcd 磁盘写满。 --quota-backend-bytes 给了 8G,但碎片不会自动回收。定期 etcdctl defrag + compact,或靠 --auto-compaction-retention 自动压缩;生产环境加磁盘使用率告警,超过 80% 就处理。
证书到期的那天。 上面签的证书是 10 年(87600h),但 kubelet 的客户端证书默认 1 年,靠 rotateCertificates: true 自动续。真正容易忘的是 etcd 那套,它没有自动轮换机制——在日历上记一笔,到期前半年重签并滚动重启,别等到 x509: certificate has expired 才发现整个集群写不进去。
#四、收尾:什么时候别用它
这套二进制方案的价值在于把集群的每一层依赖摊开给你看:证书、共识、入口、调度、网络、存储,全部亲手可见,排障时有因果可循;代价是需要自己维护安装、升级和证书轮换。所以适用边界很明确:几百节点以下、没有专职 K8s 团队、但要求完全掌控的场景最划算;如果团队已有规模或专职平台工程师,kubeadm、RKE、Talos 甚至托管的云 K8s 更省心。下一步建议按优先级做三件事:把 etcd 快照和 NFS 的告警真正接进告警通道;用 Argo CD 的 Sync Windows 给生产环境加发布窗口;以及评估是否引入 Gateway API——集群搭建只是起点,让这条链路在无人值守时也能自己保持健康,才是这套投入的回报。
系列完结。回到系列总览。
#延伸阅读
- 一个 jar 包部署到 k8s 上的全流程 —— 应用侧的最小部署闭环。
- 把博客从 docker-compose 搬到 k3s —— 单机 k3s 的迁移与切换。
- Kubernetes 核心组件详解与实战 —— 把本系列的组件机制展开。
- Argo CD 实战:用 GitOps 把 CI/CD 部署搬进 Kubernetes —— 发布与回滚的完整实现。
相关阅读
Related留言讨论
0 条还没有留言,来说点什么吧。