从去年开始,我一直在一个面向公司内部业务线的容器化项目里做架构和运维支撑,服务数量不算多,大概三四十个,但对稳定性和部署效率的要求不低。期间我们对比过不少方案,最后选择的是 Docker Swarm,一直跑到今天。这个系列我会用真实项目里的场景来拆 Swarm 的服务管理与调度策略,这篇先讲我们上线至今最常用、也最值得抠细节的几个调度实战点。如果你正好纠结怎么让副本更合理地分布在节点上,或者滚动更新时总是一升级就断连、回滚又手忙脚乱,那这篇文章应该能帮你省下不少时间。
1. 为什么我在新项目里选了 Swarm 而不是 K8s
1.1 项目背景与团队现状
这个项目最开始的需求很直接:一个小团队维护的分布式后台系统,由十几个服务组成,需要快速部署、按环境隔离、能水平扩容,部署频率大约是每周三到四次。团队里没有专职的容器平台工程师,运维工作由后端开发兼任,大家对 Docker 本身很熟,但对 Kubernetes 那套复杂概念普遍比较陌生。
如果用 Kubernetes,光是集群升级、网络插件选择、RBAC 和各类 CRD 的学习成本,就足够让这个项目的上线时间往后拖很久。而且我们只有两三个环境,没有多集群需求,也不需要复杂的租户隔离和资源配额体系。这种体量下引入 K8s,本质上是在用火箭运一箱啤酒,可以但没必要。
Swarm 最打动我的一点,是它直接内置在 Docker 引擎里,不需要额外部署控制面组件。执行docker swarm init之后,一个单节点的 Swarm 集群就起来了,后续加节点也只是一条docker swarm join命令。这对一个后端开发兼任运维的团队来说非常友好,学习曲线几乎为零。
1.2 和 K8s 对比后的取舍依据
我并不是说 Swarm 比 K8s 好,而是说在小规模、少人的场景下,Swarm 的取舍逻辑更合理。
- 运维负担:K8s 的控制面组件(etcd、kube-apiserver、kube-controller-manager)需要独立维护,遇到版本升级还得关注兼容性。Swarm 的 control plane 跑在 Docker 引擎内部,升级 Docker 引擎即可,管理节点和工作节点的角色切换也特别简单。
- 服务发现:Swarm 自带基于 DNS 的服务发现,服务名就是域名,容器间直接用服务名访问。K8s 在这个层面虽然也能通过 Service 实现,但涉及 ClusterIP、DNS Policy 一堆细节,对刚上手的人不够友好。
- 调度策略:Swarm 的调度器虽然没有调度器框架那些复杂算法,但约束、偏好、资源预留这套东西已经够用了。真实业务里,节点打上标签然后按约束分布副本,基本能覆盖绝大多数需求。
提示:如果是几十个服务以内的规模、团队人少、发布频率不是每分钟一次,Swarm 是完全够用的。如果将来确认要上 K8s,服务用 Docker Compose 表达,再到 compose spec 里的
deploy字段,迁移成本也不会高到哪里去,Swarm 本身就是在践行 compose 的部署规范。
1.3 架构形态:三管理节点加一工作节点
我们最终搭建的集群是三个管理节点加一个工作节点,管理节点同时承担业务负载。这个做法可能引起洁癖型运维的反对,但项目规模有限,三管理节点本身有高可用性,工作节点大部分时间处于备用状态,在成本受限的背景下是合理的。
Docker Swarm 的 Raft 协议要求管理节点数量为奇数,三个是最小的高可用集合。读者如果是自己搭环境,单管理节点也完全可以跑,只是机器挂了集群会短暂失去调度能力,业务容器不受影响。
2. 服务定义里的调度开关:从 mode 到 replicas 到 constraint
2.1 一条 service 命令背后的调度逻辑
很多入门教程讲到docker service create就一带而过,但这条命令的参数拆开来看,其实包含了 Swarm 调度器做决策的所有依据。每次创建或更新服务,调度器要解决三个问题:副本放哪台机器、放几个、什么时候动。
先说放几个。--replicas指定目标副本数,--mode global则会让每个节点上都跑一个副本。默认是 replicated 模式,调度器会尽量让副本分散到不同节点上,数据落地的节点编号你看不到也没关系,关键是理解它背后有个打分逻辑:节点剩余资源、已有副本数量、约束条件、偏好条件都会影响决策。
再说放哪台。--constraint是硬性过滤条件,节点不满足就直接排除;--placement-pref是软性偏好,Swarm 会优先满足,但不强制。举个例子:--constraint node.labels.ssd == true保证有 SSD 标签的节点才被选,--placement-pref spread=node.labels.rack则让副本尽量分散在不同机架上。
最后是资源维度。--reserve-cpu和--limit-cpu分别表示预留和上限,调度器以预留值作为是否放置的依据,运行时限值作为运行时限制。如果只写 limit 不写 reserve,调度器常常会把副本堆在某一台机器上,因为从它视角看,所有节点的剩余资源一样大,而它并不知道这份负载实际占用多少。
2.2 调度不是随机,但它确实会“偷懒”
这是我在使用中印象比较深刻的一个点。Swarm 的调度器有“尽力分散”的倾向,但这种分散是站在集群整体视角的,不会先想“哪个节点最近空闲”再做决定。
举个例子,我有三个节点,最初在节点 A 上跑了一个副本。此时创建第二个服务,调度器发现节点 B 和 C 目前副本数为零,会优先选择其中一个。如果第三个服务又出现,很有可能落到剩下的那个节点。看起来像在轮询,其实是因为“副本数量最少”在评分中占比较高,这也是 Swarm 默认的策略。
理解这一点之后,遇到“为什么我的服务全部挤在一台机器上”这类问题,就不用怀疑调度器坏了,优先检查你的约束、偏好和资源预留是不是把其他节点都排除掉了。
2.3 compose 文件里的 deploy 字段
用命令行直接建服务用于演示还行,生产环境我们几乎全用 stack file 管理服务定义。stack file 本质上是扩展了deploy字段的 Compose 文件,可以在docker stack deploy时被 Swarm 解析。
比如一个带约束和更新策略的服务,定义长这样:
services: web: image: nginx:1.25 deploy: mode: replicated replicas: 3 placement: constraints: - node.labels.web == true preferences: - spread: node.labels.zone update_config: parallelism: 1 delay: 15s failure_action: rollback order: start-first restart_policy: condition: on-failure delay: 10s max_attempts: 3 resources: reservations: cpus: '0.25' memory: 256M limits: cpus: '1.0' memory: 512M注意这里placement.constraints后面的写法是node.labels.web == true,为什么是双等号?因为 Swarm 的约束表达式本身就是这个语法,不是笔误。我见过有人在这里写=,服务虽然创建了,但调度器始终找不到匹配节点,日志里会一直显示 pending。
注意:约束条件里的节点标签,默认指的是
node.labels而不是你在/etc/docker/daemon.json里配置的labels。后者是engine.labels,用约束时写engine.labels.xxx。两者就差一个前缀,但含义完全不同,这是项目早期让我排查了两三个小时的问题。
3. 用 placement 约束把副本钉在指定节点上
3.1 节点标签怎么设计才能不乱
标签是 Swarm 节点调度的基础语言。我们团队的设计方案是按功能域加硬件域分两层:
- 功能域:
web、worker、db、monitor - 硬件域:
ssd=true、gpu=false、high-mem=true
打标签的命令很简单:
docker node update --label-add web=true node-01 docker node update --label-add ssd=true node-02为什么要分开而不是直接写node-01-web?因为标签是可以组合使用的,比如某个服务既要跑到web节点,又需要ssd,约束里写两条就行。标签拆得越细,调度组合的空间就越大。我们后来引入了一批只有 8GB 内存的低配机器,业务服务想避开它们,只加一个low-mem=true标签,旧服务无需改动,新服务加一条反约束node.labels.low-mem != true就实现了。
3.2 需要一个约束还是多个约束
服务对节点的要求经常不止一个,比如有状态的数据库服务,不但要求db=true,还要求ssd=true,甚至可能要固定在某两台机器上。
我们有一个后端服务依赖本机挂载的 NFS 目录,这个目录只在 node-01 和 node-02 上存在。约束如果用:
placement: constraints: - node.labels.nfs == true只要打了nfs=true的机器都可以调度,副本会自动在 node-01 和 node-02 之间分配。这是满足需求的,而且比直接指定node.hostname == node-01灵活得多。节点新增了 NFS 挂载,只需打上标签,服务就能自动利用新的容量。
如果某个服务真的只能跑在唯一一台机器上(例如依赖该机器上特定的 GPU),那才用node.hostname约束。这个约束最硬,用了它副本就完全没有可迁移空间,节点一挂服务就废了,除非你同时做好备份。
3.3 一个真实场景:让副本分散在不同机架上
线上有一次跑着一个 5 副本的服务,节点分布在两个机架上。Swarm 默认调度会尽量散布副本,但如果节点数量少,它可能把 3 个副本放在一个机架、2 个放在另一个,这已经算分散得不错了。
但我们的要求是:任何一个机架断电,服务至少还剩一半副本可用。那就需要在约束之外加偏好:
placement: preferences: - spread: node.labels.rackspread偏好会让 Swarm 在满足约束的节点集合里,尽量让副本在不同rack标签值之间均匀分布。标签值是字符串,比如rack-a、rack-b,调度器会按不同值分组,然后尽可能轮询。
实测效果:5 副本在 6 个节点上分配后,每个机架大约 2 到 3 个副本,即使一个机架全部宕机,服务也不会因为副本数为零而中断,只是容量降低。
3.4 修改节点标签对已有服务的影响
这个点很多人没意识到:给节点重新打标签后,之前创建的、没有约束的服务不会发生迁移;只有带node.labels.xxx约束的服务,如果标签变化导致节点不再满足条件,调度器才会把副本从这些节点迁走。
这个特性有好处也有坑。好处是你可以先打标签再故意约束服务;坑是你打算维护节点前改个标签,结果副本被自动迁移了,业务流量也跟着抖。
所以维护节点前建议先把节点docker node update --availability drain,这样 Swarm 会把服务任务迁到其他节点,再改标签就不至于惊动调度器。维护完再activate回来。
4. 滚动更新与回滚策略的现场调参
4.1 更新并发度与更新间隔的选择逻辑
滚动更新是发布流程的核心,也是最容易出问题的环节。Swarm 的更新策略主要由两个参数控制:
--update-parallelism:同一时间更新多少个副本--update-delay:每批更新完成后等待多久再更新下一批
我们最早图快,把parallelism设成 2,delay设为 5 秒,结果更新到一半服务出现间歇性 502。原因很简单:总共 6 个副本,每次同时更新 2 个,等于每轮有三分之一副本在重启,负载均衡器的健康检查还没反应过来,流量已经打到正在关闭的容器上了。
后来调整为parallelism=1、delay=30s,整个发布过程虽然慢一些,但每轮只有一个副本处于不可用状态,对整体流量没有任何影响。30 秒间隔是给新副本一个充分启动和完成健康检查的时间,也让监控有时间捕捉到异常。
4.2 order 参数:start-first 还是 stop-first
--update-order有两个取值,含义分别是:
start-first:先启动新副本,确认健康后再停止旧副本stop-first:先停止旧副本,再启动新副本
K8s 的 Deployment 默认策略是 RollingUpdate,本质上也是先起新 Pod 再销旧 Pod,所以默认推荐start-first,它保证了任何时刻可用的副本数不会低于目标值减一。
但start-first有一个隐藏问题:新旧副本会短暂共存,这时如果有数据库 Schema 不兼容的变更,新代码连上旧 Schema 可能导致启动失败、健康检查不通过。所以涉及重大数据库变更时,我们反而会故意用stop-first,先停旧再启新,宁可有一小段时间不可用,也不让新旧代码同时在线跑数据。
不同的业务对可用性和一致性的权重不同,这个参数没有标准答案,但要理解它背后的取舍。
4.3 一次真实回滚经历:新镜像启动即崩溃
有一次发版,新镜像启动后进程一直崩溃,Swarm 的默认重启策略on-failure会让容器反复拉起。当时没有配置healthcheck,Swarm 判断任务是否健康的依据就是进程是否退出,而崩溃后立即重启会被当作暂时性故障,一直反复尝试,旧副本又一个都没停,流量没断但日志刷得很快。
我们当时的处理路径:
- 先定位新镜像的问题,确认是代码原因,决定回滚
- 执行
docker service update --rollback web,Swarm 按之前的部署历史回退到上一次镜像 - 观察
docker service ps web,回滚任务完成后,服务恢复稳定
回滚命令非常简单,但前提是服务创建时没手动改过参数导致历史记录混乱。在 stack file 里维护服务定义的好处是,回滚后配置仍然与文件保持一致,不会出现“回滚了镜像但标签没变”的中间状态。
值得一提的是,回滚也会走滚动更新的逻辑,同样受parallelism和delay控制。如果原更新速度很快,回滚也会很快,这时候反而要盯紧一点,因为我们遇到过回滚后某个旧副本启动失败的情况,因为它所在的节点资源不足了。
4.4 healthcheck 的配置时机比想象中重要
Swarm 判断任务是否健康的机制依赖镜像里的 HEALTHCHECK 或服务定义中的healthcheck。如果镜像本身没有定义健康检查,Swarm 只能通过“进程是否退出”来判断,这在很多场景下是失真的。
比如一个 Java 服务,进程还在但端口没监听、JVM 卡死,容器并不会退出,Swarm 认为它“健康”,流量仍然会打进来。这种情况下滚动更新会把新副本都部署完了才发现问题,旧副本已经全被销毁了。
所以在服务定义里加健康检查非常关键:
healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] interval: 10s timeout: 3s retries: 3 start_period: 30sstart_period很关键,它给新容器一段“宽限期”,期间健康检查失败不立刻标记为 unhealthy,避免应用启动较慢时被反复重启。我们的经验是,start_period设置成应用真实启动时间再加 10 秒左右,比如启动约 20 秒,就设 30 秒。
5. 一个让我排查了半天的调度“意外”:DNS 与 ingress 网络之间的拉锯
5.1 现象:更新后部分请求 502
有一次滚动更新一个 4 副本的 API 服务,按前面说的parallelism=1、delay=30s执行,理论上应该很稳。但更新过程中监控还是出现了零星 502,集中在更新后第 5 到 15 秒之间。
刚开始怀疑是停车时间重叠导致的,但排查了一下任务状态,发现新旧副本在更新时间上没有重叠。这就很奇怪了,因为start-first模式下旧副本应该一直被保留到新副本健康后才停止。
5.2 排查链路:从容器日志到 DNS 解析
我先看了 Swarm 的集群事件和任务日志,没有发现任务异常。接着看服务日志,也没有明显报错。然后我检查了 ingress 网络的入口,因为流量是通过 Swarm 内置的 Routing Mesh 进入服务的,这里有一个关键机制:
Swarm 的 ingress 网络给每个服务分配一个 VIP,入站流量到达任意节点的 ingress 端口后,会被负载均衡到该服务的健康副本上。问题在于,服务名解析有两种模式:
- VIP 模式(默认):查询
tasks.服务名返回一个虚拟 IP - DNS 轮询模式:查询返回所有副本的 IP 列表,由客户端自己做负载均衡
我用docker service inspect确认了服务是 VIP 模式,理论上流量由 Swarm 内部负载均衡,客户端不和具体副本 IP 直接打交道。
然后问题回到起点:如果旧副本在停止前的一瞬间,ingress 网络还没来得及把它从负载均衡池中摘除,而此时新副本还没完全就绪,就会有一小段窗口期,入口把流量转发到一个已经关闭但尚未被摘除的旧副本上,产生 502。
5.3 根因:任务状态更新与网络摘除存在时间差
Swarm 中一个服务任务的生命周期里,从“正在关闭”到“已停止”,网络层摘除它是有一定延迟的。尤其旧容器收到 SIGTERM 后可能还需要一点时间完成清理,这期间端口可能已经关闭,但负载均衡规则还没刷新。
这不是 Swarm 的 bug,而是分布式系统里常见的一致性问题。解决办法不是去改 Swarm 内部,而是从应用和发布策略层面规避:
- 在应用里给 SIGTERM 预留优雅退出的时间:收到信号后先从注册中心反注册,再等 5 秒左右关闭 HTTP 连接
- 更新时给新副本足够长的启动和健康检查时间,避免新旧交替的节奏过于紧凑
- 如果前端有网关层,在服务更新时可以给网关加重试逻辑,出现 5xx 自动重试
5.4 用 start-period 和优雅退出把窗口期压到最低
最终我们的调整方案是:
- 应用层捕获 SIGTERM,通知负载均衡摘除自身,做连接排空等待 10 秒
- 服务定义里把
healthcheck的start_period从 30 秒调到 45 秒 update_config保持parallelism=1、delay=30s
调整之后,后续几次发布的 502 基本消失了。如果服务是无状态的,这个时间差几乎无感知;一旦碰上长连接类服务(WebSocket、消息推送),这种窗口期会特别明显,值得提前有心理预期。
提示:如果你的业务客户端没有重试机制,网关层务必加一层重试,这是分布式系统里最朴素的容错手段,成本极低但收益很大。
6. 调度之外:多环境复用与扩容的运维心法
6.1 stack file 怎么按环境复用
一套服务定义跑多个环境是很常见的需求。我们通过传递不同.env文件和docker stack deploy的-c参数,基本实现了一套配置多处使用。
具体做法是把 stack file 里的可变内容用变量占位:
services: api: image: registry.example.org/backend:${IMAGE_TAG:-latest} deploy: replicas: ${REPLICAS:-2} placement: constraints: - node.labels.env == ${ENV_NAME}部署测试环境:
IMAGE_TAG=dev-120 ENV_NAME=test REPLICAS=1 \ docker stack deploy -c docker-compose.yml test-stack部署生产环境:
IMAGE_TAG=v2.3.1 ENV_NAME=prod REPLICAS=4 \ docker stack deploy -c docker-compose.yml prod-stack需要注意的是,docker stack deploy不支持自动将 stack name 后面的变量传进去,.env文件由 shell 读取,变量展开发生在 docker CLI 层。因此有条件时,建议在 CI/CD 里生成最终渲染好的 stack file 再执行部署,避免变量展开不一致。
6.2 水平扩容的注意点:端口、存储和会话保持
docker service scale api=6可以把副本数从 4 调整到 6,但这三条经验值得记住:
- 端口不要在 service 定义里直接映射到宿主机固定端口,尤其在多个副本跑在不同节点时。固定端口意味着副本只能约束在有限节点上,比如
8080:8080一多副本就会出现端口冲突。无状态服务最好只用服务名访问,不直接暴露宿主端口。 - 扩容后副本数增多,如果有依赖本地磁盘或者本机缓存的逻辑,副本分布可能不均匀。扩容前检查任务分布,必要时给副本加软偏好让它们分散。
- 有状态服务不要轻易 scale down,副本一缩数据就少一份。数据库类服务我们始终保持固定副本数,扩容通过其他手段实现。
6.3 节点 draining 的正确姿势
做机器维护时,最怕把任务从节点上强杀。正确做法:
docker node update --availability drain node-03这条命令会让 Swarm 把该节点上所有任务优雅迁移到其他可用节点,迁移过程遵循每个服务的约束和更新策略。等维护完成:
docker node update --availability active node-03但要提醒一下:drain 过程中如果某个服务的约束只允许在 node-03 上运行,它的副本会一直停在 pending 状态而不会被迁走,因为没有任何节点满足约束。这种服务在维护前需要提前调整约束或者接受短暂降级。
6.4 轻量监控组合:cAdvisor 加 Prometheus 加 Alertmanager
调度和更新策略再完善,没有监控就是盲飞。我们在 Swarm 集群上部署了一套轻量监控,组件运行在管理节点上:
- cAdvisor 以 global 模式跑在每个节点,采集容器指标
- Prometheus 用 stack 部署在管理节点,抓取各节点 cAdvisor 数据
- Alertmanager 负责告警路由
告警规则里我们重点设了几条:
- 容器重启次数超过阈值
- 服务运行副本数小于期望副本数
- CPU 使用率连续 5 分钟超过 85%
这套组合的成本在于要维护规则文件,但胜在轻量,不需要部署整套云原生监控体系。Swarm 集群规模小,Prometheus 的抓取压力完全可以承受。
用 Swarm 做服务管理和调度,时间久了你会形成一套自己的节奏:约束定义好、标签设计清晰、更新策略保守、健康检查覆盖到位,剩下的就是让它稳定地跑。调试调度问题时,记得多观察docker service ps的输出,它记录了每个副本的每一次移动、启动、停止和失败原因,比很多日志都更能还原现场。