☰
Spring Boot 容器化链路:Harbor、Nacos、Rureka、NFS
2026/10/28 3:29:31 网站建设 项目流程

这套组合我第一次拼起来的时候,最耗时间的不是写业务代码,而是把"配置从哪来、镜像从哪拉、文件往哪写、别人怎么找到我"这四件事在集群里对齐。一个 Spring Boot 服务要真正跑在 Rancher 编排的集群里,中间隔着镜像仓库、配置中心、注册中心、共享存储四道关,任何一道没对齐,表现都是同一个:Pod 起不来,或者起来了但没人调得到它。

这篇内容讲的就是这条完整链路——Spring Boot 项目构建成镜像推进 Harbor,由 Rancher 6.3 编排调度,启动阶段从 Nacos 拉配置、向 Rureka 注册中心报到,业务文件落到 NFS 共享目录。适合已经写过 Spring Boot、但第一次把服务往容器集群上搬的同学,也适合环境已经跑起来、但三天两头出小毛病想系统梳理一遍的老手。我会把每个环节"为什么这么做"讲透,顺带把踩过的坑和排查链路都摆出来,你可以直接照着复现。

1. 先把五个组件的责任边界划清楚

1.1 每个组件管什么、不管什么

这套技术栈最大的风险不是某个组件配错,而是职责越界。我见过把数据库连接串打进镜像的,也见过拿注册中心当配置中心用的,短期都能跑,运维半年之后没人敢动。

组件它负责的那一段明确不该它管的事越界后的典型症状
Harbor存镜像、管版本、控权限不该存运行时配置和业务文件改个参数要重新构建镜像
Nacos集中管理配置、推送变更不该存服务实例的实时状态配置里塞了一堆心跳数据
Rureka服务注册、心跳、实例列表下发不该承担配置分发重启一次注册信息全丢
NFS多副本共享的持久化目录不该当数据库或高并发写入盘文件锁冲突、随机读写极慢
Rancher 6.3编排调度、发布回滚、资源配额不该手工进容器改文件节点重启后改动全部消失

标题里写的 Rureka,就是我们项目里那套注册中心组件的叫法,职责跟常见服务注册发现组件完全一致:应用启动时上报自己的地址,定时心跳续约,消费方按服务名拉实例列表。后面统一用这个名字,不再纠结它底层是哪一套。

判定的标准其实很简单:这份数据变了,需不需要重新构建镜像?需要,那就是镜像的事;不需要但要重启才生效,那是配置;不需要重启就变,那是运行时状态。按这条线切,五个组件的边界基本不会错。

1.2 一个 Pod 从创建到接流量的完整时序

理解顺序比记配置重要。脑子里有了这条线,任何一环出问题你都知道该去哪看。

  1. Rancher 把 Deployment 下发到某个节点,kubelet 开始创建 Pod;
  2. 拉镜像阶段,kubelet 读取命名空间里的imagePullSecrets,带着凭据去 Harbor 认证并拉取指定 tag 的镜像;
  3. 容器进程启动,Spring Boot 在bootstrap阶段(注意是 bootstrap,不是 application)就去连 Nacos,按namespace + group + dataId三元组把远端配置拉下来合并到 Environment;
  4. Spring 上下文继续初始化,数据源、线程池这些 Bean 用远端配置里的参数创建;
  5. 应用启动完成,向 Rureka 发起注册,上报自己的 IP、端口和元数据,同时开始定时心跳;
  6. 容器内的readinessProbe开始探测/actuator/health,连续成功若干次后 Pod 被标记为 Ready;
  7. Service 的 Endpoints 里出现这个 Pod IP,流量开始进来;
  8. 业务写入的文件通过 PVC 落到 NFS 服务器上的实际目录。

这条链路里有两个容易被忽略的点。第一个是第 3 步发生在第 5 步之前,如果 Nacos 连不上且配置了快速失败,应用根本走不到注册那一步,你会看到 Pod 反复重启但注册中心里一个实例都没有。第二个是第 6 步和第 5 步是并行的,注册成功不等于能接流量,探针没过照样不进 Endpoints。排查时把这两条分开看,能省一大半时间。

