Spring Boot容器化部署Kubernetes:从JVM参数到优雅停机实践 最近我在处理一套订单系统的容器化改造接手时看到集群里跑着一个奇怪的服务CPU不高内存动不动就顶到上限Pod 隔几小时就被 Kubernetes 杀掉重启。打开镜像一看Dockerfile 就两行JVM 参数一个没设健康检查也没配探针。这是很典型的“DockerK8s 部署Spring Boot项目”踩坑现场——很多人以为把 jar 包塞进镜像、写完 Deployment 就算完事但真正跑起来才会发现从 JVM 内存到滚动更新策略每一步都藏着不少细节。这篇文章会把 Spring Boot 上云容器化整条链路拆解开从镜像构建、探针配置、资源清单编写到滚动更新和优雅停机再穿插几个我实际踩过的坑。适合正在把 Spring Boot 项目往 Kubernetes 里搬的团队也适合那些服务已经跑上去、但时不时出点奇怪问题的同学拿来对号入座。1. 先搞定镜像构建让 JVM 在容器里懂得“分寸”1.1 容器里的 JVM 真的有内存幻觉先说一个最容易被忽视的问题。JVM 启动时会尝试读取宿主机物理内存来决定默认堆大小。在裸机上跑 jar 包这个逻辑没问题但放进容器里JVM 看到的内存上限是宿主机的全部内存而不是 Pod 的 limit。举个实际例子宿主机 32G 内存Pod 设了 limits.memory2GiJVM 启动时没配任何堆参数JVM 默认把堆大小自动算成宿主机内存的 1/4也就是 8G 左右这个 8G 的堆根本装不进 2G 的内存 cgroup 限制里。等进程慢慢把堆用起来容器内存超过 2G 就触发 OOM Kill表现为 Pod 被反复重启、状态为 OOMKilled但 Java 应用日志里看不到任何 OutOfMemoryError。JDK 从 8u191 版本开始支持-XX:UseContainerSupport从 JDK 10 开始默认开启容器感知。但如果团队里还有 Java 8 老版本8u131 到 8u191 之间默认是不感知容器内存的必须手动加参数。我的习惯是在环境变量层面统一加上JAVA_OPTS-XX:MaxRAMPercentage75.0 -XX:MaxMetaspaceSize128m这里用MaxRAMPercentage而不是-Xmx写死原因是同一个镜像可能要跑在 1Gi、2Gi、4Gi 不同规格的 Pod 上百分比形式能让 JVM 根据容器 memory limit 自动计算堆上限天然适配不同环境。为什么是 75% 而不是 90% 或者 100%Spring Boot 应用在堆之外还需要Metaspace类元数据默认无上限所以建议用MaxMetaspaceSize兜底线程栈Direct MemoryNIO 和 Netty 经常用JVM 自身代码缓存部分基础库的堆外缓存留出 25% 给这些内容是比较稳妥的分配。如果你把MaxRAMPercentage调到 100只要连接池堆外内存稍微增长容器就会被 OOM 干掉。1.2 用多阶段构建控制体积和依赖接着是 Dockerfile。我建议直接放弃“一个镜像里既有构建工具又有运行时”的做法采用多阶段构建# 构建阶段 FROM maven:3.9-eclipse-temurin-11 AS build WORKDIR /app COPY pom.xml . RUN mvn -q dependency:go-offline COPY src ./src RUN mvn -q clean package -DskipTests # 运行阶段 FROM eclipse-temurin:11-jre RUN useradd --system --create-home --shell /usr/sbin/nologin appuser WORKDIR /app COPY --frombuild /app/target/order-service.jar app.jar RUN mkdir -p /app/logs chown -R appuser:appuser /app USER appuser ENV TZAsia/Shanghai \ JAVA_OPTS-XX:MaxRAMPercentage75.0 -XX:MaxMetaspaceSize128m EXPOSE 8080 ENTRYPOINT [sh, -c, java $JAVA_OPTS -jar /app/app.jar]有几点值得注意。第一COPY pom.xml .放在COPY src ./src前面是为了利用 Docker layer 缓存。只要 pom 不变Maven 依赖层就能命中缓存本地反复构建时能省很多时间。如果你反过来先拷源码pom 哪怕只改了一个版本号整层依赖都要重新下载。第二运行时阶段用USER appuser切换非 root 用户而不是直接拿 root 跑 Java。在宿主机上 root 跑 Java 没什么感觉在 K8s 里一旦开启安全上下文、Pod 安全策略root 会被直接拒绝调度。第三TZAsia/Shanghai不是可有可无的配置。容器默认时区是 UTC日志时间会比本地时间慢 8 小时排查线上问题时会非常难受。如果项目对镜像体积有执念可以在运行阶段用 jlink 裁剪 JRE只保留 Spring Boot 需要的模块做到 50MB 以内的镜像也不难。但说实话普通业务场景没必要把时间花在这件事上直接用标准 JRE 镜像把精力留给更关键的部分。1.3 镜像 Tag 别再用 latest镜像 tag 这块我见过太多团队在 K8s 环境里用latest结果发布时节点上缓存了旧镜像imagePullPolicy: IfNotPresent不会重新拉取发布上去的代码根本不是新的。我现在的固定做法镜像地址: registry.internal/order-service:git-abc1234tag 就是 git commit 的短哈希一个 commit 对应一个不可变 tag永远不会被覆盖。回滚时只要把 Deployment 的镜像 tag 指回旧哈希即可清晰可回溯。如果是用 Gerrit 或 GitLab CI 这类工具可以在流水线里生成TAGgit-$(git rev-parse --short HEAD) docker build -t registry.internal/order-service:${TAG} . docker push registry.internal/order-service:${TAG}这比用时间戳或版本号更省心因为 commit hash 本身就是唯一的。2. 探针配置让 Kubernetes 知道服务“活着”但“还没就绪”2.1 liveness 和 readiness 的分工千万别搞混探针是 Spring Boot 部署到 K8s 后最容易被配错的地方。先记住一句口诀存活探针决定容器要不要重启就绪探针决定请求要不要转发进来。livenessProbe探测失败kubelet 会杀掉容器并重启readinessProbe探测失败Pod 会从 Service Endpoint 列表里摘除但容器不会重启于是出现了一个很常见但后果严重的配置有人把 readiness 探针指向数据库或 Redis比如探测/actuator/health而这个端点本身就包含 DataSource 和 Redis 的健康检查。某天数据库抖动几秒钟所有 Pod 的 readiness 全部失败Kubernetes 把所有副本实例的 Endpoint 全摘了服务瞬间不可用等数据库恢复后探针重新通过流量才慢慢恢复。这种自损八百的操作本质上是在“用服务完全不可用来惩罚一个外部依赖的抖动”。我的建议是readiness 探测只关心应用本身是否加载完成不关心外部依赖状态外部依赖的健康情况交给业务自身的保护机制重试、熔断、降级liveness 探针对数据库这类外部组件的依赖也不要太敏感别因为数据库闪断就让容器无限重启2.2 给 Spring Boot 配置可用的探针端点Spring Boot 2.3 及以上版本自带两组探针端点只需要开启management: endpoints: web: exposure: include: health,info,metrics endpoint: health: probes: enabled: true引入 Actuator 依赖后默认会有/actuator/health/liveness对应存活探针/actuator/health/readiness对应就绪探针但这里有个坑默认情况下这两个端点位于 Actuator 的健康检查基础上返回值大致是{status:UP}这类结构。如果项目用了 Spring Security且没有放行这些路径探针请求会被 401 挡住Pod 永远不就绪。我一般会在 SecurityConfig 里显式放行http.authorizeHttpRequests() .requestMatchers(/actuator/health/**).permitAll() // 其他路径走正常鉴权2.3 探针参数怎么给才算稳妥Deployment 里的完整探针配置大概是这样的livenessProbe: httpGet: path: /actuator/health/liveness port: 8080 initialDelaySeconds: 20 periodSeconds: 10 timeoutSeconds: 2 failureThreshold: 3 readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 0 periodSeconds: 5 timeoutSeconds: 2 failureThreshold: 2 startupProbe: httpGet: path: /actuator/health/readiness port: 8080 periodSeconds: 2 failureThreshold: 30解释一下几个参数的含义参数含义我常用的值initialDelaySeconds容器启动后多久开始首次探测liveness 20sreadiness 0speriodSeconds每次探测间隔liveness 10sreadiness 5stimeoutSeconds探测超时时间2sfailureThreshold连续失败多少次才触发动作liveness 3readiness 2successThreshold连续成功多少次才认为恢复默认 1一般不用改initialDelaySeconds只对老版本 K8s 有意义。如果你的集群版本支持startupProbe我强烈建议加上它可以覆盖 JVM 冷启动较慢的场景避免出现“应用还在加载 Spring 上下文时liveness 探针已经失败了几次Pod 被重启”的尴尬局面。startupProbe的failureThreshold乘上periodSeconds就是留给应用的启动时间预算。上面的配置是 30 * 2 60 秒如果应用启动要 80 秒就适当调高阈值。这个探针只会在启动阶段生效启动成功后 Kubernetes 会自动切到 liveness 探测。3. 资源清单怎么写Deployment、Service、Ingress 的组合方式3.1 Deployment 里值得反复检查的字段一个生产可用的 Deployment模板通常长这样apiVersion: apps/v1 kind: Deployment metadata: name: order-service namespace: production spec: replicas: 3 strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 0 maxSurge: 1 selector: matchLabels: app: order-service template: metadata: labels: app: order-service spec: affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 podAffinityTerm: labelSelector: matchLabels: app: order-service topologyKey: kubernetes.io/hostname containers: - name: order-service image: registry.internal/order-service:git-abc1234 imagePullPolicy: IfNotPresent ports: - name: http containerPort: 8080 resources: requests: cpu: 500m memory: 1Gi limits: cpu: 2 memory: 2Gi env: - name: JAVA_OPTS value: -XX:MaxRAMPercentage75.0 -XX:MaxMetaspaceSize128m几个关键点拆开说。resources.requests决定调度器把 Pod 放在哪个节点上。如果集群节点是 4C8G你在资源充足时不写 requests 也能调度但一旦某个节点上其他 Pod 吃满了资源这个不设 requests 的服务可能被驱逐。requests 是给调度器看的“最低需求”limits 是给运行时看的“最高上限”。JVM 参数和limits.memory的联动在前一章讲过MaxRAMPercentage75.0会自动按 limit 计算堆上限。这里 limit 2Gi堆最大大约 1.5Gi剩下的留给 Metaspace、线程栈和堆外。如果改了 limit 但镜像里的 JVM 参数还是固定-Xmx2g容器就会被压死这点一定注意。podAntiAffinity让同一服务的副本尽量分散到不同节点避免一个节点挂掉导致所有副本同时不可用。我用的是preferred而不是required因为如果副本数大于节点数required会导致部分 Pod 一直 Pending。imagePullPolicy如果镜像 tag 是latest必须设为Always如果 tag 是 commit hash 这类不可变标识设IfNotPresent可以省去节点每次重复拉取的时间。3.2 Service集群内部流量的入口Service 的作用是把一组 Pod 抽象成一个稳定的访问入口。一个最基础的 ClusterIP 类型apiVersion: v1 kind: Service metadata: name: order-service namespace: production spec: selector: app: order-service ports: - name: http port: 80 targetPort: http type: ClusterIPport是 Service 对外暴露的端口targetPort是容器实际监听的端口。这里有个小经验targetPort写端口名称比如http而不是数字 8080。好处是当容器端口将来调整时只需要改 Deployment 里的ports.nameService 不用跟着改。这个属于防御性写法但团队多个服务统一用这种习惯后出错率会低不少。集群内其他服务访问这个服务时直接用服务名就行http://order-service.production.svc.cluster.local如果两个服务不在同一个命名空间就得写全限定名。这个简单但实战里隔三差五就有人搞错请求 502 半天才发现 namespace 写错了。如果是 Spring Cloud 场景某个服务需要让客户端拿到每个 Pod 的独立 IP 做服务发现可以把 Service 设为clusterIP: None即 Headless Service。这时 DNS 会返回所有 Pod 的 IP 列表而不是一个 VIP。不过大多数内部调用场景用普通 Service 就够了。3.3 Ingress对外暴露的统一入口对外流量建议走 Ingress而不是直接给 Service 设 NodePort。Ingress 资源里容易出错的是路径重写。用 nginx ingress controller 时常见配置apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: order-service-ingress namespace: production annotations: nginx.ingress.kubernetes.io/rewrite-target: /$2 spec: ingressClassName: nginx rules: - host: api.example.com http: paths: - path: /order(/|$)(.*) pathType: Prefix backend: service: name: order-service port: name: http tls: - hosts: - api.example.com secretName: api-tlsrewrite-target: /$2的作用是把/order/api/v1/list重写成/api/v1/list再转发给后端。如果没有这个注解后端收到的路径还是完整的/order/api/v1/list如果 Spring Boot 的 context-path 没设置就会直接 404。TLS 证书建议用 secret 管理不要写死到镜像里。cert-manager 这类工具可以自动签发和续期配置好 secretName 之后证书到期会自动续期不用人工介入。3.4 配置外置ConfigMap 与 SecretSpring Boot 的配置文件不建议打进镜像用 ConfigMap 挂载是更常见的做法apiVersion: v1 kind: ConfigMap metadata: name: order-service-config data: application.yaml: | server: port: 8080 spring: datasource: url: jdbc:mysql://db.internal:3306/orderDeployment 里挂载volumes: - name: config configMap: name: order-service-config containers: - name: order-service volumeMounts: - name: config mountPath: /app/config readOnly: true挂载后 Spring Boot 会自动读取/app/config/application.yaml也可以在启动命令里指定--spring.config.additional-locationfile:/app/config/。敏感信息数据库密码、Redis 密码放 Secret明文放 ConfigMap。如果你是“配置文件和代码一起管理”的团队建议用 Helm 或 Kustomize用模板把环境差异抽出来而不是复制粘贴三个内容的 YAML。4. 滚动更新与优雅停机发布过程中怎么做到不掉线4.1 从新 Pod 就绪到旧 Pod 退出的完整链路Deployment 滚动更新看似“自动处理”实际有一条很具体的时序链新 ReplicaSet 创建按照maxSurge先扩容新 Pod新 Pod 启动readiness 探针通过后Kubernetes 把它的 IP 加进 Service Endpoint旧 Pod 收到 SIGTERM 信号旧 Pod 完成剩余请求处理进程退出Kubernetes 更新 Endpoint移除旧 Pod IP问题通常出在第 3 步到第 5 步之间。旧 Pod 收到 SIGTERM 时如果 Spring Boot 不处理优雅停机Web 容器会直接断掉所有连接正在处理的请求立刻失败。更麻烦的是Service Endpoint 的摘除并非瞬间完成网关或调用方可能还在往旧 Pod 转发流量。Spring Boot 2.3 以后可以通过配置开启优雅停机server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30s意思是收到 SIGTERM 后Spring Boot 会停止接收新请求等待已有请求完成最长等 30 秒。4.2 preStop 钩子为什么不能省你会发现光配优雅停机还不够。因为 Service Endpoint 准备摘除旧 Pod 是有延迟的而 SIGTERM 在下发时也不会特意等 Endpoint 更新完。在 Spring Boot 停止接收新请求的瞬间如果流量还在过来连接就会被拒绝。所以需要在容器级别加一个 preStop 钩子拖一下时间lifecycle: preStop: exec: command: - /bin/sh - -c - sleep 5preStop 执行完容器才会收到 SIGTERM。这 5 秒是给 Kubernetes 的 Endpoint 同步时间让负载均衡器有足够时间把旧 Pod 的 IP 摘掉。这个 5 秒只是个经验值如果集群规模大或者 Ingress 同步比较慢可能需要调到 8-10 秒。对应的还需要检查terminationGracePeriodSeconds默认 30 秒。这个时间要能覆盖 preStop 加优雅停机所需时间我一般设置为 40terminationGracePeriodSeconds: 40这个值如果小于 preStop 加 Spring 优雅停机上限的总和容器会被 Kubernetes 强制 SIGKILL优雅停机就等于没配。4.3 用压测验证发布过程是否掉线配置完以上内容怎么确认真的没有请求丢失我常用的方法很土但有效在发布时跑一个持续压测。这里用一段最小脚本# 在压测容器里执行 kubectl run -it load-test --imagehttpd --rm -- \ ab -n 20000 -c 50 http://order-service.production.svc.cluster.local/health然后触发滚动更新观察 ab 输出里的失败数量和错误类型。如果你看到Connection reset by peer大概率是旧 Pod 在优雅停机前就被断开了优先检查 Spring Boot 的server.shutdown配置和terminationGracePeriodSeconds。如果看到Connection refused大概率是新 Pod 还没就绪但流量已经被放进来了检查 readiness 探针的路径返回值和 Endpoint 更新节奏。这个验证最好在测试环境跑通后再上生产。第一次配置优雅停机几乎没有人能一次写对。4.4 滚动更新参数怎么配Deployment 的发布策略参数主要两个maxSurge最多可以超出期望副本数的 Pod 数量maxUnavailable最多可以有几个 Pod 同时不可用我常用的组合是strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 0 maxSurge: 1含义是先多起一个新 Pod等新 Pod 就绪再停一个旧 Pod。整个过程服务容量不低于期望值对在线业务最友好。代价是需要多一份资源的临时占用。如果你的集群资源本身很紧张也可以反过来maxUnavailable: 1 maxSurge: 0先杀掉一个旧 Pod再启动一个新 Pod。这样资源不超量但发布期间会出现短暂的容量下降。最不推荐的配置是maxUnavailable: 1和maxSurge: 1同时都设成 1发布期间可能出现两个旧 Pod 同时终止的情况副本数从 3 降到 1容量缺口太大。5. 我在实际环境里踩过的四个坑5.1 OOMKilled 但 Java 日志里没有 OutOfMemoryError这个坑前面提过一次再展开说说排查过程。现象Pod 状态反复出现OOMKilled重启后 Java 日志没有明显的java.lang.OutOfMemoryError。排查链路kubectl describe pod order-service-xxxxx你会看到Last State: TerminatedReason: OOMKilled。这时先别急着怀疑代码内存泄漏确认两件事容器 limits.memory 是多少JVM 的堆参数是怎么设置我遇到过的情况是 JVM 用了-Xmx2g而 Pod limit 只有1.5Gi堆本身就超出容器限制了。如果堆上限正常那再考虑堆外内存比如 Netty 的 Direct Memory、连接池 Buffers 等。这类内存问题通常表现为 RSS 内存持续增长但在 Java Heap 里看不到对应对象。建议在 Java 启动命令里带上-XX:NativeMemoryTrackingsummary排查时可以看堆外内存具体分布。生产环境持续开这个参数会消耗一点性能一般建议问题发生时临时开一下。5.2 ImagePullBackOff私有仓库没配拉取凭证排错过程一般是这样kubectl get pod发现ImagePullBackOffkubectl describe pod看到Failed to pull image如果是私有仓库大概率是认证问题如果是 Harbor 等私有仓库需要在 Deployment 里引用 imagePullSecretsspec: imagePullSecrets: - name: registry-secret这个 secret 要先创建kubectl create secret docker-registry registry-secret \ --namespaceproduction \ --docker-serverregistry.internal \ --docker-usernamerobot \ --docker-passwordtoken另一个容易忽略的点私有仓库如果走 HTTPS 且证书不被节点信任也会拉取失败。这个要在节点的 Docker 配置里处理属于平台侧问题不展开细说。5.3 探针打在了错误的端口上有次部署一个 Spring Boot 服务Pod 一直不 Readykubectl get endpoints发现 Service 后端一直是空列表。排查过程kubectl describe pod # Liveness probe failed: HTTP probe failed with statuscode: 404打开应用配置一看server.port8080但容器里跑着管理端用的端口是 18080探针写的还是 8080。这类错误很基础但排查起来却要绕一圈先看探针配置再看容器实际监听端口还得注意应用内是否设置了server.servlet.context-path。探针路径如果没带上 context-path也会导致 404 误判。这里我强烈建议把探针路径和端口在 Deployment 模板里显式用变量管理避免每套环境手工改 YAML 时出错。5.4 日志把容器盘写满了Spring Boot 默认日志只输出到控制台容器环境下问题不大。但有些人习惯沿用传统部署的 logback-spring.xml把日志滚动写入/app/logs又没写清理策略。容器是临时文件系统日志写满后 kubelet 会把整个 Pod 标记为异常应用可能无法写入新的日志文件也可能直接起不来。这是个安静又致命的坑。我的建议是容器环境一律把日志打到 stdout由集群侧的日志采集方案统一收集和归档。如果一定要本地写文件必须配置好滚动大小和保留时间。实际上大多数容器平台都自带日志采集应用层再往文件系统写一份日志属于双份无用功。6. 本地开发环境的推荐组合6.1 本地集群选择如果只是验证 Deployment、Service、Ingress 这些资源定义我建议用轻量级方案不用装完整版的集群无论 minikube 还是 kind 都行看你的侧重点。minikube 对 Windows 和 macOS 的兼容性更好自带 DNS 和 Ingress 插件适合验证完整的调度链路。kind 直接在 Docker 里起节点启动速度快适合本地反复调试 YAML 和镜像。两边都能满足日常开发验证没必要在选型上花太多时间关键是稳定用起来。本地日常开发和 Kubernetes 联调时镜像处理是个烦点。折腾过这套流程的话会明白本地构建的镜像不一定非要推到远端镜像仓库可以直接加载到本地集群节点里# kind kind load docker-image registry.internal/order-service:local # minikube minikube image load registry.internal/order-service:local这样改代码、构建镜像、跑本地集群验证整个循环可以控制在几分钟内。6.2 调试排错的几个高频命令把几个高频命令列出来虽然不是多高级的操作但熟练用确实能省时间# 实时看 Pod 日志 kubectl logs -f deployment/order-service -n production # 看多个容器的日志加 -c 指定容器名 kubectl logs -f pod_name -c order-service # 看事件和最近状态 kubectl describe pod order-service-xxxxx # 进入 Pod 内部排查网络、进程 kubectl exec -it order-service-xxxxx -- /bin/sh # 本地临时访问集群内服务 kubectl port-forward -n production deployment/order-service 8080:8080port-forward是我日常调试里最有用的命令可以绕过 Service 直接用一个 Deployment 的某个副本适合本地打断点联调集群内的其他依赖服务。6.3 团队协作时的资源管理方式当 YAML 文件多起来以后有几条我个人的建议别看单个 YAML用 kustomize 或 Helm 统一管理把环境差异放到 values 里处理每个环境一个 Kubernetes 命名空间隔离性更好基础配置探针参数、JVM 参数、日志策略做成模板默认值避免不同服务各写一套镜像 tag 固定用 commit hash便于回溯我现在处理多环境时基本是把模板维护在一套 Helm Chart 里测试环境和生产环境通过不同的 values 文件切换。这样的结构团队成员开新服务时只需要改模板里的镜像地址不用从头写一堆 YAML维护成本低很多。说到底把 Spring Boot 项目从裸机搬到 Docker 和 Kubernetes核心不是学会几个命令而是理解这套体系对应用形态的要求进程要能优雅退出内存要符合容器限制健康状态要能清晰暴露发布策略要经过验证。这套框架确定下来后部署和发布其实就变成了流水线操作反而不需要每次临场救火了。