Docker Swarm 服务调度实战:副本分布、约束与滚动更新
从去年开始我一直在一个面向公司内部业务线的容器化项目里做架构和运维支撑服务数量不算多大概三四十个但对稳定性和部署效率的要求不低。期间我们对比过不少方案最后选择的是 Docker Swarm一直跑到今天。这个系列我会用真实项目里的场景来拆 Swarm 的服务管理与调度策略这篇先讲我们上线至今最常用、也最值得抠细节的几个调度实战点。如果你正好纠结怎么让副本更合理地分布在节点上或者滚动更新时总是一升级就断连、回滚又手忙脚乱那这篇文章应该能帮你省下不少时间。1. 为什么我在新项目里选了 Swarm 而不是 K8s1.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 到 constraint2.1 一条 service 命令背后的调度逻辑很多入门教程讲到docker service create就一带而过但这条命令的参数拆开来看其实包含了 Swarm 调度器做决策的所有依据。每次创建或更新服务调度器要解决三个问题副本放哪台机器、放几个、什么时候动。先说放几个。--replicas指定目标副本数--mode global则会让每个节点上都跑一个副本。默认是 replicated 模式调度器会尽量让副本分散到不同节点上数据落地的节点编号你看不到也没关系关键是理解它背后有个打分逻辑节点剩余资源、已有副本数量、约束条件、偏好条件都会影响决策。再说放哪台。--constraint是硬性过滤条件节点不满足就直接排除--placement-pref是软性偏好Swarm 会优先满足但不强制。举个例子--constraint node.labels.ssd true保证有 SSD 标签的节点才被选--placement-pref spreadnode.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硬件域ssdtrue、gpufalse、high-memtrue打标签的命令很简单docker node update --label-add webtrue node-01 docker node update --label-add ssdtrue node-02为什么要分开而不是直接写node-01-web因为标签是可以组合使用的比如某个服务既要跑到web节点又需要ssd约束里写两条就行。标签拆得越细调度组合的空间就越大。我们后来引入了一批只有 8GB 内存的低配机器业务服务想避开它们只加一个low-memtrue标签旧服务无需改动新服务加一条反约束node.labels.low-mem ! true就实现了。3.2 需要一个约束还是多个约束服务对节点的要求经常不止一个比如有状态的数据库服务不但要求dbtrue还要求ssdtrue甚至可能要固定在某两台机器上。我们有一个后端服务依赖本机挂载的 NFS 目录这个目录只在 node-01 和 node-02 上存在。约束如果用placement: constraints: - node.labels.nfs true只要打了nfstrue的机器都可以调度副本会自动在 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设成 2delay设为 5 秒结果更新到一半服务出现间歇性 502。原因很简单总共 6 个副本每次同时更新 2 个等于每轮有三分之一副本在重启负载均衡器的健康检查还没反应过来流量已经打到正在关闭的容器上了。后来调整为parallelism1、delay30s整个发布过程虽然慢一些但每轮只有一个副本处于不可用状态对整体流量没有任何影响。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会让容器反复拉起。当时没有配置healthcheckSwarm 判断任务是否健康的依据就是进程是否退出而崩溃后立即重启会被当作暂时性故障一直反复尝试旧副本又一个都没停流量没断但日志刷得很快。我们当时的处理路径先定位新镜像的问题确认是代码原因决定回滚执行docker service update --rollback webSwarm 按之前的部署历史回退到上一次镜像观察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 服务按前面说的parallelism1、delay30s执行理论上应该很稳。但更新过程中监控还是出现了零星 502集中在更新后第 5 到 15 秒之间。刚开始怀疑是停车时间重叠导致的但排查了一下任务状态发现新旧副本在更新时间上没有重叠。这就很奇怪了因为start-first模式下旧副本应该一直被保留到新副本健康后才停止。5.2 排查链路从容器日志到 DNS 解析我先看了 Swarm 的集群事件和任务日志没有发现任务异常。接着看服务日志也没有明显报错。然后我检查了 ingress 网络的入口因为流量是通过 Swarm 内置的 Routing Mesh 进入服务的这里有一个关键机制Swarm 的 ingress 网络给每个服务分配一个 VIP入站流量到达任意节点的 ingress 端口后会被负载均衡到该服务的健康副本上。问题在于服务名解析有两种模式VIP 模式默认查询tasks.服务名返回一个虚拟 IPDNS 轮询模式查询返回所有副本的 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保持parallelism1、delay30s调整之后后续几次发布的 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_TAGdev-120 ENV_NAMEtest REPLICAS1 \ docker stack deploy -c docker-compose.yml test-stack部署生产环境IMAGE_TAGv2.3.1 ENV_NAMEprod REPLICAS4 \ 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 api6可以把副本数从 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的输出它记录了每个副本的每一次移动、启动、停止和失败原因比很多日志都更能还原现场。