
如果你手头有一个 Spring Boot 项目在本地或者一台单机上跑得很稳某天老板丢来一句“容器化上 Kubernetes”你大概率会心里一紧。我接过这种活前后折腾了两三周才把一套 Spring Boot 服务完整地跑在集群上。回头看Kubernetes 本身不算难学真正难的是思维模式的转变本地你只需要关心“进程能不能起来”而在集群里你得同时关心镜像怎么构建、进程怎么健康检查、配置怎么下发、发布的时候怎么不断流、Pod 万一挂了怎么恢复。这篇文章把我从容器化到部署、再从部署到排查的完整过程整理出来重点不是贴一份能跑的 YAML而是把每一步背后的考虑和踩过的坑讲清楚给准备把 Spring Boot 项目搬上 Kubernetes 的同学一个可参考的路线。1. 为什么我最终决定把 Spring Boot 项目搬到 Kubernetes 上1.1 单机部署模式下最头疼的三件事先说说我为什么要折腾 Kubernetes。之前的项目是典型的单体起家后来拆成几个服务每个服务跑在一台云主机上部署流程基本是本地打包、上传服务器、停掉旧进程、再启动新进程。听起来简单实际做起来有太多不确定性。第一个问题是环境不一致。本地上跑得好好的到了服务器上就报错查来查去是 JDK 小版本不一样、系统缺少某个依赖、端口被占用。这个问题在 Kubernetes 里基本消失因为镜像把运行时、依赖、代码都锁定在一起这其实就是镜像的核心价值把环境变成不可变产物。第二个问题是发版靠手工。多服务的情况下我要按顺序更新服务 A、服务 B、服务 C中间还要盯着日志和状态。释放是一个过程人肉操作就容易出错而且出了错很难回滚——要么重新打一次包再来一遍要么靠备份临时恢复过程相当痛苦。第三个问题是流量一起来就抓瞎。活动期间某个服务 CPU 打满最快的办法是重启、加机器但加机器的时间成本和资金成本都很可观。扩缩容如果全部依赖人工做运维的同学夜里也得不到安宁。这几个痛点凑到一起容器化和 Kubernetes 就成了绕不开的选项。1.2 Kubernetes 真正解决了哪些问题搬到 Kubernetes 之后这几个问题都有了对应的解法环境问题由镜像解决同一个镜像在测试环境跑出来是什么行为生产也是什么行为不会再出现“我本机没问题啊”这种对话。部署变成声明式的把期望状态写进 YAML集群负责把它变成现实发布流程统一人也很难再操作失误。故障自愈Pod 挂了会重建节点挂了会把 Pod 调度到别的节点不用半夜被喊起来重启服务。弹性伸缩配合 HPA流量上来自动扩容流量降了自动缩容资源利用率高不少。发布策略滚动更新、金丝雀发布都可以通过控制器实现不需要自己写一堆脚本。不过我也要泼一盆冷水如果你的项目只有一个服务、整天不怎么改动Kubernetes 带来的复杂度可能大于收益。它适合的场景是有一定数量服务、发布频繁、流量有波动这种情况下节省的时间非常可观。所以先判断值不值得再看怎么做不要为了用而用。2. Spring Boot 容器化镜像构建不是简单打个包很多人第一步就栽在镜像上。Spring Boot 项目生成的是 jar 包你以为写一个 Dockerfile 把 jar COPY 进去就行结果到了 K8s 里要么启动慢要么被 OOMKill要么容器一直重启。这一章的坑值得先看完。2.1 基础镜像应该怎么选最开始的 Dockerfile 我直接用了 alpine 版 JDK 镜像因为体积小。后来发现几个问题容器里打印的日志中文乱码、时区和本地差 8 个小时更重要的是 alpine 里用的 musl libc 和部分依赖不完全兼容踩了一次坑之后我换成了 Temurin 系列镜像。Temurin 镜像的运行时比较规范有只含 JRE 的版本体积比完整 JDK 小很多兼容性也稳定。选基础镜像的经验是这样的能用 JRE 就不用 JDK运行时不需要编译器镜像能小很多尽量不追 latest打上具体版本号方便复现基于 Debian/Ubuntu 底座的镜像遇到疑难问题排查更方便虽然体积大一点时区、字体、CA 证书这些细节直接选维护活跃的镜像会省很多事。2.2 JVM 在容器里的内存感知问题是重灾区这是我第一次遇到 CrashLoopBackOff 的元凶。当时给 Pod 设置了内存 limits 为 512MiJava 进程起来不久就被杀看 Events 显示 OOMKilled。原因很典型旧版 JVM 不会自动感知容器的内存限制默认把最大堆内存当成宿主机内存来分配一个 512Mi 的容器里JVM 可能尝试拿超过 1G 的堆直接被系统杀掉。解决办法是在启动参数里显式告诉 JVM 按容器内存百分比来设置堆内存-XX:MaxRAMPercentage75.0 -XX:InitialRAMPercentage25.0这里的 75% 是经验值因为除了堆JVM 的元空间、线程栈、GC 等也需要内存还要给可能出现的 sidecar 和系统进程留一点余地。如果 limits 设成 512Mi堆大概在 384Mi 左右具体可以通过压测确认。如果你还在用 Java 8注意要选比较新的 8u 版本老版本并不支持容器感知Java 10 以后默认就有容器感知能力但我仍然习惯显式设置百分比因为百分比与机器配置无关比固定 -Xmx 值更省心。2.3 多阶段构建与分层镜像纯单阶段 Dockerfile 也不是不行但有两个问题一是镜像里残留构建工具体积大二是代码一改整个镜像都要重新构建推送。我现在的做法是用多阶段构建第一阶段用 Maven 镜像编译生成 jar第二阶段用 JRE 镜像只拷贝产物。Dockerfile 大概是这样的# 构建阶段 FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests # 运行阶段 FROM eclipse-temurin:17-jre-jammy WORKDIR /app COPY --frombuilder /build/target/app.jar app.jar EXPOSE 8080 ENV JAVA_OPTS-XX:MaxRAMPercentage75.0 ENTRYPOINT [sh, -c, java $JAVA_OPTS -jar app.jar]如果项目用的是 Spring Boot 2.3 以上还可以利用它自带的 layertools 做分层镜像把依赖层、资源层、代码层分开代码变更时只重建最后一层推送和拉取都快很多FROM eclipse-temurin:17-jre-jammy AS builder WORKDIR /build COPY --frombuild-stage /build/target/app.jar app.jar RUN java -Djarmodelayertools -jar app.jar extract FROM eclipse-temurin:17-jre-jammy WORKDIR /app COPY --frombuilder /build/dependencies/ ./ COPY --frombuilder /build/spring-boot-loader/ ./ COPY --frombuilder /build/snapshot-dependencies/ ./ COPY --frombuilder /build/application/ ./ ENTRYPOINT [java, -XX:MaxRAMPercentage75.0, org.springframework.boot.loader.launch.JarLauncher]这种写法的价值在于依赖层轻易不变push/pull 的时候只需要传输 application 层镜像仓库和集群之间的传输效率明显提高。如果你的集群节点很多或者镜像仓库带宽有限这个优化体感很明显。2.4 容器里的健康检查接口Kubernetes 的探针依赖应用的健康检查接口Spring Boot 自带的 Actuator 是现成的方案。但是默认的/actuator/health包含的信息太笼统建议把 liveness 和 readiness 分开配置。在 application.yml 里加management: endpoint: health: probes: enabled: true health: livenessState: enabled: true readinessState: enabled: true这样会暴露/actuator/health/liveness和/actuator/health/readiness两个子路径分别代表“进程活着”和“可以接受流量”。liveness 失败会导致 Pod 重启readiness 失败只会把 Pod 从 Service 的端点里摘掉。一定要区分这两个概念否则可能出现“进程活着但业务还没就绪探针已经把 Pod 摘掉或重启”的尴尬情况。我见过有团队直接用/actuator/health作为 liveness结果数据库短暂不可用的时候所有 Pod 被反复重启雪上加霜。合理做法是对数据库等外部依赖放在 readiness 里判定liveness 只关注进程本身和内部资源。3. Deployment Service Ingress部署清单的标准套路镜像准备好之后就到了 Kubernetes 里最核心的三件套Deployment、Service、Ingress。这三者分管的事不同很多新手混在一起学反而乱了。3.1 一份能用的 Deployment 清单长什么样以 Spring Boot 项目为例我习惯的 Deployment 写法如下apiVersion: apps/v1 kind: Deployment metadata: name: demo-service namespace: default spec: replicas: 3 selector: matchLabels: app: demo-service strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 template: metadata: labels: app: demo-service spec: containers: - name: demo-service image: registry.local/demo-service:2.1.0 imagePullPolicy: IfNotPresent ports: - containerPort: 8080 env: - name: SPRING_PROFILES_ACTIVE value: prod resources: requests: cpu: 200m memory: 256Mi limits: cpu: 1 memory: 512Mi startupProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 5 periodSeconds: 5 failureThreshold: 30 readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 periodSeconds: 10 failureThreshold: 3 livenessProbe: httpGet: path: /actuator/health/liveness port: 8080 periodSeconds: 15 failureThreshold: 3这个清单里值得注意的地方有几个。启动探针是我后来才加上的Spring Boot 应用在 JVM 初始化加 Spring 容器加载的过程中接口可能很长时间没响应如果直接用 liveness 探针很容易在启动慢的时候被误杀。startupProbe 会保护启动过程等它成功之后liveness 和 readiness 才会接管。3.2 三组探针的参数怎么调才不矫情探针参数没有标准答案需要结合应用的启动时间和依赖情况调整但我可以给出一个通用的起步值startupProbeinitialDelaySeconds 设成 5periodSeconds 设成 5failureThreshold 设成 30相当于给应用最多 150 秒的启动时间。对于 Spring Boot 来说 150 秒通常足够如果不够就调大 failureThreshold而不是一开始就改 liveness。readinessProbeperiodSeconds 建议 10failureThreshold 设 3timeoutSeconds 不要设太短我习惯 3 秒。它只影响流量切换频繁一点没问题。livenessProbe周期建议拉长到 15 秒甚至 30 秒failureThreshold 设 3 即可。liveness 误杀的成本很高不要让它在请求高峰时频繁触发。还要注意探针请求本身会占用应用的一点处理能力如果接口压力大可以考虑用独立的管理端口来做健康检查避免探针请求干扰业务流量。Spring Boot 可以给 Actuator 单独指定端口这样探针路径和业务接口完全隔离。3.3 Service 类型怎么选Service 的作用是把一组 Pod 暴露成稳定的访问入口。大部分内部服务用 ClusterIP 就够了只能在集群内访问配合 Ingress 把流量从外部引进来。NodePort 存在的场景是临时调试、不装 Ingress 的小环境生产我不会用它原因是端口范围有限、绕过了 Ingress 的治理能力也不方便做 HTTPS 终止。我常用的 ServiceapiVersion: v1 kind: Service metadata: name: demo-service spec: selector: app: demo-service ports: - name: http port: 80 targetPort: 8080 type: ClusterIP注意 selector 里的 label 要和 Deployment 里的 template labels 保持一致。这个错误排查起来不起眼但很常见你创建一个 Service 之后发现 Endpoints 一直是空的多半就是 label 对不上。3.4 Ingress 配置与常见坑Ingress 负责外部流量进入。如果你用的是社区最常见的 Ingress Controller一个基础配置是这样的apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: demo-ingress spec: ingressClassName: nginx rules: - host: demo.example.com http: paths: - path: /api pathType: Prefix backend: service: name: demo-service port: number: 80容易出问题的两个点一是 pathTypePrefix 和 Exact 的匹配逻辑不一样把/api配成 Exact/api/xxx的请求就全 404 了二是一些 Controller 默认会对路径做 rewrite如果你配了/api转发后端看到的是重写后的路径Spring 里的RequestMapping不匹配就会 404需要提前理解 Ingress Controller 的 rewrite 注解行为。4. 配置管理ConfigMap 和 Secret 的正确打开方式Spring Boot 项目的配置一般集中在 application.yml 里到了 Kubernetes 里需要把“配置”从镜像中剥离出去。道理很简单镜像应该在各种环境复用环境差异通过配置注入来实现。4.1 从 application.yml 迁移到 ConfigMap我通常的做法是在 application.yml 里保留默认值把不同环境差异的部分抽出来通过环境变量覆盖。比如数据库地址、缓存地址、业务开关全部放到 ConfigMapapiVersion: v1 kind: ConfigMap metadata: name: demo-service-config data: application-prod.yml: | spring: datasource: url: jdbc:mysql://mysql-service:3306/demo username: demo redis: host: redis-service feature: new-payment: true然后在 Deployment 里挂载spec: containers: - name: demo-service volumeMounts: - name: config mountPath: /app/config volumes: - name: config configMap: name: demo-service-config配合启动参数--spring.config.additional-location/app/config/Spring Boot 会把挂载的配置文件合并进主配置。要注意的是spring.config.additional-location指向的必须是目录如果你的文件叫application-prod.yml别忘了加上SPRING_PROFILES_ACTIVEprod否则不会加载。ConfigMap 更新后Pod 里的文件会更新但 Spring Boot 不会自动重新加载配置。想让配置变更生效最简单也最可控的方式是触发一次滚动更新比如修改 Deployment 的一个 annotation让 Pod 重建。4.2 Secret 敏感信息怎么管理数据库密码、密钥这类信息不要放 ConfigMap更不要直接写在 YAML 里提交到仓库。Kubernetes 原生有 Secret 对象但 Secret 本身也只是 base64 编码不能完全当保险箱。我的建议是集群内的敏感信息用 Secret注意命名空间隔离交付给集群的 Secret 定义不要明文入库用外部工具加密后再提交有条件的话使用外部密钥管理工具配合 Controller 同步密钥轮换不需要改 YAML。一个简单示范Secret 用 stringData 字段方便维护至少避免写在镜像里apiVersion: v1 kind: Secret metadata: name: demo-service-secret type: Opaque stringData: db-password: your-password挂了 Secret 之后Pod 里就能按需读取Spring Boot 那边可以直接用环境变量注入比如SPRING_DATASOURCE_PASSWORD。注意不要通过 ConfigMap 间接引用 Secret这在逻辑上绕了一圈却没有增加安全性。4.3 配置变更如何平稳生效配置变了最稳妥的方式是走一次滚动更新让所有 Pod 按新环境变量或新挂载文件启动。如果改动很频繁还会用到配置热加载常见方案是引入配置中心但架构复杂度会上升。在没上配置中心的时候我的习惯是每次配置变更都过一遍测试环境确保 YAML 和配置文件的格式没问题再动生产。强调一句生产环境里不要在 ConfigMap 里随手改一个值就完事除非你非常确定 Spring Boot 支持运行时刷新。最常见的坑是“改了 ConfigMap 但 Pod 还是老配置”排查半天发现 Pod 根本没重建。5. 滚动更新与优雅停机为什么发布时总有错误日志你可能会发现一种现象每次发布的时候在发布前的几十秒内日志里总有一批连接中断、调用失败的报错。这个大多数时候不是代码问题而是滚动更新的细节没有处理好。5.1 preStop 钩子为什么是关键先理解 Kubernetes 杀掉 Pod 的流程向容器发送 SIGTERM 信号进入终止等待期默认 30 秒内如果容器没退出强制发送 SIGKILL。问题在于Spring Boot 收到 SIGTERM 后默认是立即退出不会等正在处理的请求完成。虽然新版 Spring Boot 支持优雅停机配置但 Kubernetes 此时可能已经把 Pod 从 Service 里摘除了如果 Pod 还有存量连接流量还是可能到达。我推荐的组合是这样的在 Spring Boot 侧开启优雅停机在 Deployment 侧增加 preStop 钩子和合理的终止宽限期spec: containers: - name: demo-service lifecycle: preStop: exec: command: [sh, -c, sleep 10] terminationGracePeriodSeconds: 60开启优雅停机后Spring Boot 会在收到 SIGTERM 后拒绝新请求并等待正在处理的请求结束而 preStop 的 sleep 给了它额外的时间。这里有个关键点sleep 时长要小于 terminationGracePeriodSeconds否则进程会被 SIGKILL 打断优雅停机的意义就没有了。我通常配 10 秒 sleep 加 60 秒宽限期等应用自带的优雅停机逻辑跑完。5.2 滚动更新参数 maxSurge 和 maxUnavailable滚动更新默认策略对很多场景够用但发布瞬间可能出现短暂的容量下降或者资源超占。两个参数要理解清楚maxUnavailable更新过程中允许最多有多少比例的 Pod 不可用。设成 0意味着旧 Pod 不会先被杀掉新 Pod 起来之前服务容量不下降maxSurge允许超出期望副本数最多多少比例。设成 1表示最多多起一个 Pod。我常用的配置是maxSurge: 1和maxUnavailable: 0这样发布期间始终保持 3 个副本可用只有新 Pod Ready 后旧的才会被替换。代价是发布期间峰值资源占用多一个 Pod如果你集群比较紧可以把maxUnavailable设成 1、maxSurge设成 0让发布的资源成本更低但容量会短暂下降。5.3 两个因为发布细节踩过的坑第一个坑上游调用方超时配置太短而应用优雅停机等待时间太长。当时上游设置的超时只有 2 秒服务端停机时还在等待数据库事务收尾结果客户端先报错了。后来我把客户端超时放宽同时检查了应用层面的连接池关闭逻辑才算稳定。第二个坑新 Pod 尚未完成 readiness 探针检测就被网关路由到。本质上是因为 readiness 周期设置过长startup 探针配了但 readiness 的 initialDelaySeconds 没有调好。网关在端点更新上有延迟所以读就绪探针通过后过个几秒再摘除旧 Pod 更稳。这个时间窗口可以通过 preStop 的 sleep 来对冲让流量自然平滑地切到新 Pod。6. 部署中遇到的三个典型故障排查实录写配置是一部分真正考验人的是出了故障之后的排查。这里记录我遇到过的三个典型问题每一个都对应一类常见场景排查路径基本可复用。6.1 Pod 一直 Pending调度不上去现象是kubectl get pods显示 PendingPod 没有进入 Running。先看状态和事件kubectl describe pod pod-name事件里最常见的是资源不足、节点选择器不匹配、存储卷无法挂载。我遇到过的问题是 requests 配置过高集群里没有任何节点能满足后来把 CPU requests 从 1 核调到 200mPod 立刻被调度。另外检查一下节点是否有污点如果你没有配置对应的容忍度也会一直 Pending。6.2 CrashLoopBackOff容器反复崩溃看到 CrashLoopBackOff 先别急着删 Pod按这个顺序排查kubectl logs pod-name --tail200 kubectl describe pod pod-name日志里如果有明显异常堆栈比如数据库连接不上、配置加载失败多半是配置或依赖问题。如果日志里什么都没有或者进程直接消失先怀疑 OOMKilledkubectl get pod pod-name -o yaml | grep -A5 lastState我遇到的一个经典案例Pod 的 memory limits 是 512MiJVM 设置了默认堆大小启动后 GC 都没跑几次就被杀。后来改成-XX:MaxRAMPercentage后解决。CrashLoopBackOff 的另一个原因是启动探针太激进应用还没起来就被判定失败反复重启这种时候看 Events 比看日志更能定位问题。6.3 ImagePullBackOff镜像拉不下来ImagePullBackOff 这名字已经说明了问题镜像拉取被拒绝。先检查 registry 地址、镜像 tag 是否存在再看节点上是否有拉取凭证。私有仓库一般需要给 Pod 配置 imagePullSecretsspec: imagePullSecrets: - name: registry-secret我遇到的一次是这个 secret 所在的命名空间和 Deployment 不一致导致拉取凭证没生效报 403。另外 imagePullPolicy 也很关键如果用 latest 标签集群可能因为缓存了旧镜像而一直拉不到最新内容我后来一律使用带版本号的 tagimagePullPolicy 用 IfNotPresent。7. 资源配额与水平伸缩让业务扛住突发流量最后聊一聊上生产之后最常见的两个需求资源限制和自动伸缩。7.1 requests 和 limits 的设置思路requests 和 limits 的作用不同requests 给调度器做资源分配决策limits 限制容器实际可用的资源上限。很多人一上来就把 requests 和 limits 设置成一样大这样会浪费集群资源因为大部分时间应用根本用不满。我的做法是先不设 limits只设合理的 requests压测观察实际使用量再根据峰值加 limits。比如 Spring Boot 服务压测时内存峰值约 400MiCPU 在大部分时间低于 200m我会把 requests 设为 200m/256Milimits 设为 500m/512Mi留出一定 buffer。注意 limits 不适合设得太低否则反而会限制应用处理突发流量的能力。7.2 HPA 配置与注意事项HPA 可以让副本数根据 CPU 或内存指标自动伸缩。前提是集群里安装了指标采集组件能采集到 Pod 的指标。一个基础的 HPA 配置apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: demo-service-hpa spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: demo-service minReplicas: 2 maxReplicas: 10 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70 behavior: scaleDown: stabilizationWindowSeconds: 300这里需要提醒的是HPA 基于 Pod 的 requests 来算利用率不是根据 limits。如果你的 CPU requests 设得很低利用率很容易超过 70%HPA 会频繁扩容反过来requests 设得虚高则可能永远不触发扩容。所以 requests 的设置直接影响 HPA 的行为并不是随便填的。scaleDown 的稳定窗口也建议设置我设过 300 秒避免流量一波动副本就大起大落。Spring Boot 应用扩容后JVM 预热和缓存加载需要时间扩得太猛反而不稳。把 Spring Boot 项目完整搬到 Kubernetes 上最后想说的是这件事的难点不在某个单独的技术点而在把容器、集群、应用三者之间的行为对准。镜像的内存参数、探针的判定逻辑、发布时的生命周期顺序、配置的注入方式每一个小点都可能成为线上故障的源头。这也就是我为什么一直建议先从测试环境把整套流程走到烂熟再谈生产。最后再分享一个小技巧任何一次配置变更或镜像升级我都会记录当时的 YAML 版本和对应的镜像 tag保证出问题的时候能一键回滚到上一个稳定状态。使用 Kubernetes 之后发布变成了一件可以重来、可以回放的事情把这套能力用好你才能真正体会到它带来的安全感。