恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
无状态应用迁移 Kubernetes 平滑落地实践:从容器化到灰度发布
首页
资讯中心
/
无状态应用迁移 Kubernetes 平滑落地实践:从容器化到灰度发布
无状态应用迁移 Kubernetes 平滑落地实践:从容器化到灰度发布
发布时间:2026/10/11 11:37:42
做无状态应用迁移第一步要搞清楚的不该是 Kubernetes 的 YAML 怎么写而是你的应用到底够不够格被容器化。无状态应用迁移到 Kubernetes 之所以被当成练手项目是因为它绕开了数据持久化、节点绑定、主从切换这些最头疼的部分Pod 随时可以销毁重建流量随时可以在多个副本之间重新分配。但这不意味着无脑写一份 Deployment然后 apply 上去就完事。我见过太多案例服务在虚拟机里跑得好好的一上容器就开始出问题启动变慢、健康检查误报、反复重启、发布时大量请求报错。锅不全在 Kubernetes 身上更多是迁移前没有把老应用的行为摸清楚也没有把平滑这两个字落实到每一个操作环节里。这篇文章会围绕一个典型场景展开把一个运行已久的 HTTP 服务器从传统部署方式平稳迁移到 Kubernetes 上同时保证切换期间用户几乎无感知。适合正在做云原生改造的运维、后端开发以及把 K8s 当作自己下一个必修技能的读者。除了资源清单本身我会把每个关键决策背后的原因、参数怎么算出来的、上线后有哪些坑一起讲透。1. 迁移前的全局规划与方案评估1.1 先分清是不是真的无状态很多团队口头喊着无状态实际上应用里藏了很多隐形的状态。判断标准其实很朴素你随便停掉一个实例用户会不会受影响如果答案是会有少量用户重新登录上传到本地的临时文件丢了内存里的缓存数据没就没了也无所谓那要小心这里面可能藏着状态。无状态应用通常要满足三件事会话信息不在本地保存比如登录态放在 Redis 或其他统一存储里、业务数据不落在本地磁盘统一走数据库或对象存储、本地不维护复杂的进程内状态比如分布式任务调度状态。如果会话还在内存 Session 里迁移前得先把它挪到 Redis否则副本一扩容用户就被踢下线。如果是老 Java Web 应用即使把 session 粘在某一台机器上也只是治标不治本滚动更新的时候 session 归属节点一消亡还是会有截断感。这个判断的意义在于决定迁移方案的复杂度。真正无状态的应用Deployment 副本数随便调发布策略随便选探针配好就能安全滚动。带状态的应用则要考虑 StatefulSet、PVC、有状态服务的高可用方案完全不是一个量级的工作量。所以第一步不是开写而是给应用做一次彻底体检。1.2 盘点依赖画清迁移边界在写第一份资源清单之前我建议先画一张依赖图。老系统在传统环境里通常依赖这些外部组件数据库、缓存、对象存储、消息队列、定时任务调度器、内部 RPC 服务、DNS 和证书配置。迁移到 Kubernetes 时外部依赖原则上不动只把应用本体搬进集群冲突最小回滚也最容易。需要重点确认的几件事应用通过什么方式读取数据库地址和账号硬编码在配置里还是读环境变量日志写到文件还是标准输出写到文件的话容器一重启日志就没了这个坑最常见。有没有定时任务直接跑在应用进程内部多副本部署后同一个任务会不会被多个 Pod 重复执行对外暴露的域名和证书托管在哪里决定灰度切换在哪个环节做。我习惯把这些信息整理成一张表格例如依赖项当前方案容器化后方案风险等级会话保持单机内存 Session迁移到 Redis去除会话粘滞高配置文件服务器文件 手动修改ConfigMap 环境变量注入中日志写本地文件定期清理标准输出 集中采集低定时任务进程内 Quartz关闭重复独立 CronJob 或保持单副本执行高静态文件本地磁盘目录迁移到对象存储或使用 PVC中做完盘点后迁移边界就清楚了这次只迁应用本身依赖继续留在原地。只要边界清晰后面的工作量就是可控的。2. 镜像与进程改造让老服务适配容器生命周期2.1 Dockerfile 的常见坑与正确姿势容器镜像的本质是把应用连同运行环境一起打包但很多人把它当成一个压缩包只负责把代码塞进去。第一版 Dockerfile 往往踩这两个坑用 root 用户跑进程、没有处理信号转发。先看一个相对合理的 Dockerfile 写法再讲为什么这么写FROM eclipse-temurin:17-jre-jammy RUN groupadd -r app useradd -r -g app app \ mkdir -p /opt/app chown -R app:app /opt/app WORKDIR /opt/app USER app COPY --chownapp:app app.jar /opt/app/app.jar EXPOSE 8080 ENTRYPOINT [java, -XX:MaxRAMPercentage75.0, -jar, /opt/app/app.jar]几点说明基础镜像尽量选择带 JRE 的发行版不要用完整 JDK 镜像体积差很多。创建非 root 用户是 K8s 安全基线里比较重要的习惯容器默认的 root 权限一旦被打穿影响面会被放大。设置MaxRAMPercentage75.0是关键它告诉 JVM 根据容器内存限制动态分配堆大小。如果写死-Xmx2g容器 Limit 设置为 2Gi 时堆加上元空间、线程栈这些非堆内存很容易把容器整体内存打到上限触发 OOMKilled。Go 或者 Node 应用也类似Go 进程本身对 SIGTERM 的处理很友好Node 则需要确认有没有捕获SIGTERM做优雅收尾。这一步不确认后面会引出容器没有按预期平滑退出的问题。还有一个容易踩的地雷是进程必须以 PID 1 方式运行。如果用了CMD [/usr/bin/supervisor, ...]或者直接跑 Shell 脚本Shell 会成为 PID 1Kubernetes 发送给容器的 SIGTERM 信号可能不会被 Java 进程收到导致强制杀掉。条件允许就尽量让应用进程直接作为容器主进程。2.2 配置管理从改文件到环境变量 ConfigMap老服务最常见的配置方式是一堆 properties 文件部署时手动改 IP。搬进 K8s 后推荐把所有配置拆成两部分需要加密的进 Secret普通配置进 ConfigMap。业务代码里不要直接读外部文件统一改成读环境变量。apiVersion: v1 kind: ConfigMap metadata: name: http-svc-config namespace: app data: APP_ENV: prod LOG_LEVEL: info DB_URL: jdbc:postgresql://10.0.0.8:5432/business REDIS_ADDR: redis://10.0.0.20:6379Pod 里通过envFrom一次性注入envFrom: - configMapRef: name: http-svc-config需要提醒一点ConfigMap 热更新和进程真正生效不是一回事。K8s 可以挂载 ConfigMap 为 Volume 并实现自动同步但应用是否支持热加载是另一回事。很多应用只会在启动时读一次配置那么你改了 ConfigMap 也不会生效必须滚动重启 Pod。不要用改了配置文件立刻生效的思路去期待这个行为生产环境最好把配置变更和发布动作绑在一起。2.3 资源规格不是拍脑袋决定的requests和limits是很多人随便写的这一点直接影响稳定性。requests决定了调度器为这个 Pod 预留多少资源limits决定容器最多能用多少。如果limits.cpu设的太小应用一压流量就被 CPU 限流limits.memory设的太小直接被 OOMKilled。反过来requests设得过大集群利用率会很难看节点塞不下太多 Pod。最可靠的方式是拿压测数据说话。迁移前在旧环境做一轮压测记录常规流量下容器的 CPU 均值、内存均值以及峰值流量下的表现。比如压测结果显示常规状态下 CPU 使用率约 0.4 核内存稳定在 800MiB 左右那么可以这样设置resources: requests: cpu: 500m memory: 1Gi limits: cpu: 2 memory: 2Girequests留一点余量limits给足突发空间。不要试图用很小的limits省资源容器被限流导致的响应变慢远比多占一点资源更致命。如果是 JVM 应用还要结合上一节的MaxRAMPercentage配合调整确保堆外内存有地方放。3. Kubernetes 工作负载设计从能跑到能扛住故障3.1 Deployment 的发布策略与滚动更新参数无状态应用的默认工作负载就是 Deployment滚动更新策略需要仔细设计。这两个参数直接决定发布过程会不会中断流量strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0maxUnavailable: 0表示发布过程中不允许有实例处于不可用状态maxSurge: 1表示允许临时多起一个副本。对于副本数为 3 的服务发布时序是先新增 1 个新版本 Pod等它就绪后再逐个销毁旧 Pod。这个策略牺牲了发布过程中的资源冗余但换来了流量的稳定很适合对可用性敏感的 HTTP 服务。为了进一步避免同一个节点的多个副本同时被更新可以在 Pod 模板里加一条 Pod 反亲和规则affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 podAffinityTerm: labelSelector: matchLabels: app: http-svc topologyKey: kubernetes.io/hostname这条规则的作用是让同一应用的副本尽量散在不同的节点上节点故障时不会所有副本一起挂掉。副本数一开始不要拍脑袋写 3建议根据历史流量峰值粗略估算。比如高峰期 QPS 是 300单实例能扛 150 QPS那么至少需要 2 个稳定副本加上故障冗余至少 3 个副本。再配合后面要讲的 HPA才能覆盖流量波动。3.2 探针设计存活探针、就绪探针必须分开探针是很多应用上 K8s 后第一个出问题的地方。常见错误是只配一个存活探针或者就绪探针和存活探针用同一个接口。设计原则是存活探针负责判断进程是否死锁、是否必须重启就绪探针负责判断这个实例是否已经能接收流量。一个内部故障导致响应缓慢的实例不应该被杀掉重启但也不应该继续接流量这就是两个探针分开的意义。推荐的探针配置readinessProbe: httpGet: path: /healthz port: http periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 3 successThreshold: 1 livenessProbe: httpGet: path: /livez port: http periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 3接口设计上/healthz最好轻量不要查数据库、不要查 Redis只检查应用进程能否响应。/livez可以更严格一些检查关键依赖是否可用但也不能因为依赖抖动就频繁重启。如果应用启动超过 60 秒建议增加startupProbe给慢启动应用预留时间否则就绪探针会把正在初始化的 Pod 一遍遍杀死重启。启动类探针示例startupProbe: httpGet: path: /healthz port: http periodSeconds: 5 failureThreshold: 30failureThreshold: 30表示允许最长约 150 秒的启动时间够慢启动应用从容完成初始化。等启动探针通过后存活探针和就绪探针才会接管。3.3 Service 与 Ingress外部流量怎么进集群Service 的类型选择要结合集群环境来看。通常对外提供 HTTP 服务的链路是Ingress Controller 负载均衡入口 → Ingress 路由规则 → ServiceClusterIP → Pod。ClusterIP 是内部通信的标准形态NodePort 或 LoadBalancer 只在需要集群外直接访问时使用生产环境一般不直接把 Pod IP 暴露到公网。核心的 Service 配置并不复杂apiVersion: v1 kind: Service metadata: name: http-svc namespace: app spec: selector: app: http-svc ports: - name: http port: 80 targetPort: http type: ClusterIP注意selector必须和 Deployment 里 Pod 模板的labels完全匹配稍微少一个标签都会让 Service 没有可用端点。Service 的port: 80是对外提供服务的端口targetPort: http则对应容器里的端口名这里用端口名比写死 8080 更稳妥因为后续改端口时不用改多个文件。Ingress 的典型写法apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: http-svc namespace: app spec: ingressClassName: nginx rules: - host: svc.example.com http: paths: - path: / pathType: Prefix backend: service: name: http-svc port: number: 80ingressClassName取决于集群里装的是哪个 Ingress Controller没有这个字段时默认 IngressClass 可能是空路由不生效。这里先按最常见的 nginx ingress 来写。无状态应用不需要 Session Affinity 这类粘滞配置因为会话已经外置粘滞反而可能让流量不均衡。3.4 优雅上下线Pod 生命周期里的两个关键细节滚动更新时用户请求被中断绝大多数原因是对 Pod 销毁过程没有做优雅处理。Kubernetes 的流程是把 Pod 标记为 Terminating 状态从 Service Endpoints 里摘除然后向容器发送 SIGTERM 信号等待优雅退出时间结束后再发送 SIGKILL。问题在于Service 端点摘除和容器进程收到 SIGTERM 之间几乎同时发生而负载均衡和网络插件感知摘除存在延迟。等新请求发过来Pod 已经在退出过程中连接直接被拒绝。解决办法是给容器加一个 preStop 钩子让进程在真正退出前假装活着一小段时间lifecycle: preStop: exec: command: [/bin/sh, -c, sleep 10]这 10 秒不是纯浪费而是给上游负载均衡一个刷新时间窗口让正在途中的请求完成转发同时不再把新请求调度到这个即将死亡的 Pod。很多人会问 10 秒怎么来的我通常取readinessProbe的periodSeconds的两倍配合网络插件摘端点的时间10 秒基本够用。如果请求耗时长可以适当拉长同时把terminationGracePeriodSeconds加长terminationGracePeriodSeconds: 30这个参数是给进程处理存量请求用的一定要大于 preStop 时间加上应用自身优雅退出时间。如果应用收到 SIGTERM 后需要 20 秒完成收尾preStop 10 秒那么terminationGracePeriodSeconds至少要 35 秒以上。设小了SIGTERM 之后还没退出完就被 SIGKILL优雅下线形同虚设。4. 灰度发布与流量切换平滑迁移的核心操作4.1 老平台与新平台并行优于一次性切割很多迁移翻车都是因为选了深夜停服整体切换这种高风险方案。无状态应用迁移的正确节奏是先在 Kubernetes 里把新环境完整搭建起来老环境继续对外服务两者并行通过入口网关按比例切流量。切换的载体可以是 DNS 轮询、负载均衡器的权重配比也可以是 Ingress Controller 的灰度功能。并行期间老环境是一个天然的对照实验组。新环境有异常时只要把入口权重调回老环境就能立刻止血。这样做回滚成本极低而且可以把验证过程拉长到几天甚至几周观察高峰期的表现。我建议的切换路径是这样老环境和新环境都接入同一个入口网关或负载均衡。初始权重设为老环境 100%新环境 0%。新环境完成冒烟测试后切 5% 到 10% 流量过去。观察日志、错误率、P99 延迟正常则逐步提升权重。流量全部切完后保留老环境 24 到 48 小时再下线。这套流程本质上就是灰度发布把迁移这件事拆成了一个个可回退的小步骤。4.2 用 Ingress 完成百分比灰度如果你用的是 nginx ingress可以基于 Ingress Annotation 轻松实现按百分比分配流量。假设老环境是一个继续指向旧服务的 Ingress新环境创建一个 canary IngressapiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: http-svc-canary namespace: app annotations: nginx.ingress.kubernetes.io/canary: true nginx.ingress.kubernetes.io/canary-weight: 10 spec: ingressClassName: nginx rules: - host: svc.example.com http: paths: - path: / pathType: Prefix backend: service: name: http-svc-new port: number: 80这里的关键是两条 Annotationcanary: true表示开启灰度canary-weight: 10表示把 10% 的流量转发到这个服务。主 Ingress 继续处理剩余 90% 流量。权重调大可分多次操作每次kubectl apply后立刻生效。这里还要注意canary Ingress 和主 Ingress 必须处于同一个 Namespace域名一致否则功能不生效。如果集群没有 Ingress Controller也可以直接在入口负载均衡上按后端服务器组配权重老环境服务器组和 K8s 节点组各占一定比例达到同样效果。灰度期间不只是看界面能不能打开要重点观察业务日志里有没有异常堆栈、有没有用户反馈变卡、有没有订单或请求丢失。灰度比例足够小时不会影响大局但足够暴露问题。4.3 流量切完后的验证与老环境退役流量全部切到新环境后先别急着销毁老环境。我的习惯是留 48 小时每天检查一轮数据包括核心接口的成功率、数据库连接池使用率、消息队列消费积压情况、定时任务有没有重复执行。确认没有隐藏问题后再按流程下线老环境。下线前要备份老环境的配置和部署脚本并写清楚回滚条件。一旦新环境出现极端故障还能快速把入口权重切回去。老环境退役后再统一清理入口网关上的后端服务器组配置避免残留的探针请求出于惯性打到已不存在的机器上。5. 上线后的可观测性与稳定性建设5.1 日志与监控容器环境救命的两个能力老环境里登录机器看日志是常态上 K8s 后这个习惯必须改掉。容器文件系统是临时的Pod 一动日志就没了所以第一时间要把应用日志全部打到标准输出由集群里的日志采集器统一收集常见的开源组合是 Loki 或 Elasticsearch 配合采集器。应用代码里把日志打到 stdout 只是第一步还要注意日志格式。建议统一为 JSON 格式带上请求 ID、耗时、接口路径、业务追踪 ID。容器环境下日志集中后会跨多个 Pod 混排没有这些字段排障时根本串不起来。我在实际迁移中就见过这样的例子某个老系统只在启动配置里打开了控制台日志结果部署到 K8s 后日志体积直接撑爆了采集器的缓冲。这里需要配合日志轮转配置把单文件上限控制在合理范围同时从源头调整日志级别避免DEBUG级别全量输出。监控方面即便团队暂时没有 Prometheus 经验也应该在迁移完成后的三天内接入。至少盯住四个指标Pod 的 CPU 使用率、内存使用率、HTTP 请求 QPS、错误率。如果是 Spring Boot 应用内置 Actuator 加一个 Prometheus 端点就能把指标暴露出来如果是手写的 Go HTTP 服务用标准库的expvar也能撑起基础监控。有了指标才能回答一个最关键的问题这个应用在 K8s 里的资源规格到底合不合理。5.2 告警规则的一个实用示例告警不要贪多设置十个不如设置三个真正有用的。我建议先上这几类告警类型推荐规则触发含义Pod 重启increase(kube_pod_container_status_restarts_total[15m]) 0容器不断重启多半是探针或 OOM5xx 错误率5xx 请求占比超过 1%持续 5 分钟链路异常或业务故障P99 延迟P99 超过 500ms持续 5 分钟性能劣化可能资源受限如果是 Prometheus 表达式比较典型的 P99 延迟告警写法类似histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, job)) 0.5这里 0.5 秒只是个起点阈值具体值要根据业务压测数据调整。迁移初期的告警阈值可以放宽一点避免告警噪音淹没真正的问题稳定后再逐步收紧。5.3 用 HPA 应对流量波动无状态应用上 K8s 后最直观的好处就是可以按指标扩缩容。HPA 配置写起来简单但有两个细节需要留意指标类型选对扩容窗口和缩容策略调好。apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: http-svc namespace: app spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: http-svc minReplicas: 3 maxReplicas: 12 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 60 - type: Resource resource: name: memory target: type: Utilization averageUtilization: 70minReplicas不要设成 1一是可用性不够二是扩容发生时新 Pod 还没就绪流量压力会瞬间压在已有 Pod 上。maxReplicas要结合集群总容量来设不能无脑设大。HPA 的 CPU 目标值建议取 60% 左右留出 40% 的余量应对突发。如果直接取 90%还没等扩容完成 Pod 就已经被压垮了。第一批上 HPA 的时候建议先跑两天手动扩容模式用压测和历史流量数据验证触发条件是否合理然后再切自动模式。很多团队一上来就配自动扩缩容结果发现扩容滞后、缩容过快流量一抖副本忽多忽少比没有 HPA 还难排查。6. 常见问题与排查技巧实录6.1 容器反复重启第一件事看什么现象是 Pod 状态反复 CrashLoopBackOff页面时好时坏。排查顺序不要乱先看事件再根据状态判断是探针失败还是 OOM。用下面这条命令可以快速看到最近的事件kubectl describe pod pod-name -n app事件里如果看到Liveness probe failed大概率是探针设计得太敏感比如/livez连了数据库数据库抖动就重启。如果看到OOMKilled说明内存 limit 设置得小于应用的实际需求先查压测数据再适当增大 limit 或调优 JVM 堆参数。6.2 发布时出现 502 / 503 / 连接拒绝这个现象通常指向优雅下线没做好。检查顺序是Pod 终止流程里有没有 preStop 钩子terminationGracePeriodSeconds是否足够应用进程有没有正确响应 SIGTERM 并完成收尾入口层缓存的后端 IP 是否已及时摘除。实际中很常见的问题是应用用的不是官方启动脚本而是包了一层 Shell 启动器结果 SIGTERM 被 Shell 吞掉Java 进程完全没有收到退出信号。排查时可以在容器里直接执行kill -TERM pid观察进程行为如果几秒内没有退出响应就要重新设计启动方式让 Java 进程直接作为容器主进程。6.3 环境变量看起来没生效如果你的配置变量名和业务代码里的读取 Key 不一致K8s 也不会报错只是注入了一个空值。这个问题的坑在于 ConfigMap 和 Secret 属于同一 Namespace 的注入但 Deployment 在另一个 Namespace 的话envFrom引用实际上找不到目标对象也不会报错只是环境变量为空。可以先检查注入结果kubectl exec -n app deploy/http-svc -- env | grep DB然后和仓库里的配置模板做对比。老系统配置是散落在多个文件里的迁移时容易漏掉某一个配置项。上线前最好把配置文件里出现过的主要变量列成清单逐项核对是否都已进 ConfigMap不要靠肉眼扫 YAML。6.4 配置更新了应用没反应很多人以为改 ConfigMap 之后 Service 就会自动平滑更新实际上只有通过 ConfigMap Volume 挂载的文件会同步环境变量方式注入的配置进程在里面是读不到的。即使挂载方式是 Volume 同步应用如果只在启动时读取一次也等于没生效。正确做法是把配置变更当成一次版本发布来看待改完 ConfigMap 后滚动重启 Deploymentkubectl rollout restart deployment/http-svc -n app这个命令会先起新 Pod 再杀旧 Pod流量连续性由滚动策略保证。频率上注意不要在同一分钟内多次改配置触发重启避免把滚动更新队列打乱。6.5 老环境退役前的一次踩坑提醒有一次我们在老环境退役前一天发现数据库里还有来自老环境的写请求。查了半小时才定位到是某个定时任务在老环境中仍处于激活状态每 5 分钟跑一次数据同步接到了同一个数据库。容器里默认只部署了应用本体漏掉了定时任务关闭这个环节。多副本部署之后定时任务重复执行的问题还会被放大。迁移前真的要把每个同学的定时任务关闭开关都确认一遍这个比调参容易遗漏得多。写在最后个人在实际迁移中的体会是Kubernetes 编排能力和门槛都摆在那里真正决定迁移成不成功的反而是一堆不起眼的细节进程能不能优雅退出、探针路径是不是合理、日志有没有标准化、配置注入有没有对齐。K8s 只是把问题放大了并不会自动解决应用本身的问题。做这类迁移我最喜欢的一句话是先把应用在容器里学会优雅地生与死再把编排能力加进去。这句话听起来平淡却是我踩过几次坑后最具体的总结。每迁移一个服务我都会在操作列表最后加一次老环境余留任务清扫这个习惯帮我挡住过不少突发问题。