1.3 三种最常见的越界写法

第一种,把application-prod.yml直接COPY进镜像,环境变量都不留。看着省事,实际是给自己挖坑:测试环境和生产环境要打两个镜像,tag 还得区分,一旦发错版本就是事故。正确做法是镜像里只保留application.yml的骨架,所有环境差异项走 Nacos 或者环境变量。

第二种,把配置中心的dataId按实例来分,每个 Pod 一份。这等于把配置中心退化成了本地文件,还多了一次网络调用。配置应该按"应用 + 环境"维度组织,同一个应用的所有副本读同一份,差异通过命名空间隔离。

第三种,把日志目录挂到 NFS 上然后开 DEBUG。NFS 适合存用户上传的附件、生成的报表这类"写一次读多次"的文件,日志这种高频追加写的场景放上去,轻则写入延迟飙升,重则把 NFS 服务端的锁资源打满,连累所有挂载了这块存储的应用。日志走标准输出,交给集群的日志采集组件处理。

2. 把 Spring Boot 打成"能上集群"的镜像

2.1 为什么用多阶段构建而不是本机打包再 COPY

很多人习惯本机mvn clean package出一个 jar,然后写个三行的 Dockerfile 把 jar 拷进去。这个做法在单人开发时没问题,一旦多人协作就暴露三个问题:构建机的 JDK 版本不一致导致字节码版本对不上;构建产物不可复现,同一个 commit 在不同人机器上打出来的包可能不一样;本地仓库里可能有 SNAPSHOT 依赖,构建成功但集群上跑不起来。

多阶段构建把"编译"和"运行"彻底分开:第一个阶段用完整的 Maven + JDK 镜像编译,第二个阶段只保留 JRE 和 jar。好处是构建过程完全在容器里完成,任何人任何机器跑出来的结果一致;最终镜像里没有 Maven、没有源码、没有本地仓库缓存,体积能压到原来的一半以下。

代价是首次构建要下载全量依赖,比较慢。解决办法是在COPY src之前先COPY pom.xml并执行一次dependency:go-offline,这样只要pom.xml没变,依赖层就能命中缓存,后续构建只重新编译业务代码。

2.2 一份可以直接抄的 Dockerfile

# ---------- 构建阶段 ---------- FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /build # 先只拷贝 pom,让依赖层独立成层,命中缓存 COPY pom.xml . RUN mvn -B -q dependency:go-offline # 再拷贝源码编译 COPY src ./src RUN mvn -B -q clean package -DskipTests # 利用 Spring Boot 分层能力拆出依赖层 RUN java -Djarmode=layertools -jar target/*.jar extract --destination /build/layers # ---------- 运行阶段 ---------- FROM eclipse-temurin:17-jre-jammy ENV TZ=Asia/Shanghai \ LANG=C.UTF-8 \ JAVA_OPTS="-XX:MaxRAMPercentage=70 -XX:+UseG1GC -Dfile.encoding=UTF-8" RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone \ && groupadd -r app && useradd -r -g app app WORKDIR /app COPY --from=builder /build/layers/dependencies/ ./ COPY --from=builder /build/layers/spring-boot-loader/ ./ COPY --from=builder /build/layers/snapshot-dependencies/ ./ COPY --from=builder /build/layers/application/ ./ RUN mkdir -p /app/data /app/logs && chown -R app:app /app USER app EXPOSE 8080 ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"]

这段里有几个细节值得单独说。

jarmode=layertools是 Spring Boot 自带的分层能力,它把 fat jar 拆成dependencies(第三方依赖,几乎不变)、spring-boot-loader、snapshot-dependencies、application(业务代码,每次都变)四层。这么做之后,每次发版只有最后一层变化,前面几层全部命中节点上的镜像缓存,拉取时间从几十秒降到几秒。集群规模越大,这个优化的收益越明显。

exec java里的exec不能省。不加的话,sh是 1 号进程,Java 是子进程,你发SIGTERM的时候信号到不了 JVM,优雅停机逻辑根本不执行,容器被硬杀,正在处理的请求全部断连。

