如果你在一台服务器上用docker service create起了个服务,想当然地认为 Swarm 的负载均衡是开箱即用、自动扩缩容无非是docker service scale敲两下就完事,那后面踩坑的肯定是你。我在生产环境维护 Docker Swarm 集群这几年,最大的体会就是:Swarm 的部署门槛确实比 K8s 低一大截,但"负载均衡"和"自动扩缩容"这两个词后面藏的东西,一点都不比 K8s 少。
这篇文章就专门聊 Swarm 集群里的 autoscaling 与 load balancing。我会先讲清楚流量在集群内部的真实路径,再讲没有原生 HPA 的 Swarm 是怎么做自动扩缩容的,然后把我踩过的那些坑——包括一次扩容后流量倾斜到差点把线上打挂的事故——原原本本复盘一遍。适合正在维护 Swarm 集群、或者正准备从单机 Docker 迁到 Swarm 的运维和开发同学参考。
1. Swarm集群中负载均衡和自动扩缩容到底管什么
1.1 先分清Swarm里的三个"负载均衡"
很多人一听到 Swarm 负载均衡,就以为只是"外部请求能均匀打到多个副本"。其实 Swarm 的负载均衡至少分三个层面,三层搞混了,排查问题的时候就会像无头苍蝇。
第一层是服务发现层面的负载均衡。集群内部任何服务通过服务名访问另一个服务时(比如前端服务请求后端服务),web这个 DNS 名会解析到一组后端容器,流量在幕后被切分到不同副本。这一层默认开着,用的是 Linux 内核的 IPVS,性能很好,但问题在于"均匀"是会话级别的,不是请求级别的——你压测的时候经常看到个别副本 CPU 高、其他副本闲着,原因多半就在这里。
第二层是 Ingress 网络的路由网格(Routing Mesh)。当你给一个服务执行--publish published=8080,target=80,Swarm 会在所有节点上监听 8080 端口,无论请求打到哪个节点,都会被 Ingress 网络转发到真正跑着容器的节点上。这一层的好处是:外部负载均衡器随便挂到哪个节点都行;坏处是:每个发布端口会在每个节点占住一个监听端口,端口号就成了集群级稀缺资源。
第三层才是你真正要关心的对外入口。请求从用户的浏览器或者客户端进来,经过 DNS、云负载均衡、Swarm 集群节点、Ingress 网络、IPVS,最后才到容器。任何一个环节分布不均,最后表现出来的都是"负载不均衡"。
1.2 自动扩缩容在Swarm里的真实位置
Docker Swarm 没有 Kubernetes 那样的 HPA(Horizontal Pod Autoscaler),原生层面只有docker service scale和docker service update --replicas这种手动操作。这不是 Swarm 团队偷懒,而是他们的设计哲学:编排器只负责把副本数调度到位,至于"什么时候该加副本"这种业务决策,留给外部系统和运维自己判断。
所以你在 Swarm 里谈 autoscaling,实际上要解决的是两件事:
- 决策来源:CPU、内存、请求量、队列长度,到底看哪个指标;
- 执行通道:怎么把"需要从 5 个副本变成 12 个副本"这个动作安全地交给 Swarm 执行。
我见过不少团队搞自动扩缩容,脚本逻辑也就十分钟写完了,可一上生产就出事。原因基本都出在没搞清楚上面三个负载均衡层面和自动扩缩容之间的联动关系。比如扩容时只看 CPU,没考虑新副本需要多久才能注册进 VIP 的负载池,结果流量进来了,容器还没就绪,直接给用户返回 503。
1.3 VIP与DNSRR:两种endpoint-mode的取舍
Swarm 创建服务时有个--endpoint-mode参数,默认是vip,可选dnsrr。这个参数直接决定了负载均衡的工作方式,很多人从来不看。
在vip模式下,Swarm 给服务分配一个虚拟 IP,集群内所有节点上的转发规则会把发往这个 VIP 的流量均匀分发到服务的各个副本。优点是对客户端完全透明,客户端只管连接服务名即可,负载分配由集群内部完成。缺点是会话粘性不好控制,虽然 IPVS 支持源地址哈希,但 Swarm 默认策略下同一客户端的多次请求不保证打到同一个副本。
在dnsrr模式下,Swarm 不做 VIP 转发,DNS 查询直接返回所有任务容器的 IP 列表,让客户端自己选择一个去连。好处是省掉了 VIP 这一层,适合那些客户端数量不多、而且自己会做连接重试的场景,比如某些内部批处理任务。坏处也很明显:不是所有客户端都会对多个 A 记录做加权或合理重试,有的客户端拿到第一个 IP 就死磕,结果就是一台副本被打满,其他副本闲着。
我的建议很简单:除非你明确知道自己在做什么,否则永远用默认的vip模式。我在生产环境碰到过两次"服务之间调用慢",最后查出来都是有人图省事开了dnsrr,而客户端库的负载均衡策略又恰好不适合。
2. 扩容前必须想清楚的三个问题:健康检查、资源余量与节点亲和性
2.1 健康检查就是自动扩缩容的生命线
Swarm 判断一个容器"是否健康、能不能进入负载池",靠的是容器健康检查(healthcheck)。如果你的镜像没有定义健康检查,Swarm 就会把所有处于运行状态(running)的容器都当成健康的,哪怕你的应用其实还在启动过程中、端口都还没监听。
这在自动扩缩容场景下是致命的。试想一下:流量高峰期触发扩容,Swarm 在 3 秒内把新副本拉起来,容器进程启动了但应用还没完成初始化,此时 VIP 转发已经把这个新副本加入了负载池——前几个请求直接打到还没就绪的应用上,超时、报错、重试,然后又触发新一轮扩容。我见过有个团队因此搞出了扩容雪崩,30 分钟内副本数从 6 个一路涨到 80 个。
正确的姿势是给服务加上完善的健康检查,并配合start_period参数给应用留出初始化时间。下面是我常用的配置:
services: web: image: nginx:1.27-alpine healthcheck: test: ["CMD", "curl", "-f", "http://localhost/healthz"] interval: 10s timeout: 3s retries: 3 start_period: 30s deploy: replicas: 3 update_config: order: start-first这个配置里有几个细节值得注意。interval是两次健康检查的间隔,timeout是单次检查超时,retries是连续失败几次后才判定不健康,start_period则是启动期内不计入失败次数。我把start_period设成 30s,就是给 Spring Boot 这类启动慢的应用一个缓冲期,避免它还没起来就被打上"不健康"标签踢出负载池。
2.2 资源限制与预留:别让任务卡在pending
自动扩缩容的本质是"按需加副本",那加出来的副本跑在哪?跑在集群节点的空闲资源上。如果你的服务在创建时压根没定义 CPU 和内存的 limits 与 reservations,Swarm 调度器就不知道新副本需要多少资源,结果是扩容指令下了,新任务可能全部卡在 pending 状态,因为节点资源早就被其他任务吃光了。
我强烈建议每个服务都写清楚资源预留(reservations)和资源上限(limits):
services: web: image: nginx:1.27-alpine deploy: resources: limits: cpus: "0.5" memory: 512M reservations: cpus: "0.2" memory: 128M这里有个容易忽略的点:limits只限制容器能用的资源上限,reservations才是调度器用来决策的关键值。你的副本能不能被调度到某台节点上,看的是该节点是否满足所有任务的reservations总和。如果你把reservations设得虚高,明明实际只吃 100M 内存却预留 1G,那集群利用率会变得非常难看;设得太低,扩容时又容易导致多个高负载副本挤在同一台节点上互相抢 CPU。
2.3 有状态服务别随便参与自动扩缩容
写自动扩缩容策略之前,先回答一个问题:你的服务是有状态还是无状态?如果它持有会话状态、写本地磁盘、或者依赖固定的网络标识,那它就不适合参与自动扩缩容。
我见过一个团队把 Redis 集群当成无状态服务,在 CPU 飙升时执行docker service scale redis=6,结果新副本是起来了,但数据全部是空的,客户端请求被路由到新副本后疯狂报错。Swarm 不负责数据同步,也不负责把已有数据"迁移"到新副本。Redis 哨兵、Kafka、MySQL 这类有状态中间件,在 Swarm 里更应该用固定副本数加外部存储的方案,而不是用无脑扩容来解决。
自动扩缩容最合适的场景就是无状态 Web 服务和计算型任务:请求打到哪个副本都行,副本消失了也不影响数据一致性。如果你的服务不符合这个前提,先不要急着上 autoscaling,否则它扩的越快,事故越大。
3. 没有内置HPA的Swarm,自动扩缩容落地实战
3.1 选型思路:第三方工具、商业面板还是自研脚本
Swarm 自动扩缩容没有统一标准答案,我大体上把方案分成三类。
第一类是商业面板。像 Portainer 的企业版自带服务伸缩策略,可以在界面上配置基于 CPU 或内存的自动扩容。好处是快、图形化、团队容易接受;坏处是收费,而且策略的灵活度有限,想按请求量、队列深度这类自定义指标来做就很吃力。
第二类是开源第三方组件。GitHub 上能找到一些 Swarm autoscaler 项目,原理基本是监听监控系统的告警然后调用 Docker API 调整副本数。但多数项目维护不活跃,我建议谨慎评估后再引入,毕竟自动扩缩容是要在线上自动改副本数的,代码没人维护的风险太大。
第三类是自研小脚本。这也是我最推荐、且实际生产环境用得最稳的方案。原理不复杂:定时采集服务指标,判断是否越过阈值,越过就调 Docker API 改变副本数,同时引入冷却时间避免抖振。整套逻辑两百行 Python 就能写完,出了问题也容易排查。
3.2 指标采集的两条路:Docker API与Prometheus
自研自动扩缩容,第一步是指标采集。这里我走过两条路。
一条是直接用 Docker API 的 stats 接口。好处是零额外组件,原生就有;坏处是拿到的数据是单容器维度的 CPU 和内存,如果你想按"服务整体的请求量"或"队列长度"来伸缩,它就无能为力了。
另一条是接 Prometheus。用 cAdvisor 采集容器指标,配上节点指标(node-exporter),再用 PromQL 把服务下所有副本的指标聚合成一个值。这条路前期配置工作多一些,但后续想加任何自定义指标都很顺手。比如你想按"HTTP 502 响应数每秒超过多少就扩容",只要应用把指标暴露成 Prometheus 格式,自动扩缩容脚本稍微改一行查询语句就行。
如果让我给建议,我会说:生产环境直接接 Prometheus。用 Docker stats 做 POC(概念验证)没问题,但线上自动扩缩容的决策必须基于可靠、可追溯的指标源,Prometheus 的查询结果可以随时拉出来核对,出问题时不至于连"它到底看到了什么数据"都答不上来。
3.3 一份可以直接跑起来的Python POC脚本
这里我给一份我早期做 POC 时用的脚本,基于 Docker SDK for Python。逻辑很简单:每隔 30 秒查一次服务下所有副本的平均 CPU 使用率,超过高水位就扩容两个副本,低于低水位且当前副本数多于最小值就缩容一个副本,每次操作后进入冷却期。
import time import docker import logging logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") CPU_HIGH = 70.0 # 平均 CPU 超过该值触发扩容 CPU_LOW = 30.0 # 平均 CPU 低于该值触发缩容 STEP_UP = 2 # 每次扩容增加的副本数 STEP_DOWN = 1 # 每次缩容减少的副本数 MIN_REPLICAS = 3 # 服务的最小副本数 MAX_REPLICAS = 20 # 服务允许的最大副本数 COOLDOWN_SECONDS = 120 # 冷却时间 client = docker.from_env() service = client.services.get("web") def get_avg_cpu(service): tasks = [t for t in service.tasks() if t["Status"]["State"] == "running"] if not tasks: return 0.0 cpu_percents = [] for task in tasks: container_id = task["Status"]["ContainerStatus"]["ContainerID"] container = client.containers.get(container_id) # 第一次采样通常拿不到可靠的 precpu_stats,间隔 2 秒再采一次 container.stats(stream=False) time.sleep(2) stats = container.stats(stream=False) cpu_delta = stats["cpu_stats"]["cpu_usage"]["total_usage"] - \ stats["precpu_stats"]["cpu_usage"]["total_usage"] sys_delta = stats["cpu_stats"]["system_cpu_usage"] - \ stats["precpu_stats"]["system_cpu_usage"] if sys_delta == 0: cpu_percents.append(0.0) else: cpu_percents.append(round(cpu_delta / sys_delta * 100.0, 2)) return sum(cpu_percents) / len(cpu_percents) def current_replicas(service): return service.attrs["Spec"]["Mode"]["Replicated"]["Replicas"] last_op_time = 0.0 while True: avg_cpu = get_avg_cpu(service) replicas = current_replicas(service) logging.info(f"replicas={replicas} avg_cpu={avg_cpu}%") now = time.time() if now - last_op_time < COOLDOWN_SECONDS: time.sleep(30) continue if avg_cpu > CPU_HIGH and replicas < MAX_REPLICAS: new_replicas = min(replicas + STEP_UP, MAX_REPLICAS) service.scale(new_replicas) logging.info(f"scale up: {replicas} -> {new_replicas}") last_op_time = now elif avg_cpu < CPU_LOW and replicas > MIN_REPLICAS: new_replicas = max(replicas - STEP_DOWN, MIN_REPLICAS) service.scale(new_replicas) logging.info(f"scale down: {replicas} -> {new_replicas}") last_op_time = now time.sleep(30)这份脚本的本质逻辑可以沿用,但直接照搬到生产环境之前,有几个点必须改。
第一个是容器的状态会变。扩容后新任务还在启动中时,Status["State"]可能不是running,脚本里过滤掉即可;但任务在节点间漂移时,容器 ID 会变,甚至服务在缩容时会把正在查的容器删掉,这时候client.containers.get()会抛异常,生产版本需要加异常处理。
第二个是别用单个服务的 stats 算整个集群的伸缩。真实场景要接 Prometheus,用一句avg(rate(container_cpu_usage_seconds_total{service_name="web"}[5m]))代替上面的采样逻辑,稳定性会好很多。
第三个是必须加操作审计。每次自动扩缩容的触发原因、当时的指标值、操作结果,都要写日志甚至推到告警平台,不然线上副本数变了,你想查是谁、什么时候、为什么变的,一点线索都没有。
4. Ingress网络与VIP:一次请求在Swarm内部到底怎么走
4.1 Ingress网络的建立与发布端口的隐蔽代价
Swarm 集群初始化的时候会自动创建一个名为ingress的 overlay 网络,所有声明了发布端口的服务都会挂到这个网络上。这个网络是集群级别的,意味着每一台加入 Swarm 的节点,无论它是 manager 还是 worker,都会在这个网络上有一个接口。
当你给服务设置--publish published=8080,target=80,所有节点的宿主机都会监听 8080 端口。也就是说,请求打到你集群里任何一台机器上,只要端口对上,Swarm 都有办法把它转发到正确的容器。这个设计初衷是好的:外部负载均衡器不用感知每一台具体跑容器的节点,随便指一台能通的就行。
但代价是端口号在集群中是全域占用的。你在 A 服务上发布了 8080,就不能在 B 服务再发布宿主机 8080。集群规模一大,服务一多,端口冲突是早晚的事。我在一个跑着 40 多个服务的集群里,花了很大力气做端口规划,最后还是决定把大部分服务收敛到少数几个统一的对外端口上,靠外部负载均衡按域名路径转发,端口压力这才缓解。
4.2 从外部请求到容器响应的完整链路
把一次请求的完整链路拆开看,你会发现它比想象中长。
假设外部负载均衡把请求送到了集群的 node-3 这台机器,目标端口是 8080。第一步,node-3 上的 Swarm 代理进程接收这个连接,因为 8080 是web服务发布的端口,而web服务的副本实际可能运行在 node-1 和 node-2 上,所以 node-3 不能直接把请求交给本地进程。
第二步,这个连接被送入ingress这个 overlay 网络,通过 VXLAN 隧道到运行着副本的节点。在那一端,Swarm 内置的 IPVS 规则会根据目标服务的 VIP 做负载均衡决策,把连接交给某个具体容器。
第三步才是容器内应用处理请求、返回响应,响应再原路返回。
这里面有一个很容易被忽视的性能特征:跨节点转发是常态,不是意外。只要发布端口,流量就可能从任意节点进来再绕到其他节点,多一跳网络开销。所以在 Swarm 里贴标签做调度约束(比如让前端服务的副本固定跑在与入口节点相同的机器上)对性能改善非常明显,尤其是对延迟敏感的服务。
4.3 为什么很多团队还是愿意在Swarm前面放一层负载均衡
有人可能会问:Swarm 自己已经有 Ingress 负载均衡了,为什么还要在前面单独放一层负载均衡呢?
我的答案很直接:因为 Ingress 的负载均衡是集群内部的,它解决不了对外的高可用问题。用户访问你的服务,DNS 解析到的是你的 VIP 或者域名,这一层入口挂了,集群内部的负载均衡再完美也没有用。而且外部负载均衡能干的很多事,Swarm 的 ingress 干不了:按 URL 路径路由到不同服务、按域名做分流、TLS 证书统一终结、基于请求内容的灰度发布。
我生产环境的典型架构是:云负载均衡作为最外层入口,后面挂着 2-3 台 Swarm 集群的节点作为后端,健康检查打到这些节点的某个统一端口。外部负载均衡只负责"哪台节点活着就派活给哪台",节点内部的服务路由和副本分发,全部交给 Swarm 自己完成。这样既利用了 Swarm 内置负载均衡的能力,又把入口的高可用和灵活的流量治理留给了更专业的组件。
5. 对外流量入口:PublishPort、外部负载均衡和动态发现的配合
5.1 三种对外暴露方式的对比
我把 Swarm 对外暴露服务的常用方式整理成了一张对比表,这里直接放出来:
| 方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 直接 PublishPort | 小型集群、内部工具 | 配置最简单,一条命令搞定 | 端口占用严重,节点故障要自己处理,无会话粘性 |
| 外部负载均衡 + PublishPort | 生产集群、中大规模 | 入口高可用,健康检查自由控制 | 需要额外维护负载均衡层 |
| 反向代理(Nginx/HAProxy)入 Swarm | 多服务、按域名路由 | 路由灵活,TLS 终结集中,便于灰度 | 代理本身成为新的运维对象 |
如果你只有三五台机器的 Swarm 集群、服务就几个内部组件,那直接 PublishPort 也不是不行,省事。但一旦服务数量超过十个,或者有对外提供服务的要求,我强烈建议至少走到第二种方案。我自己是从第二种起步的,后来服务从十几个涨到四十多个,才被迫加了第三种方案的反向代理层。
5.2 外部负载均衡如何感知Swarm的扩缩容
这里有个特别容易被忽略的联动问题:外部负载均衡的健康检查对象,是节点,不是容器。Swarm 自动扩缩容是在服务副本层面操作的,副本数从 3 变成 10,外部负载均衡完全不知道,也不关心——因为它只需要把请求送到节点的发布端口,剩下的事 Swarm 内部自己分发。
但有一个陷阱:如果你的服务发布端口在集群里的某个服务上,而该服务的副本数为 0,或者服务的任务全部处于 unhealthy 状态,Swarm 依然会在节点上保留端口监听,只是转发时没有可用后端,请求会被丢弃或返回连接拒绝。外部负载均衡的健康检查这时候是"假绿"的——它能 TCP 连上端口,但业务已经挂了。
我在生产环境里特意给外部负载均衡配了业务级健康检查:每个服务对外暴露一个/healthz接口,返回 200 才健康。外部负载均衡的探测路径不是只探节点端口,而是走完整的 HTTP 请求链路,这样配置在自动扩缩容场景下尤其重要。因为缩容可能会把某个服务的副本缩到最小,如果此时探活还是按 TCP 端口来做,很可能出现"探活通过、请求全挂"的诡异事故。
5.3 自动扩缩容时代的端口规划与域名收敛
做了自动扩缩容之后,服务的副本数不再是一个固定值,但对外暴露的维度最好是稳定的。这听起来像废话,但我见过不止一个团队在扩容时遇到底层网络配置跟不上:服务加了副本,安全组没放行新节点的端口,外部负载均衡探测失败,流量 502 了好几分钟运维才发现。
所以我的经验是:端口规划要站在集群维度统一管理,域名收敛要站在用户维度统一收敛。每个服务尽量只暴露少量端口,能走 80/443 就走,TCP 短连接能复用就在代理层复用。域名也好,路径也好,最终都收敛到同一组入口端口上。这样自动扩缩容的脚本只需要关注副本数,不需要关心底层网络放行和负载均衡配置是否跟得上。
6. 一次线上事故复盘:扩容时引发的流量倾斜与连接中断
6.1 事故现象:新副本CPU很低,错误率却在上升
那次事故发生在一个流量高峰期。我监控着服务的平均 CPU,发现指标已经压到 75% 以上,自动扩缩容脚本按预案扩容,副本数从 8 个加到 14 个。扩容指令执行得很顺利,任务全部进入 running 状态,但诡异的事情发生了:新副本的 CPU 使用率极低,几乎空转,可服务的整体错误率反而在往上走。
一开始我以为是监控数据延迟,或者新副本还没被加入到 VIP 负载池。可等了五分钟,新副本还是凉凉的,错误率也没有回落。我用docker service ps web看了任务分布,又手动登录到新副本所在的节点,从容器里直接 curl 本机的健康检查接口,发现应用本身是好的,能正常响应。那就奇怪了:应用没问题,为什么流量就是到不了新副本?
6.2 排查链路:任务状态、健康检查生命周期与ingress重平衡
我先查了任务在 VIP 转发规则里的登记情况。Swarm 的任务只要进入 running 状态,就会往 VIP 的负载池里注册,新副本确实已经在池子里了。但为什么没有流量打过来?这里涉及一个我之前没太在意的机制:Ingress 网络不会因为新副本加入而立刻把已有连接打散重分配。
那些在扩容前就建立的、还在存活期内的长连接,依然被 IPVS 钉在旧的副本上。新副本能分到的,只有扩容后新建的连接。而那个场景下客户端大量复用长连接,新建连接占比不高,于是新副本一直空转,老副本继续承压,整体错误率还在上升——因为老副本真的扛不住了。
另外一个隐蔽问题是健康检查的阶段交替。部分新副本的start_period设得太短(只有 10 秒),而应用真正可服务需要 20 秒左右,导致新副本起来后短暂被标记为 unhealthy,被踢出负载池。等它恢复健康,客户端那波重试请求已经打完了,流量早就撤走。
所以那次排查的最终结论其实非常朴素:新副本不是没有被路由,而是连接迁移机制加上健康检查窗口共同导致它在最需要接流量的时候没有接上流量。Ingress 的 IPVS 转发是连接级的,不是请求级的,这个特性决定了 Swarm 的负载均衡对"长连接服务"天然不友好。
6.3 修复方案与后续防复发措施
那次事故之后,我做了一系列调整,这里分享给你。
第一,给服务开启update-config的order: start-first,并且严格要求镜像健康检查。保证新副本在完全就绪之后才会被加入负载池,旧副本在替换时先摘除流量再停止。配置示例:
deploy: update_config: order: start-first failure_action: rollback rollback_config: parallelism: 0 delay: 30s第二,健康检查的start_period按应用实际启动耗时放宽到 30-40 秒,宁可探活慢一点,也不要造成"假健康进 LB,然后半分钟后又踢出来"的抖动。
第三,对外服务的连接策略改为短连接优先,或者至少客户端要做连接建立失败时的重试。长连接全部堆在一批固定副本上,任何扩容都没有意义;只有连接能够分散建立,扩容的新副本才能接到流量。
第四,自动扩缩容脚本增加了一个"观察期"——扩容后必须等 90 秒再判断效果,避免因为在启动期内 CPU 指标还没升上来,就误判扩容无效又继续扩,把集群副本数顶到上限。
老实说,这次事故让我对 Swarm 的负载均衡有了一个更清醒的认识:它的转发机制是可靠的,但"可靠"不等于"让你感知不到存在"。连接级的负载均衡意味着你扩容后不可能立刻看到新副本 CPU 飙升,这是机制决定的,不是配置错了。理解了这一点,再去设计自动扩缩容策略,整个思路都会不一样。
7. 自动扩缩容策略的调参建议:抖动抑制、冷却时间与容量规划
7.1 上下阈值与冷却时间:用迟滞区间避免抖振
自动扩缩容最容易犯的错,是照着"CPU 超过 70% 扩容、低于 70% 缩容"这种单阈值逻辑写。后果就是抖动:指标在阈值附近晃动,缩容指令刚执行完,CPU 又因为流量反弹超过 70%,于是又扩容,一小时内副本数上上下下,前端监控图锯齿状,后端集群被无效调度折腾得半死。
正确做法是设置迟滞区间(hysteresis),也就是高水位和低水位之间留一个空白带。比如扩容触发条件是平均 CPU 高于 75%,缩容触发条件是低于 35%。这样指标从 35% 涨到 75% 需要跨越一大段,不会因为毛刺触发扩容;而扩容之后,如果 CPU 回落到 70%,也不会立刻触发缩容,要跌破 35% 才缩,给扩容后的副本留足生效时间。
我常用的调参表供你参考:
| 指标 | 值 | 说明 |
|---|---|---|
| 扩容阈值 | 75% CPU,持续 5 分钟 | 避免瞬时毛刺 |
| 缩容阈值 | 35% CPU,持续 15 分钟 | 缩容更保守 |
| 扩容步长 | 当前副本数的 50%,向上取整 | 快速应对尖峰 |
| 缩容步长 | 每次缩 20%,最小不小于配置下限 | 平滑释放资源 |
| 冷却时间 | 扩容后 10 分钟,缩容后 20 分钟 | 防止抖振 |
这里的"持续 5 分钟"不是指测量间隔,而是指指标越过阈值后需要持续一段时间才真正确认要扩容。实现上也可以在脚本里记录"连续 N 个采样周期都超过阈值"再触发,至少能过滤掉绝大多数因为 GC(垃圾回收)、偶发慢请求带来的假信号。
7.2 扩缩容的不同心态:扩容要快,缩容要慢
我把这个原则写进过团队的操作手册:扩容要快,缩容要慢。
原因很现实。扩容慢半拍,流量的尖峰可能直接把你仅剩的几个副本打垮,这是可用性事故;缩容快一拍,副本数快速回落到低水位,结果流量又涨回来,应用还在冷启动阶段就被迫再扩容,这是稳定性事故。两者比起来,可用性事故的代价要高得多。
所以我在设置脚本参数时,扩容的阈值可以相对激进一些(比如 70% 就扩),冷却时间也可以短一些(5 分钟);缩容的阈值则要保守(35% 以下才缩),冷却时间要拉长到 15 分钟以上,甚至要求 CPU 低水位持续 20 分钟再缩。缩容时也建议一次只缩少量副本,而不是一次从 20 个缩回 5 个——万一流量反弹,至少还有 10 个副本在顶着。
另外,凡是有状态依赖的服务,或者启动时需要加载大数据缓存的服务,缩容更得格外小心。新副本冷启动代价很高,每缩一个副本,就是在把流量和压力往剩下的副本上摊,摊得过猛就是连锁故障。
7.3 容量规划:自动扩缩容是保险丝,不是主引擎
做自动扩缩容最成功的一种认知,是把它定位成"保险丝",而不是"容量主引擎"。什么意思?就是你的集群日常容量规划,应该按预估峰值的 1.5 到 2 倍做,自动扩缩容只是用来应对突发流量和预测外的事情。
我见过团队把自动扩缩容当成万能药,平时集群只有 2 台节点,扩容全靠脚本临时加副本。结果高峰一来,脚本倒是触发了,但集群是所有节点总资源都不够,新副本全在 pending 状态,再多的副本数规划也没用。自动扩缩容能解决的,是"有资源但副本数不够"的问题;它解决不了"集群总容量本身就撑不住"的问题。
所以我的容量规划思路是三层:日常运行层,副本数固定在基线水平,满足大部分请求;自动扩缩容层,处理 30%-100% 的流量波动,在集群预留资源范围内自动调整;紧急预案层,一旦逼近集群总容量上限,人工介入、加节点或者降级非核心服务。自动扩缩容脚本的MAX_REPLICAS一定要根据集群总资源和现有任务占用量做计算,不要在脚本里拍脑袋填一个很大的数。
最后再分享一个小技巧:扩容之后别急着看那几个新副本的 CPU 数值,先把docker service ps <service>和docker service logs --since 5m <service>打开观察两分钟。如果新副本的日志是干净的、健康检查是通过的,那大概率扩容是成功的;如果新副本还在不断拉取镜像、初始化连接池,你的健康检查参数得再松一档。我在实际操作中,类似的"观察期"挽回过至少三次以为要凉的局面,这套流程比任何监控告警都来得直观。