1. 转型前夜:我们为什么要动这个架构
先说明一下背景。2021年年初,我们团队接手了一个有点年头的财务管理SaaS平台——单体应用、虚拟机部署、凌晨发版、月末扩容靠人肉。这个产品承载着几十万中小企业的账务、报表、税务等核心业务,用户对稳定性和数据准确性要求极高。但每次到月底结账高峰期,数据库和中间件压力都很大,应用层无法水平扩展,运维群里全是告警。
那时候“云原生”这个概念已经很热了,社区里到处是Kubernetes、微服务、DevOps的分享。我们调研了大半年,最终下定决心做架构转型。这篇文章想完整复盘这段实战历程:从当初为什么转型、怎么设计新架构,到容器化、微服务拆分、弹性伸缩、灰度发布这些环节的落地细节,再到我们踩过的坑和沉淀下来的工具化经验。整个过程前后用了大约10个月,中间走过不少弯路,写出来希望对同样在做SaaS或企业应用的团队有点参考价值。
1.1 旧架构的典型困境
我们最早的部署架构很简单:一台负载均衡,后面挂三四台VM,每台VM里部署一个Tomcat,运行一个Spring Boot的单体应用。数据库用MySQL,缓存用Redis,搜索用Elasticsearch,主要中间件都跑在独立机器上。这套架构支撑了平台两三年,业务增长之后开始明显吃力。
先说说最让人头疼的发布。当时发一次版本大概需要两个小时,而且必须在凌晨操作。流程是:开发打包 -> 上传到服务器 -> 停应用 -> 替换jar包 -> 启动 -> 冒烟验证。因为是多台VM,还要一台一台处理,万一某台启动失败,又要回滚,经常折腾到天亮。版本发布频率也因此被压到每两周一次,产品需求排期长期被技术发布节奏卡住,业务侧意见很大。
其次是容量问题。财务类应用有很强的潮汐特征——每个月月末、季末的结账高峰期,用户集中使用报表计算、批量记账这些重CPU功能,应用服务器的CPU直接飙到90%以上。为了应对这种峰值,我们只能提前申请更高配置的机器,常年维持冗余。平时CPU使用率不到15%,资源浪费非常严重。成本部门每年看服务器账单都想找我聊一次,但我们确实没有更聪明的办法。
还有故障恢复的问题。有一次某台VM硬盘损坏,上面部署的应用实例全部不可用。因为没有自动化调度,硬是等了半小时人工摘流量、切换、重启,才把服务恢复过来。期间用户无法记账,客服电话被打爆。这种体验放在SaaS行业是不可接受的,客户对系统的信任度会一点一点被消磨掉。
1.2 转型目标不是“上K8s”,而是解决三个实际问题
在立项讨论时,团队内部有过很多说法:有人说要全面使用最新技术栈,有人说要自研一套容器管理平台,还有人提议直接无脑迁到公有云。我的观点一直是:云原生是手段,不是目的。我们必须先定义出要解决的业务问题,再去匹配技术方案。
我们最终把目标收敛成了三点,并写成了可量化的指标:
- 发布效率:一次版本发布从两小时缩短到30分钟以内,且不需要深夜执行。
- 弹性能力:在月末、季末等业务高峰期,应用层可以在10分钟内自动扩容,不需要提前准备机器。
- 资源成本:通过容器化、提高资源利用率,整体计算成本下降30%以上。
除此之外,还有一个底线要求:迁移过程中不能出现长时间服务中断,核心账务数据不能丢,客户侧的感知要尽量小。这意味着我们不能搞“推倒重来”式的大版本重建,只能在现有系统基础上做渐进式改造。后面所有技术选型和落地节奏,都是围绕这几点展开的。
2. 整体架构怎么设计:拆服务、搭底座、铺管道
目标明确了,接下来是架构设计。我们花了大约一个月梳理业务边界和系统依赖,确定了大方向:以Kubernetes为底座,把单体应用按业务能力拆分成微服务,再配合一套完整的CI/CD流水线和可观测性体系。这个方向现在看不算新鲜,但当时我们内部争论了很久。
2.1 为什么选Kubernetes,而不是自建调度平台
我们最早也考虑过自己写一套基于Docker的调度系统。原因是团队里有两个后端工程师对容器技术很感兴趣,想造轮子。但冷静评估之后否决了。
Kubernetes虽然学习曲线陡峭,但它已经形成了完整生态,社区有大量的故障案例和最佳实践。自研平台意味着从容器网络、存储、调度、服务发现到故障恢复全部要自己维护,这个成本远远超出当时的团队承载能力。我们是一家做财务SaaS应用的公司,不是做基础设施平台的,核心优势应该在业务逻辑上。
在具体形态上,我们没有选择自己采购物理机搭K8s集群,而是用了云厂商提供的托管Kubernetes服务。原因很现实:自建集群需要运维团队具备非常强的底层能力,而且etcd、kubelet、节点网络这些组件一旦出问题,排查成本和恢复时间都不好控制。托管集群虽然会有一些版本更新上的约束,但可以让我们把精力集中在业务应用上。
2.2 微服务拆分:别按代码层拆,按业务能力拆
微服务拆分是这次转型里最容易用力过猛的地方。网上很多文章强调微服务的好处,但没人提醒你:拆得越细,运维复杂度越高。我们团队只有20多个人,不可能支撑几十个微服务的高效发布和排障。
拆分的第一个原则是按业务域划分。我们花了几天梳理平台的功能模块,把账务处理、往来管理、财务报表、税务申报、消息通知、用户权限、支付网关这些相对独立的领域切出来作为候选服务。每个服务要有独立的业务语义、独立的数据库表(至少逻辑上隔离),并且能够独立部署和扩展。
第二个原则是从影响面小的模块开始拆。我们没有一上来就动最核心的账务引擎,而是先把消息通知、文件生成、报表导出这些边缘功能拆出去。这些模块依赖少、业务逻辑相对简单,即使拆分过程中出现问题,对主流程影响也有限。等团队整体熟悉了容器化部署和微服务调试之后,才逐步挑战账务和报表这些核心域。
第三个原则是拆分后一定要单测和集成测试覆盖到位。尤其对于财务系统,金额计算、科目汇总、余额结转这些规则的准确性是绝对不能妥协的。我们每次拆分一个模块,都要把原有业务规则测试脚本完整跑一遍,确保输出结果与旧系统一致。
2.3 容器化改造的关键迁移路径
容器化不是简单写个Dockerfile就完事,尤其是对运行了好几年的老应用,里面有很多隐性问题。我们把改造过程分了三个阶段。
第一阶段是“镜像化”。先把现有单体应用做成Docker镜像,但运行方式不变,继续在虚拟机里跑。这个阶段主要解决镜像规范问题:基础镜像用什么、时区怎么设置、字体库有没有、JVM参数怎么传。我们踩过一个很典型的坑:财务系统导出PDF报表时需要用到中文字体,基础镜像里没有安装,结果迁移测试时导出的文件全是乱码。后来在基础镜像里预装中文字体包,才解决这个问题。
第二阶段是“无状态化”。把会话信息从本地内存迁到Redis,把上传的文件转移到对象存储,让每个应用实例不保存本地状态。这是容器化最难的一步,因为代码里总有开发图省事塞在本地磁盘的临时文件。我们用了一个笨办法:把所有写本地磁盘的日志和文件路径都拿全局搜了一遍,一个个改掉。
第三阶段才是真正接入Kubernetes。我们用Deployment管理应用实例,用Service做服务发现,用Ingress做外部流量接入。首次接入时没有做全量切换,只在测试环境跑了两周,确认稳定后才逐渐扩大。
这里我想强调一个容易被忽视的点:镜像仓库和版本管理。我们统一使用一套私有镜像仓库,所有镜像都打上git commit号作为标签,确保每个版本都能追溯源码变更记录。否则一旦出现问题,你根本分不清线上跑的是哪一版代码。
3. 核心落地细节:流水线、扩缩容、灰度与可观测
架构骨架搭好之后,真正花时间的是围绕Kubernetes构建一套配套体系。这部分如果只搭个集群、把应用扔进去,那跟把大象装进冰箱没有区别。我们花了大量精力在CI/CD、弹性伸缩、灰度发布、可观测性这四块上,每一块都有很多细节值得展开讲。
3.1 CI/CD流水线:从人工发版到一键发布
很多团队把CI/CD理解成“自动化打包”,这是不够的。理想中的发布流程应该是:开发push代码到仓库 -> 自动触发单元测试和代码扫描 -> 构建镜像并推送到镜像仓库 -> 自动更新测试环境 -> 测试通过后提交发布单 -> 审批通过后自动部署到生产环境。我们最终通过流水线工具把这条路跑通,一次生产发布从两小时压到了15到20分钟。
整个流水线里有几个细节需要注意。
第一,环境区分要清晰。我们划分了dev、test、prod三套环境,分别使用不同的Kubernetes命名空间,网络策略和权限也做了隔离。开发环境可以随意部署,测试环境需要合并主干后自动部署,生产环境则必须走审批流程。权限上严格限制普通人不能直接操作生产集群的kubectl命令,所有变更都通过流水线执行。
第二,镜像构建阶段要做依赖缓存。Java应用依赖非常大,第一次构建可能要好几分钟,后面每次构建如果都重新拉依赖,效率非常低。我们在构建机上挂了私有制品仓库做依赖缓存,把构建时间从6分钟降到了2分钟以内。
第三,部署阶段要合理设置滚动更新参数。比如maxSurge和maxUnavailable这两个参数,决定了更新的速度和可用性。我们希望每次发布至少有三分之二的实例在线,所以会把maxUnavailable设为25%,maxSurge设为25%。这样的好处是发布期间整个集群仍然能承受峰值流量。
第四,发布完成后必须自动执行健康检查,不只是看Pod状态是Running,还要检查应用的健康检查接口、数据库连接池是否正常。我们有几次遇到Pod很健康但应用实际上无法处理请求,就是因为健康检查配置不够敏感,后来专门加了业务级的健康检查端点,比如检查核心依赖的连通性。
3.2 弹性伸缩:HPA参数调优的实战笔记
弹性伸缩是Kubernetes最吸引人的能力之一,但用不好就会变成事故现场。我们遇到过两种情况,一种是缩容太激进导致服务被打垮,另一种是扩容太慢导致高峰期还是扛不住。这两种情况我们都真实遇到并排查过。
先说说资源声明的重要性。使用HPA之前,每个容器必须配置resources.requests和resources.limits。最开始我们没认真设置,很多容器只写了limits没写requests,结果调度器以为每个Pod占用的资源非常少,在同一台节点上堆了几十个Pod,最终导致节点CPU跑满、Pod互相争抢。后来我们逐一梳理应用负载特征,给每个服务设置了合理的requests和limits,调度行为才变得可控。
HPA的指标选择也要根据业务场景设计。单纯依赖CPU使用率做弹性,对于财务平台这种重计算应用其实不够。举个例子,报表服务在月末高峰时CPU使用率正常,但请求队列已经很长了,用户点击导出后迟迟拿不到结果。这时候需要根据QPS或者队列深度扩容。
我们最终采用了CPU和自定义QPS指标混合的方式。下面是生产环境里报表服务的HPA配置,可以作为参考:
apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: report-service-hpa namespace: prod spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: report-service minReplicas: 3 maxReplicas: 20 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 60 - type: Pods pods: metric: name: qps_per_pod target: type: AverageValue averageValue: "200"这里minReplicas: 3保证基础实例数量,maxReplicas: 20防止无限扩容打爆下游数据库。averageValue: "200"表示当单Pod QPS超过200时触发扩容。这套配置整体效果不错,但有个前提:自定义指标需要部署一套指标采集组件,比如基于Prometheus Adapter从应用暴露的Metrics端点拉取数据。我们最初没做这个,后期补上的时候又花了两周时间。
在弹性伸缩上最大的教训是:扩容速度再快也比不上流量突增的速度。如果某个月的结账需求突然翻倍,自动扩容只能在流量已经进来之后发生反应。所以务必给关键业务预先设置一个基础缓冲池,比如在业务高峰来临前手动把minReplicas调到10,等高峰过去再调回来。这个操作我们沉淀成了平台的一个“应急预案”按钮。
3.3 多环境隔离与灰度发布
Kubernetes环境隔离相对容易,难的是如何做到平滑的版本升级和灰度验证。
我们的做法是采用滚动更新加金丝雀发布组合策略。常规小版本直接使用Deployment的滚动更新,由Kubernetes自动控制新老Pod的替换。大版本或涉及数据库表结构变更的发布,则采用金丝雀发布:先只更新一个副本到新版本,观察30分钟日志和错误率,确认无问题后台继续更新剩余副本,一旦发现问题立即回滚。
金丝雀的具体实现方式并不需要很复杂。我们最开始考虑过Istio服务网格,但评估后发现团队当时没有人能熟练掌握Istio,且多一个Sidecar容器也会增加资源消耗和排障复杂度。最后选择的是在Ingress层按权重切换流量,配合一个内部配置开关,把部分请求引到金丝雀版本。用下来发现,简单的方案在中小规模团队里往往比炫技的架构更可靠。
发布中还有一类特别容易出错的是数据库变更。传统单体应用改一张表结构很简单,但微服务拆分后,不同服务可能涉及同一个库的不同表,发布顺序不对就会导致新代码引用不存在的字段。我们的经验是:数据库变更脚本必须在应用发布之前先执行,并且要尽量做到向后兼容。也就是说,新增字段先用旧代码也能容忍,等新代码发布后再逐步清理冗余字段。这套流程我们称之为“先库后应用”,执行了半年之后,发布失败率明显下降。
3.4 可观测性:日志、监控、链路追踪缺一不可
云原生架构的分布程度更高,故障定位的难度也比单体时代大得多。以前一台VM里的应用挂了,登录上去看日志就行;现在一个用户请求可能经过网关、账务服务、报表服务、消息队列等多个环节,必须建设一套完整的可观测性体系。
监控方面采用Prometheus采集指标,Grafana展示。我们设计了几个核心Dashboard:集群节点资源水位、Pod重启次数、应用错误率、接口P99延迟、消息队列积压量、数据库连接池占用等。其中Pod重启次数这一个指标特别有用,很多容器内存泄漏问题都是通过观察它在悄悄上涨才发现。
日志采集方面,把容器标准输出接入统一的日志平台,按服务名、Pod名、TraceID做索引。这里踩过坑:最初部署日志采集组件时,没有给业务日志加上TraceID,排查一个跨服务调用的问题时,日志之间对不上,全靠人工拼时间线,效率极低。后来引入OpenTelemetry做链路追踪,在关键服务之间传递traceId,日志也自动带上,问题定位时间缩短了一大半。
还有一个很多人忽略的点:业务可观测性不等于技术可观测性。不仅要看Pod是否健康、CPU是否高,还要看业务指标是否正常,比如每分钟新产生的记账凭证数、报表导出成功率、支付回调延迟等。我们把这些业务指标通过Prometheus暴露出来,做成业务大盘,一旦业务数据出现掉零或突增,比看系统监控更加直观。这也是财务类系统运维时特别值得投入的一环。
4. 迁移过程中最常见的坑与排查实录
这一节我整理了迁移过程中我们踩过最深的几个坑。每个坑都耗费了我们少则一天、多则一周的排查时间,记录下来帮后来人少走弯路。
4.1 容器里进程管理:PID 1、优雅退出和preStop钩子
第一个坑来自Java应用的优雅退出。
Spring Boot应用在正常退出时会触发ShutdownHook,Web容器会停止接收新请求,然后把正在处理的请求处理完再退出。但容器环境下有一个特殊问题:如果PID 1是Java进程,而Java应用没有妥善处理收到的SIGTERM信号,就会导致Pod直接被杀掉,正在处理的请求被中断。尤其是财务系统里有对账、结转这种需要事务完整性的操作,中断一次就可能产生脏数据。
我们的解决方案是两招并用。第一招是在容器启动时使用tini或类似轻量级init进程,让它成为PID 1并负责转发信号给Java进程。第二招是给应用配置preStop钩子,在Pod真正停止前预留一个等待时间:
lifecycle: preStop: exec: command: ["/bin/sh", "-c", "sleep 10"]sleep 10秒的目的,是给前置负载均衡器和注册中心一个时间窗口,先把该Pod标记为不可用,避免流量继续打进来。执行完这个钩子之后,应用再优雅关闭,双管齐下之后,发布时请求中断的问题基本消失了。
4.2 集群资源碎片化与拓扑分布问题
运营了几个月之后,我们发现一个奇怪的现象:集群总资源还很充裕,但某些服务扩容时始终提示资源不足。刚开始以为是云厂商的资源配额问题,查了很久才发现是资源碎片化。
原因是有些节点上已经跑了很多Pod,剩余资源不足以容纳新Pod的requests,但又没有合理触发调度。Kubernetes调度器在调度单个Pod时只考虑当前请求的资源,不会自动压缩或重排存量Pod。而且我们还配置了PodAntiAffinity,想让服务副本尽量分散到不同节点,结果某些服务副本数增加后,同节点已经没有合适位置。
这个问题的最终解法有几个层面。一是尽可能做好节点亲和性和Pod分布规范,比如把实例数较多的核心服务均匀打散到不同可用区。二是定期观察节点水位,对资源利用率长期过低的节点做缩容或封锁,让Pod重新分布。三是引入集群成本治理工具,定期分析PodRequested资源与真实使用量的差距,把一些明显过高的request调整下来,释放可调度空间。这里的经验是:云原生不是“塞进去就完事”,资源治理是一个持续运营的过程。
4.3 数据持久化:数据库到底要不要容器化
这是转型过程中争论最激烈的问题。很多资料建议数据库和中间件也要容器化,这样能统一运维、提升弹性。但我们经过评估,最终保留了物理机部署MySQL和MongoDB的做法,容器化只覆盖了无状态应用和一部分中间件。
原因不是容器无法跑数据库,而是我们作为财务平台,对数据一致性、备份恢复、性能调优有更高的要求。数据库容器化之后,存储卷、网络性能、持久化保障都比物理机方案多了一层抽象。真到了要恢复一个误删的表时,容器环境的复杂性反而会增加恢复时间。我们有同事尝试过把Redis容器化,但每次节点重启或者网络抖动都会引发主从切换,排查起来比虚拟机方案更吃力。
所以如果你也在做SaaS架构转型,我的建议是:不要被“一切皆容器”的口号绑架。核心数据库可以继续使用云厂商提供的高可用实例,只把应用层做好无状态化和弹性伸缩,成本收益已经非常可观。等团队对容器环境足够熟悉、存储方案足够成熟之后,再考虑逐步推进更多组件云原生化。
4.4 配置与密钥管理:别把数据库密码写在镜像里
这个问题虽然老生常谈,但我们真实遇到过:有工程师为了方便,在应用启动脚本里写死了数据库密码,然后跟随镜像一起构建。幸好内网安全扫描发现得早,没有造成实际损失,但也给自己提了个醒。
引入Kubernetes之后,配置管理已经有了一套办法:ConfigMap管理普通配置,Secret管理敏感信息。但Secret本身如果只是Base64编码存储在etcd里,安全等级也不够。我们的处理方式是接入云厂商的密钥管理系统,应用启动时动态获取密钥,集群里只保存一个加密后的引用。另外,每三个月强制轮换一次数据库密码,并要求所有应用在轮换后自动重连验证,保证不因为这步操作导致线上服务中断。
密钥管理的另一个细节是不能只关注数据库密码,还要关注第三方API Key、消息队列凭证、对象存储AK/SK等。我们当时成立了一个“敏感信息盘点”专项,把所有环境变量和配置文件全部拉出来审查,统一纳入密钥管理平台。这件事枯燥但必须做,不然云原生带来的自动化程度越高,敏感信息泄露的扩散风险也越大。
5. 转型结果与沉淀下来的几件事
转型项目结束到现在也有一段时间了,我可以比较客观地回顾一下成果和不足,也算给这个实战历程做一个阶段性的交代。
5.1 前后效果对比
直接上一组我们在复盘报告里真实记录的数字。先说明,这些数字来自我们自己的业务环境,可能不完全适用于所有团队,但能说明问题:
| 指标 | 转型前 | 转型后 |
|---|---|---|
| 版本发布时长 | 120分钟,需凌晨操作 | 15-20分钟,随时可发 |
| 月末高峰扩容方式 | 提前3天人工申请高配VM | 自动扩容,10分钟拉起10~20个实例 |
| 资源CPU平均使用率 | 15%左右 | 45%到55% |
| 整体计算资源成本 | 基线 | 下降约35% |
| 核心服务故障恢复时间 | 30到60分钟 | 10分钟以内 |
| 单次发布回滚时间 | 30分钟以上,手动操作 | 3分钟内,一键执行 |
这些数字不是马上出现的,上线的前两三个月,部分指标甚至比旧架构还差。原因也很简单,新体系的稳定需要一个磨合期,团队对容器排障不熟悉,配置参数也需要反复调优。大概到第四个月,各项指标才逐步稳定并超过旧架构。
5.2 给同样在考虑转型的团队几条建议
复盘之后,我们总结了几条非常有共识的经验,也分享给后来者。
第一条,不要一上来就想“全量迁移”。选一个非核心、有明确痛点、不影响主流程的业务模块做试点,跑通之后再推广到其他服务。我们最开始选的是“消息通知和文件导出”,上线后虽然遇到一些问题,但至少没有把核心账务业务拖下水。
第二条,先把可观测性做好,再谈微服务和弹性。如果连日志、监控、链路追踪都还没做好,微服务拆分只会让故障定位更难。可观测性建设要放在和业务拆分同等重要的位置。
第三条,方案能简单就不要复杂。服务网格、Serverless这些技术听起来很好,但如果团队没有能力驾驭,反而会成为负担。我们最后用的基本都是Kubernetes原生能力,加上少量自研脚本,系统运行反而更稳定。
第四条,四十八条,团队里至少要有一个人真正理解Kubernetes的调度原理、网络模型和存储机制。只会敲命令是没法做故障排查的。我们后来安排了一位核心成员专职钻研底层原理,每次遇到诡异问题都是他带头找到根因,这个投入非常值得。
从我个人的体会来说,云原生架构转型最难的其实不是技术本身,而是团队协作方式和运维思路的转变。以前我们关注的是“这台机器挂了怎么办”,现在关注的是“这一批实例异常时如何自动恢复”;以前发布是一个需要小心翼翼维护的仪式,现在只需要按下按钮,然后盯着监控大盘。这种变化带来的成就感是实实在在的,只是需要一点时间和耐心去适应。