无状态应用迁移到Kubernetes:从虚拟机到容器的平滑实战

发布时间:2026/10/17 5:30:02
无状态应用迁移到Kubernetes:从虚拟机到容器的平滑实战
1. 开工前先想清楚无状态应用的边界在哪里这次要迁的是一个跑在虚拟机上的HTTP服务内部习惯叫它“接口网关”其实就是个很朴素的Go进程监听8080端口暴露十几个REST接口上游对接数据库和消息队列下游被好几套业务系统轮询调用。在旧环境里它活得挺好日常CPU占用不到10%内存稳定在300MB上下除了偶尔半夜被调用方打爆连接数基本不用人管。但问题在于这台虚拟机已经运行了快三年系统盘余量告急内核版本老旧补丁不想打也不敢打几个核心依赖库和当前系统版本的兼容性越来越微妙。之所以把这台HTTP服务拿出来做迁移是因为它属于典型无状态应用不写本地磁盘、不保存会话、不依赖固定IP回调、集群内任何节点都能接管它的工作。这类服务迁移到Kubernetes的收益最直接——弹性伸缩、故障自愈、发布回滚都变得非常顺手。但也要先泼一盆冷水很多应用表面上“无状态”仔细一查全是隐藏依赖。所以动手之前先把边界划清楚。1.1 无状态不是“没存数据”而是“数据不落在自己身上”我之前见过一个项目开发说“我们这个服务绝对无状态”结果一查进程会在本地临时目录写一个缓存文件如果文件被删服务会报错但能自愈。这种算不算无状态严格说算因为它不依赖缓存文件的持久性丢了能重建。但迁移到Kubernetes之后Pod重建时文件系统是全新的如果你还在代码里硬编码了某个宿主机路径比如/data/app/cache/那就要么早早改成环境变量要么彻底移除这个逻辑。判断无状态的经典标准就三条第一本进程的内存和磁盘数据丢掉之后服务还能正常启动并对外服务第二请求不要求连续落在同一台机器上任意实例都能独立处理第三服务实例之间不需要互相发现和同步状态。如果三句话都符合就可以放心走“多副本PVC免挂载”的路线。反之有状态应用迁移要考虑StatefulSet、持久卷、Headless Service复杂度完全是另一回事。这次迁移的HTTP服务就很简单日志打到stdout配置通过环境变量注入没有任何写死的文件路径也没有内存会话保持。唯一微妙的地方是它启动时要从配置中心拉一次全量配置加载完再监听端口。这个行为不影响无状态判定但直接影响健康检查的设计后面会细说。1.2 为什么优先拿无状态应用试水Kubernetes很多团队迁移容器化第一反应是选个边缘小服务试水这个思路没错。无状态应用在Kubernetes里的待遇和成熟度是最高的Deployment天然支持多副本滚动Pod重建速度快流量调度规则简单出了问题回滚也容易。相比之下如果一上来就迁移一个带MySQL或Redis实例的服务光是数据卷的ReadWriteMany和节点亲和性就能折腾掉一星期。所以我的建议是如果团队Kubernetes经验还不深第一次迁移一定选“能随时打死重建”的服务。就算迁移过程中出了篓子把流量切回旧机器就行数据不会丢用户也无感知。这份底气就是无状态应用给的。2. 迁移前的“体检”配置、会话、日志三件事必须落地很多人拿到一个服务第一反应是写Dockerfile这不对。迁移容器化最大的坑不在构建镜像而在于把服务原有的“隐性地基”一件件搬到新环境里。我这次迁移前做了一次系统性的盘点花了大概半天时间把三类问题彻底解决掉后面才一路顺畅。2.1 配置外置化别再让IP和密码躺在代码里旧环境里服务配置是靠一个本地config.yaml文件加载的里面写了数据库地址、队列地址、各下游服务的调用超时。这种部署方式最大的问题是配置和代码混在一起环境一换就要重新打包或手动改文件稍不注意就把测试环境的地址带到生产。迁移到Kubernetes之前我先把这份配置做了三件事敏感字段拆到环境变量、非敏感字段放进ConfigMap、可变参数从启动命令中剥离。简单说最终落地方式是ConfigMap承载服务端口、日志级别、下游超时这类参数数据库密码和密钥这类敏感信息用Secret保存应用启动时从环境变量读取。服务代码也要对应小改一下优先读环境变量读不到再回退到默认值这样开发环境干干净净容器环境也不依赖任何本地文件。这里有个容易被忽视的细节Kubernetes的ConfigMap更新之后不会自动重启Pod。如果你改了配置内容要手动做一次滚动重启kubectl rollout restart deployment/xxx才能生效。而挂在环境变量里的配置更新ConfigMap后重新创建Pod才会重新注入。实测中我建议把这些配置当作“发布产物”的一部分每次变更都走一次完整的镜像或滚动流程而不是图省事直接kubectl edit。2.2 会话保持无状态服务也要防“隐形的粘性”有些无状态应用会被反向代理或网关层悄悄变成“半有状态”最常见的是session粘滞。这次迁移的这个Go服务本身没有session但它和调用方之间有一个老旧的Nginx负载均衡Nginx配置了ip_hash导致同一来源IP的请求总打到同一台后端。这个配置在单机时代无所谓但到了Kubernetes多副本之后就是灾难——新Pod没流量旧Pod忙到爆滚动更新时还会出现“明明旧Pod已经Terminating还有粘滞过来的新请求”的诡异现象。所以迁移前建议先问一句上游调用方是怎么访问这个服务的如果经过了负载均衡把会话保持策略改成轮询或者least_conn如果是域名直连确认没有DNS缓存的旧IP残留。这次我们就是先把Nginx的ip_hash改成了轮询模式再观察了两天流量曲线确认调用方没有报错才继续下一步。否则你迁完一定会在某个夜深人静的时刻收到“大量502报警”。有一句经验之谈平滑迁移的前提是流量入口已经“对后端实例无感”。无状态应用最怕的不是Pod内的问题而是入口层还残留着“某台机器必须活着”的逻辑。2.3 日志与临时文件容器里没有“永久”目录老服务把运行日志写到了/var/log/app/通过logrotate做轮转保留了30天。容器迁移之后如果你还把日志写到本地文件Pod一重启日志就没了而且容器文件系统膨胀会让Pod被驱逐。正确做法是日志写到stdout由容器运行时和日志采集组件统一收集如果你已经有Elasticsearch或Loki这套链路直接在采集端加一个namespace标签就行。再一个是临时目录。服务跑起来会生成一些临时文件用完即删这类没问题用emptyDir挂到/tmp就够了。注意不要在容器里创建“永久目录”也不要把这类路径挂到持久卷上那既浪费存储又拖慢Pod调度。我这次迁移时把服务原有的/var/log/app直接删了代码里只保留log.Printf输出到stdout。这个改动很轻但换来了日志集中排查的便利后端同事查问题再也不用登录到容器里翻文件了。3. 镜像与Deployment把你的HTTP服务器装进Kubernetes完成体检之后才轮到真正的“容器化”。这一步涉及两个产物镜像和Deployment编排文件。很多人会把重心放在写Dockerfile上但真正决定服务稳定性的其实是Deployment里的那几个字段。逐个拆开讲。3.1 Dockerfile的三个细节基础镜像、非root、时区这个HTTP服务是Go写的编译后是静态二进制所以镜像结构可以非常干净。我用的多阶段构建第一阶段用golang编译第二阶段只拷贝一个二进制和必要的证书文件进去。基础镜像选了一个很精简的发行版从原来的几百MB缩到几十MBPod拉取速度快很多。但精简镜像有个隐形坑默认没有CA证书也没有tzdata。如果你的服务要调用外部的HTTPS接口容器里必须装上CA证书否则TLS握手直接失败。时区问题更隐蔽旧机器上日志时间是Asia/Shanghai新容器如果没设置时区日志变成UTC排查问题时会错乱8小时。解决方法是Dockerfile里安装tzdata并设置TZ环境变量或者在启动命令里挂载/etc/localtime。另一个容易踩的坑是容器内运行用户。很多基础镜像默认是root虽然能跑但一旦被入侵权限过大安全隐患高。而且Kubernetes有restricted的Pod安全上下文限制如果集群开启了强制校验root用户直接创建Pod失败。我这次给服务建了个普通用户文件权限调到只读启动命令也改成非root执行。这个习惯建议从第一天就养成后面省很多事。复制二进制时要留意Go的静态编译很好办但如果你是Java应用、Python应用镜像里要带上运行所需依赖和JDK体积会大不少这也是另一套技巧。核心思路不变镜像内容最小化、进程用户非root、时区和证书提前配好。3.2 Deployment的核心字段探针、资源、优雅终止这个环节是整个迁移的“技术心脏”。先说探针。旧服务启动时需要从配置中心拉取配置大概消耗3到5秒之后才监听8080端口。如果探针配置得太激进Pod一启动就被livenessProbe打死陷入“启动-被杀-重启”的循环。我用了两组探针livenessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 10 periodSeconds: 15 timeoutSeconds: 3 failureThreshold: 3 readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 5 periodSeconds: 5 timeoutSeconds: 2 successThreshold: 1 failureThreshold: 3liveness负责“进程活着但可能卡死”的场景periodSeconds设大一点避免频繁打探针给服务增加不必要的压力。readiness负责“是否接收流量”设得细一点配合滚动更新时能快速摘除不健康的Pod。为什么要分开因为没有readinessProbe的话Pod只要处于Running状态Service就会把流量打过来如果端口还没监听请求就会连接拒绝。而只有readinessProbe却没有livenessProbe的话进程一旦死锁readiness会一直失败但Pod永远不会被重启。两个探针互为补充一个管存活一个管流量不能互相替代。再说资源限制。旧虚拟机的内存基线是300MB左右但我没有直接把limits压到300MB而是给了requests: 200m/256Milimits: 500m/512Mi。requests是调度依据limits是硬上限。如果直接把requests压得太低调度时会过度超卖高峰流量一来Pod会被OOM Kill如果limits设得太高集群资源容易碎片化。建议以一周流量的P99内存占用为基准加上30%的缓冲作为limits的参考值。最后是terminationGracePeriodSeconds。默认值是30秒对这个服务来说足够。但如果你的服务有长连接的清理逻辑记得调大这个值。关闭时Pod会先收到SIGTERM如果服务没在优雅退出时间内退出Kubelet会强制SIGKILL。所以你的HTTP服务要正确实现signal.NotifyContext或类似的优雅关停逻辑先停止接收新请求再等待存量请求处理完毕最后关闭端口。这一步如果没做每次发布都会出现少量请求失败。我这次把Deployment的副本数设为3这个数字也有讲究。旧机器是单点迁移后至少3个副本才能保证单节点故障时服务不受影响同时滚动更新时也有多余的Pod承载流量。如果业务量不大5个副本可能过于浪费3个是均衡之选。3.3 镜像标签和拉取策略版本管理不是小事镜像tag用latest是新手最容易犯的错。它会导致更新时无法确定线上跑的是哪个版本回滚时更是分不清哪跟哪。建议用git commit短哈希或语义化版本号比如info-gw:v1.2.3每次构建都打上唯一标签。同时注意imagePullPolicy如果tag不是latest默认策略是IfNotPresent也就是节点上已经有这个镜像就不再拉取。这在滚动更新时会产生一个很隐蔽的问题新Pod调度到某个缓存了旧镜像的节点上用的镜像版本反而比期望版本旧。为了避免这种低级事故我在Deployment里显式设置imagePullPolicy: Always或者干脆每次发版都换一个新tag保证节点必须重新拉取。4. 服务质量与流量接入从“能跑”到“平滑”镜像和Deployment写完后服务在集群里已经能跑了但这只是“自嗨”状态外面还访问不到。要让迁移平滑必须把流量从旧环境逐步引到新集群这个过程涉及Service和Ingress的配置以及入口层如何配合切换。4.1 Service集群内稳定的访问入口Pod的IP是随时变化的所以必须通过Service来提供一个稳定的虚拟IP和DNS记录。对内访问我建了一个ClusterIP类型的Service选择器指向Deployment管理的Pod标签。这里要提醒一句Service的selector一定要和你Deployment里的Pod模板标签完全一致如果不一致Endpoints列表会一直是空的流量进不来。apiVersion: v1 kind: Service metadata: name: info-gw-svc spec: selector: app: info-gw ports: - name: http port: 8080 targetPort: 8080端口名建议加上name: http以后做Ingress规则时可以引用端口名比裸数字更可读。port是Service对外暴露的端口targetPort是容器内实际监听的端口这两个经常有人搞混导致curl Service IP却连接被拒。4.2 对外暴露NodePort还是Ingress新旧流量切换的难点在“入口”。如果你的调用方在公网或跨网络需要在集群入口层做流量转发。集群在私有网络里跑的话最省事的方式是给Service指定type: NodePort每个节点会开放一个固定端口默认30000-32767外部直接访问节点IP:端口就能到达Service。但这样有几个问题一是端口范围有限服务多了容易冲突二是绕过了集群的七层路由能力SSL终结、路径匹配都做不了。更稳妥的方案是在Service前面加Ingress资源。Ingress本身不是凭空出现的它需要一个控制器来处理路由比如Nginx Ingress Controller。我这次选了Nginx方案原因是团队对Nginx配置文件最熟悉出了问题排查思路能延续。Ingress配置里把域名指向info-gw-svc同时指定路径/api这样只有特定路径的请求才会进入这个服务其他流量可以另走他路。流量切换的核心技巧在于新集群的Ingress入口先不急着加入线上流量而是先用测试请求验证连通性。具体做法是用curl -H Host: your-domain.com http://ingress-address/api/healthz来模拟线上域名的访问。确认Ingress链路通、返回数据正确之后再去上游DNS或负载均衡器做切换。5. 滚动更新与蓝绿部署迁移过程中的切换与回滚策略迁移不是把Pod建好就完事关键在“切换”这一步能不能做到用户无感知。这里有两种路线滚动更新适用于“流量已经全部在K8s里后续发版用”蓝绿部署适用于“旧机器和新集群并存需要一次性或灰度切换流量”。这次迁移由于是全新环境切换我走的是蓝绿思路切换完成之后后续发版回到滚动更新。5.1 滚动更新的参数陷阱maxSurge与maxUnavailable先讲滚动更新因为你迁移完成后平时发版都会用到。Deployment默认的滚动策略是25%的maxUnavailable和25%的maxSurge也就是一次最多先删25%的Pod再多建25%的Pod。这个默认值对大多数服务够用但两个坑要注意。第一个坑maxUnavailable25%时3副本的Deployment意味着最多先删1个Pod剩余2个还在承接流量。如果Service后端能扛住2个Pod的负载那是安全的但如果你只有2个副本一次删掉1个剩下1个要承受全部的流量高峰期顶不顶得住所以副本数少的时候建议把maxUnavailable设为0先把新Pod建起来再逐批删旧Pod。第二个坑maxSurge如果很大新Pod建起来后会有一段时间新旧Pod并存。如果旧Pod和新Pod用的镜像完全一样没问题如果版本差异大双版本同时在服务可能出现短时间的行为不一致。对无状态服务来说通常可以容忍但对核心交易链路还是要谨慎。我这次迁移后的Deployment设置的策略是strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0这个组合的含义是始终保持至少3个可用副本一次最多多建1个新Pod。虽然发布速度会慢一些但流量始终有保障。如果你的服务对发布速度敏感可以放宽maxSurge到50%但一定要保证maxUnavailable别设太大。5.2 蓝绿切换旧机器与K8s集群之间的流量交接这次从虚拟机切到Kubernetes我更推荐蓝绿方式。具体做法是先在集群里把新服务完整跑起来健康检查通过后在流量入口层也就是旧Nginx那一层新增一组上游指向Ingress Controller的地址权重先设为0然后逐步调高权重。每次调整后观察调用方错误率、P99延迟和错误日志确认稳定后再继续提升。比权重更稳的是全量切换旧Nginx的后端直接从一个旧虚拟机IP换成Kubernetes的NodePort或Ingress地址一次性切完。这个操作看起来粗鲁但对无状态服务其实很安全。因为Service后面已经有3个Pod在承接流量切换过程中旧连接如果还在处理中只要优雅停机逻辑做得好存量请求会在几十毫秒内完成新连接全部打到新集群理论上不存在处理中的断层。我这次实际操作顺序是这样的把新Deployment在集群里建好等所有Pod进入Ready状态。用kubectl port-forward临时验证服务接口正常。通过Ingress访问测试域名确认路由链路无问题。通知调用方维护窗口实际是凌晨流量很低在入口层切后端地址。观察5分钟错误日志和监控曲线确认无异常后把旧虚拟机关机待命。这里有一个重要细节整个切换过程中不要直接删掉旧服务。万一新集群有问题入口层的后端可以瞬间改回去。蓝绿策略的精髓就是“保留一个能立即回退的旧环境”直到新环境稳定运行超过72小时再清理。还有一个容易被忽略的点DNS和缓存。入口切换后调用方的本地DNS缓存和连接池可能还保留着旧IP导致部分流量在切换后几分钟内仍摸到旧机器。这不算问题但观察窗口要拉长到15分钟以上不要看到前5分钟没报错就宣布大成功。6. 上线后我踩过的几个坑探针误判、优雅停机、日志时间错乱到这里迁移已经算完成了。但真正让我觉得“这套系统稳了”的时刻是上线后那几天踩坑又修坑的过程。分享几个实际遇到的坑帮大家提前绕开。6.1 探针路径不对一直重启却查不出原因上线第一个小时有一个Pod一直在重启。排查kubectl describe pod发现livenessProbe失败但奇怪的是我刚用curl访问过服务的/healthz明明是通的。仔细一看发现探针访问的路径是/healthz服务里面也实现了这个接口但是服务要求带上一个自定义Header否则直接返回404。旧环境里没有这个拦截逻辑是开发同学之前为了内部调试加上去的没有在配置里关闭。这提醒我探针测试的是“服务是否健康可用”不是“接口是否存在”。如果你的健康检查接口需要鉴权、需要特殊Header请务必让它对探针放行或者单独暴露一个独立于业务逻辑的健康端口。否则就是典型的“探针误报”服务明明活着却被一遍遍重启。6.2 SIGTERM处理不当滚动更新时偶尔有请求失败这个问题是在第3天做一次小版本升级时暴露的。当时用滚动更新发布新镜像流量监控上出现零星几条500错误。排查下来发现服务没有监听SIGTERM信号Kubelet在terminationGracePeriodSeconds到期后强制杀掉进程正在处理的请求直接被中断。解决方案很直接代码里加一段优雅关停逻辑收到SIGTERM后先停止接收新请求然后等待已接收请求处理完再退出进程。Go标准库的http.Server.Shutdown配合signal.NotifyContext很好用几十行代码就解决了。改造完再发布滚动更新期间错误率直接降到零。6.3 容器日志时间错乱排查问题先对时区迁移后的日志时间比旧机器慢了8小时排查问题的时候一度以为服务在凌晨出现过真空期后来才意识到是时区问题。前面Dockerfile那一步如果忽略时区后面一定会在日志排查上踩坑。我当时的临时方案是在启动命令里加TZAsia/Shanghai后来干脆改在镜像里装好时区数据并设置环境变量一劳永逸。6.4 副本数和资源限制不匹配调度卡在Pending另一个同事在迁移另一个无状态服务时遇到过一个问题Deployment设了3个副本资源requests配得很高集群里没有一台节点能同时容下3个Pod导致部分Pod一直Pending。这种情况要么把requests调低要么把副本数降低要么给节点加资源。无状态应用的大优势是可以跨节点分布但资源配额必须跟集群的真实容量对齐否则“弹性伸缩”就成了一句空话。这个问题的排查方法很简单kubectl describe pod会显示Unschedulable的原因通常能看到Insufficient cpu或Insufficient memory。如果集群里有多类节点还可以通过nodeSelector或nodeAffinity把服务调度到特定资源池但要谨慎使用别把灵活性锁死。7. 迁移完成后的一点个人体会整个迁移从准备到稳定运行前后用了一周多。如果让我重新走一遍最想提前做的有两件事一是在旧环境里先把健康检查接口和优雅关停逻辑都补上而不是等迁移时再改二是提前跟调用方确认清楚他们访问服务的完整链路包括DNS缓存、连接池复用时间、Nginx负载均衡策略。这两件事做在前面后面切换时根本不用熬夜。还有个小技巧分享一下迁移前后把旧环境的系统监控曲线截图留档特别是CPU、内存、P99延迟和错误率。新集群上线后拿同一时间段的曲线做对比能直观看出迁移是否引入了性能损耗比盯着告警日志更靠谱。无状态应用迁移到Kubernetes这件事难的不是YAML怎么写而是“迁移过程中不丢请求、不中断业务”。把流量入口、探针、优雅关停这三件事处理好迁移本身就是一次普通的发布处理不好就是一场排障大战。这次迁移给我最大的感受是Kubernetes不会让一个烂服务变好但能让一个好服务变得更皮实。那些在旧环境里模糊的边界——健康检查、优雅退出、配置外置、日志标准全都被容器平台逼着你摆到台面上而这恰恰是服务长期稳定运行最需要的。