5 台服务器(五):Spring Cloud 上线与验收

5 台服务器系列最终篇:MySQL/Redis/Nacos、Spring Cloud 微服务上线、Argo CD 发布与回滚、灰度,以及整条链路的验收清单。

技术文章约 14 分钟2 次阅读

系列导航:系列总览 · 上一篇: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.sql

Redis 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: 20Gi

YAML 里这种一行写完的 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.yaml

Argo 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——集群搭建只是起点,让这条链路在无人值守时也能自己保持健康,才是这套投入的回报。


系列完结。回到系列总览。

#延伸阅读

相关阅读

Related

留言讨论

0 条

还没有留言,来说点什么吧。

5 台服务器(五):Spring Cloud 上线与验收 · LXH·BLOG