很多人第一次把单体应用拆成容器、丢进集群之后,会经历一个相当反直觉的阶段:发布流程变长了,故障排查变难了,半夜被叫起来的次数反而多了。于是团队里开始出现一种声音——"云原生就是给自己找麻烦"。我带过几个从虚拟机迁移过来的项目,几乎每一批都有人这么说过。问题不出在容器和编排本身,而出在迁移方式:绝大多数团队在做的是"搬家",把原来跑在几台物理机或云主机上的进程原封不动塞进镜像,然后指望编排层自动带来弹性和稳定。云原生这套东西的价值,只有在你按它的假设去设计系统时才会兑现——进程随时会被杀掉、实例随时会被调度到别处、网络是短暂不可靠的、状态要外置。这篇内容面向正在做或准备做云原生落地的后端、运维和架构同学,我会把容器化、编排单元设计、流量治理、可观测性这几块的原理和实操细节拆开讲,也会完整复盘一次线上事故的排查链路,包含可以直接抄的配置和踩过的坑。
1. 云原生不是"把应用塞进容器":先厘清它要解决的四类问题
1.1 一个常见误判:容器化之后发布反而更慢了
我见过一个很典型的案例,某公司的订单服务原本部署在三台虚拟机上,靠一台负载均衡轮询。迁移到集群之后,他们把副本数设成 3,镜像用了接近 1.5 GB 的完整基础镜像,每次发布都要经历漫长的拉取、启动、预热。更麻烦的是,应用启动需要 40 秒加载本地缓存,而健康检查配置的初始延迟只有 5 秒,于是每次滚动发布都会经历一轮"新实例没起来就被判死、旧实例已经下线"的循环,用户侧看到的是一分多钟的间歇性 502。
这个案例里没有任何一项技术是错的,容器是对的,编排是对的,滚动更新也是对的,错的是把"能跑起来"当成了迁移完成的标志。云原生对应用有一组隐含契约:启动要快、状态要外置、失败要能被快速感知、退出要优雅。你不满足这些契约,编排层提供的所有能力都会变成负担——它会频繁搬运你的实例,而每一次搬运都是一次小型事故。
所以判断一次云原生改造是否合格,我通常不看用了多少组件,而是看三个数字:单个实例从进程启动到可以接收流量需要多久,一个实例被强制终止时有多少请求受影响,以及一次回滚需要多长时间。这三个数字降不下来,后面上再多的服务网格和链路追踪都是在给一个不健康的地基做装修。
1.2 弹性、韧性、可观测、自动化交付:四个词背后的真实诉求
把云原生的能力拆开,其实就四件事,但每一件落到工程上都有非常具体的含义,不是买几个组件就能自动获得。
弹性的真实诉求不是"能扩容",而是"扩容足够快且代价可控"。这就要求应用是无状态的、镜像体积小、启动路径短。很多团队做压测时发现扩容策略配置得很激进,但新实例起来要两分钟,这两分钟里流量已经打满了。真正有效的弹性,依赖的是镜像分层复用、启动时懒加载、以及合理的预热机制。
韧性的真实诉求是"局部失败不扩散"。这涉及超时、重试、熔断、隔离这一整套东西,编排层只负责把挂掉的实例替换掉,它不会阻止你的 A 服务因为 B 服务超时而把线程池打满。我见过很多团队把韧性完全寄托在编排的自愈能力上,结果一个下游接口变慢,整条链路雪崩,编排层重启了一百次容器也没用。
可观测性的真实诉求是"能在五分钟内定位到是哪一层出了问题"。这要求指标、日志、链路三者能互相跳转,而不是三个互不相干的系统。指标告诉你"出事了",链路告诉你"慢在哪一跳",日志告诉你"那一跳具体报了什么错"。
自动化交付的真实诉求是"发布过程可重复、可回滚、不需要人手动干预"。这背后是镜像不可变、配置与代码分离、发布流程声明化这几条原则。手改一次线上配置,这套体系就破产了。
1.3 什么样的系统暂时不适合云原生
这一点很少有人讲,但对中小团队很重要。云原生是有成本的:你需要有人懂容器网络、懂存储卷、懂调度策略,需要一套监控和日志基础设施,需要把 CI/CD 流程重做。如果下面的条件大部分成立,我建议先别急着迁。
| 系统特征 | 迁移前的建议 |
|---|---|
| 单体应用、日活很低、一年发布两次 | 先做容器化打包,不上编排,用单机运行时就够了 |
| 强依赖本地磁盘、有大量本地文件状态 | 先做状态外置改造,别硬上无状态编排 |
| 团队没有专职运维或平台工程角色 | 优先用托管编排服务,别自建控制面 |
| 应用启动需要几分钟、无法优化 | 先解决启动性能,否则滚动发布永远是事故源 |
| 数据库等有状态服务 | 除非有明确收益,否则先留在外部托管 |
我见过一个只有四个后端的小团队,花了两个月自建集群,最后因为一次 etcd 磁盘写满导致整个控制面不可用,业务停了四个小时。同样的业务量,用托管的容器服务成本可能更低,而且不需要有人半夜处理控制面故障。技术选型的核心不是先进,是匹配团队当前的运维能力和业务节奏。
2. 容器化的实际边界:镜像层、进程模型与启动速度
2.1 容器轻在哪:命名空间、cgroups 与共享内核
很多人对容器的理解停留在"轻量级虚拟机",这个类比害了不少人。容器本质上是被内核隔离的一组普通进程,它没有自己的内核,也没有硬件虚拟化层。真正起作用的是两样东西:命名空间负责"看不见",cgroups 负责"用不多"。
命名空间让进程拥有独立的视图——独立的进程树、独立的网络栈、独立的挂载点、独立的主机名。所以容器里的 1 号进程和宿主机上的 1 号进程完全不是一回事,你在容器里ps看到的只有自己这一窝进程。cgroups 则限制这组进程能用多少 CPU、内存、IO 带宽。这两者组合起来,就形成了一个"看起来像独立机器、实际是共享内核的一组进程"的东西。
理解这一点非常关键,因为后面所有的坑都源于此。第一,容器内的 1 号进程需要负责回收僵尸进程,如果你的应用不处理 SIGCHLD,长时间运行会产生大量僵尸进程,这就是为什么很多镜像要用一个极小的 init 进程做入口。第二,容器看到的/proc/meminfo、CPU 核数默认是宿主机的,Java 应用在早期版本里会因为读到宿主机的大内存而把堆开得过大,直接被 cgroup 限制杀掉。第三,容器共享内核意味着内核参数是全局的,你在容器里改net.core.somaxconn只会影响自己这个命名空间,宿主机层面的限制依然存在。
2.2 基础镜像选型:alpine 与 distroless 的真实取舍
基础镜像的选择是个需要权衡的事,不是越小越好。我把常见的几类放在一起对比。
| 基础镜像类型 | 体积量级 | 优点 | 代价 |
|---|---|---|---|
| 完整发行版(如 debian/ubuntu) | 70-150 MB | 工具齐全,排查方便,兼容性最好 | 体积大、攻击面大、拉取慢 |
| 精简发行版(如 alpine) | 5-10 MB | 体积小、拉取快 | 使用 musl libc,部分依赖 glibc 的二进制跑不起来,DNS 解析行为有差异 |
| 极简运行时(如 distroless) | 2-20 MB | 无包管理器、无 shell,攻击面极小 | 无法进容器排查,调试要靠临时容器或副本 |
| scratch + 静态编译 | 1-10 MB | 极致精简 | 只适用于静态链接的程序,缺少时区、证书等文件需要手动拷 |
我在生产环境的选择通常是:业务镜像用精简发行版或极简运行时,调试镜像单独构建一套,需要排查时用调试副本或者临时容器挂进去。这样既保证了生产镜像的攻击面小,又不会在出故障时抓瞎。
有一个容易被忽略的点:alpine 用的是 musl libc,它的 DNS 解析器在某些场景下行为和 glibc 不一致,特别是并发解析大量域名时可能触发超时。如果你的服务依赖大量的外部域名调用,用 alpine 前务必做一次压测。我踩过一次,某个依赖服务在 alpine 镜像里偶发 5 秒超时,排查了两天才定位到是解析器行为差异。
2.3 一个可直接抄的多阶段构建 Dockerfile
多阶段构建是压缩镜像体积最有效的手段,原理很简单:编译环境和运行环境分开,最终镜像只保留运行所需的产物。下面这个是 Go 服务的写法,Java、Node 同理。
# 阶段一:构建 FROM golang:1.22-alpine AS builder WORKDIR /src # 先只拷依赖描述文件,利用层缓存,代码改动不会触发重新下载依赖 COPY go.mod go.sum ./ RUN go mod download COPY . . # 静态编译,关闭 CGO,保证能在极简镜像里跑 RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w" -o /out/app ./cmd/server # 阶段二:运行 FROM gcr.io/distroless/static-debian12:nonroot # 时区文件,日志时间戳必须正确 COPY --from=builder /usr/share/zoneinfo/Asia/Shanghai /etc/localtime COPY --from=builder /out/app /app USER nonroot:nonroot EXPOSE 8080 ENTRYPOINT ["/app"]这个 Dockerfile 里有几个刻意为之的设计。COPY go.mod go.sum单独一行放在前面,是为了让依赖层在代码改动时命中缓存,构建时间能从几分钟降到几十秒。-trimpath去掉构建路径信息,避免泄露目录结构,也让构建结果可复现。-ldflags "-s -w"去掉符号表和调试信息,体积通常能再少 25% 左右。用nonroot用户运行是硬性要求,很多集群的安全策略会直接拒绝以 root 运行的容器。时区文件必须拷,否则容器里是 UTC 时间,排查问题时日志时间戳和你的本地时间差八小时,非常容易误判。
注意:多阶段构建的缓存是有条件的。如果 CI 环境是每次全新拉起的,需要额外配置缓存挂载或镜像仓库缓存,否则你看到的构建加速只存在于本地。
2.4 容器化时最常被忽略的三个细节
信号处理。容器停止时,运行时会向 1 号进程发 SIGTERM,等一段时间后再发 SIGKILL。如果你的应用没有捕获 SIGTERM,或者用了 shell 脚本作为入口但没有把信号透传下去,进程会直接被 SIGKILL 强杀,正在处理的请求全部中断。用ENTRYPOINT ["/app"]这种 exec 形式而不是ENTRYPOINT /app && ...这种 shell 形式,就是为了让信号能直达进程。
启动探针之外的预热。有些应用进程起来了,但内部连接池还没建立、本地缓存还没加载,这时候它就"看起来健康"。健康检查如果只查端口存活,流量会立刻打进来,然后一批请求超时。解决办法要么是把健康检查做成真实依赖检查,要么在应用里维护一个"就绪状态"标志,等预热完成再置位。
文件描述符与临时目录。容器内默认的 fd 上限通常比宿主机低,高并发服务需要显式调高。另外临时目录如果是可写层,性能很差且占用节点磁盘,写量大的要挂内存卷。这些都不是容器独有的问题,但因为容器环境更"干净",默认值往往比你原来的虚拟机更保守,迁移后会突然暴露。
3. Kubernetes 编排单元设计:Pod、资源配额与探针
3.1 声明式与控制器循环:为什么 Pod 不该被手工修改
编排系统的核心机制其实只有一个:控制器循环。你提交一份期望状态,控制器不断读取实际状态,两者不一致就采取动作让它趋于一致。这个循环每秒都在跑,所以任何手工干预都会被它"纠正"回来。
举个最直观的例子:你手动删掉一个 Pod,控制器发现副本数不够,立刻创建新的;你手动修改一个 Pod 的镜像版本,控制器发现它和模板不一致,会把它替换掉。很多人第一次遇到这个现象会觉得系统"不听话",实际上这正是它的设计意图——唯一可信的输入是那份期望状态的声明,而不是运行中的实体。
这带来两个非常重要的实践结论。第一,所有变更必须改声明,不能改实体。改 Deployment 的模板,让控制器去滚动替换,这是唯一正确的姿势。第二,Pod 本身是一次性的、不可变的,不要去修它、不要去里面装东西、不要依赖它的本地存储。任何"我在这个 Pod 上手动改了配置,先这样跑着"的行为,都会在下一次调度时消失,而且是静默消失,等你发现的时候往往已经过了很久。
我见过最典型的一次事故,是有人在 Pod 里手动调了一个 JVM 参数,跑了两周都很正常,某天节点维护,Pod 被调度到新节点,参数回到默认值,然后大流量下堆内存溢出。事后复盘时所有人都以为是代码问题,因为"这两周一直好好的"。
3.2 requests 与 limits 设错的连锁反应
资源配额是编排里最容易设错、影响也最大的一项配置。先厘清两者的区别:requests 是调度依据,调度器根据它决定把 Pod 放到哪个节点;limits 是运行时上限,超过就采取动作。CPU 超过 limit 会被限流,表现为响应变慢;内存超过 limit 会直接被杀,表现为容器重启。
基于这两个值,系统给 Pod 分了三个服务质量等级。
| 等级 | 条件 | 被驱逐的优先级 | 适用场景 |
|---|---|---|---|
| Guaranteed | 每个容器都设置了 CPU 和内存的 requests 且等于 limits | 最低 | 核心服务、延迟敏感型 |
| Burstable | 设置了 requests 但不等于 limits,或只设了部分 | 中等 | 大部分普通业务服务 |
| BestEffort | 完全没设置任何 requests 和 limits | 最高 | 临时任务、批处理 |
绝大多数生产服务应该落在 Burstable,核心服务可以给 Guaranteed。但这里有个反直觉的点:CPU 的 limits 我通常建议不要设,或者设得比较宽松。原因是 CPU 是可压缩资源,超过 limit 只是被限流,不会死;而把 CPU limit 设得刚好等于 requests,会让服务在流量突增时被硬性限流,延迟曲线直接翘起来。相比之下,内存 limit 必须设,因为内存是不可压缩资源,不设 limit 的容器一旦泄漏会拖垮整个节点。
一个具体的配置示例:
resources: requests: cpu: 500m # 调度时按半个核预留 memory: 1Gi # 调度时按 1G 预留 limits: memory: 1Gi # 内存硬上限,与 requests 相同,避免被过度超卖后 OOM # CPU 不设 limits,允许突发时借用节点空闲算力这个配置的取舍逻辑是:CPU 让它在突发时抢得到,内存给它一个明确的边界。代价是这个 Pod 在节点内存紧张时被驱逐的优先级是中等,所以关键服务我会把内存的 requests 和 limits 都设成相同的值,让它进入 Guaranteed 级别。
3.3 liveness 与 readiness 用反了,等于给事故装放大器
这两个探针的区别,我用一句话概括:liveness 决定"要不要重启你",readiness 决定"要不要给你流量"。搞反了后果非常严重。
如果一个接口依赖下游服务挂掉导致响应慢,你把这种检查配成 liveness,那么所有实例都会因为下游挂掉而被判定为不健康,然后被批量重启。重启之后下游还是挂的,新实例起来依然不健康,再被重启。本来只是一个下游故障,被你放大成了全量服务不可用,而且重启风暴会进一步压垮下游,形成死循环。
正确的用法是这样:
- liveness只检查进程自身是否还能正常工作,比如一个不依赖任何外部组件的内部健康端点。它回答的问题是"这个进程是不是死锁了、是不是僵住了"。
- readiness检查所有依赖是否就绪,包括数据库连接、缓存连接、必需的配置加载。它回答的问题是"现在把流量给它,它能不能正常处理"。
- 启动探针(startupProbe)专门给启动慢的应用用,它通过之前 liveness 和 readiness 都不生效,避免启动阶段被误杀。
还有一个参数经常被忽略:failureThreshold和periodSeconds的乘积决定了判定失败的容忍时间。如果探针每 10 秒查一次,连续 3 次失败才判定不健康,那就是 30 秒的容忍窗口。对于有短暂抖动依赖的服务,这个窗口要留够,否则一个瞬时超时就会触发误判。
3.4 优雅退出:preStop、terminationGracePeriod 与业务代码的配合
容器被删除时,事件顺序是这样的:状态变为 Terminating,从服务端点列表中摘除,执行 preStop 钩子,发送 SIGTERM,等待宽限期,超时则 SIGKILL。这里有个非常隐蔽的时间差问题:摘除端点和通知进程几乎是同时发生的,但负载均衡器和调用方的配置刷新是有延迟的。也就是说,进程收到 SIGTERM 的那一刻,可能还有流量正在往它这里打。
所以标准的做法是让进程在收到停止信号后,先等一小段时间,再开始关闭。这个等待可以用 preStop 钩子实现:
lifecycle: preStop: exec: command: ["/bin/sh", "-c", "sleep 10"] terminationGracePeriodSeconds: 40这 10 秒的作用是给流量摘除留出传播时间。而terminationGracePeriodSeconds要覆盖 preStop 的 10 秒,加上应用处理完存量请求所需的时间,再留一点余量。上面这个配置意味着:10 秒等待 + 30 秒处理存量请求。
应用侧的配合同样重要。收到 SIGTERM 后应该立刻做三件事:停止接受新连接(关闭监听端口)、等待正在处理的请求完成、关闭连接池和资源。很多框架默认不会这么做,需要显式注册信号处理。如果应用不做这件事,那 preStop 的等待只是把问题往后推了 10 秒——10 秒后流量还是会被硬性切断。
4. 服务暴露与流量治理:Service、Ingress 到服务网格的分层
4.1 三种服务暴露方式各自该出现在哪一层
集群内的服务暴露有三层,很多人分不清它们的职责边界,结果把所有东西都堆在入口层,配置乱成一团。
ClusterIP是集群内部的虚拟地址,只在集群网络内可达。它解决的是"服务发现"问题:后端实例会不断变化,但调用方只需要认一个固定的名字。背后的实现是一个虚拟 IP 加上转发规则,以及一组记录着真实端点地址的对象。这一层是绝大多数内部服务间调用的默认选择。
NodePort在每个节点上开一个端口,把流量转发到服务。它的价值主要在于调试和特殊场景,生产环境直接用会有明显问题:端口范围有限、没有健康检查、没有 TLS 终止、暴露的是节点 IP 而不是稳定域名。
Ingress是七层的入口,负责基于域名和路径做路由、TLS 终止、限流等。它需要一个控制器实现(比如基于 Nginx、Envoy 的各种实现),Ingress 资源本身只是一份路由规则声明。生产环境的南北向流量应该走这一层。
分层的原则很清晰:东西向内部调用用 ClusterIP,南北向外部流量用 Ingress,NodePort 只在没有 Ingress 控制器的临时环境里用。我见过把上百条路由规则全配在 Ingress 上的项目,改一条规则要动整个入口配置,风险极高,其实内部服务间调用根本不需要经过入口。
还有一个小坑:Ingress 默认的超时和后端服务的超时经常不匹配。比如入口默认读超时是 60 秒,而你的应用设置的是 30 秒,用户会在第 30 秒收到应用的错误,但连接还挂着,直到第 60 秒才断。这种"半开"状态会持续占用连接资源,高并发下很致命。所有链路上的超时必须从上到下递减,并且显式配置。
4.2 上服务网格之前,先问自己三个问题
服务网格解决的是服务间通信的治理问题:双向 TLS、精细流量路由、熔断重试、调用链埋点。它的能力确实强,但代价也很实在——每个 Pod 多一个边车容器,带来额外的内存和 CPU 开销,多一跳网络延迟,配置复杂度上一个台阶,出问题时排查路径也变长。
我建议上之前先回答三个问题:
第一个问题:我现在的痛点是不是"通信治理"?如果痛点是"故障定位困难",那该上的是可观测性,不是网格。如果痛点是"发布不安全",那该上的是灰度能力,网格只是其中一种实现方式。
第二个问题:我的团队能维护它吗?网格的控制面本身是个需要运维的系统,版本升级、配置校验、证书轮换都需要人。如果团队里没有人愿意深入这个组件,上线之后它就会变成一个黑盒,出问题时没人能定位。
第三个问题:我用得上它多少能力?如果只是想要双向加密,很多场景下用更轻量的方案就够了。如果真的需要按请求头做流量切分、需要精细的故障注入做演练,那网格的价值才能体现。
我参与的一个项目上了网格,实际只用了它的流量切分能力,其他功能全部闲置,但每个 Pod 的成本增加了约 15%,链路延迟增加了 1-2 毫秒。对于延迟敏感的服务,这个代价需要认真评估。后来那个项目把流量切分能力用入口控制器实现了,把网格撤掉了,复杂度大幅下降。
4.3 灰度发布的两种落地路径对比
灰度发布是云原生落地中最有实际价值的能力之一,它把"发布"从一次赌博变成了一次可控的渐进过程。实现路径主要有两种,各有适用场景。
| 维度 | 基于入口层的流量切分 | 基于网格的流量切分 |
|---|---|---|
| 切分粒度 | 按权重、按请求头、按 Cookie | 同上,还能按源服务、按方法、按更细的匹配规则 |
| 东西向支持 | 不支持,只管南北向 | 支持,服务间调用也能切 |
| 部署复杂度 | 低,控制器配置即可 | 高,需要注入边车和管理控制面 |
| 额外延迟 | 无 | 每跳增加少量延迟 |
| 适用场景 | Web 前端、API 网关入口、大部分常规发布 | 复杂微服务链路、需要精细切流的场景 |
我的实践建议是:先在入口层把基于权重的灰度跑通,配合完善的可观测性,把"发布出问题能在两分钟内发现并回滚"这条链路打通。这一步的投入产出比最高,能解决八成以上的发布风险。当服务数量多到内部调用关系复杂、需要按调用来源做差异化发布时,再考虑网格。
灰度发布还有一个常被忽略的配套:新旧版本的兼容性。灰度期间两个版本同时在跑,如果新版本改了数据库结构或者改了接口语义,就会出现新旧不兼容。所以灰度的前置条件是接口和数据的向后兼容,这一点在方案设计阶段就要确认,不能等发布时才发现。
5. 可观测性落地顺序:先定指标口径,再选工具
5.1 从 SLI 出发反推要采什么指标
大多数团队做可观测性的方式是反的:先装一堆采集组件,把能采的全采了,然后发现仪表盘上几百个图表没人看。正确的顺序是先定义"什么叫服务正常",再倒推要采什么。
这个"什么叫正常"就是 SLI——服务水平指标。常见的有四类:可用性(成功请求占比)、延迟(请求耗时分布)、吞吐(每秒请求数)、饱和度(资源使用率)。围绕每个 SLI 定义一个 SLO,比如"过去 30 天内,99.9% 的请求在 300 毫秒内返回成功"。
有了这个定义,要采什么就很清楚了:你需要一个能计算"在 300 毫秒内成功返回的请求数占总请求数比例"的指标。这个指标必须带足够的信息——服务名、接口名、状态分类,但不能带太多——后面会讲基数问题。
我通常建议先建三个仪表盘:服务总览(每个服务的成功率、P99 延迟、QPS)、依赖视图(每个下游依赖的错误率和延迟)、资源视图(每个服务的 CPU、内存、副本数)。三个盘足够覆盖 90% 的日常排查,剩下的按需再做。
5.2 指标基数爆炸:一次标签设计的教训
这是云原生监控里最经典也最致命的坑。时序数据库的存储和查询成本与你产生的"时间序列数量"成正比,而时间序列数量等于指标名乘以所有标签值的组合数。这个乘法非常可怕。
我遇到过的一次真实情况:某个团队在 HTTP 请求指标上加了一个path标签,本意是想看每个接口的延迟。一开始没问题,直到有人把资源 ID 放进了路径里,/api/order/12345、/api/order/12346……每个订单号都变成一条独立的时间序列。两周后监控系统内存打满,写入失败,所有告警失效。而且这个失效是静默的——没人知道监控已经停了,直到真的出事。
避免这类问题的几条硬规则:
- 路径标签必须归一化。把路径中的 ID 部分替换成占位符,
/api/order/{id}而不是真实 ID。这通常在采集端或应用埋点时就处理掉。 - 绝对不要在标签里放用户 ID、请求 ID、时间戳、会话 ID这类高基数值。
- 状态码要按类别聚合,用 2xx/4xx/5xx 而不是每个具体码一条。
- 对时间序列总量做监控,设一个阈值告警,涨到一定程度就介入。
- 高基数信息应该放在日志或链路里,那里是按事件存储的,不会因为维度多而爆炸。
对于延迟指标,用直方图(histogram)比用平均值有意义得多。平均值会掩盖长尾,一个 P99 是 5 秒但平均值 100 毫秒的服务,用户体验是灾难性的。直方图的分桶也要设计,默认分桶往往和你的业务场景不匹配,需要按实际延迟分布调整。
5.3 日志采集:DaemonSet 与 Sidecar 的取舍
日志采集有两种主流模式,选择取决于你的约束条件。
节点级代理模式:每个节点跑一个采集代理,收集该节点上所有容器的标准输出。优点是资源开销小、部署简单、对业务无侵入,一个节点一个代理,采集能力不随 Pod 数量线性增长。缺点是采集器的配置是全局的,无法针对单个业务定制解析规则,而且它读的是节点的日志文件目录,需要知道日志的文件路径约定。
边车模式:每个业务 Pod 配一个采集容器,采集自己的日志。优点是配置隔离、可以针对每个服务定制解析和多行合并规则、采集与业务生命周期绑定。缺点是资源开销随 Pod 数量线性增长,一百个 Pod 就有一百个采集容器。
我的选择通常是:标准输出日志用节点级代理,需要特殊解析的(比如多行堆栈合并、特殊编码)用边车。日志的价值不在于"存了多少",而在于"能不能被检索到"。所以结构化的日志格式比采集方式更重要,输出 JSON 格式、包含时间戳、服务名、追踪 ID、日志级别这几个字段,检索效率会高一个数量级。
提示:日志量是做成本优化的第一优先级。我见过把调试级别日志开到生产环境的项目,日志量占了整个可观测性成本的七成。上线前把日志级别统一调到 info,关掉请求体的完整打印,成本立刻降下来。
5.4 链路追踪的采样率与成本
链路追踪能回答一个其他手段都回答不了的问题:这个慢请求,到底慢在哪一跳。但全量采集的代价很高,一条复杂链路可能涉及十几跳,每跳一个跨度,存储成本和请求量成正比。
采样策略的选择很关键。头部采样是在请求入口就决定采不采,实现简单,但如果按固定比例采,会漏掉很多异常请求。尾部采样是先收集完整链路再决定,可以基于"有没有错误""耗时是否超阈值"来决定保留哪些,对排查更有价值,但需要额外的收集组件。
实际落地的建议:入口层做低比例采样,比如 1%-5%,然后对错误请求和超过阈值的慢请求做强制保留。这样正常流量的存储成本可控,而所有你真正关心的异常链路都能拿到。同时要在网关和应用之间透传追踪 ID,否则链路是断的,断掉的链路几乎没用。
还有一个容易忽略的问题:链路数据必须能和指标、日志关联。指标上发现某个服务 P99 飙高,点进去能看到那段时间的慢链路样本,链路里能看到具体某次调用对应的日志。这个关联靠的是统一的追踪 ID 和服务名标签,在设计阶段就要定好规范,事后补非常麻烦。
6. 一次滚动更新引发的雪崩:完整排查链路复盘
6.1 现象描述与第一反应
某天下午做例行发布,一个核心服务更新镜像版本,副本数 8,滚动更新策略是默认值。发布开始大约一分钟后,监控大盘上该服务的成功率从 99.9% 掉到 82%,P99 延迟从 200 毫秒飙到 8 秒,同时上游服务的错误率也跟着上升。
值班同学的第一反应是"新版本有 bug",直接执行了回滚。回滚之后成功率恢复到 99%,但二十分钟后又出现了一次小幅抖动,说明根因可能不在新版本代码本身。这个判断很关键——如果第一反应就认定是代码问题,剩下的排查方向全都会偏。
这里有一个我在多次事故里总结出的经验:发布期间出现的故障,不要默认是"新代码有问题",先确认是"发布这个动作本身有问题"还是"新代码有问题"。两者的排查方向完全不同,前者查配置和流程,后者查代码和逻辑。
6.2 逐层定位:从 kubectl 到应用日志
第二天的复盘我们把整个链路走了一遍,顺序是从编排层到应用层。
第一步看副本状态和事件:
kubectl get pods -l app=order-service -o wide kubectl describe deployment order-service kubectl get events --sort-by=.lastTimestamp | tail -50事件里看到大量的实例创建和销毁记录,说明滚动更新过程中反复有 Pod 起来又消失。这很可疑,因为正常的滚动更新应该平稳推进。
第二步看滚动更新的配置和实际进度:
kubectl rollout status deployment/order-service kubectl rollout history deployment/order-service发现滚动更新的最大不可用副本数是默认值 25%,8 个副本意味着更新期间允许有 2 个不可用。这个数值本身不算激进,但配合后面的发现就很致命。
第三步看健康检查的配置和实际执行记录:
kubectl describe pod <pod-name> | grep -A 10 -E "Liveness|Readiness|Startup"这里发现了第一个问题:这个服务的 readinessProbe 指向的是一个会检查下游数据库连接和缓存连接的端点,periodSeconds: 5,failureThreshold: 2。也就是说,只要连续 10 秒内两次检查失败,这个实例就被摘流量。而新版本启动时需要预热本地缓存,预热期间下游连接是"未就绪"状态,于是新实例刚起来就被判定为未就绪。
第四步看上一轮容器的退出日志:
kubectl logs <pod-name> --previous这是最关键的一步。日志里看到大量"连接被重置""请求被中断"的记录,时间戳集中在容器被终止的那几秒。结合事件记录,我们看到的现象是:旧实例被通知终止后,几乎立刻就不再处理请求,但上游还在往它这里打流量。
第五步确认优雅退出的配置:
kubectl get deployment order-service -o jsonpath='{.spec.template.spec.terminationGracePeriodSeconds}'返回值是 30。看起来很合理,但实际上这个服务处理一个批量查询请求最长要 25 秒,再加上 preStop 不存在、应用收到 SIGTERM 后立刻关闭监听,等于留给存量请求的时间窗口几乎为零。
6.3 根因分析:探针与优雅退出的时间错配
把上面几条串起来,根因就清楚了,是三个问题叠加。
问题一:readiness 检查过严且周期太短。它把"下游依赖可达"这种随时可能抖动的状态作为就绪判据,且容忍窗口只有 10 秒。下游稍有抖动,一批实例同时被摘流量,可用容量骤降。
问题二:缺少 preStop 等待,且应用收到信号后立刻关闭监听。旧实例在通知终止的瞬间就停止服务,而流量摘除的传播有延迟,这段时间的请求全部失败。
问题三:滚动更新的节奏与业务特征不匹配。这个服务有冷启动预热,新实例起来后需要一段时间才真正可用,而滚动更新按默认节奏推进,新旧交替期间整体可用容量低于预期。
三个问题单独看都不致命,叠加在一起就会形成雪崩:新实例因为 readiness 过严无法及时上线,旧实例因为缺少优雅退出而中断服务,可用容量在更新期间跌到临界点以下,剩余实例被突发流量压垮,导致更多实例健康检查失败,进入恶性循环。
6.4 修复方案与回归验证
修复分三步走,每一步都对应一个根因。
第一步,拆分健康检查语义。readiness 只检查进程自身和必要的本地资源,对下游依赖的检查降级为观测项而不是就绪判据。同时把failureThreshold从 2 提到 3,periodSeconds从 5 提到 10,容忍窗口从 10 秒放宽到 30 秒。
第二步,补上优雅退出的完整链路:
lifecycle: preStop: exec: command: ["/bin/sh", "-c", "sleep 8"] terminationGracePeriodSeconds: 45同时改应用代码,收到 SIGTERM 后执行三段式关闭:先关闭监听端口,再等待进行中的请求完成(最长 30 秒),最后关闭连接池。这个改动是核心,配置只是给它留出时间。
第三步,调整滚动更新节奏。把maxUnavailable设为 0,maxSurge设为 2,也就是先扩容再缩容,更新期间总容量不低于原值。同时给新实例加上启动探针,让 liveness 和 readiness 在启动完成前不生效。
strategy: rollingUpdate: maxUnavailable: 0 maxSurge: 2这套配置的代价是发布期间需要额外的节点容量,所以集群要有一定的资源余量,或者配合节点自动扩容。
验证方式是在预发环境做三轮完整的发布演练,用持续压测工具保持稳定流量,观察发布全程的成功率和 P99。同时故意在新版本启动阶段制造下游抖动,验证 readiness 不会误判。修复上线后,同样的发布流程持续压测三轮,成功率维持在 99.95% 以上,P99 波动在 30 毫秒以内。
这次事故给我最大的教训是:发布流程的健壮性比发布内容的质量更基础。新版本代码可以有问题,那是可以通过测试和灰度挡住的;但如果发布这个动作本身会让系统进入不稳定状态,那你连"回滚"这个兜底手段都可能失效,因为回滚也是一次发布。
7. 中小团队的分阶段落地路线与成本控制
7.1 三个阶段:能跑、能查、能自动
我不建议一上来就追求完整的云原生体系,那会耗尽团队所有的精力还看不到收益。分三个阶段走,每个阶段有明确的验收标准。
| 阶段 | 目标 | 关键动作 | 验收标准 |
|---|---|---|---|
| 一:能跑 | 容器化 + 基础编排 | 镜像规范、资源配额、健康检查、日志采集 | 服务能被自动拉起,故障时能自愈,日志可检索 |
| 二:能查 | 可观测性成体系 | 指标埋点、仪表盘、告警规则、链路追踪 | 故障能在十分钟内定位到具体服务和服务层 |
| 三:能自动 | 交付流程自动化 | CI 流水线、声明式部署、灰度能力、自动回滚 | 发布全程无人干预,异常能自动止损 |
每个阶段之间建议间隔至少一两个月的稳定运行期。跳过第一阶段直接做第二、第三阶段,会发现所有数据都是脏的,因为基础不牢,指标的意义都不明确。
我见过一个团队两个月内把三个阶段全做完了,看起来很高效,结果每个阶段都是半成品:健康检查是随手写的、仪表盘没人维护、流水线里塞了一堆手工步骤。半年后他们重新做了一遍第一阶段。
7.2 资源成本的几个控制点
云原生的成本容易被低估,几个常见的浪费点需要专门盯。
副本数按峰值配置。很多人为了稳妥,每个服务都配 4 个副本常年跑着,而实际负载只有峰值的 20%。正确做法是配置基于 CPU 或自定义指标的自动伸缩,设定最小副本数保证基础可用性,高峰期自动扩。
资源 requests 设置虚高。requests 是调度预留,设得太高会导致节点利用率极低,集群看起来"没资源了",实际上大量被预留但没使用。建议定期对比 requests 和实际使用量,把偏差超过 50% 的服务调整一遍。
镜像仓库存储和拉取流量。不清理的旧镜像会持续占用存储,跨区域拉取会产生流量费用。建议配置镜像保留策略,只保留最近若干个版本。
日志和指标的保留周期。默认配置往往是全量长期保留,这个成本增长很快。按访问频率分层,近期数据高频可查,历史数据降采样或归档。
我通常建议每季度做一次资源审计,把上面这几项过一遍,往往能省下两到三成的成本,而且不需要做任何架构改动。
7.3 规范先行:命名、标签与目录结构
这一条看起来最不起眼,但对长期维护的影响最大。集群里的对象一旦多起来,没有规范的命名和标签体系,你根本无法做批量操作和成本归集。
我建议在第一天就定下这几条:
- 统一的标签体系,至少包含四个维度:所属应用、所属团队、环境、成本中心。这四个标签决定了你能按应用聚合指标、按团队分摊成本、按环境做策略。缺任何一个,后面都要补,而补历史数据的成本极高。
- 命名规范,资源名称包含应用名和环境后缀,不要用
test、temp、new这种词。我见过集群里同时有三个叫service-new的部署,没人知道哪个是哪个。 - 目录结构与部署单元对应,一个应用一个目录,里面放它的所有声明文件,配置按环境分目录。这样任何一个应用的状态都可以从代码库完整还原,这是 GitOps 的基础。
规范这件事的价值在于降低认知成本。新人接手时,看到标签就知道这个服务归谁管、跑在什么环境、成本算在哪个部门,这个效率提升是长期的。相反,一个没有规范的集群,运行一年之后基本没有人敢做大范围变更,因为没人说得清改了会影响什么。
最后分享一个我个人坚持的做法:把所有服务的健康检查、优雅退出、资源配额这三项做成一个可以复用的配置模板,新服务接入时直接从模板继承,而不是每个服务自己写一遍。这三项是事故高发区,统一了之后,大部分"发布引发故障"的问题在源头就被规避掉了。模板本身也要随着事故复盘不断迭代,每次出事之后问一句"这条经验能不能沉淀进模板",能的话就改模板,而不是只改那一个服务。