扩容十台机器,监控显示"实例全部就绪",流量一进来却整片超时。翻时间线才发现:注册中心在实例端口刚好能连上的那一刻就把它当可用节点导入了,而那一刻 JVM 刚加载完类、连接池是空的、热点数据一条没有、业务代码还全在解释执行。
冷启动不是"启动慢",是从进程起来到具备稳态承载能力之间存在一个非稳态窗口。这个窗口的长度,决定了扩容在关键时刻到底有没有用;而它几乎从不进入容量规划——压测、容量核算、SLA 都是拿稳态数字签的字。
1. 把冷启动拆成四段,量级差三个数量级
| 段 | 发生了什么 | 典型量级 | 主要变量 |
|---|---|---|---|
| 进程启动 | 拉镜像、加载可执行文件、运行时初始化 | 毫秒级到十秒级 | 镜像体积、拉取带宽、运行时类型 |
| 框架初始化 | 类路径扫描、Bean 装配、代理生成、配置解析 | 秒级到十秒级 | 类路径规模、反射与代理用量 |
| 连接与缓存预热 | 建连、鉴权、热点数据装载、首次查询 | 百毫秒级到分钟级 | 依赖侧建连成本、热点数据体积 |
| 达到编译稳态 | 解释执行 → C1 → C2,内联与去虚化生效 | 秒级到分钟级 | 流量强度、热点路径是否集中 |
前三段在启动期一次性消耗,第四段不同:它要等流量进来才开始计费,时长还取决于流量强度。低流量下编译稳态可能几十分钟都到不了,"负载一上来就慢"经常是这一段没走完。
| 影响因素 | 影响最大的段 | 规律 |
|---|---|---|
| 代码体积 / 类路径规模 | 框架初始化 | 近似线性,反射与代理多的框架斜率更陡 |
| 依赖数量 | 连接与缓存预热 | 逐个建连,串行等待时近似相乘 |
| 镜像体积 | 进程启动 | 节点本地层是否命中,差距可达一个数量级 |
| 请求路径集中度 | 编译稳态 | 越集中越快,越分散越久到不了稳态 |
// 依赖与业务代码分层,让依赖层跨版本命中节点本地缓存
FROM eclipse-temurin:17-jre AS base
WORKDIR /app
COPY target/dependency/ ./
COPY target/app.jar ./
ENTRYPOINT ["java","-XX:+UseContainerSupport","-XX:MaxRAMPercentage=70","-jar","app.jar"]
分层不缩短启动,缩短的是镜像拉取:业务代码层重传、依赖层不动,节点上已存在的层不重下。分层键写法按构建工具版本核对。
2. "实例启动了"与"实例可服务"是两个状态
健康检查最常见的写法是探端口,而端口能连只证明进程在监听。这两个状态之间的差距,就是冷启动窗口里被误当成可用容量的那部分。
| 检查方式 | 实际判定内容 | 漏掉了什么 |
|---|---|---|
| TCP / 端口探测 | 进程已监听 | 框架未就绪、依赖不可达、预热未完成 |
| HTTP 健康页恒返回 200 | 进程能响应请求 | 依赖、预热、编译状态一律不反映 |
| 依赖自检 + 就绪位 | 依赖可用且预热完成 | 需显式维护就绪标志,标志会过期 |
判据只有一句:就绪检查必须包含"依赖可用"与"预热完成"两个显式条件,而不是"进程活着"。 存活与就绪混用,会把还没准备好的实例反复重启,把冷启动窗口拉得更长。
// 存活失败重启进程,就绪失败只摘流量:两者必须分开
readinessProbe:
httpGet: { path: /readyz, port: 8080 } // 依赖自检 + 预热完成位
initialDelaySeconds: 5
periodSeconds: 3
failureThreshold: 2
livenessProbe:
httpGet: { path: /livez, port: 8080 } // 不查依赖,避免依赖抖动引发重启风暴
initialDelaySeconds: 30
字段名按 Kubernetes 1.2x 核对;initialDelaySeconds 从实测时长分布取值,别拍整数。
3. 扩容与缩容的时序风险
冷启动窗口在规划里不可见,是因为规划看的是"实例数 × 单实例稳态容量"。峰值期的真实表现取决于时序,而不是实例数。
| 时序组合 | 实际发生的事 | 峰值期后果 |
|---|---|---|
| 缩容后立刻扩容 | 新实例冷启动,叠加在缩容后的更小容量上 | 有效容量出现最低点 |
| 冷却窗口过短 | 上一批还没到稳态,又扩一批 | 实例数够了,承载没够 |
| 扩容阈值取稳态指标 | 指标越线才扩,冷启动长度已超过峰值持续时间 | 扩出来时高峰已过 |
| 常驻实例 | 没有冷启动 | 成本是长期资源闲置 |
自动扩缩容盯的是已经发生的指标。冷启动越长,"越线 → 扩容 → 可服务"的滞后越长;当滞后大于峰值持续时间,扩容的产出全落在峰值之后,钱花了、峰值没接住。判断只需一次比对:冷启动 p95 与峰值持续时间放同一条时间轴。
// stabilizationWindowSeconds 只治抖动,治不了冷启动滞后
behavior:
scaleUp:
stabilizationWindowSeconds: 0 // 扩容不等:宁可多扩,别滞后
policies: [{ type: Percent, value: 100, periodSeconds: 30 }]
scaleDown:
stabilizationWindowSeconds: 300 // 缩容要等:避免缩完立刻又被要求扩
缩容冷却窗口要覆盖一次冷启动时长,否则会形成缩容后立刻扩容的循环。
4. 预热的三层,成本与失效条件不同
| 预热层 | 做什么 | 成本落在谁身上 | 失效条件 |
|---|---|---|---|
| 预建连接池 | 启动时建到 minIdle | 依赖侧连接数瞬时上升 | 对端空闲超时回收 |
| 预加载热点数据 | 拉取热点键进缓存 | 预热流量本身打下游 | TTL 到期、数据变更 |
| 预热代码路径 | 用代表性请求打一遍 | 需要真实请求样本 | 样本分布与真实不符 |
最容易做错的是第三层:它必须用接近真实的请求才有意义,而预热脚本通常写最简请求——路径没被覆盖,编译稳态也没建立。第二层有反向风险:预热会把冷数据变热,把真正的热数据挤出缓存。
滚动发布的批次大小直接与冷启动时长耦合:
| 批次策略 | 冷启动暴露量 | 代价 |
|---|---|---|
| 逐台滚动(maxUnavailable=1) | 每批吃一次冷启动,发布时间拉长 | 发布窗口可能不够 |
| 半数批次 | 冷启动集中爆发,承载容量掉一半 | 需要约 2 倍常备冗余 |
| maxSurge 预留冗余 | 新实例起好再停旧,容量不损失 | 峰值期需要额外资源配额 |
| 停机发布 | 全量冷启动 | 服务中断,错误率峰值最大 |
// 先起新、再停旧:用并发换容量,代价是峰值期资源
strategy:
rollingUpdate:
maxSurge: 1 // 至少 1 台新实例进入冷启动
maxUnavailable: 0 // 不见底:全程维持旧实例的完整承载
type: RollingUpdate
maxSurge 与 maxUnavailable 的取舍本质:发布期愿意多付多少资源,换冷启动窗口内不掉容量。
5. 镜像拉取、测量口径与度量
镜像拉取与冷启动耦合得很隐蔽:节点本地没有那几层时,拉取时长直接加在进程启动之前,而启动日志里看不到任何痕迹。
| 测量口径 | 得到的数 | 偏差来源 |
|---|---|---|
| 进程启动到端口可连 | 四段里最短的一段 | 漏掉框架与预热 |
| 进程启动到就绪通过 | 覆盖前三段 | 漏掉编译稳态 |
| 扩容动作到承载稳态份额 | 最接近业务口径 | 需与滚动策略一起读 |
| 首次请求耗时 | 变量最多的一个数 | 含网络、依赖抖动、首次查询 |
首次请求的耗时混了太多别的东西,不能当冷启动时长;可用做法是记录两个事件的差值。
// 记录"扩容事件"到"就绪通过"的时间差,而不是看首请求耗时
kubectl get events --field-selector reason=SuccessfulCreate -o wide
kubectl logs deploy/app --timestamps | grep -E 'readyz (ok|fail)'
// 镜像拉取是否在关键路径上:看节点上镜像层是否命中
crictl images | grep APP_IMAGE
度量要留四个数,缺一个就会判断错位:
| 指标 | 口径 | 用途 |
|---|---|---|
| 冷启动时长分布 | p50 / p95 / p99,按镜像版本分组 | 判断预算是否被破 |
| 可服务延迟 | 扩容动作 → 就绪通过 | 扩容是否真的产出了容量 |
| 扩容到承载滞后 | 扩容动作 → 实例 QPS 达稳态份额 | 响应曲线是否已晚于峰值 |
| 冷启动期错误率 | 就绪通过之前的 5xx 与超时占比 | 最直接的业务口径 |
6. 排错:五种"扩了也没用"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 实例数达标但错误率不降 | 就绪检查只探端口,未就绪实例已在接流量 | 加依赖自检与预热完成位 |
| 扩容后短暂好转又恶化 | 新实例进入冷启动,同时旧实例被缩掉 | 核对缩容冷却窗口是否短于一次冷启动 |
| 峰值结束才看到实例数上升 | 阈值取稳态指标,滞后大于峰值时长 | 提前扩容或用常驻实例兜住 |
| 同镜像在不同节点启动差数倍 | 节点本地镜像层未命中 | 预拉镜像或加大镜像分层粒度 |
| 启动完成但前几分钟 QPS 只有零头 | 编译稳态未达成,热点路径尚未被编译 | 预热代码路径,或按低容量预留算容量 |
最后一条值得单独说:QPS 只有零头时 CPU 往往也不高,按 CPU 做扩容指标的系统会认为"资源充裕",完全不会扩。
7. 边界:什么情况冷启动预算只做记录
| 场景 | 为什么可以不做优化 | 建议做法 |
|---|---|---|
| 无状态极简服务 | 无缓存预热、依赖极少,四段塌缩成一段 | 只记录时长分布,不设预算 |
| 流量平稳且有常驻冗余 | 冷启动只发生在滚动发布,不在峰值期 | 保证 maxSurge 冗余即可 |
| 运行时本身启动极快 | 进程与初始化合起来可忽略 | 关注镜像拉取,不关注运行时 |
| 离线批处理任务 | 启动时长相对任务时长可忽略 | 计入任务总时长即可 |
反过来,只要同时存在"按流量扩缩容"与"峰值持续时间和冷启动同量级"两个条件,冷启动预算就必须进容量规划:此时它不是启动优化问题,而是峰值期能不能扛住的问题。
8. 自测
- 冷启动四段里,哪一段不在启动期消耗、要等流量进来才开始计费?
- 就绪检查只探端口,会在冷启动窗口里造成什么后果?
- 缩容冷却窗口该按什么设定,不设会引发哪种循环?
- 预热的三层里,哪一层的成本落在下游依赖身上,失效条件是什么?
- 为什么首次请求耗时不能当作冷启动时长使用?
- 什么条件下,冷启动预算可以只做记录不做优化?