MaxRAMPercentage=70代替固定的-Xmx,是因为容器里的内存配额随时可能调整,硬编码的堆大小要么浪费要么 OOM。留 30% 给堆外内存、元空间、线程栈和 JVM 自身开销,这个比例在绝大多数业务服务上是安全的。

2.3 时区、编码、非 root 用户这些"小事"

时区不对是最隐蔽的坑之一。容器默认 UTC,日志时间戳比北京时间差 8 小时,排查问题时对不上业务方的描述,能来回扯半小时。ENV TZ加上软链/etc/localtime两步都要做,只设环境变量有些基础镜像不认。

编码问题在 Windows 开发的团队里更常见。LANG=C.UTF-8和-Dfile.encoding=UTF-8双保险,避免容器内读中文文件名、生成 CSV 报表时出现乱码。

非 root 用户这条,很多团队为了省事直接用 root 跑。短期没感觉,一旦挂载了 NFS,容器内的 root 会被服务端的root_squash映射成匿名用户,写文件直接 Permission denied——这个问题在本地开发环境永远复现不了,只有上了集群才炸。提前用普通用户跑,能把这个坑挡在上线之前。

2.4 镜像 tag 的命名规则

别用latest。latest意味着"我不知道这是哪个版本",出问题想回滚都找不到回滚目标,而且imagePullPolicy: Always配合latest会导致每次重启都去拉一次镜像,Harbor 挂了整个集群都起不来。

我习惯用分支名-短commit-时间戳,比如release-3f8a1c2-20240612。这个 tag 自带三个信息:从哪个分支出的、对应哪个 commit、什么时候构建的。出问题直接拿 tag 去代码库定位,不用问任何人。

3. Harbor 侧的准备:项目、机器人账号与拉取凭据

3.1 项目划分与镜像命名

Harbor 的层级是项目/仓库:tag,对应到集群里就是harbor.example.internal/order-service/app:release-3f8a1c2-20240612。项目这一层建议按"环境 + 用途"划分,比如prod、staging、base(基础镜像)。不要所有东西都往library里塞,后面权限没法控。

镜像路径里的仓库名用应用名,跟 Nacos 的spring.application.name、Rureka 里注册的服务名保持一致。三处同名,排查问题时不用做映射转换,看到哪个都能对上。

3.2 为什么用机器人账号而不是个人账号

用个人账号拉镜像,最直接的后果是这个人的账号一旦停用或者改了密码,所有引用该凭据的工作负载全部拉取失败。而且个人账号通常有推送权限,等于把写权限发给了每一个需要拉镜像的节点。

Harbor 的机器人账号(Robot Account)是为这种场景设计的:只勾选pull权限,不绑定具体的人,可以设置有效期。给每个集群或者每个环境建一个独立的机器人账号,权限最小化,出问题也能快速定位是哪个环境在用。

创建之后会拿到一个类似robot$prod-cluster的用户名和一串 token 作为密码,这两个值只在创建时显示一次,务必当场存进密码管理工具。

3.3 在命名空间里创建拉取凭据

kubectl create secret docker-registry harbor-cred \ --docker-server=harbor.example.internal \ --docker-username='robot$prod-cluster' \ --docker-password='<创建时生成的那串token>' \ --namespace=prod

创建完在 Deployment 的spec.template.spec里引用:

spec: template: spec: imagePullSecrets: - name: harbor-cred

如果嫌每个 Deployment 都写一遍麻烦,可以把 secret 挂到 ServiceAccount 上,这样该 SA 下的所有 Pod 自动带上凭据。不过我倾向于显式写,因为隐式绑定在排查"为什么这个 Pod 拉不到镜像"时会多绕一层。

3.4 拉取失败的四种返回,分别指向不同问题

报错关键词大概率原因验证方式
unauthorized/401凭据没生效,或机器人账号权限不足换一台机器手动docker login试
manifest unknown/not foundtag 写错,或镜像推到了别的项目去 Harbor 界面按 tag 搜一下
x509: certificate signed by unknown authorityHarbor 用的是自签证书,节点不信任在节点上curl -v https://harbor地址
no such host/i/o timeout节点 DNS 解析不了仓库域名进节点nslookup或ping

