云原生给开发者立的隐性规矩
一个服务上了 Kubernetes,不代表它是”云原生的”。Kubernetes 会尽力调度、重启、扩容你的服务,但它对你的代码有一份隐性的期望清单——不写进文档,但违反了就会出问题。
这份清单里的大多数条目,开发者在本地开发时不会遇到,只有在生产环境里才会被打脸。
SIGTERM 契约:优雅退出不是可选项
Kubernetes 停止一个 Pod 时,会先发 SIGTERM 信号,给进程一段时间(默认 30 秒)处理完当前请求、关闭连接、清理资源,然后再发 SIGKILL 强制终止。
大部分框架默认不处理 SIGTERM。进程收到信号,直接退出,正在处理中的请求全部中断。用户看到 502 或者请求超时,日志里没有任何异常——因为进程是”正常”退出的。
正确的做法是显式注册 SIGTERM 处理器:
1 | |
还有一个容易被忽略的细节:如果你的进程是在 shell 脚本里启动的(CMD ["/bin/sh", "-c", "python app.py"]),SIGTERM 会发给 shell 进程,而不是 Python 进程。解决方法是直接 exec 目标进程,或者在 Dockerfile 里用 exec 格式:
1 | |
这个问题在本地开发时完全不可见,因为你用 Ctrl+C 发的是 SIGINT,而且没有滚动更新。
健康检查:liveness 和 readiness 不是一回事
Kubernetes 的探针有两种,语义完全不同。
Liveness probe:判断进程是否需要被重启。失败了,Kubernetes 会杀掉 Pod 重新拉起。
Readiness probe:判断进程是否准备好接收流量。失败了,Kubernetes 会把 Pod 从 Service 的 Endpoint 列表里摘掉,但不会杀掉它。
一个常见的错误配置是把同一个 /health 接口同时挂给 liveness 和 readiness,接口里做数据库连通性检查。结果是:数据库抖动 30 秒,所有 Pod 的 liveness 探针失败,Kubernetes 把所有 Pod 都重启了——本来只是个外部依赖的临时故障,变成了自己把自己打死的雪崩。
正确的设计:
Liveness:只检查进程自身是否存活,不依赖外部服务。进程没有死锁、没有内存 OOM、主线程还在跑,就返回 200。
Readiness:检查服务是否能正确响应请求,包括数据库连通性、缓存预热完成状态、依赖服务可达性。这个探针失败,流量会被分流走,但进程不会被杀。
两个接口,两套逻辑,语义不同,不要复用。
无状态的真实含义
无状态不只是”不存 session”。它是说:你的服务的任意一个实例,在任意时刻,处理同一个请求的结果是相同的。
违反这个假设的常见场景:
本地缓存。进程内缓存了一些数据(用户配置、特征向量、AB 实验分组),不同实例的缓存状态不一致,相同请求打到不同 Pod 行为不同。如果你必须用本地缓存,需要接受这种不一致性,或者用版本号 + 缓存失效来控制。
临时文件。把中间结果写到本地磁盘,假设下一步操作还在同一个节点上。Pod 重启后本地文件消失,流程断掉。临时数据应该写到对象存储或共享卷,不要依赖本地文件系统的持久性。
连接状态。WebSocket 或者长连接把客户端状态绑在了某个 Pod 上。这个 Pod 被驱逐,客户端状态丢失。有状态的连接需要在架构层显式处理——要么用粘性会话(sticky session),要么把状态外置到 Redis,要么重新考虑协议设计。
资源 Limits 不是运维填的数字
Resource requests 和 limits 决定了 Kubernetes 如何调度你的 Pod,也决定了你的进程在资源竞争时的行为。
requests 是调度依据:Kubernetes 保证 Pod 被调度到有足够资源的节点上。
limits 是上限约束:CPU 超出 limit 会被限速(throttling),内存超出 limit 进程会被 OOM Kill。
两个常见的问题:
不设 limits,设了也不准确。不设 limits 的 Pod 可以无限消耗资源,在资源竞争时挤占其他 Pod。设了不准确的值(比如给 Java 服务设了 512Mi 内存,但 JVM 启动就要 700Mi),Pod 会在启动阶段就被 OOM Kill,循环重启,CrashLoopBackOff。
CPU throttling 的隐性影响。CPU limit 的实现机制是 Linux cgroup 的 CPU quota,每 100ms 周期里只允许用一定量的 CPU 时间。如果你的服务有短暂的 CPU 密集操作(JVM GC、加密计算、JSON 序列化),即使平均 CPU 使用率很低,也可能在某个 100ms 窗口内被限速,造成尾延迟升高。Go 的 GOMAXPROCS 如果设置为宿主机 CPU 数量而不是容器 CPU limit,也会引入类似的问题——可以用 automaxprocs 库自动修正。
资源配置需要基于实测数据,不是拍脑袋填。压测 + 性能 profiling 的结果,才是 requests/limits 的依据。
配置与代码的边界
12-Factor App 的第三条:配置存在环境变量里,不存在代码里。这不只是一个最佳实践,在 Kubernetes 里它是一个操作约束。
ConfigMap 和 Secret 通过环境变量或挂载卷注入到 Pod 里。如果你的配置硬编码在代码里,或者打进了镜像,想修改就必须重新构建镜像——每一次配置变更都变成了一次发布流程。
更实际的问题是多环境部署。同一套代码要在 dev / staging / prod 三个环境跑,区别只在数据库地址、服务发现配置、日志级别。如果这些配置不通过环境变量注入,你要么维护多套镜像,要么在代码里写环境判断逻辑,两种都是麻烦。
日志:写给机器看,不只是给人看
在 Kubernetes 里,stdout 和 stderr 的输出会被节点上的日志收集器抓走,发到日志聚合系统(Elasticsearch、Loki、CloudWatch)。这个路径是标准化的,代价是:日志格式如果不是机器可解析的,在聚合系统里就是废纸。
非结构化日志的问题在查问题时最明显。你有 10 个 Pod、几百万条日志,想找某个用户的请求链路,只能 grep,效率极低,还不支持过滤和聚合。
结构化日志(JSON 格式,每条日志是一个 key-value 对象)可以在聚合系统里直接查询:
1 | |
输出:
1 | |
在 Loki 里可以直接 {app="my-service"} | json | user_id="u123" 查询,不需要 grep。
小结
这份清单不是 Kubernetes 的问题,是分布式系统对应用代码的基本要求——Kubernetes 只是把这些要求显化了。
传统部署里,一台虚拟机可能跑几个月不重启,服务启动缓慢、配置写死、状态存本地,这些问题被稳定的基础设施掩盖了。Kubernetes 把节点当成可随时替换的资源,这些隐藏问题就会浮出水面。
上云不改代码,是把问题从本地搬到了生产环境。