先说我自己的一个经历。有次帮一个团队排查Swarm集群问题,现象很典型:三台机器,一台是全新NVMe盘,两台是普通SATA盘,数据库服务副本却总是三个节点各放一个,慢的那两台天天报延迟告警。后来加了节点标签,用约束把数据库钉在SSD节点上,问题立刻消失。这类问题在Swarm里非常常见,因为默认调度器根本不关心“哪台机器更快”“谁在哪个可用区”“谁是GPU机器”,它只认节点可用资源和副本数量。想让调度按业务维度走,核心就是两件事:给节点打上语义化标签,然后用调度策略让服务按标签落位。
这篇文章我会用10个完整例子,把节点标签从创建、查看、更新、删除,到与约束调度、偏好分布、节点维护模式结合的全生命周期串一遍。所有命令都是我在真实环境里反复用过的,适合正在用Swarm做中小规模集群、又不想被Kubernetes的复杂度劝退的运维和开发同学参考。
1. 为什么 Swarm 里调度离不开节点标签
1.1 默认调度器到底做了什么
Swarm的默认调度逻辑可以简化成一句话:把副本尽量均匀地撒到所有可调度节点上。具体判断依据主要是节点当前运行的任务数、CPU和内存水位、以及节点是否处于drain或pause状态。这套逻辑在节点规格完全一致的测试环境里挺好用,但一到生产环境就露馅了。
生产集群一定有差异。某个节点磁盘是SSD,某个是HDD;某些节点配置了GPU,某些没有;有的节点在可用区A,有的在可用区B,数据库和缓存最好放同一个可用区。默认调度器无法理解这些业务语义,它只做“均匀分配”这一件事。这时候节点标签就是那层“翻译”:把节点的物理属性、环境归属、位置信息,翻译成调度器能理解的键值对。
1.2 标签在调度链路中的位置
你可以把Swarm的调度链路理解成一道流水线:
- 服务创建或更新时,调度器先做硬性过滤,也就是约束(constraint)。约束不满足的节点直接被淘汰。
- 剩余节点进入软性排序阶段,也就是偏好(placement preference)。调度器会尽量让副本按你指定的标签维度均匀展开,但如果实在满足不了,也不会报错。
- 最后从排序最优的节点里选一个放置任务。
节点标签在这条链路里扮演的角色是“候选节点的筛选依据”。没有标签,约束和偏好就无从谈起,调度器只能退回到默认的均匀分布策略。所以标签管理是Swarm调度体系的地基。
2. 标签体系怎么规划才不容易踩坑
2.1 标签命名:先定规矩
很多同学上来就docker node update --label-add a=b这样随意打,等标签多了再回头看,根本不知道每个键是干嘛的。我建议从一开始就做分层命名,比如zone=A表示可用区,disk=ssd表示磁盘类型,env=prod表示环境,role=gpu表示是否GPU机器。
键名统一小写,值尽量用短枚举。布尔值不要用yes/no混着写,全部用true/false。带环境语义的多环境共用集群,环境名固定用test/staging/prod三种,别搞出pre、pre-prod、production-2025这种变体。命名规范的最大价值是让人不查文档也能猜出标签含义,排查调度问题时能少走很多弯路。
2.2 标签粒度与值域设计
标签粒度很考验经验。太细,比如把每台机器的IP、内存条数都打上标签,调度策略会变得极其死板,加机器就要改一批服务配置;太粗,比如整个集群只有env=prod一个标签,过滤效果约等于无。
我的经验是:先想清楚这个集群里有哪些“调度维度”是真的需要区分的,再决定打哪些标签。常用维度就这四类:
| 标签维度 | 示例 | 典型用途 |
|---|---|---|
| 环境归属 | env=prod | 隔离测试与生产流量 |
| 硬件差异 | disk=ssd、gpu=true | 需要高性能磁盘或有GPU的任务 |
| 位置信息 | zone=A | 高可用部署,跨可用区分散副本 |
| 节点角色 | role=monitor、role=storage | 固定专用组件到特定机器 |
2.3 两个必须避免的反模式
第一个反模式是把动态信息当标签。比如有人把ip=10.0.0.8打上去,机器IP一变,服务约束就失效,甚至出现调度器把任务放到一个已经完全不符合条件的节点上。节点固有属性里本来就有node.hostname和node.id,能直接约束,不需要自己重复打标签。
第二个反模式是一台节点扛太多标签。有人把运维平台里的主机属性全部塞进来,标签一多,约束条件写起来又长又难维护,而且一旦某个标签被误删,影响面很难定位。标签是给调度器用的“挑选依据”,不是CMDB的替代品,保持精简,够用就好。
3. 节点标签的完整生命周期:添加、查看、更新与删除
3.1 例1:给节点打上第一个标签
先看环境。假设集群有node1、node2、node3三台节点,我要给node1打两个标签:环境是test,磁盘是ssd。
docker node update --label-add env=test node1 docker node update --label-add disk=ssd node1上面的写法是一条命令只打一个标签,没问题。实际我更喜欢一次打完,减少一条命令的“窗口期”:
docker node update --label-add env=test --label-add disk=ssd node1--label-add可以重复出现多次,Swarm每看到一个--label-add就更新一个键值对。验证一下:
docker node inspect node1 --format '{{json .Spec.Labels}}'期望输出类似{"disk":"ssd","env":"test"}。注意这里用的是.Spec.Labels,它存的是集群管理面的节点规格信息,由manager节点写入并同步到raft状态库。
这里有个细节:修改节点标签必须要manager权限,worker节点上没有命令行入口能改自己或别人的标签。这也符合Swarm“所有控制面操作都走manager”的设计原则。
3.2 例2:批量盘点集群节点标签
节点一多,逐个docker node inspect会烦死人。我习惯写一行shell循环,把所有节点的主机名和标签一次性打出来:
for node in $(docker node ls -q); do name=$(docker node inspect "$node" --format '{{.Description.Hostname}}') labels=$(docker node inspect "$node" --format '{{json .Spec.Labels}}') echo "$name: $labels" done这段逻辑不复杂,但胜在直观。docker node ls -q拿到的是所有节点的ID,循环里先取主机名(用ID去查而不是直接读docker node ls的Hostname列,是因为后续inspect操作能保证节点ID和名称严格对应,避免窗口期内主机名变动导致的脏数据),再取标签JSON,最后拼成一行。
实际跑出来的效果类似:
node1: {"disk":"ssd","env":"test"} node2: {} node3: {"disk":"hdd","env":"prod"}看到node2的标签是空对象,就说明它还没打标签。这种批量盘点动作要养成习惯,每次调整标签前先跑一遍,避免凭记忆操作。
3.3 例3:更新标签与删除标签
标签更新和添加是同一个动作。比如把node1的环境从test改成prod:
docker node update --label-add env=prod node1Swarm没有“修改”这个动作,--label-add幂等处理:键存在就覆盖,不存在就新建。所以“更新”就是重新--label-add。这一点接手别人集群时尤其要留意,别看到--label-add就以为只是新增。
删除标签用--label-rm:
docker node update --label-rm env node1同样支持一次删多个:
docker node update --label-rm env --label-rm disk node1删完再跑一遍批量盘点,确认目标节点的标签已经变成预期状态。
提示:无论是更新还是删除节点标签,Swarm都不会自动重新调度已运行的任务。标签变化只影响“下一次新建或更新服务”时的调度决策。想让存量任务立刻按新标签落位,需要手动
docker service update --force或临时把节点drain掉再恢复。这是个非常容易踩的坑,具体做法在第6章会展开。
3.4 例4:通过 daemon.json 注入引擎标签
除了手动打标签,还有一种标签来自Docker守护进程自身,叫引擎标签(Engine Labels)。它写在节点的/etc/docker/daemon.json里,节点启动时上报给Swarm集群。
{ "labels": ["net=fast", "gpu=true"] }改完配置重启dockerd才能生效:
systemctl restart docker验证引擎标签是否被守护进程识别:
docker info --format '{{json .Labels}}'在Swarm里调度服务时,引擎标签和手动标签的引用方式完全一样,都是node.labels.gpu、node.labels.net这种写法。唯一区别是:引擎标签不能通过docker node update --label-rm删除,只能改daemon.json再重启。
这里提醒一句:如果给manager节点改daemon.json重启docker,raft成员会短暂离线。单manager集群会导致整个集群的控制面不可用几秒钟;多manager集群一般没事,但最好还是挑维护窗口操作。worker节点重启不影响Swarm控制面,但节点上正在运行的任务会先漂走再回来,要有这个预期。
4. 约束式调度:让服务精确落到指定节点
4.1 例5:基于标签的硬性约束
约束是调度器过滤候选节点的第一道关卡,也是最常用的调度手段。先看一个基础例子:只把服务调度到disk=ssd的节点上。
docker service create --name web-demo \ --constraint 'node.labels.disk==ssd' \ --replicas 3 \ demo/web:1.0.0如果集群里正好有一台disk=ssd的节点,那么三个副本会全部落到这台机器上。这里有人会问:三个副本放一台,不是浪费另外两台机器吗?对,约束只是“硬过滤”,它不管节点均衡,这是偏好(placement preference)要解决的问题。
创建之后验证约束确实写进去了:
docker service inspect web-demo --format '{{json .Spec.TaskTemplate.Placement}}'输出里会包含Constraints数组,能看到["node.labels.disk==ssd"]。这个命令比看创建返回信息靠谱得多,排查服务配置时我第一件事就是看这里。
4.2 例6:多约束组合的 AND 逻辑
真实场景通常不止一个筛选条件。比如生产环境的SSD机器,才允许跑某个核心服务:
docker service create --name app-prod \ --constraint 'node.labels.env==prod' \ --constraint 'node.labels.disk==ssd' \ --replicas 2 \ demo/app:2.0.0多个--constraint之间是AND关系,每个条件都必须满足,节点才有资格入选。上面这个服务只会落到env=prod且disk=ssd的节点上,两个条件缺一不可。
这里有一个大家容易搞混的细节:如果同一个标签键写了多个不同的值,SwarmKit会把这些值按OR语义解析。比如:
--constraint 'node.labels.env==prod' --constraint 'node.labels.env==staging'意思是节点只要是env=prod或env=staging都算满足。不同标签键之间是AND,同一个键的多个值是OR。这种隐藏语义容易给排障造成困惑,我建议在正式环境里不要依赖这种隐式OR,直接改用正则表达式把意图写得明明白白,见下一个例子。
4.3 例7:用正则表达式描述枚举范围
Docker 20.10及以上版本,约束里支持正则匹配。操作符是=~和!~,语法用的是RE2,不支持反向引用、零宽断言这类PCRE高级特性。
假设有一个服务可以跑在test或staging环境,但不允许上prod:
docker service create --name app-pre \ --constraint 'node.labels.env=~^(test|staging)$' \ --replicas 3 \ demo/app:1.1.0这条约束的意思是:node.labels.env的值必须匹配合法正则。^和$锚点很重要,不然production会因为包含prod子串被误匹配。同理,排除某个环境:
--constraint 'node.labels.env!~^prod$'我把这条规则称为“少数枚举用OR,范围匹配用正则”。正则约束让调度条件可以写得很紧凑,可读性也高。
再补充一个shell细节:!~里有个!,在交互式bash里紧跟着字符会触发历史扩展,所以写约束时务必用单引号包裹整个表达式,不光是美观问题,更是安全问题。
对于20.10之前的版本,正则约束不会报错,而是被当成普通字符串做字面比较,结果就是约束永远不匹配、任务一直卡在Pending。所以用了这条特性之后,建议先确认版本:
docker version --format '{{.Server.Version}}'4.4 例8:约束节点的固有属性
除了node.labels.*,Swarm还内置了几个不需要打标签就能用的节点属性:
node.id==xxx:按节点ID精确匹配node.hostname==xxx:按主机名匹配node.role==manager|worker:按角色匹配node.platform.os==linux:按操作系统匹配node.platform.arch==x86_64:按CPU架构匹配
比如把集群监控组件固定在manager节点上,可以利用leader节点的稳定性,避免监控组件和业务容器互相抢资源:
docker service create --name monitor-svc \ --constraint 'node.role==manager' \ --replicas 1 \ demo/monitor:1.0.0这里提一个问题:既然有node.hostname,为什么还要打标签hostname?很多新手会这么做,这是一个典型的冗余操作。Swarm能直接读取的主机属性,就没必要再打一份标签,标签应该用于描述“Swarm默认不知道”的业务维度。如果机器换主机名,node.hostname会跟着变,实体IDnode.id却保持不变;反过来,你打标签存进raft的Spec.Labels也不会自动同步。两者含义完全不同。
5. 软性调度:placement preferences 与副本上限
5.1 例9:用 spread 让副本按标签均匀分布
约束负责“能不能放”,偏好负责“放得均不均匀”。Swarm提供的偏好指令叫placement-pref,目前主要用的就是spread。
需求场景:有6个副本要跨可用区分布,避免所有副本挤在同一个可用区里。假设每个节点打了zone标签,值为A、B、C:
docker service create --name api-svc \ --replicas 6 \ --placement-pref spread=node.labels.zone \ demo/api:2.0.0调度器执行任务分配时,会优先把6个副本均匀分散到不同的zone值上。理想情况下每个可用区2个副本。如果某个可用区只有1台节点,那这台节点就会扛下2个副本,这是spread的“尽力而为”特性:它尽量均匀,但不会因为某个标签值下节点资源不够就拒绝调度。
还可以给同一个服务配置多个偏好,先按大维度再按小维度展开:
--placement-pref spread=node.labels.zone \ --placement-pref spread=node.labels.rack这条命令的意思是:先尽量让副本跨可用区均匀,再在同一个可用区里尽量跨机柜均匀。多个偏好按顺序生效,前面一级的优先级高于后面。
偏好和约束是两种完全不同的调度语气。约束说“必须”,偏好说“最好”。约束不满足的任务会一直Pending,偏好不满足的任务会照常运行,只是分布可能不够理想。排查调度问题时要先分清你用的是哪种,别把Pending的锅甩给偏好。
5.2 例10:综合实战:约束 + 偏好 + 副本上限
20.10版本开始,Swarm支持--replicas-max-per-node,限制单个节点上最多运行多少个副本。它本身不依赖标签,但和标签组合起来特别有用。
完整需求:生产环境服务,只能跑env=prod的节点,尽量跨可用区均匀分布,同时单个节点最多2个副本,总副本数8。
docker service create --name prod-core \ --constraint 'node.labels.env==prod' \ --placement-pref spread=node.labels.zone \ --replicas 8 \ --replicas-max-per-node 2 \ demo/core:3.0.0调度器处理这条命令的大致流程:
- 先按
env==prod过滤节点,所有非生产节点直接出局。 - 对剩余节点按
zone标签值计算均匀度,目标是让8个副本尽可能分散到不同zone。 - 在放置每个副本时,检查目标节点当前已分到的副本数是否已经达到2,达到就换下一个候选节点。
最终效果往往是:生产环境3个可用区、每个可用区一台机器,则每台机器2个副本,刚好8个。这是一个非常典型的组合拳,既有硬性约束控边界,又有软性偏好控分布,还有数量上限控密度。
这个配置里我特别建议用docker service inspect把最终Placement配置导出来核对一遍,因为命令行的参数一旦多了,人和机器的理解会出现偏差。实际排查时也遇到过类似情况,明明写了四个flag,导出来看才发现某个偏好写错了标签名。配置被正确解析,服务创建成功的返回值并不代表它按你想象的方式工作。
5.3 约束与偏好的调度决策过程
把第4、5章的内容合并成一张调度决策链路图,便于理解整个选点过程:
| 阶段 | 机制 | 作用 | 不满足时 |
|---|---|---|---|
| 第一关 | 约束(constraint) | 硬性过滤候选节点 | 任务Pending,服务无法正常调度 |
| 第二关 | 偏好(preference) | 软性打分排序节点 | 不报错,分布可能不理想 |
| 第三关 | 副本上限(max-per-node) | 限制单节点任务密度 | 任务继续选下一个候选节点 |
三个机制可以单独使用,也可以全部叠加。用一句话记住它们的分工:约束管“能不能”,偏好管“均不均”,上限管“挤不挤”。
6. 服务更新、节点维护与标签策略的联动细节
6.1 服务更新时调度策略的调整
服务创建之后,约束和偏好不是一锤定音,可以在服务更新时动态调整。比如早期没加SSD约束,现在想把服务迁移到SSD节点:
docker service update --constraint-add 'node.labels.disk==ssd' app-prod去掉某个约束用--constraint-rm,注意参数必须和当初添加时完全一致,包括空格。如果之前写的是node.labels.disk == ssd(带空格),删除时也得用同样的写法,否则Swarm匹配不上,约束会一直残留。
偏好同样支持动态调整:
docker service update --placement-pref-add 'spread=node.labels.zone' api-svc更新约束和偏好之后,Swarm会重新评估所有副本的分布,并把不符合新条件的任务重新调度到别的节点。这是和节点标签变更最大的不同点:改服务配置是调度信号的“主动触发”,改节点标签则不会。
6.2 节点维护模式与标签的协同
节点要维护了,先看它上面跑了哪些服务,然后把它切到drain模式:
docker node update --availability drain node2进入drain模式之后,Swarm会把它上面所有非global任务全部停掉,再按这些服务各自的约束和偏好,在其它节点上重建副本。这正是标签体系价值最明显的时刻:如果没有标签约束,drain之后的任务会被随机撒到所有节点上;有标签约束,它们只会落到满足条件的节点,整个集群的调度秩序不会乱。
维护结束,把节点切回active:
docker node update --availability active node2节点恢复active之后,之前被迁走的任务不会自动迁回来。要想让它回来,要么等下一次服务更新,要么手动触发重新调度。这是Swarm的默认行为,不是bug。要我排名的话,这是生产环境中仅次于标签不触发迁移的第二大“假故障”。
节点还有第三种状态pause,它比drain更彻底:节点上的现有任务继续跑,但不接受任何新任务。drain是“连锅端”,pause是“只出不进加不进”。实际维护场景里,短暂重启docker守护进程用pause更安全,长时间硬件维护用drain更合适。
7. 调度陷阱与排查实录:对账式速查
7.1 陷阱一:标签改了,任务还在老地方
这是最经典的“以为会动但没动”场景。某天你给一批节点加了env=prod标签,想让app-prod服务重新调度,结果等了五分钟任务纹丝不动。
原因:节点标签是调度器的“静态档案”,改动不会触发存量任务的重新评估。Swarm只在服务创建、服务更新、节点状态变化这些显式事件发生时才重新调度。
正确做法有两种。一是强制刷新服务:
docker service update --force app-prod二是临时把相关节点drain后再active,让任务自然迁走再决定是否回来。
7.2 陷阱二:服务一直Pending,约束明明写在服务里
创建带约束的服务,任务状态卡在Pending,docker service ps能看到错误提示“no suitable node”之类。大多数时候不是约束条件本身错了,而是节点标签和约束条件对不上。
排查套路按顺序来:
# 1. 看所有节点的标签 for node in $(docker node ls -q); do docker node inspect "$node" --format '{{.Description.Hostname}}: {{json .Spec.Labels}}' done # 2. 看服务的实际约束配置 docker service inspect app-prod --format '{{json .Spec.TaskTemplate.Placement}}' # 3. 自己手动找一找,有没有节点同时满足所有键值第3步常被忽略。实际操作里很多人只盯着某个标签是否存在,却忘了服务可能同时限制了环境、磁盘、角色等多个维度。我习惯在第1步的循环里顺手把所有节点的标签打出来,然后人肉对照服务约束逐条核对,多数时候能立刻发现问题。
另一个高频原因就藏在引号里。--constraint 'node.labels.env==prod'写成--constraint "node.labels.env == prod"也能被接受,但后续操作、--constraint-rm、脚本化的inspect解析全都会因为空格不一致而变得不可控。我的建议是约束表达式永远用单引号、永远不加多余空格,形成肌肉记忆。
7.3 陷阱三:偏好被当成约束用
有同事把--placement-pref spread=node.labels.disk当成“把服务尽量放SSD”来用,结果任务随机散落在HDD节点上,因为HDD和SSD的zone值都参加了spread,均匀分布后两者都会被选。
这里想清楚一点:spread的核心是“均匀”,不是“优先选值”。你想筛选指定硬件,请用--constraint 'node.labels.disk==ssd';你想让副本在不同值之间均匀,才用spread。这两个维度完全不同,混用的结果就是调度结果完全不符合直觉。
7.4 对账式速查表
最后把排查过程中最常用的命令整理成一张速查表,贴在运维文档里比翻网页快得多:
| 排查目标 | 命令 |
|---|---|
| 单节点标签 | docker node inspect <node> --format '{{json .Spec.Labels}}' |
| 引擎标签 | docker info --format '{{json .Labels}}' |
| 所有节点标签 | 循环inspect(见例2) |
| 服务实际约束与偏好 | docker service inspect <svc> --format '{{json .Spec.TaskTemplate.Placement}}' |
| 任务调度状态与错误 | docker service ps <svc> |
| 查看节点维护状态 | docker node ls(看AVAILABILITY列) |
| 强制重新调度 | docker service update --force <svc> |
我个人在实际操作中的体会是,标签和调度策略这个东西,单一命令并不难,难的是整个集群在既定标签体系下长期保持秩序。把标签命名规范定好,把约束表达式的写法固定下来,把“标签变更不迁移任务”这个预期刻进团队意识里,Swarm的调度问题能少一大半。如果你也在维护Swarm集群,建议从今天开始先把集群里所有节点的标签盘一遍,认认真真打上环境、磁盘、可用区三个基础维度,再回头看看服务的调度分布,多半会发现不少可以立刻优化的地方。