Skip to content
Charles Shao
Go back

广告服务上云实战 · 容器化部署与调优

views

前四篇我们从下往上打通了地基:容器原理K8s 核心对象调度与弹性,再到用 Jenkins CI/CD 流水线把镜像自动构建并发布上去。这最后一篇不再讲概念,而是把它们全部落到一件事上:把一个真实的广告竞价服务,生产级地送上云、并调优到能扛住尖峰、发布不掉单、故障能自愈。

竞价服务是广告链路里最苛刻的一环——延迟以毫秒计(超时就 no-bid,直接丢钱)、流量尖峰剧烈、发布频繁、还不能中断在途竞价。这些约束叠在一起,把 K8s 部署里每一个”能跑就行”的默认配置都变成了坑。这一篇就把这些坑逐个填平。

本文是容器化与 K8s 部署系列的第 5 篇(收官 · 实战)。 全系列 5 篇:

  1. 容器化(开篇)· Docker 原理与镜像分层
  2. Kubernetes 核心对象 · Pod/Deployment/Service/Ingress
  3. K8s 调度、资源与弹性伸缩
  4. CI/CD 流水线 · Jenkins 构建镜像与自动部署到 K8s
  5. 广告服务上云实战 · 容器化部署与调优(本篇)

一句话定位:把竞价服务送上云的核心,是把”无状态、可随时被杀重建”这件事做到位——JVM 感知 cgroup 别 OOM、发布走金丝雀别一把梭、停机先摘流量再退出别切竞价、跨 AZ 反亲和 + PDB 别被一次维护打没、HPA + spot 兼顾扛量与省钱。

TL;DR

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。后果是:

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)+ 非堆,非堆包括:

所以 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 线程数、编译线程、各种默认线程池大小。两个注意点:

3. 滚动更新与金丝雀发布

竞价服务发布频繁,发布策略直接关系到收入曲线上会不会砸坑

3.1 滚动更新的节奏控制

Deployment 默认就是滚动更新,靠两个参数控制节奏:

strategy:
  type: RollingUpdate
  rollingUpdate:
    maxSurge: 25%           # 更新时最多超出期望副本数多少(临时多起)
    maxUnavailable: 0       # 更新时最多有多少副本不可用——竞价服务设 0!

3.2 金丝雀:先放一小批看指标

滚动更新是”无差别逐步替换”,但对高风险变更(改竞价算法、换依赖版本),更稳的是金丝雀(canary)——先只放一小撮 v2,用真实流量验证核心指标,确认没问题再全量

  1. 部署一个小副本数的 v2(或用单独的 canary Deployment),通过 Ingress/Mesh 按权重 只切 1~5% 流量过去;
  2. 紧盯业务指标:竞价成功率、bid p99 延迟、错误率、收入 / eCPM(竞价服务尤其要看钱的指标,技术指标正常不代表出价逻辑没退化);
  3. 稳定后逐步放大权重 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 会并行做两件事:

  1. 从 Endpoints 摘除:把这个 Pod 从 Service 的 Endpoints 里拿掉,kube-proxy 更新转发规则,新流量不再路由到它
  2. 发终止信号:先执行容器的 **preStop hook**(如果配了),然后给容器主进程发 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 时间

这正是网关那次事故(发布未先优雅摘流,注册列表短暂脏、打到”死人”)在 K8s 语境下的对应版本。竞价服务尤其敏感——一次不优雅的停机就是一批 no-bid。发布/缩容不掉单,靠的就是”先摘流量、后 drain、再退出”这套时序。

5. 高可用:跨 AZ 反亲和与 PDB

竞价服务不能因为”一台机器挂了""一个可用区抖了""一次节点维护”就大面积掉容量。三个机制协作保住可用性:

5.1 跨可用区分散 + 反亲和

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 节点池

竞价流量有明显波峰波谷 + 大促尖峰。既要扛住峰值,又不想低谷养一堆闲置机器烧钱——靠弹性 + 混合节点池

这样低谷只留少量按需节点、高峰用便宜的 spot 顶量,在保证稳定性的同时把成本压下来。更宏观的多机房/多区域容量规划见 数据中心设计

7. 恢复风暴:批量重建时的二次雪崩

优雅停机(§4)解决的是”怎么把 Pod 干净地关掉”,但它的镜像问题——怎么把一大批 Pod 干净地拉起来——同样致命。节点池轮换、可用区恢复、spot 被批量回收后重调度、集群升级、一次大范围回滚……这些场景会让几十上百个 Pod 几乎同时冷启动。如果不加节制,恢复本身就会引发第二次雪崩。

7.1 恢复为什么会”风暴”

一批 Pod 同时冷启动时,坏事是叠加发生的:

竞价场景尤其怕这个:恢复期的每一秒过载都是 no-bid,而”批量恢复”往往正发生在故障刚过、流量还很高的时候。

7.2 让恢复”细水长流”而不是”一拥而上”

核心思路和优雅停机对称——不要让所有 Pod 在同一瞬间都变 Ready、都去打下游

一句话:优雅停机保证”走得慢一点不掉单”,抗恢复风暴保证”回得稳一点不二次雪崩”——两头都要有节奏,广告服务才能在故障前后都不出血。

尾声:这个系列讲了什么

四篇下来,我们把一个广告竞价服务从容器内核一路送上了云

  1. 容器化开篇:容器 = namespace + cgroup + 分层文件系统;镜像瘦身让弹性真的能弹。
  2. K8s 核心对象:声明式 + reconcile;Pod/Deployment/Service/Ingress 怎么协作把流量送到不断变化的 Pod。
  3. 调度与弹性:requests/limits/QoS 定稳定与成本;scheduler 选节点;HPA/探针决定弹得起、发得动、不误杀。
  4. 本篇实战:JVM 别 OOM、发布走金丝雀、停机先摘流量再 drain、跨 AZ 反亲和 + PDB、HPA + spot 兼顾扛量与省钱。

贯穿始终的一条主线是:云原生的一切弹性、自愈、无损发布,都建立在”服务是无状态的、Pod 是可以被随时杀掉重建的”这个前提上——而把这个前提做扎实(状态外置、探针配对、停机优雅、资源留足),才是广告服务在云上既跑得快又睡得着的根本。上云不是把 jar 塞进容器那么简单,是把每一个”默认能跑”的配置,都按你的业务苛刻度重新校准一遍。

参考


views
Share this post on:

Previous Post
程序化广告生态全景:DSP / SSP / ADX / Ad Server / DMP 在一次曝光里各管什么
Next Post
CI/CD 流水线 · Jenkins 构建镜像与自动部署到 K8s