上个月陪一个团队做架构复盘,他们把单体应用拆成二十多个模块,机器从三台涨到四十台,账单翻了四倍,发布故障率却没降。这类事我见得不少。云原生构建这套东西——微服务、容器化、容器编排——经常被当成三个独立的技术名词来讲,好像学会写 Dockerfile、会敲几条 kubectl 命令就算入门。实际上它是一条完整的链路:业务边界决定微服务怎么切,微服务的运行形态决定容器镜像怎么打,镜像的运行特征又反过来决定编排层的调度、探针、扩缩容参数怎么配。任何一环拍脑袋,后面都要用故障和账单来还。这篇内容面向正在做或准备做云原生改造的后端、运维和架构同学,从拆分判断、镜像构建、编排配置到线上排查,把我踩过的坑和能直接照抄的参数一并写出来,尽量让刚接触的同学少走两圈弯路。
1. 云原生改造前先把账算明白
1.1 微服务不是目的,可独立演进才是
我见过太多团队把"微服务"当成绩指标,仿佛服务数量越多越先进。但拆分的本质是解耦变更,让一个需求的修改范围收敛在一个小团队手里,而不是把一个大泥球切成二十个小泥球再通过网络调用粘回去。
判断一个模块该不该独立,我通常看三个信号:变更频率是否显著高于周边、伸缩需求是否与周边差异大、故障影响面是否需要隔离。比如商品详情页里的价格计算,规则变动频繁且需要独立的灰度节奏,这类适合剥离;而像用户表的基础字段查询,几乎不变又高频,拆出去只会平白增加一次网络往返和一次序列化开销。
还有一个常被忽略的信号是团队边界。服务边界如果和团队边界错位,跨服务的接口协商会变成跨团队的会议,拆分带来的不是效率而是沟通成本。业界的经验是让一个团队端到端负责一到三个服务,从开发、发布到值班全包,这样服务粒度自然会收敛到合理区间。一个团队管十几个服务的结局,通常是没人敢动其中任何一个。
注意:组织架构没准备好之前,不要为了技术先进性先拆。拆完再补组织,中间那段时间的故障率会非常难看。
1.2 容器化与编排的隐性成本清单
容器化带来的直接收益很好讲:环境一致、交付标准化、资源利用率提升。隐性成本很少有人提前列出来。我把实际项目里最常见的几项整理成表,方便你在立项时对照评估。
| 成本项 | 具体表现 | 应对方式 |
|---|---|---|
| 镜像治理成本 | 镜像仓库膨胀、基础镜像版本散乱、漏洞扫描告警堆积 | 统一基础镜像、固定 tag 规范、定期重建 |
| 网络排查成本 | 跨节点通信问题定位链路变长,抓包不再简单 | 提前部署链路追踪,统一日志采集 |
| 存储与状态 | 有状态服务上容器后持久化方案复杂 | 先用托管中间件,状态服务晚一步容器化 |
| 学习曲线 | 编排层概念多,配置项动辄上百个 | 只启用当前需要的对象,禁止一次性全上 |
| 成本可见性 | 资源申请虚高,账单不可归因 | 强制设置 requests,按命名空间分摊成本 |
这里面最容易被低估的是最后一行的资源申请。默认不设 requests 的 Pod 会被调度器当作零需求,节点上可以塞进远超实际承载能力的副本数,一旦流量上来就集体争抢 CPU,表现为全站延迟毛刺。我在一个项目里见过一台 8 核节点跑了七十多个未设 requests 的 Pod,高峰期节点负载冲到 60 以上,谁看监控都以为是应用代码慢。
1.3 演进路线:别一上来就全量重构
比较稳妥的路线是绞杀者模式:新功能直接以新服务形态长出来,老系统通过一层适配入口承接剩余流量,逐步把功能搬走,最后老系统自然萎缩。这条路线的好处是任何时刻都有可回退的路径,坏处是需要维护一段时间的双写或流量分流逻辑。
具体节奏我建议分四步走:先把无状态、依赖少的边缘服务容器化,验证镜像构建流水线和编排基础能力;再把核心无状态服务迁进来,重点打磨探针、资源配额、滚动发布;然后处理有状态服务,优先用托管中间件替代自建;最后才考虑服务网格、多集群这类进阶议题。每一步之间留出至少两个迭代的观察期,别指望一个季度全干完。
2. 微服务拆分的判断标准与落地节奏
2.1 按业务能力切,不按技术分层切
最典型的分错方式是按 controller、service、dao 各起一个服务。这种切法的直接后果是任何一次业务改动都要同时改三个服务、发三次版,事务还跨了三次网络,分布式事务的复杂度全吃满,收益接近零。
正确的切法围绕业务能力和数据归属展开:一个服务应该拥有它管理的数据,数据的写入权只能有一个服务持有,其他服务通过接口读取。这条规则听起来简单,但它能挡掉九成的错误拆分。我评审新服务时只问一句:这个服务独占哪张表?答不上来的,大概率是技术分层的产物。
领域事件是另一种可用的切分线索。订单创建后需要通知库存、积分、消息推送,这三件事彼此独立且失败可重试,天然适合拆成独立的消费者。它们的共性是"被通知",而不是"被调用",走异步消息比同步调用更稳,也顺便解决了扇出调用带来的超时叠加问题。
2.2 服务粒度与调用深度的平衡
粒度并没有统一答案,但有个可量化的观察指标:一次用户请求经过的同步调用链深度。我一般控制在三层以内,超过四层就要警惕。每多一跳,P99 延迟就会叠加一次,故障概率也乘一次,链路上一旦有个慢服务,整条请求全被拖住。
如果发现调用链太深,处理方式通常是合并或并行化。合并是把强关联、总是一起变更的服务合回一个进程,用模块化的方式保持代码边界;并行化是把串行调用改成并发发起,用整体超时兜底。两者都比继续往下拆更划算。
还有一个参考维度是团队认知负荷。一个服务如果接口数超过三十、配置文件超过两百行、新人入职两周还摸不清它干什么,那它要么该拆,要么该重写。反过来,一个只有两个接口、每次发版都要跟着别人一起动的服务,就该考虑合回去。
2.3 通信、超时与重试的默认约定
微服务一旦落地,通信约定就必须统一,否则每个服务各写一套,排查时心态会崩。我的默认约定是这样的:同步调用只用于强一致的查询场景,超时时间统一设成上游可接受预算的一半,重试最多两次且必须带指数退避与抖动,写操作默认不重试。
超时预算的算法值得展开。假设端到端 SLO 是 800 毫秒,网关层预留 100 毫秒,业务层可用 700 毫秒,其中主服务自身计算 200 毫秒,剩下 500 毫秒给下游调用。如果主服务要并行调三个下游,每个下游的超时设 400 毫秒,重试一次就是 800 毫秒,已经超预算,所以这三个调用必须手动设置总超时上限,或者干脆不重试。很多线上雪崩就是重试次数乘以超时时间超过了上游承受能力。
提示:重试策略一定要配熔断或并发限制,否则一个下游抖动会引来三倍流量,把抖动的服务直接打挂,形成正反馈。
2.4 数据一致性的务实处理
跨服务的数据一致性,我的建议是尽量别用强一致的分布式事务,改成状态机加事件驱动的最终一致。下单扣库存这个经典场景,做法是先在本地事务里写入订单和一条扣减事件,再由消息投递到库存服务,库存服务幂等消费。中间任何一步失败,都有补偿或对账兜底。
关键在幂等。消费者必须用业务唯一键去重,比如订单号加事件类型,落地到一张去重表或者用消息本身的幂等语义。别指望消息队列的"精确一次",那是个容易让人放松警惕的说法,实际业务里还是要自己做去重。
对账是最后一道防线,也是被忽略最多的一环。每天跑一次差异比对,把订单状态和库存流水对齐,差出来的单据人工或自动修正。这套机制平时看起来没什么产出,真出事的时候是唯一能兜住的东西。
3. 容器化实操:从能跑到跑得稳
3.1 镜像分层原理与缓存命中率
镜像是一层层叠出来的,每条构建指令生成一层,某一层的内容一旦变化,它之后的所有层缓存全部失效。这个机制决定了 Dockerfile 里指令的顺序比指令本身更影响构建速度。
最常见的浪费是把整个源码目录先 COPY 进去,再执行依赖安装。源码里随便改一个注释,依赖就要重新下载一遍,构建从两分钟变成十分钟。正确顺序是先拷贝依赖声明文件,执行安装,再拷贝源码。依赖文件的改动频率远低于业务代码,缓存命中率一下就上来了。
下面是我常用的一个 Java 服务构建片段,思路对其他语言同样适用。
FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /build # 先只拷贝依赖声明,让依赖层单独缓存 COPY pom.xml . RUN mvn -B dependency:go-offline # 再拷贝源码并打包 COPY src ./src RUN mvn -B clean package -DskipTests FROM eclipse-temurin:17-jre-jammy WORKDIR /app RUN useradd -r -u 1001 appuser COPY --from=builder /build/target/app.jar ./app.jar USER appuser EXPOSE 8080 ENTRYPOINT ["java","-XX:MaxRAMPercentage=75","-jar","/app.jar"]两个细节值得说。一是用了独立阶段构建、只把产物拷进运行镜像,最终镜像从八百多兆降到两百兆左右,拉取时间直接影响扩容速度;二是用了非 root 用户,很多集群的安全策略默认禁止 root 运行,早设好省得后面被拦。
3.2 基础镜像选型:别只看体积
体积最小的镜像不一定最合适。精简发行版用的是另一套 C 库,某些依赖系统本地库的组件会出现找不到符号或者 DNS 解析行为差异的问题,这类问题在构建阶段完全看不出来,上线后才炸。我建议的取舍是:对稳定性要求高的核心服务用成熟的完整版精简镜像,对体积敏感的边缘任务再用极小镜像。
无 shell 的镜像还有个隐性代价是排障困难。容器里进不去、没法看进程、没法测网络连通性,只能靠日志盲猜。折中方案是保持生产镜像精简,排障时用临时调试容器注入到同一个 Pod 的命名空间里,或者平时留一个带工具的调试镜像版本,出问题时临时替换。
注意:无论选哪种基础镜像,都必须固定到具体的版本标签,禁止使用浮动标签。浮动标签意味着同一个提交今天构建和下周构建出来的镜像内容不同,回滚时你根本不知道回到了什么状态。
3.3 资源申请与限制的参数算法
这部分是容器化里最容易拍脑袋的地方,而它直接决定了稳定性和成本。先说结论:requests 按实测 P90 用量设定,limits 按峰值加余量设定,内存的 limits 建议等于 requests,CPU 的 limits 可以放宽甚至不设。
具体算法以 Java 服务为例。假设压测下单实例稳定处理 QPS 200,堆内存稳定在 1.2G,加上元空间、线程栈、直接内存,实际内存占用约 1.8G,那么 requests 设 2Gi 比较合适。limits 如果设成 2Gi,一旦有内存泄漏或者大对象分配就会触发 OOM 被杀,进程退出码是 137;如果设成 4Gi 而 requests 是 2Gi,节点会按 2Gi 调度,实际用了 4Gi 的时候节点内存就超卖了。所以内存这一项,我的实践是 requests 与 limits 相等,宁可给足,也不要留下超卖空间。
CPU 则不同。CPU 是可压缩资源,用超了只会被限流不会被杀。requests 按 P90 设,比如 500m;limits 设成 2 核这种宽松值,让突发流量能吃到节点的空闲算力。但要注意,CPU limits 会带来节流,表现为延迟尾部毛刺,对延迟敏感的服务我倾向于干脆不设 limits,只靠 requests 保障基本份额,同时用命名空间配额防止整体失控。
JVM 在容器里还有个坑:老版本 JDK 认不出容器限制,会按宿主机核数去分配线程池和 GC 线程,结果在 2 核的容器里起了几十个 GC 线程,互相抢时间片。较新的 JDK 已经支持容器感知,但为了保险,我通常还是显式指定最大堆比例参数,让 JVM 自己按容器内存上限换算,避免写死数值在换规格时忘记改。
3.4 探针设计:决定自愈能力的三个开关
探针配错比不配更危险。三种探针的职责必须分清:存活探针回答"这个进程是不是死了",就绪探针回答"它现在能不能接流量",启动探针回答"它启动完了没有"。
最常见的事故是把下游依赖写进存活探针。比如应用的健康检查接口里查了一次数据库,数据库某次短暂抖动,所有实例的存活探针同时失败,编排层认定全部实例已死,开始集体重启,重启期间又连不上数据库,形成一个自我放大的雪崩。存活探针只该检查进程自身的状态,能响应 HTTP 就算活。
就绪探针则相反,它可以检查依赖是否可用,因为它只影响流量分配,不会触发重启。但阈值要给宽一点,比如连续失败五次才摘流量,避免一次网络抖动就把整个实例踢出服务。
启动探针是给启动慢的应用准备的。没有它,只能把首次延迟设得很大,代价是运行期的检测变迟钝。有了它,可以设置最多容忍 300 秒启动,期间存活检测不生效,启动成功后无缝切换到正常的检测节奏。三个参数的关系要算清楚:首次延迟加上周期乘以失败阈值,得到的就是最大容忍故障时间,这个时间必须小于业务能接受的中断时长。
startupProbe: httpGet: { path: /healthz, port: 8080 } failureThreshold: 30 periodSeconds: 10 # 最多容忍 300 秒启动 livenessProbe: httpGet: { path: /livez, port: 8080 } periodSeconds: 10 failureThreshold: 3 # 30 秒内连续失败才重启 readinessProbe: httpGet: { path: /readyz, port: 8080 } periodSeconds: 5 failureThreshold: 5 # 25 秒内摘除流量3.5 优雅退出:被忽略的 502 制造机
滚动更新时出现零星 502,九成是优雅退出没做。流程是这样的:编排层决定下线一个实例,先把它从服务端点里摘掉,同时给容器发终止信号,然后等一段宽限期,宽限期到了还没退就强杀。如果应用收到终止信号立刻关闭进程,正在处理的请求全断,而流量摘除在部分实现里是异步的,就会有一小段时间新请求还打过来,直接 502。
解决办法是两步。应用侧注册终止回调,收到信号后停止接收新请求,等待正在处理的请求完成,再退出。编排侧加一个停机前钩子,先睡几秒让负载均衡完成摘流,再让应用开始关闭。宽限期要大于"摘流延迟加最长请求处理时间",一般设 30 秒够用。
提示:加了停机前钩子之后,总下线时间等于钩子时长加重启时间,滚动更新的整体耗时变长,发布窗口要相应放宽,别让流水线的等待超时先触发。
4. 容器编排:把配置写对,比写多更重要
4.1 滚动更新参数的计算方法
发布策略的核心是保证可用副本数始终达标。两个参数配合使用:一个控制超量创建的上限,一个控制不可用的下限。副本数少的时候,百分比取整的规则容易出意外。
举例:8 个副本,超量比例 25%,按向上取整得到 2,不可用比例 0。更新过程是先新建 2 个新版本实例,等它们就绪,再销毁 2 个旧实例,循环往复。全程可用副本不低于 8,容量不降,代价是峰值多占 25% 资源。
反过来,如果设置不可用比例为 25%,则允许先停 2 个旧实例再补新的,全程资源占用不变,但容量会短暂下降。哪种更好取决于业务:无法接受容量波动的用前者,资源紧张到极致的用后者。
副本数是 2 或 3 的小服务要特别小心。3 个副本、不可用比例 25%,向上取整是 1,可以接受;但如果写的是 20%,向下取整就成了 0,此时两个参数的行为会导致更新卡死,必须靠超量创建来推进。所以小规模服务我建议直接把超量设成 1、不可用设成 0,用绝对值而不是百分比。
还有一个必配项是中断预算。节点维护、集群升级时会主动驱逐 Pod,如果没有中断预算保护,可能一次性把某个服务的所有副本赶到别的节点,期间必然不可用。预算的配置原则是保证至少一半副本或至少一个副本存活,配合滚动更新的参数一起看,才能保证任何主动操作都不破坏可用性。
4.2 服务暴露:从集群内到集群外
内部访问用集群内虚拟地址就够,做负载均衡和基本服务发现。这里要提醒一点:服务发现依赖 DNS,容器内的 DNS 解析超时设置会影响故障恢复速度。如果超时设成默认的 5 秒,下游实例全部不可用时,上游会卡在解析上一次一次重试,表现为看似无关的全面变慢。把解析超时和重试次数调小,能明显加快故障感知。
对外暴露要看流量规模。小规模直接给节点端口就能用,缺点是端口管理混乱、无法按域名路由。正规做法是用入口控制器,它是个独立部署的组件,负责监听外部流量并按规则转发到内部服务。这里有个高频踩坑点:只写了入口规则对象,没部署入口控制器,规则永远不生效,查半天以为是配置写错了。
入口规则本身只描述路由,不负责 TLS 终止以外的流量治理。跨域、限流、重写路径这些能力,要么在入口控制器层用注解配置,要么放到网关层统一做。我的建议是别把业务逻辑塞进入口注解里,不同控制器的注解互不兼容,换实现时全是隐形成本。
4.3 配置与密钥:更新不生效的根因
配置对象挂载进容器有两种方式:环境变量和文件挂载。环境变量的特点是一次性注入,配置改了必须重建 Pod 才生效;文件挂载支持热更新,但应用得自己监听文件变化。
最常见的困惑是"我改了配置,Pod 里还是旧值"。原因就是用了环境变量注入,而工作负载对象本身没变,编排层认为不需要重建。解决办法有两个:一是在工作负载模板里加一个配置的校验和注解,配置一变注解值就变,自然会触发滚动更新;二是接受重建,把配置变更纳入发布流程。
密钥的处理要更谨慎。密钥对象在集群里只是做了编码而不是加密,任何有读取权限的人都能还原。所以权限控制必须收紧,按命名空间限定可读范围,敏感值尽量不在配置里出现,改为启动时从外部配置中心拉取。另外,很多团队把密钥明文提交到代码仓库,这个习惯必须改掉,仓库里只放模板和占位符。
4.4 自动扩缩容的触发条件与陷阱
自动扩缩容看起来是最省事的特性,实际用起来限制不少。它基于资源用量做决策,就意味着必须先设置 requests,没有 requests 的实例永远不会被纳入计算。指标采集组件没装或者没跑起来,扩缩容也不会工作,而且不报错,只是安静地不动作。
算法本身很朴素:期望副本数等于当前副本数乘以当前指标值除以目标值,再向上取整。举个具体数:当前 4 个副本,CPU 平均用量是请求值的 90%,目标是 60%,那么期望值是 4 乘以 90 除以 60 等于 6。这里注意,分母用的是 requests 而不是 limits,所以 requests 设小了会导致频繁扩容。
扩容快、缩容慢是刻意的设计,缩容有冷却窗口,避免流量波动引起副本数反复横跳。这个窗口一般设 5 分钟,太短会震荡,太长会浪费。对于流量有明显波峰波谷的业务,纯靠指标触发往往滞后,更好的做法是定时扩缩容打底、指标触发兜底,把已知的大促时间段提前排进去。
注意:数据库连接池大小必须和最大副本数对齐。扩缩容把实例数从 4 拉到 20,每个实例 20 个连接,瞬时就是 400 个连接,数据库端很可能直接拒绝。
5. 可观测性与故障排查实操
5.1 三件套怎么搭才不白搭
指标、日志、链路这三样东西,价值不在采集全,而在能串起来。我的最低要求是:每个请求有统一的标识,日志里带上它,链路的根 span 带上它,指标异常时能通过标识反查到具体请求。
指标层面,容器维度的资源和应用维度的业务指标要分开看。资源指标回答"是不是机器不够",业务指标回答"用户是不是感知到了"。很多告警只盯资源,CPU 到 90% 就告警,结果天天误报,因为短时高峰是正常的;而真正该告警的是错误率和延迟分位数,这些直接对应业务损失。
延迟指标必须看分位数而不是平均值。平均值 50 毫秒的服务,P99 可能是 2 秒,用户感受到的是后面那一批。我一般同时盯 P50、P95、P99 三条线,P99 和 P50 的差距突然拉开,通常意味着有慢查询、锁竞争或者下游某个实例变慢。
链路的采样率要按流量定。低流量服务全采,高流量服务采百分之一到十分之一,但错误请求要全采,所以需要尾部采样策略。这块配置不复杂,坑在于跨进程的上下文透传,异步线程池、消息队列、定时任务三处最容易丢标识,链路断在中间,排查时就只能靠时间戳猜。
5.2 故障速查表
这张表是我从实际处理过的问题里整理出来的,按现象归类,遇到问题先对照再动手。
| 现象 | 高频原因 | 排查动作 |
|---|---|---|
| 实例反复重启 | 存活探针误判、启动超时、内存超限 | 看上次退出原因的退出码,137 是内存被杀,1 是应用异常 |
| 镜像拉不下来 | 标签写错、仓库凭证过期、网络策略阻断 | 在节点上手动拉一次,区分是凭证问题还是网络问题 |
| 实例一直挂起 | 节点资源不足、亲和性规则冲突、卷挂载失败 | 看调度事件里的失败原因,多半写着具体哪条约束不满足 |
| 间歇性 502 | 优雅退出缺失、就绪探针过早通过 | 对照发布时间和错误时间是否吻合 |
| 配置改了不生效 | 环境变量注入、工作负载未重建 | 进容器打印实际环境变量确认 |
| 集群内解析失败 | 解析超时设置过长、服务无就绪实例 | 在容器里手动解析一次域名,看返回地址列表 |
| 全站延迟毛刺 | CPU 限流、节点超卖、GC 停顿 | 对比限流指标与延迟曲线的时间点是否重合 |
| 扩容不触发 | 未设 requests、指标组件异常 | 直接查扩缩容对象的事件记录 |
5.3 几个必须提前设的防护
第一是命名空间配额。没有配额,一个跑飞的批处理任务能把整个集群的资源吃干净,连监控组件都调度不上。配额按环境分,开发环境给低额度强制大家精简,生产环境留足余量。
第二是网络访问控制。默认情况下集群内所有 Pod 可以互相访问,一个被入侵的边缘服务能直接连到数据库。用网络策略做默认拒绝、按需放通,一开始配会麻烦,但这是唯一低成本的控制面。
第三是镜像准入检查。禁止无标签、禁止来自非白名单仓库、禁止高危漏洞等级以上的镜像进入集群。这三条能挡掉大量低级事故,配置一次长期受益。
第四是发布前的自愈演练。随机杀掉一个实例,看服务是否在预期时间内恢复;故意把一个下游停掉,看熔断是否生效、错误是否被正确降级。演练的价值在于把"应该会自愈"变成"确实自愈过"。
6. 我踩过的坑与实操心得
头一个坑是过早引入服务网格。它解决的是细粒度流量治理和零信任通信,但代价是每个 Pod 多一个代理容器、多一跳网络、多一层配置。我在一个十几个服务的项目里上过,结果排查链路问题的时间比排查业务问题还长,最后撤了,把重试、超时、熔断直接做在客户端库里,简单可控。流量治理的需求真正复杂到需要独立控制面时再上,别为了架构图好看提前背成本。
第二个坑是镜像标签用分支名。分支名会变,同一个标签在不同时间指向不同内容,回滚时回到的根本不是你以为的版本。后来统一改成提交号,配合一份发布记录,回滚就是改一个数字,清清楚楚。
第三个坑是资源配额一刀切。全公司统一都是 1 核 2G,结果 Java 服务天天内存告警,而几个轻量任务闲置一半资源。后来按语言和角色分了几个档位,允许团队在档位内自选,超出档位要写理由,资源浪费明显下降,也没人再因为配额不够去偷偷改大。
第四个坑最隐晦:容器时区。基础镜像默认是世界标准时间,日志时间戳和业务时间差了八小时,排查时死活对不上。修复很简单,构建时设好时区或者挂载时区文件,但这种问题不踩一次很难想到。
最后一个经验是关于指标体系的自建。别一上来就追求全覆盖,先把四个数拉出来:请求量、错误率、延迟分位数、饱和度。这四个数能覆盖绝大多数线上问题的第一判断,等它们稳定了再往上叠业务指标。我见过采集了两百多个指标、告警规则写了三百条,最后没人看告警,全在等用户投诉。指标的价值在于被使用,不在于被采集。
至于后续还能怎么往下走,我的建议是把重心放在两件事上:一是把发布流程里的手动步骤彻底消灭,包括数据库变更、配置刷新、缓存预热,全部纳入流水线;二是把容量规划从经验值变成压测数据,每次大版本发布前跑一次基准压测,用数据决定副本数和资源规格。这两件事做完,云原生的收益才真正落到日常。