第一种和第二种占九成。特别是第一种,很多时候 secret 建在了default命名空间,Deployment 却在prod里,跨命名空间是读不到 secret 的,报错看起来一模一样。

第三种情况如果 Harbor 用的是 HTTP 而不是 HTTPS,需要在每个集群节点的容器运行时配置里把仓库地址加进insecure-registries。这一步在 Rancher 里改起来比较麻烦,因为要改节点上的运行时配置文件然后重启服务,滚动做的时候注意别把所有节点一起重启,否则集群会短暂不可用。我的建议是能给 Harbor 配上正式证书就别用 HTTP,省掉这一整类问题。

4. Nacos 里怎么组织配置,半年后还能看懂

4.1 命名空间、分组、dataId 三级怎么分工

这三层不是随便填的,各有用意。我的用法是:

  • 命名空间对应环境:dev、staging、prod。命名空间之间完全隔离,改错了环境也不会串。
  • 分组对应业务域:ORDER、USER、COMMON。跨应用共享的配置放COMMON分组。
  • dataId对应应用:order-service.yaml。一个应用一份主配置,个别需要拆分的再加后缀。

这么分的好处是,新人接手时按"我要改哪个环境、哪个域、哪个应用"三步就能定位到具体文件,不用在一堆xxx-prod-v2-final.yaml里猜。

4.2 Spring Boot 2.4 之后 bootstrap 不生效的问题

这是升级框架时最常见的坑。Spring Boot 2.4 重构了配置加载机制,bootstrap.yml默认不再被加载了。表现是本地跑得好好的,配置也确实写在bootstrap.yml里,但应用启动后读到的全是本地默认值,Nacos 上的配置完全没生效。

两个解法,选一个就行:

方案一,在pom.xml里加依赖:

<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-bootstrap</artifactId> </dependency>

加完之后老的bootstrap.yml方式继续可用,改动最小,适合存量项目。

方案二,在application.yml里用spring.config.import:

spring: config: import: - optional:nacos:order-service.yaml?group=ORDER&refreshEnabled=true

这种方式更符合新版的设计思路,但要注意加上optional:前缀,否则 Nacos 连不上时应用直接启动失败。生产环境我反而不加optional——配置中心不可用的时候,服务带着错误的配置启动起来危害更大,宁可起不来。

4.3 一份可用的 bootstrap.yml

spring: application: name: order-service cloud: nacos: server-addr: nacos.example.internal:8848 discovery: namespace: 6f1e2d3c-4b5a-6789-0abc-def012345678 group: ORDER metadata: version: v1 config: namespace: ${spring.cloud.nacos.discovery.namespace} group: ORDER file-extension: yaml shared-configs: ->spring: cloud: nacos: discovery: ip: ${POD_IP:} network-interface: eth0

POD_IP通过 Downward API 注入:

env: - name: POD_IP valueFrom: fieldRef: fieldPath: status.podIP

这么做的好处是注册的地址跟 kubelet 看到的完全一致,省掉一层推导。

5.2 心跳、租约与优雅下线

注册之后,客户端会按固定间隔(默认 5 秒左右)发送心跳续约,注册中心超过一定时间(默认 15 秒)收不到心跳就把实例标记为不健康,再超过一定时间(默认 30 秒)直接剔除。

容器滚动发布的时候,这个过程跟 k8s 的 Pod 生命周期是两套独立机制,很容易打架。典型场景:k8s 决定终止一个 Pod,立刻发SIGTERM,应用进程收到信号开始关闭。如果关闭得太快,注册中心还没收到"主动注销"的请求,实例列表里还留着这个 IP,调用方继续往这儿发请求,全部连接被拒。

解决方案是三步一起上:

lifecycle: preStop: exec: command: ["sh", "-c", "sleep 15"] terminationGracePeriodSeconds: 45

第一步,preStop里 sleep 15 秒。这 15 秒里容器还在跑,但 k8s 已经从 Endpoints 里摘掉了这个 Pod,不再有新流量进来;同时应用有机会向注册中心发送注销请求。

第二步,应用层面注册关闭钩子,SIGTERM时主动 deregister,然后等待正在处理的请求完成。

