尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

SpringBoot应用K8s部署全链路实践:从镜像构建到流量接入

SpringBoot应用K8s部署全链路实践:从镜像构建到流量接入 最近帮一个做后端的朋友排查线上故障他们的SpringBoot服务在测试环境跑得好好的一上k8s就频繁重启。日志里看不到明确的业务异常只有一行Killed甚至有时连这行都没有。查了一圈才发现部署清单里没写资源限制探针一个都没配所有配置还全部塞在JAR包里打进了镜像。这种状况其实很典型——SpringBoot本身没做错什么错的是我们对“k8s到底怎么运行Java应用”这件事的了解停留在“能启动就行”的层面。这篇文章我会把k8s部署SpringBoot项目的完整链路拆开讲清楚从镜像构建、部署清单编写、探针配置、配置管理到滚动更新和流量接入每一块都会结合我在实际项目里的使用经验来说。适合正在把单体服务往k8s上迁、或者已经迁上去但总觉得哪里不对劲的开发者。1. 基础镜像和Dockerfile决定你在k8s里的第一层命运很多人写Dockerfile只有三行FROM java:8、COPY jar、ENTRYPOINT java -jar。本地和测试环境能跑是因为你有一台配置还算够用的物理机在帮你兜底。进了k8s之后容器的资源边界由cgroup约束严格管控这里面的坑就藏不住了。1.1 JVM在容器里的内存感知问题JDK 8u131之前的版本JVM在容器里默认读的是宿主机内存而不是cgroup限制的内存额度。你想限制Pod最多用512M但JVM一看宿主机有32G直接按30%计算初始堆大小启动后疯狂占用最终被内核OOM Kill掉。这类问题的典型表现是Pod一直在重启kubectl describe里面写着OOMKilled。关键就在于 —— 你的JVM打包进去的是哪个版本、哪种基础镜像直接决定这个问题出现的概率。现在的基础镜像选择我建议优先考虑带完整JRE的发行版比如eclipse-temurin这类。这类镜像自带-XX:MaxRAMPercentage75之类的容器感知参数JVM会主动读取cgroup限制来计算堆大小。如果还在用老旧的java:8镜像至少要在启动参数里手动加上-XX:MaxRAMPercentage和-XX:InitialRAMPercentage让JVM放弃看宿主机内存。1.2 分层构建改一行代码不用重新推几百MBSpringBoot项目打出来的JAR动辄一两百MB如果你在Dockerfile里写的是FROM eclipse-temurin:17-jre WORKDIR /app COPY app.jar app.jar ENTRYPOINT [java, -jar, app.jar]那么每次改一行Java代码构建镜像时都会完整COPY整个JAR推送和拉取都是几百MB的体量在k8s滚动更新时还会拖慢整个发布节奏。更好的做法是借助Spring Boot官方插件生成的layers分层文件。先在你的pom.xml里确认build部分配了分层信息plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId configuration layers enabledtrue/enabled /layers /configuration /plugin然后Dockerfile按依赖层、资源层、应用层分别复制FROM eclipse-temurin:17-jre AS builder WORKDIR /builder COPY app.jar app.jar RUN java -Djarmodelayertools -jar app.jar extract FROM eclipse-temurin:17-jre WORKDIR /app COPY --frombuilder /builder/dependencies/ ./ COPY --frombuilder /builder/spring-boot-loader/ ./ COPY --frombuilder /builder/snapshot-dependencies/ ./ COPY --frombuilder /builder/application/ ./ ENTRYPOINT [java, -jar, app.jar]这样依赖层基本不变每次只有application层会重建镜像推送量大幅减少发布速度明显提升。1.3 时区和用户权限虽然不起眼但关键时刻很疼默认的基础镜像是UTC时区如果你的业务日志、入库时间需要东八区建议在Dockerfile里设置ENV TZAsia/Shanghai并安装对应的tzdata包或者用ln -sf做时区链接。另一个容易被忽略的是运行用户以root身份跑Java服务在容器环境里不是一个好习惯建议在镜像里创建非root用户并指定RUN groupadd -r app useradd -r -g app app USER app这不仅仅是安全问题还关系到k8s的安全上下文配置。很多集群开启了强制校验不允许容器以root运行你镜像里不建用户部署清单根本通过不了校验。2. 编写Deployment清单时最容易翻车的三类配置到了写YAML这一步常见的反面例子是只写image和containerPort就完事了。这样在手工测试环境或许能启动但离“稳定运行”差得很远。下面三块配置是生产环境部署时我每次都会认真检查的。2.1 资源限制不写就是拿宿主机内存开玩笑不写resources字段意味着k8s认为这个Pod可以随意使用宿主机资源。一旦某个节点的内存被多个不设限的Pod吃满kubelet就会按照优先级清理Pod通常最先清理的就是这种“没爹没娘”的。写资源限制时要区分requests和limitsresources: requests: memory: 512Mi cpu: 250m limits: memory: 1Gi cpu: 1000mrequests是调度依据limits是硬上限。这里有个常见的取舍limits里的内存如果设置得小于JVM最大堆加上Metaspace、线程栈等预留就会出现Pod没到limit就被JVM吃满然后OOM或者反过来容器触发OOMKilled。稳妥的做法是limits内存 JVM最大堆 300MB左右的预留空间给GC、线程栈、native内存留足余量。2.2 镜像拉取策略别让旧版本一直在那儿跑imagePullPolicy有三个值Always、IfNotPresent、Never。默认情况下如果镜像tag不是latest则默认是IfNotPresent。很多人在测试环境用latest打镜像但k8s默认如果节点上已经有同名镜像它不会去拉新的于是你明明推了新镜像Pod里跑的还是旧代码。建议要么发布时用唯一tag比如带上构建号或commit短hash要么显式设置imagePullPolicy: Always。生产环境我一般不用latest原因很简单——你无法从镜像名直接判断当前线上跑的是哪个版本回滚时也不知道该回滚到哪。用registry.example.com/spring-app:v1.2.3这种语义化版本配合RollingUpdate可以做到快速回退。2.3 环境变量注入先规划后动手SpringBoot应用读取配置的方式有很多种最常见的是环境变量。在Deployment里注入环境变量有两种写法直接写env或者通过envFrom引用ConfigMap和Secret。env: - name: SPRING_PROFILES_ACTIVE value: prod - name: DB_PASSWORD valueFrom: secretKeyRef: name: app-secret key: db-password这里要注意的是key的命名格式。SpringBoot读取环境变量时会把环境变量名转成小写并用下划线替代点号例如SPRING_DATASOURCE_URL对应spring.datasource.url。很多第一次接触的人直接写成SPRING_DATASOURCE_URLxxx放进env却忘记SpringBoot还需要对应配置项的key名是spring.datasource.url结果应用根本没读到。3. 探针配置不是锦上添花是k8s健康运维的基础探针这块大多数出问题的项目都是直接抄了一个固定模板把initialDelaySeconds写成30periodSeconds写成10然后觉得万事大吉。等你真正遇到“Pod Running状态但访问502”的时候就会理解探针不只是存活检查它深刻影响流量分发和发布行为。3.1 存活探针与就绪探针的区别存活探针决定要不要重启Pod就绪探针决定要不要把流量派发给这个Pod。这两者禁止混用。一个非常经典的事故链条是这样的应用启动需要一分钟但就绪探针初始延迟设成了5秒连续三次失败后Pod被标记不就绪但这时候存活探针还没失败。你发现服务间歇性502翻日志又找不到应用报错——因为流量压根没进来。反过来如果存活探针的判定标准太脆弱比如拿一个耗资源的接口做健康检查请求稍微慢一点就判定失败Pod就会被反复杀掉。SpringBoot应用里加一行依赖就能获得标准的健康端点dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-actuator/artifactId /dependency然后配置探针指向/actuator/healthlivenessProbe: httpGet: path: /actuator/health/liveness port: 8080 initialDelaySeconds: 30 periodSeconds: 15 failureThreshold: 3 readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 20 periodSeconds: 5 failureThreshold: 23.2 startupProbe专门处理启动慢的应用如果你的应用启动就要40秒以上用readinessProbe的initialDelaySeconds硬等会比较浪费——这个时间值写大了影响发布效率写小了又容易误判。k8s提供了startupProbe来兜底探测成功前其他探针不生效startupProbe: httpGet: path: /actuator/health port: 8080 failureThreshold: 30 periodSeconds: 10这个配置意味着给应用最多300秒的准备时间。只要启动期内一直没挂后续就交给存活和就绪探针处理。实际项目里冷启动加载大量缓存、初始化连接池的场景用这个方案运行效果会稳定很多。3.3 探针配置引发的实际事故复盘某次线上发布新版本启动比平时慢了10秒就绪探针连续探测失败Pod被标记NotReady。滚动更新策略这时候是会等就绪探针成功的于是发布流程卡住。更糟的是由于就绪探针失败Service把流量全打到了剩余老Pod上老Pod扛不住突发流量响应时间飙升部分请求超时。这个事故让我形成了一个核心认知探针的每个参数都不是随手填的要结合应用真实的启动时序来定。建议压测环境里先裸启动一次应用记录从进程开始到健康接口可用之间的时间再反向推算探针参数。4. 配置管理把application.yml的内容从JAR包里搬出来SpringBoot默认的行为是把配置打进JAR包这在单机部署时没问题但在k8s里非常别扭——不同环境用不同配置难道要分别打镜像这个问题需要用ConfigMap和Secret来解决。4.1 ConfigMap的正确使用方式我是推荐把整个application.yml作为ConfigMap管理的这种方式比零散地用SPRING_xxx环境变量覆盖每个配置项要清晰得多。可以先用文件创建ConfigMapkubectl create configmap app-config --from-fileapplication.yml --from-fileapplication-prod.yml -n your-namespace然后在Deployment里挂载volumeMounts: - name: app-config mountPath: /app/config volumes: - name: app-config configMap: name: app-configSpringBoot会自动加载/app/config下的application.yml和application-prod.yml不需要额外命令参数。这里有个细节不要在jar包的同级目录放同名配置文件否则会出现外部配置和内部配置合并的复杂情况建议挂载到一个独立目录靠SpringBoot的配置优先级加载外部文件。4.2 Secret只是编码不是加密Secret里存储的Value是base64编码它防的只是“凭肉眼在YAML里看到明文密码”这一层。如果你在Secret里放了数据库密码它不等于进了保险箱。正确做法是配合外部密钥管理组件或者至少保证Secret只通过RBAC授权的账号访问。另外Secret和ConfigMap一样受限于1MB的存储上限别把日志、证书之类的大文件往里塞。4.3 配置修改不自动重启需要工具配合k8s默认不会因为你改了ConfigMap就重启Pod。SpringBoot应用即使配置支持热加载靠容器内文件变更触发重新读取也不是开箱即用的。我们可以通过两种方式解决一是通过部署平台在ConfigMap变化后自动触发滚动更新二是利用每次配置变更都会产生新版本的特性在应用侧做一个配置监听检测到ConfigMap变化时主动调用内部刷新接口。当然最省事的方案还是在镜像tag不变的情况下手动执行一次滚动重启kubectl rollout restart deployment/spring-app -n your-namespace这会让新Pod重新挂载新的ConfigMap内容旧Pod平滑退出。发布后记得通过kubectl describe pod确认挂载的配置版本是否符合预期。5. 滚动更新发布不中断的关键在于优雅停机与刚好错开的节奏SpringBoot应用在k8s里更新时默认的停止流程是Pod收到SIGTERM信号进入Terminating状态。如果你的SpringBoot应用没有配置优雅停机服务线程会直接被kill正在处理的请求可能中断造成用户看到500。5.1 先配置应用的优雅停机在application.yml里加上server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 20s这会让Spring Boot在收到SIGTERM后停止接收新请求等待现有请求处理完最多等20秒。配合k8s的terminationGracePeriodSeconds把k8s层允许的终止时间设置得稍长一点比如terminationGracePeriodSeconds: 40这样应用有20秒优雅处理剩下的时间留给preStop钩子和最后的资源回收。5.2 preStop钩子处理连接池和注册中心的解绑优雅停机解决的是“请求还没处理完就杀进程”的问题但像注册中心这类服务发现组件还需要服务主动把自己从注册列表里摘除避免新流量再打过来。可以在Deployment里配置preStop钩子通过命令方式让应用先调用内部的摘除接口或者延迟几秒等已经建立的连接排空lifecycle: preStop: exec: command: [sh, -c, sleep 20]这里sleep的时长需要根据实际请求耗时的P99来定不是越长越好。太长会拖累发布节奏太短则可能在新注册生效前就有流量涌入。5.3 maxUnavailable与maxSurge的搭配滚动更新时k8s通过这两个参数控制新老Pod的更替节奏strategy: type: RollingUpdate rollingUpdate: maxSurge: 25% maxUnavailable: 0maxUnavailable: 0表示更新过程中任何时候都不能有Pod停留在不可用状态适合对可用性要求高的核心服务。maxSurge: 25%允许先额外拉起一些新Pod等新Pod就绪后再逐个销毁旧Pod。这段配置必不可少的是配合就绪探针。如果新Pod启动慢maxUnavailable等于0时发布会一直等新Pod就绪不会出现流量全部打空而如果只追求快速发布设了maxUnavailable: 1在只有一个副本的情况下更新期间服务直接不可用这就是为什么很多小规模项目把副本数设为1时发布必闪断的原因。5.4 发布验证与快速回滚发布时不要只看Pod变成Running就宣告完成要跟进到确认所有新Pod通过就绪探针后再观察一段时间。kubectl有一个好用的命令kubectl rollout status deployment/spring-app -n your-namespace发布过程中出现异常第一时间回滚而不是钻进Pod里调试kubectl rollout undo deployment/spring-app -n your-namespace回滚后同样要观察Pod状态和流量恢复情况而不是回滚完就撒手。6. 流量接入选对Service类型还要处理Ingress的版本差异应用在集群内部正常流转后最后一步是把流量从外部引进来。这里涉及Service和Ingress的选择很多人在这块吃了Ingress版本迁移的亏。6.1 Service三种类型怎么选Service的三种类型适合不同场景类型适用场景注意点ClusterIP集群内部服务间调用外部无法直接访问NodePort临时测试、小型环境端口范围30000-32767LoadBalancer对接云厂商负载均衡需要云平台支持成本较高测试环境我经常用NodePort快速验证但如果服务要对外开放还是得走Ingress——它帮你把域名、路由规则统一接入配合证书管理比裸奔的NodePort安全得多。6.2 Ingress版本迁移问题k8s 1.19之前用的是networking.k8s.io/v1beta11.19到1.22之间逐步弃用1.22之后必须使用networking.k8s.io/v1。如果你在网上抄的Ingress配置还是老的API版本在新集群上apply的时候就会报错。v1版本的写法稍有变化apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: spring-app-ingress namespace: your-namespace spec: ingressClassName: nginx rules: - host: app.example.com http: paths: - path: / pathType: Prefix backend: service: name: spring-app-service port: number: 80806.3 Service端口命名与多端口场景Service暴露多个端口时每个端口必须有名字ports: - name: http port: 8080 targetPort: 8080 - name: management port: 8081 targetPort: 8081Ingress里引用端口时要写port的name比如name: http而不是数字否则某些Ingress Controller会无法识别。这个细节在新手调试时很隐蔽配置看着没问题但流量就是进不来。6.4 会话保持与灰度发布的简单思路SpringBoot应用如果是无状态的不需要配置会话保持。但如果确实需要Session亲和可以在Service上配置sessionAffinityspec: sessionAffinity: ClientIP sessionAffinityConfig: clientIP: timeoutSeconds: 10800灰度发布方面如果已经引入了Ingress Controller可以通过Ingress的annotation按Header或权重分流到不同版本的Service不需要专门搭整套服务网格就可以做到基础的灰度效果。实际操作中的一些体会调试k8s部署的SpringBoot项目最值钱的一招其实是把事件流完整看一遍。当Pod起不来或反复重启时先别急着看日志执行kubectl describe pod spring-app-xxxxx -n your-namespaceEvents字段会告诉你Pod失败的真正原因是拉镜像失败、探针失败、还是被OOMKilled。这是排查问题的起点也是很多人在慌乱中跳过的一步。另一个小技巧是给Deployment打上版本号注释每次发布改一下template: metadata: annotations: version: v1.2.3这样每次rollout都会生成新的ReplicaSetkubectl rollout history里能看到完整发布记录回滚时也明确知道退回到哪个版本。如果你的服务目前跑在k8s上但配置还是最简模式可以从这篇文章里提到的几件事开始改进分层构建镜像、补齐资源配置和探针、把配置从JAR包里迁到ConfigMap、配上优雅停机。这几项做完稳定性的提升会比加两台机器明显得多。
返回列表