1. 为什么服务管理要先想清楚
1.1 单机 Docker 的瓶颈在哪里
如果你已经有两台以上的 Docker 主机,还在用docker run一台上手动起容器、用docker restart一个个拉起来恢复,那这篇文章值得认真看完。Docker Swarm 的核心,不是简单把你从单机搬到多机,而是把“服务怎么跑、跑几个、跑在哪、挂了怎么办”变成一句声明式描述。这一篇是本系列第 003 篇,前面两篇主要处理集群初始化和跨节点网络,这一篇集中聊服务管理与调度策略。
先聊一个很现实的场景。假设你有三台服务器,每台上面手动跑着四五个容器,更新版本的时候要挨个停掉旧容器、再启动新容器;某台机器突然磁盘满了或者宕机了,上面的服务不会自动切走,你只能在半夜爬起来手动迁移。这就是单机容器管理的典型痛点:没有“期望状态”这个概念,所有运维动作都依赖人肉执行。而 Docker Swarm 做的事情,就是把“我希望这个服务始终有 3 个实例在跑”这个意图交给集群自己维护,一旦实例数少了,它会自动补;节点出故障了,它会自动把任务调度到健康节点。
1.2 服务、任务、节点:Swarm 思考问题的方式
在正式看调度策略之前,一定先把三个概念分清楚:Service、Task、Node。
- Service 是服务定义,描述的是镜像、副本数、网络、端口、资源限制、更新策略等,它代表“期望状态”。
- Task 是调度单元,可以理解成一个“待运行的容器实例”,Service 想要 3 个副本,Swarm 就会创建 3 个 Task。
- Node 是集群里的机器,分为 Manager 节点和 Worker 节点,Task 最终会被调度到某个 Node 上运行。
我习惯用一个比喻:Service 是岗位说明书,Task 是在岗员工,Node 是工位。你只管写清楚岗位需要几个人、需要什么技能,人事系统会自动把人分到合适的工位上,并在有人离职时及时补员。这个思维转换是这个系列里最重要的一步,因为后面所有调度策略、滚动更新、故障恢复,本质上都是围绕这三个概念展开的。
2. 实战案例:某订单系统的集群设计与服务编排
2.1 案例拓扑与设计思路
为了把调度策略说透,我拿一个典型的某订单系统来做例子。这个系统不需要多大,但架构层次要够,能覆盖无状态服务、有状态服务和固定节点调度三类典型问题。
整个系统分四层:
| 服务名 | 镜像 | 副本数 | 角色说明 |
|---|---|---|---|
| orders-web | nginx:1.25 | 2 | 前端入口,对外暴露 80 端口 |
| orders-api | orders-api:v1 | 3 | 业务 API,CPU 敏感型 |
| orders-cache | redis:7 | 2 | 缓存,可以随时重建 |
| orders-db | postgres:15 | 1 | 业务数据库,有状态且不能随便漂移 |
设计考虑很直接:无状态服务全部多副本,既能扛流量又能容忍节点故障;Redis 虽然带数据但缓存可以重建,所以也给了 2 个副本;数据库是唯一一个有状态服务,数据落在本地盘上,一旦容器被调度到别的主机,旧数据就找不到了,所以我这里把它固定到特定节点。这个“固定”动作就是一次典型的调度约束应用,后面第 3 章会详细展开。
2.2 集群节点规划与标签设计
集群我用了 3 台机器,全部加入同一个 Swarm。如果你条件有限,3 台虚拟机也可以,重点是理解整条链路。节点角色上,为了管理面高可用,三台全部初始化为 Manager,同时它们也承担 Worker 职责跑业务任务,适合中小规模集群。
初始化集群的命令很常规:
# 在第一台机器上执行,拿到 join token docker swarm init --advertise-addr 192.168.1.10 # 其余两台机器加入,按照输出的 token 补全 docker swarm join --token <worker-token> 192.168.1.10:2377之后把节点提升为 Manager,保证 Raft 协议下有奇数个管理节点:
docker node promote node-2 node-3节点本身没有“用途”概念,全凭标签。我给 node-1 打上数据库标签,给 node-2 和 node-3 打上 API 角色标签,后续服务创建时通过约束条件把这些标签用起来:
docker node update --label-add db=true node-1 docker node update --label-add role=api node-2 docker node update --label-add role=api node-3再建一个独立的 overlay 网络,让所有服务跑在同一个二层逻辑网络里,服务名直接作为 DNS 域名互访:
docker network create -d overlay --attachable orders-net2.3 一条 service create 命令,搞清楚这些参数才算入门
下面是我创建 orders-api 服务时实际用到的命令,几乎覆盖了大部分核心调度和服务管理参数:
docker service create \ --name orders-api \ --replicas 3 \ --network orders-net \ --constraint node.labels.role==api \ --reserve-cpu 0.5 \ --reserve-memory 512m \ --limit-cpu 1 \ --limit-memory 1g \ --update-config parallelism=1,delay=10s,failure-action=rollback,monitor=30s \ --rollback-config parallelism=2,monitor=30s \ --restart-condition any \ --health-cmd "curl -f http://localhost:8000/health" \ --health-interval 10s \ --health-timeout 5s \ --health-retries 3 \ orders-api:v1逐条说几个关键点:
--constraint node.labels.role==api让副本只调度到打了role=api标签的节点上,实现角色隔离。--reserve-cpu 0.5和--reserve-memory 512m是给调度器看的,每个副本要求节点预留这么多资源。--limit-cpu 1和--limit-memory 1g是给容器运行时看的,限制容器最多用多少。--update-config控制滚动更新节奏,parallelism=1表示一次只更新一个副本,delay=10s表示每个批次间隔 10 秒,failure-action=rollback表示更新失败自动回滚。--health-cmd让 Swarm 能感知容器健康状态,不健康的容器会被自动重建或摘除流量。
很多新手容易把reserve和limit搞混,后面第 3 章会专门算这笔账。
3. 调度策略落地:让任务跑在它该跑的地方
3.1 Swarm 调度器到底是怎么想的
先说一个容易踩的坑:网上不少老教程会让你在 Docker Swarm 里配置spread、binpack、random三种全局调度策略,那是早期 standalone Swarm 的功能。现在 Docker 内置的 Swarm Mode,已经没有直接切换全局策略的命令了,调度器会自动做决策。它决策时主要看三件事:
- 容量够不够:节点剩余 CPU、内存是否满足服务的
reservation。 - 约束满不满足:服务配置了
constraint,节点标签、角色、可用性是否匹配。 - 分散优先:在满足前两条的前提下,Swarm 默认把同服务的副本尽量分散到不同节点,避免一台机器挂了导致整个服务全挂。
这个默认的分散优先逻辑,本质上就是当年spread策略思路的延续,也是生产环境最合理的默认选择。如果某天你发现所有副本都挤在一个节点上,先别怀疑调度器“坏了”,优先检查是不是约束条件把其他节点都排除了。
| 策略理念 | 核心思想 | 适合场景 | Swarm Mode 中的实现 |
|---|---|---|---|
| spread | 任务尽量分散 | 高可用优先 | 默认倾向,节点资源打分 |
| binpack | 任务尽量集中 | 资源紧张,省节点 | 用约束和 availability 组合模拟 |
| random | 随机放置 | 压测验证 | 不推荐在生产使用 |
3.2 用标签和约束把任务引到正确节点
调度器很聪明,但它不知道你的业务规则。比如数据库必须跑在机房 A 的磁盘阵列上,API 服务希望和缓存服务在同一台机器上降低网络延迟,这类语义必须靠人告诉 Swarm。手段就是节点标签加约束。
常见的约束规则有这些:
| 约束写法 | 含义 | 示例 |
|---|---|---|
node.labels.xxx==yyy | 节点标签必须等于指定值 | node.labels.db==true |
node.labels.xxx!=yyy | 节点标签不能等于指定值 | node.labels.role!=web |
node.role==manager | 必须是管理节点 | node.role==manager |
node.hostname==node-2 | 必须是某个主机名 | node.hostname==node-2 |
engine.labels.xxx==yyy | 匹配 Docker 引擎标签 | engine.labels.cloud==ali |
多个约束之间是“并且”关系,也就是说要全部满足才能调度。我在案例里用到的约束组合是:数据库服务只跑db=true的节点,API 服务只跑role=api的节点,前端 nginx 不设置约束,这样它可以被调度到任何存活节点,充分利用集群资源。
还要强调一个细节:约束条件里的等号和空格写法在不同地方不一样。docker service create命令行里可以写成node.labels.role==api,而在 compose stack 文件里通常写作node.labels.role == api,两边对齐时很容易抄错。如果你用docker stack deploy方式管理服务,注意别把单等号写进 YAML,那是无效约束。
3.3 资源预留与限制:调度器的“算账方式”
调度策略里最容易被低估的是资源参数。很多人以为只要在service create里写了--limit-memory 1g,调度器就会保证这个节点只吃下这么多任务。实际完全不是这样。
在 Swarm 里:
reservation是“调度账本”,调度器在做放不放任务决策时,只认这个数。它表示这个任务至少需要多少资源,节点剩余资源不够就拒绝调度。limit是“运行账本”,它只用来配置容器的运行时上限,不会参与调度判断。
举个例子,一个节点总内存 4G,上面已经跑了两个各预留 1G 的服务,调度器会认为这个节点可用内存只剩 2G。如果你再创建一个预留 1.5G 的服务,它大概率不会调度到这个节点。但如果你只写了--limit-memory 1g而没写--reserve-memory,调度器默认认为这个任务“不占资源”,它可能把四个任务全放到同一个节点,运行一段时间后容器因为超卖被 OOM 杀掉,这就是典型的事故现场。
所以我的建议很简单:凡是 CPU 敏感或内存敏感的服务,reservation一定要写,而且写实际基线值;limit写上限值,防止某个实例异常占用把整机拖垮。拿案例里的 orders-api 来说,平时稳定用量大约 0.4 核和 300M 内存,峰值能到 0.9 核和 800M,所以我写了--reserve-cpu 0.5、--reserve-memory 512m,同时用--limit-cpu 1、--limit-memory 1g挡住异常。
3.4 有状态服务的调度:数据卷与固定节点
有状态服务和调度策略之间有一对天然矛盾:调度器想让任务跑得更分散更安全,但本地数据卷却把容器死死钉在原节点上。这个问题在数据库、消息队列这类服务上几乎一定会遇到。
案例里的 orders-db 我只有一个副本,并且把数据卷挂载到宿主机路径:
docker service create \ --name orders-db \ --constraint node.labels.db==true \ --mount type=volume,source=db-data,target=/var/lib/postgresql/data \ --network orders-net \ postgres:15如果不加约束,Swarm 正常按“分散优先”逻辑,完全可能在某次节点故障后把这个数据库任务调度到另一台机器。问题来了:另一台机器上根本没有db-data卷,调度器会默默新建一个空卷挂上去,数据库进程是起来了,但里面的表和数据全没了。这不是数据损坏,是数据“不在现场”。
两个解决方向:第一,老老实实固定节点,用node.labels.db==true约束住,同时把该节点设置为避免意外 drain;第二,换用支持多主机共享的卷插件,让任何节点都能挂载同一个共享存储。共享存储更适合真正需要高可用的数据库,但多数中小团队一开始用第一种就够了。切记:本地数据卷 + 无约束调度,是这个系列最危险的一个组合。
4. 服务生命周期实操:扩缩容、更新、故障转移
4.1 扩缩容:一条命令改变副本数
服务运行一段时间后,流量上来了,需要把 API 从 3 个副本扩到 5 个。Swarm 有两种等价方式:
docker service scale orders-api=5 # 或者 docker service update --replicas 5 orders-api命令执行后,Swarm 会立刻创建两个新的 Task,并按照之前的约束和资源预留条件,寻找合适的节点完成调度。缩容同理,把5改成2即可,Swarm 会优先停止哪几个副本由它自己决定,通常是把不同节点上的任务依次停掉,尽量保证剩余副本仍然分散在不同节点。
这里有个小经验:缩容前先把业务流量切换走,别在高峰期直接缩,否则瞬间断连很容易引发一连串报错。扩缩容本身不影响集群里的服务定义,属于很轻量的操作,配合docker service ps观察任务状态,几秒钟就能确认结果。
4.2 滚动更新与一键回滚
版本升级是运维里最常做也最怕出问题的事。用 Swarm 做滚动更新,要义是先控制节奏,再控制失败处理。假如我现在要把 orders-api 从 v1 升到 v2:
docker service update \ --image orders-api:v2 \ --update-parallelism 1 \ --update-delay 10s \ --order start-first \ --update-failure-action rollback \ orders-api几个参数的实际效果:
--update-parallelism 1:每一批只更新一个副本,避免把整个服务同时重启。--update-delay 10s:每批之间等 10 秒,给新副本留出启动和自检时间。--order start-first:先启动新容器再停旧容器,更新期间新旧短暂共存,减少断服影响。前提是你的应用能接受同一服务临时多出一个实例。--update-failure-action rollback:如果新副本启动后健康检查失败,Swarm 自动回滚到旧版本。
如果更新到一半发现不对劲,可以手动干预:
docker service rollback orders-apirollback会按创建服务时设定的--rollback-config把任务批量退回上一版配置,相当于给线上环境加了一道保险。更新过程中最好开着docker service ps orders-api实时观察每个 Task 的状态流转,一旦看到连续几个Failed,就该果断回滚或暂停,而不是等它反复重试。
4.3 节点维护与故障演练
节点是要维护的,内核升级、磁盘扩容、网络调整,都需要把节点上的任务挪走。Swarm 提供了 availability 三态:active正常调度、pause不调度新任务但保留现有任务、drain不调度新任务且把现有任务全部迁走。
维护节点前,执行:
docker node update --availability drain node-2node-2 上所有服务任务会被 Swarm 自动调度到其他可用节点。维护完成后再改回 active:
docker node update --availability active node-2如果你对“故障自动恢复”没底,建议做一次故障演练:直接把某个 worker 节点关机,观察 Swarm 在节点失联一段时间后,自动把上面的任务在新节点拉起来。实测下来,只要集群有足够的空闲容量、约束条件能被满足,恢复过程完全不需要人工干预。需要特别注意的是数据库类服务,它被约束在固定节点上,如果那个节点整机宕机,Swarm 找不到任何满足约束的节点,任务会一直停留在待调度状态,直到节点恢复或你主动改约束,这是有状态服务高可用的天然代价。
4.4 服务发现与 Ingress 负载均衡
跨节点通信是集群服务管理的一半,Swarm 在这方面做得相当顺滑。所有加入同一个 overlay 网络的服务,都能直接用服务名作为域名互相访问。比如 orders-web 想访问 orders-api,直接请求http://orders-api:8000即可,Swarm 内置 DNS 会自动解析到服务对应的虚拟 IP。
默认情况下服务使用 VIP 模式,Swarm 给每个服务分配一个内部虚拟 IP,再通过节点上的负载均衡组件把流量分发到具体容器实例。对外暴露端口时,用--publish published=80,target=80,mode=ingress或简写-p 80:80,那么集群任意一个节点的 80 端口都能访问到该服务,Ingress 网络会自动把请求转发到后端副本。这意味着你不需要为每台服务器单独配置负载均衡,Swarm 的 Ingress 已经帮你做了一层。之前我在生产里就遇到过困惑:明明服务副本只在 node-2 上,但从 node-1 访问端口也能通,这就是 Ingress 负载均衡在起作用。
5. 踩坑记录与排查技巧
5.1 任务一直停在 New / Preparing 状态
出现这种情况,最直接的感受是docker service ls显示副本数总是 0/3,docker service ps里任务状态一直在New或Preparing。常见原因有三个:镜像拉取特别慢、镜像 tag 不存在、节点磁盘空间不足。
排查命令:
docker service ps orders-api --no-trunc docker inspect <task-id> | grep -i error--no-trunc非常重要,否则输出会被截断,真正的错误信息看不到。镜像拉取问题可以在对应节点上手动执行docker pull验证;磁盘问题用df -h检查 Docker 数据目录所在分区。
5.2 任务反复 Rejected / Failed
任务刚启动就退出、反复重试,通常和镜像启动命令、端口绑定、健康检查有关。比如镜像里其实没有curl,健康检查命令永远失败,Swarm 就会认为容器不健康,按restart-condition无限重启。再比如你把宿主机 8000 端口用 host 模式绑给了两个不同服务,第二个任务必然报端口冲突。
先看日志:
docker service logs orders-api再确认是否所有节点都绑定了同一个主机端口。健康检查命令尽量用镜像内置的工具,或者干脆在容器里安装一个轻量检查脚本,别让健康检查成为新的不可用因素。
5.3 调度失败:no suitable node
当docker service ps显示任务状态为Pending,并在事件消息里明确提示no suitable node时,说明当前节点集里没有任何一台同时满足容量和约束要求。我遇到最多的是三种情况:
- 约束条件写错了,比如
role=api标签只打在 node-2 上,但 node-2 被 drain 了。 - 节点内存不够,所有节点的 reservation 都被占满。
- 服务设置了
node.role==manager,但所有 manager 节点都被设成 drain。
先把约束列出来检查,再docker node ls看节点 availability 是否正常。必要时临时改约束或把某个节点恢复 active,任务就会自动继续调度。
5.4 数据库任务迁移导致数据找不到
这是有状态服务的“经典翻车现场”。通常表现是数据库容器显示 Running,但客户端连上去发现库是空的。原因前文已经说过:本地卷没有跟随任务迁移,新节点上自动创建了一个空卷。
查的时候先确认任务实际在哪个节点:
docker service ps orders-db --format "{{.Node}} {{.CurrentState}}"然后去对应节点查看卷是否存在:
docker volume ls | grep db-data生产环境建议要么把数据库约束死,要么直接上共享存储卷。无状态服务随便漂移没问题,有状态服务一定先把“数据在哪里”这个问题想清楚再谈调度。
5.5 任务状态速查表
最后整理一张速查表,方便你遇到状态异常时快速定位:
| 任务状态 | 含义 | 常见处理方式 |
|---|---|---|
| New | 任务已创建,等待处理 | 等待调度,若长时间不变化检查约束 |
| Pending | 等待调度器分配节点 | 检查资源、约束、节点可用性 |
| Preparing | 正在拉镜像或准备容器 | 检查镜像源、磁盘、网络 |
| Starting | 容器启动中 | 一般正常,短时间后可进入 Running |
| Running | 正在运行 | 无需处理 |
| Failed | 任务异常退出 | 用docker service logs查应用日志 |
| Rejected | 节点拒绝任务 | 检查端口冲突、约束语法 |
| Shutdown | 任务已被停止 | 常见于缩容或节点 drain |
| Orphaned | 编排器失去跟踪 | 一般少见,必要时手动清理 |
写在最后:一点个人实操心得
玩 Swarm 这几年,我最大的体会是:调度策略不是给调度器看的,是给业务定义看的。先把服务分成无状态和有状态两类,无状态放心交给多副本和分散调度,有状态老老实实绑定节点或上共享存储;再把约束、资源预留、健康检查、更新回滚这几件事写成可重复执行的服务定义文件,调度器自然会帮你把事情办妥当。
如果你手头正好有一批 Docker 服务想搬到集群上,又不知道从哪开始,我的建议是先拿一两个无状态服务练手,把service create、scale、update、rollback这套命令跑熟,等对任务状态流转有了手感,再动数据库这类有状态服务。很多人一上来就把数据库放进 Swarm,结果被数据卷和节点迁移坑了一整晚,其实这都是顺序问题。把调度规则想清楚,再把命令敲熟,Swarm 真的能让你省下大量维护机器的精力。