第三步,terminationGracePeriodSeconds要给够。它的含义是"从发 SIGTERM 到强杀,最多等多久"。这个值必须大于preStop的 sleep 时间 + 应用自身的关闭耗时,否则 k8s 会在应用还没关完的时候直接 SIGKILL,前面两步白做。

45 秒是个比较稳妥的取值:15 秒 preStop + 20 秒等待在途请求 + 10 秒缓冲。

5.3 健康检查别指向依赖下游的接口

/actuator/health默认会检查数据库、Redis 等下游组件的连通性。如果有人把它配成livenessProbe,那么数据库抖动一下,所有 Pod 都会被判定为"不健康"然后重启——本来是下游的问题,结果把自己也搞崩了,雪上加霜。

正确的分工是:

  • livenessProbe只判断"进程是否还活着、是否死锁",用/actuator/health/liveness,它只反映应用自身的状态;
  • readinessProbe判断"能不能接流量",可以用/actuator/health/readiness,它会考虑下游依赖,依赖不可用时把 Pod 摘出 Endpoints,但不重启;
  • startupProbe专门给启动慢的应用用,启动完成前不执行前两个探针,避免启动过程中被误杀。

Spring Boot 2.3 之后内置了这两个分组,需要在配置里打开:

management: endpoint: health: probes: enabled: true health: livenessstate: enabled: true readinessstate: enabled: true

5.4 多副本与元数据

同一个服务起多个副本时,注册中心会拿到多个实例。如果各副本的能力不一样(比如有的机器内存大、能处理大任务),可以在metadata里打标签,消费方按标签做路由。

metadata: version: v1 zone: zone-a weight: "100"

灰度发布就靠这个:新版本先起一个副本,version: v2,通过网关把 1% 的流量导过去,观察没问题再逐步放大。比一次性全量滚动安全得多,出问题影响面可控。

6. NFS:容器里唯一"有状态"的那部分

6.1 为什么不能用 hostPath 和 emptyDir

emptyDir的生命周期跟 Pod 绑定,Pod 一删数据就没了,只适合放临时缓存。hostPath把节点上的目录挂进容器,数据确实不会随 Pod 消失,但它把 Pod 和节点绑死了——下次调度到另一台机器,数据就找不到了;多副本的时候每个副本看到的是各自节点上的目录,彼此不共享。

用户上传的附件、生成的报表、导入的 Excel 这类东西,必须满足两个条件:Pod 重建后还在,多个副本看到的是同一份。这就只能上网络存储。NFS 是最简单的一种,配置成本低,小团队用足够了。

6.2 用 nfs-subdir-external-provisioner 动态供给

手动创建 PV 的方式在小规模下能用,但每加一个应用就要建一对 PV/PVC,路径还得手工规划,很容易冲突。用动态供给省事得多:装一个 provisioner,声明一个 StorageClass,之后每个 PVC 自动在 NFS 上生成一个子目录。

apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: nfs-app provisioner: k8s-sigs.io/nfs-subdir-external-provisioner parameters: archiveOnDelete: "true" reclaimPolicy: Retain volumeBindingMode: Immediate

archiveOnDelete: "true"意味着 PVC 删除时不会真删数据,而是把目录改名加个 archived 前缀。这个参数救过我一次——误删了一个 PVC,数据还在,改个名字重新挂上就恢复了。存储成本远比数据丢失便宜,生产环境务必打开。

reclaimPolicy: Retain是 PVC 删了之后 PV 保留,手动确认无误再清理。配合archiveOnDelete双保险。

6.3 目录怎么规划

一个应用挂一个 PVC,内部再分几个子目录:

volumeMounts: - name: app-data mountPath: /app/data/upload subPath: upload - name: app-data mountPath: /app/data/export subPath: export

用subPath的好处是同一个 PVC 可以挂到多个路径,不必为每个子目录建一个 PVC。但要注意subPath的目录必须提前存在,k8s 不会自动创建,Pod 会一直卡在ContainerCreating,事件里提示failed to create subPath directory。解决办法是在镜像构建时就把目录建好(前面的 Dockerfile 里mkdir -p /app/data就是干这个的),或者用一个 initContainer 预先创建。

