前四篇我们从下往上打通了地基:容器原理、K8s 核心对象、调度与弹性,再到用 Jenkins CI/CD 流水线把镜像自动构建并发布上去。这最后一篇不再讲概念,而是把它们全部落到一件事上:把一个真实的广告竞价服务,生产级地送上云、并调优到能扛住尖峰、发布不掉单、故障能自愈。
竞价服务是广告链路里最苛刻的一环——延迟以毫秒计(超时就 no-bid,直接丢钱)、流量尖峰剧烈、发布频繁、还不能中断在途竞价。这些约束叠在一起,把 K8s 部署里每一个”能跑就行”的默认配置都变成了坑。这一篇就把这些坑逐个填平。
本文是容器化与 K8s 部署系列的第 5 篇(收官 · 实战)。 全系列 5 篇:
一句话定位:把竞价服务送上云的核心,是把”无状态、可随时被杀重建”这件事做到位——JVM 感知 cgroup 别 OOM、发布走金丝雀别一把梭、停机先摘流量再退出别切竞价、跨 AZ 反亲和 + PDB 别被一次维护打没、HPA + spot 兼顾扛量与省钱。
TL;DR
- JVM in container 必做 cgroup 感知:老 JVM 不认 cgroup limit,按宿主机物理内存规划堆 → 堆开太大 → 撞 cgroup
memory.max被 OOMKilled。用**-XX:MaxRAMPercentage**(按 limit 的百分比定堆,而非固定-Xmx),JDK 需 cgroup-aware(JDK 11+,容器里默认开启UseContainerSupport)。 - 内存要留非堆 headroom:limit 必须覆盖 堆 + 非堆(Metaspace/线程栈/直接内存/JIT/GC 结构)+ 波动 buffer;
MaxRAMPercentage常设 50~75%,把剩下的留给非堆(呼应第 3 篇 的 OOM 风险)。 - CPU limit 与 GC/线程数联动:容器 CPU 影响 JVM 的
availableProcessors()(GC 线程、fork-join 池、连接池默认大小);CPU 卡太死会 throttle 拉高 p99,要么放宽 CPU limit,要么显式设 GC/线程参数。 - 发布走金丝雀,不一把梭:先放一小批 v2(1~5%),看核心指标(竞价成功率、p99、错误率、收入)稳了再逐步放量;用 Deployment 滚动更新的
maxSurge/maxUnavailable控制节奏,或用 Ingress/Service Mesh 按权重分流(呼应 发布与变更安全)。 - 优雅停机三件套:
readinessProbe(发布/缩容时先让新流量停止路由)+**preStophook**(sleep 等 Endpoints 摘除传播)+ 应用处理 SIGTERM 做 drain(停收新请求、处理完在途竞价、关连接池),全部在terminationGracePeriodSeconds内完成。 - 别在摘流量前就退出:Endpoints 摘除是异步传播的,Pod 收到 SIGTERM 时可能 kube-proxy 还在往它转流量——所以要用 preStop 兜一个”缓冲期”,否则在途竞价被切断 → no-bid。
- 优雅停机的镜像是抗恢复风暴:节点轮换、AZ 恢复、spot 批量回收后,几十上百个 Pod 同时冷启动会把缓存 miss、连接重建、重试波同步叠加,把下游(画像 / 预算 Redis / 配置中心)打成”回源风暴”引发二次雪崩——要靠分批放量 ready + 启动预热 + 回源合并 + 退避抖动让恢复细水长流,而不是一拥而上。
- 跨 AZ 反亲和 + PDB:
topologySpreadConstraints把副本摊到多可用区、podAntiAffinity避免同节点堆叠、PodDisruptionBudget 保证自愿驱逐时存活副本不低于阈值——防”一次节点维护把竞价打没”。 - 成本与弹性:按需节点池扛基线容量、spot/竞价实例池接 HPA 的弹性溢出(便宜但可能被回收,配合 PDB 和多 AZ 降风险)。
- AdTech 血泪:一次发布没配 readiness(或 readiness 太宽松),滚动更新时新 Pod 一 Running 就被加进 Endpoints 开始接竞价,但 JVM 还没预热、下游连接池还没建好 → 发布瞬间大量 no-bid 和超时,收入曲线砸出一个坑。
Table of contents
Open Table of contents
1. 上云部署的总拓扑
先看竞价服务上云后的整体拓扑,后面每一节都是在给这张图的某个部件做调优。
竞价服务上云部署拓扑图。顶部入口链:上游竞价流量(ADX/SSP)→ Ingress/LB(L7 + 金丝雀分流)→ bid-service Service(ClusterIP + Endpoints)。右上角两条绿色标注:HPA 按 QPS 扩缩副本、Cluster Autoscaler 加/减节点。下方三个并排的可用区面板 AZ-a、AZ-b、AZ-c(topologySpread 均摊),每个可用区内含:按需节点池 on-demand(基线容量)上跑两个 bid Pod(1c/2Gi),以及竞价实例池 spot(弹性溢出)上在高峰时由 HPA 扩出的 bid Pod。Service 用箭头扇出到三个可用区。底部说明三点:topologySpreadConstraints + podAntiAffinity 把副本均摊到 3 个可用区且同 AZ 内不挤同一节点,任一 AZ 挂掉仍有 2/3 容量;PodDisruptionBudget 保证自愿驱逐时存活副本不低于阈值;成本上按需节点池扛基线、spot 节点池接 HPA 弹性溢出,兼顾稳定与成本。
竞价服务上云拓扑:入口经 Ingress/Service 分流,副本跨 3 个可用区反亲和均摊,HPA + Cluster Autoscaler 做弹性,PDB 兜底可用性,spot 节点池接高峰溢出降成本。
这张图把前几篇的概念全串起来了:Service/Ingress 管流量入口、requests/limits/QoS 管每个 Pod 的资源、topologySpread/反亲和 管分布、HPA/CA 管弹性。下面逐块调优。
2. JVM in container:别让 Java 在容器里 OOM
广告竞价服务多是 Java。Java 上云最经典、最普遍的坑,就是 JVM 和容器 cgroup 的内存/CPU 认知不一致——上一篇的 OOMKilled 雪崩,根子就在这。
2.1 问题:JVM 不认 cgroup limit
早期 JVM(JDK 8u131 之前)在容器里,Runtime.maxMemory()、默认堆大小、availableProcessors() 全都读的是宿主机的物理配置,而不是容器的 cgroup limit。后果是:
- 一台 128GB 物理内存的宿主机上,容器 limit 只给了 2GB,但 JVM 不设
-Xmx时会按”物理内存的 1/4”= 32GB 去规划默认堆——堆还没用起来,光是 JVM 觉得”我能用 32G”就已经和 2G 的 cgroup limit 严重冲突,稍微一涨就 OOMKilled。 availableProcessors()返回宿主机核数(比如 64),于是 GC 线程数、fork-join 池、各种连接池按 64 核规划,实际容器只给了 1 核 → 大量线程挤在 1 核上,上下文切换爆炸。
2.2 解法:cgroup 感知 + 按百分比定堆
JDK 10+(8u191+ 也 backport 了)默认开启 UseContainerSupport,JVM 会正确读取 cgroup 的内存/CPU limit。在此基础上,堆大小别再用固定的 -Xmx,改用按 limit 百分比:
# 推荐:按容器内存 limit 的百分比定堆,随 limit 变化自动适配
-XX:InitialRAMPercentage=50
-XX:MaxRAMPercentage=70 # 堆最多用 limit 的 70%,留 30% 给非堆
为什么是百分比而不是 -Xmx1400m:如果写死 -Xmx,改了 Pod 的 memory limit 却忘了同步改 JVM 参数,就又对不上了。MaxRAMPercentage 让堆跟着 limit 走,改一处即可,容器化下更稳。
2.3 关键:给非堆留足空间
这是最容易被忽略、也是最致命的一点:JVM 的内存 ≠ 堆。一个 Java 进程的总内存 = 堆(Heap)+ 非堆,非堆包括:
- Metaspace(类元数据,几十~几百 MB)
- 线程栈(每线程默认 ~1MB,几百个线程就是几百 MB)
- 直接内存 / 堆外(Netty、NIO、
DirectByteBuffer——广告服务大量网络 IO,这部分可能很大) - JIT 代码缓存、GC 自身的数据结构、JNI
所以 cgroup 的 memory.max(Pod limit)必须覆盖”堆 + 全部非堆 + 波动 buffer”。如果 MaxRAMPercentage=100(堆吃满 limit),非堆一分配就立刻越界 OOMKilled。经验值:**MaxRAMPercentage 设 50~75%**,把剩下的留给非堆,且通过监控(container_memory_working_set_bytes vs limit)验证水位。
2.4 CPU limit 与 GC/线程
CPU 侧同理:容器 CPU limit 影响 availableProcessors(),进而影响 GC 线程数、编译线程、各种默认线程池大小。两个注意点:
- CPU limit 太小会 throttle:竞价是低延迟服务,被 CPU throttle 会直接拉高 p99(第 3 篇 讲过 CPU 是软刹车)。核心服务可以不设 CPU limit(只设 requests 保底)或设得宽松,避免 throttle 卡尾延迟。
- 显式控制线程数:必要时用
-XX:ActiveProcessorCount或应用层参数显式设 GC/业务线程数,别放任它按核数瞎猜。GC 选型(G1/ZGC)与容器内表现见 JVM GC 深挖。
3. 滚动更新与金丝雀发布
竞价服务发布频繁,发布策略直接关系到收入曲线上会不会砸坑。
3.1 滚动更新的节奏控制
Deployment 默认就是滚动更新,靠两个参数控制节奏:
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25% # 更新时最多超出期望副本数多少(临时多起)
maxUnavailable: 0 # 更新时最多有多少副本不可用——竞价服务设 0!
**maxUnavailable: 0**:竞价服务对容量敏感,发布期间不允许可用副本掉下去——先把新 Pod 拉起来、ready 了,再缩旧的。maxSurge给临时超配空间。- 配合 readiness 探针:新 Pod 必须真正 ready(JVM 预热完、连接池建好) 才被算作”可用”、才接流量、才允许缩旧 Pod。这是 §8 事故的核心——readiness 没配好,“ready”来得太早,滚动更新就在把流量往没准备好的 Pod 上倒。
3.2 金丝雀:先放一小批看指标
滚动更新是”无差别逐步替换”,但对高风险变更(改竞价算法、换依赖版本),更稳的是金丝雀(canary)——先只放一小撮 v2,用真实流量验证核心指标,确认没问题再全量:
- 部署一个小副本数的 v2(或用单独的 canary Deployment),通过 Ingress/Mesh 按权重 只切 1~5% 流量过去;
- 紧盯业务指标:竞价成功率、bid p99 延迟、错误率、收入 / eCPM(竞价服务尤其要看钱的指标,技术指标正常不代表出价逻辑没退化);
- 稳定后逐步放大权重 10% → 50% → 100%;异常立即把权重打回 0(秒级回滚,比重建 Pod 快)。
金丝雀 + 可回滚 + 指标门禁,是把”发布”从赌博变成可控实验的关键(体系化的灰度/回滚/变更编排见 发布与变更安全)。
4. 优雅停机:发布/缩容时不切断竞价
这是竞价服务上云最反直觉、也最容易出事的一环。滚动更新、缩容、节点维护都会杀 Pod——如果杀得太粗暴,正在处理的在途竞价请求会被硬切断 → no-bid / 报错。优雅停机要保证:先让新流量不再进来,再等在途请求处理完,最后才退出。
优雅停机与流量摘除时序图。四个参与者纵向泳道:控制面(rollout)、Endpoints/kube-proxy、应用容器(bid Pod)、上游竞价流量。时序步骤:1. 控制面删除 Pod 并置为 Terminating;注释:以下两件事并行发生;2. 控制面从 Endpoints 摘除该 Pod(停止路由新流量);3. 控制面执行 preStop(sleep 5~10s)随后发 SIGTERM;4. 上游新竞价请求已不再路由到本 Pod(走其他副本,虚线);5. 应用容器自身动作:preStop 期间等摘除生效,仍处理已建连接的在途竞价;6. 应用容器收到 SIGTERM:停收新请求、drain 在途竞价、关连接池;绿色注释:在 terminationGracePeriodSeconds 内优雅退出,超时才被 SIGKILL 强杀;红色注释(反例):无 preStop / 未等摘除 → SIGTERM 立即杀 → 在途竞价被切断 → 大量 no-bid / 报错。
优雅停机时序:Pod 被删除后,K8s 一边从 Endpoints 摘除(停新流量)、一边执行 preStop + 发 SIGTERM。preStop 缓冲等摘除生效,应用收到 SIGTERM 后 drain 在途竞价,在宽限期内退出。没 preStop 就会切断在途竞价。
4.1 Pod 终止的完整流程
当一个 Pod 被删除(发布/缩容/驱逐触发),K8s 会并行做两件事:
- 从 Endpoints 摘除:把这个 Pod 从 Service 的 Endpoints 里拿掉,kube-proxy 更新转发规则,新流量不再路由到它。
- 发终止信号:先执行容器的
**preStophook**(如果配了),然后给容器主进程发 SIGTERM;等terminationGracePeriodSeconds(默认 30s)后若还没退出,发 SIGKILL 强杀。
4.2 关键陷阱:Endpoints 摘除是异步的
这是最致命的时序问题:上面两件事并行,但”从 Endpoints 摘除”要经过 apiserver → Endpoints 控制器 → 各节点 kube-proxy 更新规则,是异步、有延迟的。而 SIGTERM 可能几乎立刻就发到了容器。结果是:
Pod 收到 SIGTERM 开始关闭时,kube-proxy 可能还在往它转发新的竞价请求——如果应用一收到 SIGTERM 就立即退出,这些新流量和在途请求就被硬切断。
4.3 解法:preStop + SIGTERM drain
用两个机制兜住这个时序缺口:
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 8"] # 缓冲:等 Endpoints 摘除传播到位
terminationGracePeriodSeconds: 45 # 宽限期 > preStop + drain 时间
**preStophook 睡几秒**:给”从 Endpoints 摘除”传播争取时间。preStop 执行期间容器还活着、还能处理已建连接上的在途请求,但新流量已经在这几秒里被逐渐切走。preStop 跑完才发 SIGTERM。- 应用正确处理 SIGTERM(drain):收到 SIGTERM 后,应用应当:① 停止接受新请求(关闭监听/拒绝新连接);② 处理完在途的竞价请求(in-flight drain);③ 关闭连接池、注销、flush 指标;④ 干净退出。
**terminationGracePeriodSeconds要够长**:必须 >preStop 时间 + 最长在途请求处理时间,否则还没 drain 完就被 SIGKILL 强杀。
这正是网关那次事故(发布未先优雅摘流,注册列表短暂脏、打到”死人”)在 K8s 语境下的对应版本。竞价服务尤其敏感——一次不优雅的停机就是一批 no-bid。发布/缩容不掉单,靠的就是”先摘流量、后 drain、再退出”这套时序。
5. 高可用:跨 AZ 反亲和与 PDB
竞价服务不能因为”一台机器挂了""一个可用区抖了""一次节点维护”就大面积掉容量。三个机制协作保住可用性:
5.1 跨可用区分散 + 反亲和
**topologySpreadConstraints:把副本均摊到多个可用区(zone)**,maxSkew控制各 zone 副本数差异。任意一个 AZ 整个挂掉,还有 2/3 容量在(第 3 篇 讲过)。**podAntiAffinity:同一服务的副本别挤在同一个节点**——否则那台节点一挂,一下子丢好几个副本。
5.2 PodDisruptionBudget:给自愿驱逐设下限
节点缩容、滚动升级、排空(drain)节点等自愿驱逐(voluntary disruption),如果不加约束,可能一次性赶走太多副本。PodDisruptionBudget(PDB) 给这类操作设一个”存活下限”:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: bid-service-pdb
spec:
minAvailable: 80% # 自愿驱逐时,至少保持 80% 副本可用
selector:
matchLabels: { app: bid-service }
有了 PDB,kubectl drain 一个节点做维护时,如果驱逐会导致可用副本低于 minAvailable,K8s 会阻塞驱逐、等新副本在别处起好再继续——杜绝”一次节点维护把竞价打没大半”。注意 PDB 只管自愿驱逐,节点硬件突然宕机这类非自愿中断它管不了(那靠多 AZ + 反亲和 + 快速重调度)。
6. 成本与弹性:HPA + spot 节点池
竞价流量有明显波峰波谷 + 大促尖峰。既要扛住峰值,又不想低谷养一堆闲置机器烧钱——靠弹性 + 混合节点池:
- HPA 按业务指标扩缩:竞价服务按 QPS 或竞价请求队列 扩容比按 CPU 更准(第 3 篇),高峰自动加副本、低谷缩回。
- Cluster Autoscaler 加减节点:HPA 扩出的 Pod 没节点放(Pending)时,CA 自动加节点;节点空闲则缩(缩容会触发优雅停机 + PDB,回到 §4/§5)。
- 混合节点池:
- 按需节点池(on-demand) 承载基线容量——稳定、不会被回收,跑必须常在的副本。
- spot / 竞价实例节点池 接 HPA 的弹性溢出——价格常低 50%~90%,但云厂商可能随时回收。所以 spot 上只放”可容忍中断”的溢出副本,配合多 AZ + PDB + 优雅停机降低回收冲击(spot 被回收也是一次驱逐,走的还是 §4 那套 drain)。
这样低谷只留少量按需节点、高峰用便宜的 spot 顶量,在保证稳定性的同时把成本压下来。更宏观的多机房/多区域容量规划见 数据中心设计。
7. 恢复风暴:批量重建时的二次雪崩
优雅停机(§4)解决的是”怎么把 Pod 干净地关掉”,但它的镜像问题——怎么把一大批 Pod 干净地拉起来——同样致命。节点池轮换、可用区恢复、spot 被批量回收后重调度、集群升级、一次大范围回滚……这些场景会让几十上百个 Pod 几乎同时冷启动。如果不加节制,恢复本身就会引发第二次雪崩。
7.1 恢复为什么会”风暴”
一批 Pod 同时冷启动时,坏事是叠加发生的:
- 缓存全冷:本地缓存(定向规则、创意特征)、下游连接池、JVM 热点全是空的,头几秒每个请求都要现建连接、现查下游、现编译热点。
- 缓存击穿放大到下游:N 个新 Pod 各自 miss、各自回源,把用户画像、预算 / 频控 Redis、配置中心、DB 同时打成一个尖峰——本来平稳的下游被这波”回源风暴”打到过载。
- 重试同步化:下游一慢,大批 Pod 在同一时刻触发重试,流量瞬间翻几倍,正反馈把下游彻底压垮(与依赖韧性的重试放大同源)。
- readiness 抖动成环:下游被打爆 → 新 Pod 探活失败 → 一直 NotReady 或被杀重建 → 再次冷启动、再打一轮,恢复过程反复震荡起不来。
竞价场景尤其怕这个:恢复期的每一秒过载都是 no-bid,而”批量恢复”往往正发生在故障刚过、流量还很高的时候。
7.2 让恢复”细水长流”而不是”一拥而上”
核心思路和优雅停机对称——不要让所有 Pod 在同一瞬间都变 Ready、都去打下游:
- 分批放量恢复:靠
maxSurge/maxUnavailable(§3)、startupProbe+ 真实 readiness(§8)控制”变 Ready”的节奏,让 Pod 一批批进 Endpoints,而不是齐刷刷。大范围重建时宁可慢几十秒,也别一拥而上。 - 启动预热 + 本地兜底:Pod 在 ready 之前主动预建连接池、预加载热点缓存(§8);缓存未命中时先走本地快照 / 保守出价,避免把 miss 全部透传到下游。
- 回源合并(singleflight)+ 下游限流:同一 key 的缓存重建用互斥锁 / 请求合并,N 个 Pod 不各自独立回源;下游侧再叠一层限流兜底(见服务限流、依赖韧性)。
- 重试退避加抖动:所有重试都配指数退避 + 随机抖动(jitter),打散同步化的重试波峰,别让大家踩同一个节拍。
- 依赖就绪顺序:readiness 要探到关键下游(预算 / 频控 Redis、配置中心)——下游没起来时 Pod 保持 NotReady,别在依赖还没恢复时就抢着接竞价、然后成批 fail。
- 恢复也要限速的弹性:spot 批量回收是一次驱逐,走 §4 的 drain;重调度回来时同样受 §3/§8 的放量节奏约束,配合多 AZ + PDB(§5)把”同时挂、同时起”的规模拆小。
一句话:优雅停机保证”走得慢一点不掉单”,抗恢复风暴保证”回得稳一点不二次雪崩”——两头都要有节奏,广告服务才能在故障前后都不出血。
尾声:这个系列讲了什么
四篇下来,我们把一个广告竞价服务从容器内核一路送上了云:
- 容器化开篇:容器 = namespace + cgroup + 分层文件系统;镜像瘦身让弹性真的能弹。
- K8s 核心对象:声明式 + reconcile;Pod/Deployment/Service/Ingress 怎么协作把流量送到不断变化的 Pod。
- 调度与弹性:requests/limits/QoS 定稳定与成本;scheduler 选节点;HPA/探针决定弹得起、发得动、不误杀。
- 本篇实战:JVM 别 OOM、发布走金丝雀、停机先摘流量再 drain、跨 AZ 反亲和 + PDB、HPA + spot 兼顾扛量与省钱。
贯穿始终的一条主线是:云原生的一切弹性、自愈、无损发布,都建立在”服务是无状态的、Pod 是可以被随时杀掉重建的”这个前提上——而把这个前提做扎实(状态外置、探针配对、停机优雅、资源留足),才是广告服务在云上既跑得快又睡得着的根本。上云不是把 jar 塞进容器那么简单,是把每一个”默认能跑”的配置,都按你的业务苛刻度重新校准一遍。
参考
- Kubernetes. Pod Lifecycle(终止流程、preStop、terminationGracePeriod):优雅停机与信号处理的一手权威说明。
- Kubernetes. Specifying a Disruption Budget(PDB):PodDisruptionBudget 与自愿/非自愿驱逐。
- Oracle / OpenJDK. JDK Container Awareness(UseContainerSupport / MaxRAMPercentage):JVM 在容器中感知 cgroup 内存/CPU 的官方说明。
- Kubernetes. Rolling Update & Deployment Strategy:maxSurge/maxUnavailable 与滚动更新节奏。
- Google Cloud. Best practices for running cost-optimized Kubernetes(spot / autoscaling):混合节点池、spot 与弹性的成本优化实践。