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-webnginx:1.252前端入口对外暴露 80 端口orders-apiorders-api:v13业务 APICPU 敏感型orders-cacheredis:72缓存可以随时重建orders-dbpostgres:151业务数据库有状态且不能随便漂移设计考虑很直接无状态服务全部多副本既能扛流量又能容忍节点故障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 dbtrue node-1 docker node update --label-add roleapi node-2 docker node update --label-add roleapi 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.roleapi \ --reserve-cpu 0.5 \ --reserve-memory 512m \ --limit-cpu 1 \ --limit-memory 1g \ --update-config parallelism1,delay10s,failure-actionrollback,monitor30s \ --rollback-config parallelism2,monitor30s \ --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.roleapi让副本只调度到打了roleapi标签的节点上实现角色隔离。--reserve-cpu 0.5和--reserve-memory 512m是给调度器看的每个副本要求节点预留这么多资源。--limit-cpu 1和--limit-memory 1g是给容器运行时看的限制容器最多用多少。--update-config控制滚动更新节奏parallelism1表示一次只更新一个副本delay10s表示每个批次间隔 10 秒failure-actionrollback表示更新失败自动回滚。--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.xxxyyy节点标签必须等于指定值node.labels.dbtruenode.labels.xxx!yyy节点标签不能等于指定值node.labels.role!webnode.rolemanager必须是管理节点node.rolemanagernode.hostnamenode-2必须是某个主机名node.hostnamenode-2engine.labels.xxxyyy匹配 Docker 引擎标签engine.labels.cloudali多个约束之间是“并且”关系也就是说要全部满足才能调度。我在案例里用到的约束组合是数据库服务只跑dbtrue的节点API 服务只跑roleapi的节点前端 nginx 不设置约束这样它可以被调度到任何存活节点充分利用集群资源。还要强调一个细节约束条件里的等号和空格写法在不同地方不一样。docker service create命令行里可以写成node.labels.roleapi而在 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.dbtrue \ --mount typevolume,sourcedb-data,target/var/lib/postgresql/data \ --network orders-net \ postgres:15如果不加约束Swarm 正常按“分散优先”逻辑完全可能在某次节点故障后把这个数据库任务调度到另一台机器。问题来了另一台机器上根本没有db-data卷调度器会默默新建一个空卷挂上去数据库进程是起来了但里面的表和数据全没了。这不是数据损坏是数据“不在现场”。两个解决方向第一老老实实固定节点用node.labels.dbtrue约束住同时把该节点设置为避免意外 drain第二换用支持多主机共享的卷插件让任何节点都能挂载同一个共享存储。共享存储更适合真正需要高可用的数据库但多数中小团队一开始用第一种就够了。切记本地数据卷 无约束调度是这个系列最危险的一个组合。4. 服务生命周期实操扩缩容、更新、故障转移4.1 扩缩容一条命令改变副本数服务运行一段时间后流量上来了需要把 API 从 3 个副本扩到 5 个。Swarm 有两种等价方式docker service scale orders-api5 # 或者 docker service update --replicas 5 orders-api命令执行后Swarm 会立刻创建两个新的 Task并按照之前的约束和资源预留条件寻找合适的节点完成调度。缩容同理把5改成2即可Swarm 会优先停止哪几个副本由它自己决定通常是把不同节点上的任务依次停掉尽量保证剩余副本仍然分散在不同节点。这里有个小经验缩容前先把业务流量切换走别在高峰期直接缩否则瞬间断连很容易引发一连串报错。扩缩容本身不影响集群里的服务定义属于很轻量的操作配合docker service ps观察任务状态几秒钟就能确认结果。4.2 滚动更新与一键回滚版本升级是运维里最常做也最怕出问题的事。用 Swarm 做滚动更新要义是先控制节奏再控制失败处理。假如我现在要把 orders-api 从 v1 升到 v2docker 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 自动调度到其他可用节点。维护完成后再改回 activedocker 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 published80,target80,modeingress或简写-p 80:80那么集群任意一个节点的 80 端口都能访问到该服务Ingress 网络会自动把请求转发到后端副本。这意味着你不需要为每台服务器单独配置负载均衡Swarm 的 Ingress 已经帮你做了一层。之前我在生产里就遇到过困惑明明服务副本只在 node-2 上但从 node-1 访问端口也能通这就是 Ingress 负载均衡在起作用。5. 踩坑记录与排查技巧5.1 任务一直停在 New / Preparing 状态出现这种情况最直接的感受是docker service ls显示副本数总是 0/3docker 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时说明当前节点集里没有任何一台同时满足容量和约束要求。我遇到最多的是三种情况约束条件写错了比如roleapi标签只打在 node-2 上但 node-2 被 drain 了。节点内存不够所有节点的 reservation 都被占满。服务设置了node.rolemanager但所有 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容器启动中一般正常短时间后可进入 RunningRunning正在运行无需处理Failed任务异常退出用docker service logs查应用日志Rejected节点拒绝任务检查端口冲突、约束语法Shutdown任务已被停止常见于缩容或节点 drainOrphaned编排器失去跟踪一般少见必要时手动清理写在最后一点个人实操心得玩 Swarm 这几年我最大的体会是调度策略不是给调度器看的是给业务定义看的。先把服务分成无状态和有状态两类无状态放心交给多副本和分散调度有状态老老实实绑定节点或上共享存储再把约束、资源预留、健康检查、更新回滚这几件事写成可重复执行的服务定义文件调度器自然会帮你把事情办妥当。如果你手头正好有一批 Docker 服务想搬到集群上又不知道从哪开始我的建议是先拿一两个无状态服务练手把service create、scale、update、rollback这套命令跑熟等对任务状态流转有了手感再动数据库这类有状态服务。很多人一上来就把数据库放进 Swarm结果被数据卷和节点迁移坑了一整晚其实这都是顺序问题。把调度规则想清楚再把命令敲熟Swarm 真的能让你省下大量维护机器的精力。