不要把临时文件目录挂到 NFS 上。很多框架会在临时目录里放大量小文件(上传缓冲、序列化临时对象),本地磁盘几毫秒的事,走网络存储就是几十毫秒,积少成多直接把接口拖慢一个数量级。临时目录用emptyDir,配一个sizeLimit就够了。

6.4 权限:root_squash 与非 root 用户的冲突

这是 NFS 上最常见、也最难自己想到的问题。

NFS 服务端默认开启root_squash:客户端以 root 身份发起的访问,在服务端会被映射成nobody用户。如果你的容器以 root 跑,写文件时服务端认为你是个匿名用户,有没有权限取决于目录的属主和权限位。目录是 root 建的 755,那写入直接失败。

表现是:ls能看,cat能读,一写就Permission denied。日志里可能只有一行java.io.IOException: Permission denied,看不出跟 NFS 有什么关系。

三种处理方式,按推荐顺序:

首选,容器用非 root 用户跑,并且在 Pod 里指定fsGroup:

securityContext: runAsUser: 1000 runAsGroup: 1000 fsGroup: 1000

fsGroup会让 kubelet 在挂载时把目录的组改成指定 GID 并赋予组读写权限。NFS 场景下这个机制的支持程度取决于 provisioner 和底层实现,不是所有环境都生效,需要实测。

次选,provisioner 创建目录时指定 UID/GID。nfs-subdir-external-provisioner 支持在 StorageClass 里配置onDelete和目录权限,也可以在 provisioner 的 Deployment 里设置运行用户,让生成的目录属主就是应用用户。

最后,服务端配置no_root_squash或者把目录权限放到 777。前者等于放弃了 NFS 的权限隔离,后者是安全问题,都不建议在生产用。真到了只能用这两招的地步,说明存储方案本身需要重新评估。

6.5 NFS 不适合承载的东西

明确几个禁区,能省掉很多事故:

  • 数据库数据目录:NFS 的文件锁实现跟本地文件系统差异很大,数据库依赖的fcntl锁在 NFS 上行为不可靠,轻则性能暴跌,重则数据损坏。数据库要么用云盘,要么用本地 SSD + 主从。
  • 高频追加写的日志:前文说过,会拖垮整个 NFS 服务端。
  • 大量小文件的随机读写:NFS 每次操作都有网络往返,小文件场景下延迟放大非常明显。压缩包、大附件这种顺序读写的大文件才是它的舒适区。
  • 需要跨 Pod 强一致锁的场景:NFS 的锁机制在容器环境下表现不稳定,分布式锁请用专门的组件。

7. Rancher 6.3 上把工作负载编排起来

7.1 三层结构:集群、项目、命名空间

Rancher 的层级比原生 k8s 多了一层"项目"。项目是 Rancher 自己的概念,用来把若干命名空间打包做统一的权限和配额管理。实际使用中我按"业务线 = 项目,环境 = 命名空间"来分,比如订单业务项目下有order-dev、order-staging、order-prod三个命名空间。

这样分的好处是资源配额可以按项目设置,一个业务线吃满了不会影响其他业务线;权限也能按项目授予,开发只能进 dev 和 staging,prod 需要单独申请。

不同版本的 Rancher 在界面位置上会有差异,但底层都是生成标准的 k8s 资源,所以下面的 YAML 在任何版本上都通用。习惯写 YAML 之后我更少用界面操作,因为 YAML 能进代码库、能 review、能 diff。

7.2 一份完整的工作负载定义

apiVersion: apps/v1 kind: Deployment metadata: name: order-service namespace: order-prod spec: replicas: 3 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 selector: matchLabels: app: order-service template: metadata: labels: app: order-service version: v1 spec: imagePullSecrets: - name: harbor-cred terminationGracePeriodSeconds: 45 containers: - name: app image: harbor.example.internal/prod/order-service:release-3f8a1c2-20240612 imagePullPolicy: IfNotPresent ports: - containerPort: 8080 env: - name: POD_IP valueFrom: fieldRef: fieldPath: status.podIP - name: JAVA_OPTS value: "-XX:MaxRAMPercentage=70 -XX:+UseG1GC" resources: requests: cpu: "500m" memory: "1Gi" limits: cpu: "2" memory: "1Gi" startupProbe: httpGet: path: /actuator/health/readiness port: 8080 failureThreshold: 30 periodSeconds: 5 livenessProbe: httpGet: path: /actuator/health/liveness port: 8080 periodSeconds: 10 failureThreshold: 3 readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 periodSeconds: 5 failureThreshold: 2 lifecycle: preStop: exec: command: ["sh", "-c", "sleep 15"] volumeMounts: - name: app-data mountPath: /app/data/upload subPath: upload volumes: - name: app-data persistentVolumeClaim: claimName: order-service-data

几个参数的选择逻辑说明一下。

maxUnavailable: 0配maxSurge: 1,意思是滚动更新时先起一个新的,等它就绪了再停一个旧的,全程可用副本数不低于设定值。代价是更新过程中会多占一份资源,好处是任何时刻都有足够的实例接流量。对可用性要求高的服务应该这么配。

startupProbe的failureThreshold: 30×periodSeconds: 5= 150 秒,意思是给应用最多 150 秒的启动时间。Spring Boot 应用连上 Nacos、初始化数据源、预热缓存,超过这个时间的不少。这个值要按实际情况调,给短了会导致启动中被误杀,陷入CrashLoopBackOff。

requests和limits的内存设成一样,是为了避免节点内存超卖导致 OOMKilled。CPU 可以设得不一样,requests决定调度时的资源分配,limits决定运行时的上限。CPU 可压缩,内存不可压缩,所以内存必须锁死。

7.3 探针参数怎么算

探针参数不是拍脑袋定的,有几个基本关系:

  • startupProbe的总时长(failureThreshold × periodSeconds)必须大于应用的最长启动时间,留 30% 余量;
  • livenessProbe的failureThreshold × periodSeconds决定了故障检测的灵敏度,设太短容易误杀,太长故障恢复慢。10 秒 × 3 次 = 30 秒,是个平衡点;
  • readinessProbe可以比 liveness 更灵敏,5 秒 × 2 次 = 10 秒,依赖恢复后能快速重新纳入流量。

另外,initialDelaySeconds在使用startupProbe之后就不需要了,因为 startupProbe 成功之前 liveness 和 readiness 都不会执行。

7.4 发布与回滚

Rancher 界面上有版本历史和回滚按钮,底层对应的是 ReplicaSet 的保留。默认revisionHistoryLimit是 10,意味着能回滚到最近 10 个版本。如果发版频繁,建议调大一点,20 比较合适。

命令行回滚:

# 看历史 kubectl rollout history deployment/order-service -n order-prod # 回滚到上一个版本 kubectl rollout undo deployment/order-service -n order-prod # 回滚到指定版本 kubectl rollout undo deployment/order-service -n order-prod --to-revision=3 # 观察滚动过程 kubectl rollout status deployment/order-service -n order-prod

回滚之前先想清楚一件事:配置回不回滚?镜像回滚了,但 Nacos 上的配置还是新的,两者版本不匹配可能引用到不存在的字段。所以配置变更最好跟代码发布绑定,打同一个版本标记,回滚时一起回。

8. 上线后最常撞上的几类故障与排查链路

8.1 Pod 卡在 ImagePullBackOff

先看事件,这是信息量最大的地方:

kubectl describe pod <pod名> -n order-prod | tail -30

事件里会明确写出是 401、not found 还是证书问题,对照第 3.4 节的表格定位。如果事件里显示的是no such host,先排除 DNS;如果是unauthorized,检查 secret 是否在同一个命名空间、用户名密码是否正确(机器人账号的 token 有没有复制全)。

有一个隐蔽情况:secret 建的时候用的--docker-server跟镜像地址里的域名不完全一致。比如 secret 里写的是harbor.example.internal,镜像地址写的是harbor.example.internal:443,k8s 认为这是两个不同的仓库,凭据匹配不上。端口号要么都带,要么都不带。

8.2 启动成功但注册中心里没有实例

按这个顺序查:

  1. 先确认应用日志里有没有注册成功的记录。如果没有,看注册中心的地址、命名空间、分组配得对不对;
  2. 检查网络连通性。从 Pod 里curl一下注册中心的地址,容器的网络策略可能限制了出向流量;
  3. 看注册上去的 IP 是不是 Pod IP。如果是 127.0.0.1 或者某个内网地址,说明网卡选错了,按第 5.1 节的方式指定;
  4. 看分组对不对。客户端注册的分组和服务端查询的分组不一致,等于在同一个注册中心的两个平行世界里,谁都看不见谁。

最后一种最难查,因为两边都正常工作,就是不互相认。有个小技巧:在注册中心控制台上按服务名搜索,确认实例确实存在,然后检查消费方配置的分组。

8.3 配置改了但应用读到的还是旧值

分几种情况:

  • 如果完全没生效,先确认命名空间 ID 填对了(第 4.4 节),再看 dataId 和 group 是否完全匹配,大小写敏感;
  • 如果重启后生效、热更新不生效,检查@RefreshScope有没有加,或者有没有配置refreshEnabled;
  • 如果部分字段生效部分不生效,检查那些字段是不是静态的、或者是不是在 Bean 初始化时就固化了。@ConfigurationProperties覆盖不到的地方,通常是因为那个值在构造函数里就被读走了。

8.4 挂载目录写不进去

按这个顺序排查:

# 1. 确认挂载成功了 kubectl exec -it <pod> -n order-prod -- df -h | grep app # 2. 看目录属主和权限 kubectl exec -it <pod> -n order-prod -- ls -ld /app/data/upload # 3. 用应用用户试写 kubectl exec -it <pod> -n order-prod -- su -s /bin/sh app -c 'touch /app/data/upload/test.txt'

如果第 1 步就看不到挂载点,说明 PVC 没绑定上,回去看 PVC 状态;如果看得到但属主是nobody,是root_squash导致的,按第 6.4 节处理;如果是Read-only file system,检查 PV 的accessModes是不是写成了ReadOnlyMany。

8.5 反复重启 CrashLoopBackOff

# 看上一次崩溃的日志 kubectl logs <pod> -n order-prod --previous

--previous这个参数很多人不知道,它能看到容器上一次退出的日志。如果应用启动阶段就崩了,当前的日志可能是空的,必须加这个参数。

常见原因:连不上配置中心(并且配了快速失败)、数据源参数错误导致连接池初始化失败、堆内存超出 limits 被 OOMKilled(kubectl describe pod里会有Last State: Terminated, Reason: OOMKilled)、启动时间超过 startupProbe 的容忍范围被强制杀掉。

区分 OOMKilled 和进程自身退出很简单:OOMKilled 会在 Pod 状态里明确标注,退出码是 137;进程自身异常的退出码通常是 1 或者具体的异常码。

8.6 一份速查表

现象第一条命令高频根因
ImagePullBackOffkubectl describe pod凭据不在同命名空间 / 域名带端口不一致
CrashLoopBackOffkubectl logs --previous配置中心连不上 / 堆内存超限
Running 但不 Readykubectl describe pod看探针readiness 路径错 / 依赖未就绪
注册中心没实例进 Podcurl注册中心分组不一致 / 网卡选错
配置不生效对比 dataId 与命名空间 ID回退到 public / 前缀缺失
写入 Permission deniedls -ld挂载目录root_squash / fsGroup 未生效
滚动发布期间大量超时kubectl get endpointspreStop 太短 / 未主动注销

这套链路我前后搭过三四个环境,最大的感受是:大部分故障都不是某个组件坏了,而是两个组件之间的约定没对齐。镜像地址和 secret 里的域名要一致,Nacos 的命名空间 ID 要一致,注册中心和消费方的分组要一致,容器内用户和 NFS 目录属主要一致。每个"一致"单看都是小事,凑在一起就是一套能自愈的系统。

还有一个实操上的建议:把上面这份 Deployment、StorageClass、bootstrap.yml 整理成一个模板仓库,新服务上线直接改名字复制。配置项多了之后,靠记忆去对齐这些约定迟早会出错,把它固化成模板,出错概率能降一个数量级。最初的模板可能只有二十行,跑上一年会慢慢长到几百行,每一条都是某次事故换来的,别嫌它臃肿。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询