☰
Docker Swarm轻量编排实战:面向稳态业务的确定性交付
2026/10/14 16:28:01 网站建设 项目流程

1. 为什么今天还要学 Swarm——在 K8s 主流时代里,它解决的恰恰是“不该被忽略的那部分问题”

很多人看到这个标题第一反应是:“都 2024 年了,还讲 Docker Swarm?K8s 不是早成标配了吗?”
我试过在某技术分享会上抛出这个问题,台下一半人点头,另一半人沉默三秒后说:“我们线上跑着 17 个 Swarm 集群,三年没动过主控节点,去年扩容到 42 台物理机,运维日志平均每天不到 3 条告警。”

这不是段子。这是某中型 SaaS 公司的真实运维侧写。他们没上 K8s,不是因为不懂,而是因为算过一笔账:

  • 业务模块稳定、更新频率低(平均每月 1~2 次镜像发布);
  • 团队 DevOps 仅 2 人,其中 1 人兼管监控、备份、CI/CD 和安全基线;
  • 所有服务均基于 HTTP/HTTPS + Redis + PostgreSQL 构建,无状态服务占比 91%;
  • 对“秒级扩缩容”“多租户隔离”“GPU 调度”零需求,但对“部署命令敲完 8 秒内所有节点生效”“配置改错能立刻 rollback”“故障时自动剔除坏节点不需人工介入”有强依赖。

Swarm 在这种场景下,不是“退而求其次”,而是精准匹配。它把分布式系统里最消耗人力的“协调复杂度”压到了最低水位——不是靠抽象掉问题,而是靠把问题收敛到极少数可验证、可审计、可回滚的原语上:docker stack deploy、docker service update、docker node drain。

关键词里虽然空着,但整套实践天然锚定三个核心词:轻量编排、确定性交付、运维收敛性。

  • “轻量编排”意味着你不需要理解 etcd 的 Raft 日志同步机制,也不用调优 kube-scheduler 的 predicate/priority 插件链;你只需要知道--replicas=5就真会起 5 个副本,且每个副本的启动顺序、健康检查路径、重启策略都由同一份 YAML 定义,全局一致。
  • “确定性交付”指每次docker stack deploy -c prod.yml myapp执行后,集群状态必然收敛到 YAML 所声明的拓扑,没有“最终一致性”的灰色地带——如果某个服务卡在pending状态超过 30 秒,一定是网络不通、镜像拉取失败或资源不足,原因明确、路径唯一。
  • “运维收敛性”是最容易被低估的价值:一个docker node ls命令就能看到全部节点角色、可用内存/CPU、健康状态、是否处于维护模式;一个docker service ps myapi就能列出所有实例的部署节点、启动时间、退出码、重启次数;所有操作日志默认打到本地 journal 或 syslog,无需额外对接 Loki/Promtail。

这背后的技术选择逻辑很朴素:当你的系统复杂度还没高到需要“调度器智能决策”时,强行引入 K8s 的控制平面,相当于给自行车加装 F1 赛车的空气动力学套件——不仅不提升速度,反而让每次补胎都得先拆卸前翼。

我带过的两个团队,一个用 K8s 管理 8 个微服务,另一个用 Swarm 管理 34 个服务(含 12 个定时任务、7 个数据管道、3 个 WebSocket 网关),后者在三年间发生的“需 P0 级响应的集群级故障”为 0 次,前者同期处理过 5 次因 CoreDNS 解析异常导致的跨命名空间服务发现中断。差异不在技术优劣,而在问题域与工具边界的严丝合缝程度。

所以这篇实战,不教你怎么“替代 K8s”,而是带你重新看见 Swarm 的设计哲学:它不是容器编排的“简化版”,而是面向中小规模、稳态业务、有限运维人力场景的“专注版”。接下来的所有步骤、配置、避坑点,都将围绕这个前提展开——每一步都在回答一个问题:“这个设计,是否让‘确定性’更牢一分,让‘意外’更少一分?”

2. 集群初始化的四个反直觉细节:为什么--advertise-addr必须填内网 IP,而--listen-addr却可以绑定 0.0.0.0

Swarm 集群初始化看似只有一条命令:docker swarm init --advertise-addr <IP>。但就是这个<IP>,踩过坑的人才知道它有多致命。我见过三次生产事故,根源全在这里:第一次是某开发者填了公网 IP,结果所有工作节点尝试连主控时走的是外网路由,延迟高达 400ms,心跳超时被踢出集群;第二次填了127.0.0.1,工作节点根本连不上;第三次填了docker0网桥 IP(如172.17.0.1),集群能起来,但服务间 DNS 解析失败——因为 Swarm 内置的 DNS 服务只监听docker_gwbridge网络,而docker0是宿主机与容器通信的桥梁,两者隔离。

2.1--advertise-addr的真实含义:它是集群成员“认出你”的唯一身份证

Swarm 节点间通信依赖一个叫Gossip 协议的机制,它不像 K8s 那样依赖中心化 etcd 存储状态,而是靠节点之间互相广播(gossip)来同步集群视图。每个节点启动时,会把自己注册为一个“可被发现的端点”,这个端点地址就是--advertise-addr。

关键点在于:这个地址必须是其他节点能直接、低延迟、无 NAT 转换地访问到的 IP。

  • 如果你有 3 台服务器,内网 IP 分别是10.10.1.10、10.10.1.11、10.10.1.12,那么主控节点初始化时,--advertise-addr必须填10.10.1.10;
  • 工作节点加入时,执行docker swarm join --token <TOKEN> 10.10.1.10:2377,它们会尝试连接10.10.1.10:2377这个地址;
  • 如果这个地址不可达,节点就永远卡在pending状态,docker node ls里显示Unknown。

提示:永远用ip addr show | grep "inet " | grep -v "127.0.0.1"查看本机所有非回环 IPv4 地址,从中选那个属于你数据中心内网段的地址。不要猜,不要抄文档示例,不要用hostname -I(它可能返回多个 IP,顺序不确定)。

2.2--listen-addr的自由度:它只管“我听谁”,不管“别人怎么找我”

--listen-addr参数常被忽略,但它决定了 Swarm 控制平面监听哪个网络接口。默认值是0.0.0.0:2377,意味着监听所有 IPv4 接口的 2377 端口。这没问题,但如果你的服务器有多个网卡(比如一块接内网、一块接管理网、一块接存储网),你可能想限制只监听内网网卡:

docker swarm init \ --advertise-addr 10.10.1.10 \ --listen-addr 10.10.1.10:2377

这样做的好处是:

  • 防止误将管理端口暴露到非信任网络(比如管理网段只允许跳板机访问,而内网段允许所有业务服务器访问);
  • 避免防火墙规则冲突(你只需在内网网卡上开 2377 端口,不用在所有网卡上都开);
  • 当--advertise-addr和--listen-addr不一致时(比如--advertise-addr填10.10.1.10,--listen-addr填0.0.0.0:2377),Swarm 仍能正常工作,因为它会自动绑定到0.0.0.0,但对外宣称自己是10.10.1.10。

注意:--listen-addr的端口必须是2377(Swarm 默认管理端口),不能改。改了会导致工作节点无法加入,因为 join 命令硬编码了:2377。

2.3 初始化后的 token 管理:为什么你该立即生成 manager token 并禁用 worker token

初始化成功后,Docker 会输出类似这样的 join 命令:

docker swarm join --token SWMTKN-1-abc123...-xyz789 10.10.1.10:2377

这个 token 分两种:

  • Worker token:用于普通工作节点加入,权限最低,只能运行服务;
  • Manager token:用于新管理节点加入,权限最高,能修改集群配置、升级主控、删除节点。

问题来了:初始输出的 token 是 worker token。如果你后续需要添加第二个管理节点(比如做高可用),必须手动从主控节点上获取 manager token:

# 在主控节点执行 docker swarm join-token manager # 输出:docker swarm join --token SWMTKN-1-def456...-uvw012 10.10.1.10:2377

但很多人会忽略一个关键动作:禁用旧的 worker token。因为 worker token 是长期有效的,一旦泄露,攻击者可以用它把任意机器加入集群并运行恶意服务。

正确做法是:

  1. 初始化后,立即用docker swarm join-token manager获取 manager token;
  2. 用 manager token 添加至少一个备用管理节点(推荐 3 个管理节点,奇数防脑裂);
  3. 然后执行docker swarm join-token --rotate worker,生成新 token 并使旧 token 失效;
  4. 同理,定期轮换 manager token(docker swarm join-token --rotate manager)。

实操心得:我把 token 轮换写进了 CI/CD 流水线,每周日凌晨自动执行一次--rotate worker,并在 Slack 机器人里推送通知。这样既保证安全,又避免人工遗忘。manager token 轮换频率可低些(比如每月一次),但必须做。

2.4 节点角色与标签:为什么--label比--role更值得你花时间设计

Swarm 节点有三种角色:manager、worker、drain(维护模式)。--role只能设为manager或worker,且一旦设置无法更改(要改必须先leave再join)。但真正影响服务调度的是node labels(节点标签),它才是你控制“服务在哪跑”的核心杠杆。

比如,你有两类服务器:

  • A 类:32 核 128GB 内存,专跑计算密集型服务;
  • B 类:8 核 32GB 内存,跑 API 网关和前端静态资源。

你可以在节点加入时打标签:

# 加入 A 类节点(计算型) docker swarm join \ --token SWMTKN-1-... \ --label com.example.type=compute \ --label com.example.disk=ssd \ 10.10.1.10:2377 # 加入 B 类节点(IO 型) docker swarm join \ --token SWMTKN-1-... \ --label com.example.type=io \ --label com.example.disk=hdd \ 10.10.1.10:2377

然后在docker-compose.yml中用deploy.placement.constraints限定服务部署位置:

version: '3.8' services: >deploy: restart_policy: condition: any # 任何退出都重启 delay: 5s max_attempts: 5 window: 120s

注意,这不是“重启失败就放弃”,而是“只要容器退出,就立刻尝试重启,最多重试 5 次,每次间隔 5 秒;如果 120 秒内连续失败 5 次,则标记为 failed 并停止尝试”。

这个默认值极其重要。它意味着:

  • 你的服务进程崩溃(比如 Python 抛出未捕获异常)、OOM 被 kill、磁盘满导致写失败——所有这些,都会触发自动重启;
  • 重启不是简单docker start,而是完整重建容器:拉取最新镜像(如果用了:latest)、重新挂载卷、重新分配网络端口;
  • 它和 Kubernetes 的restartPolicy: Always逻辑一致,但实现更轻量——没有 controller-manager 轮询,而是由本地 dockerd 直接监听容器状态变更事件。

实操陷阱:某次上线新版本,我们忘了在docker-compose.yml里指定image: myapp:v1.2,只写了image: myapp:latest。结果docker stack deploy后,所有节点都去拉取最新的:latest镜像。其中一个节点拉取失败(网络抖动),容器启动报错退出,触发restart_policy,dockerd 立即重试,又失败,再重试……如此循环,直到max_attempts耗尽。此时docker service ps myapp显示该实例状态为Failed,但服务整体仍是Running(因为其他节点成功了)。我们花了 20 分钟才定位到是镜像拉取问题,而不是代码 bug。教训:永远用带版本号的 tag,latest只用于开发环境。

3.2healthcheck的默认缺失,等于主动放弃服务自愈能力

Swarm 不会自动为你的服务加健康检查。如果你的docker-compose.yml里没写healthcheck,那么:

  • docker service ps里的STATUS列永远显示Running,哪怕你的服务进程早已卡死、端口监听失败、数据库连接断开;
  • docker service scale或update时,Swarm 会把流量切过去,导致用户请求 100% 失败;
  • docker node drain时,它不会等你的服务健康检查通过,而是直接停掉容器,可能造成请求中断。

一个健壮的healthcheck应该包含三要素:

  • 测试命令:必须是容器内可执行的命令,返回 0 表示健康,非 0 表示不健康;
  • 检查间隔:太短(<10s)增加负载,太长(>60s)发现故障慢;
  • 超时与重试:避免单次网络抖动误判。

示例(Node.js 服务):

services: api-server: image: registry.example.com/api:v3.4 healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"] interval: 30s timeout: 10s retries: 3 start_period: 40s # 容器启动后等待 40 秒再开始检查

这里start_period: 40s很关键。Node.js 应用冷启动可能需要 20~35 秒(加载依赖、连接 DB、预热缓存),如果健康检查在 5 秒后就开始,必然失败,导致容器被反复重启。

经验技巧:把healthcheck的test命令单独拿出来,在容器里手动执行一次,确认它真的能反映服务真实状态。我曾见过一个healthcheck写成curl -f http://localhost:3000/,结果/返回的是 HTML 登录页,HTTP 状态码 200,但服务实际卡在数据库连接池耗尽上——这个检查完全无效。真正的健康端点应该做最小闭环检查:DB 连接、Redis ping、必要外部 API 可达性。

3.3deploy.resources的硬限制:为什么limits比reservations更值得你优先设置

Swarm 支持两种资源约束:

  • reservations:告诉 Swarm “这个服务至少需要这么多资源才能启动”,用于调度时预留;
  • limits:告诉容器运行时 “这个容器最多能用这么多资源”,用于 cgroups 限流。

很多人只设reservations,觉得“够用就行”,但这是危险的。因为:

  • reservations不阻止容器超用资源。如果一个服务内存泄漏,它会吃光节点所有内存,导致其他服务 OOM 被 kill;
  • limits是硬边界。一旦容器内存达到limits.memory,Linux kernel 会触发 OOM Killer 杀掉其内部最耗内存的进程(通常是你的主程序),容器退出,触发restart_policy重启——这比让整个节点卡死要好得多。

正确姿势是:

  • reservations设为服务日常运行所需资源的 80%(留 20% buffer);
  • limits设为reservations的 1.5 倍(给突发流量一点空间,但不过度);
  • CPUlimits用cpus: "1.5"(表示最多用 1.5 个逻辑核),reservations用cpus: "0.8"。
deploy: resources: reservations: cpus: '0.8' memory: 1G limits: cpus: '1.5' memory: 1.5G

注意:memory单位必须是字节(1G= 1073741824 字节),不能写1GB或1024MB,否则 Docker 会报错。这是 Swarm YAML 解析的一个硬性要求。

3.4networks的默认桥接:为什么ingress网络不是给你暴露服务用的

Swarm 自动创建一个叫ingress的 overlay 网络,所有服务默认都连在这个网络上,用于服务间通信。但很多人误以为:

  • “我把服务端口 publish 到ingress,就能被外部访问”;
  • “ingress网络有内置负载均衡,我 publish 80 端口,所有 manager 节点都能代理流量”。

这是巨大误解。ingress网络的负载均衡只对Swarm 内部服务发现有效。当你执行:

ports: - "80:3000"

Docker 实际做的是:在所有manager 节点上,用 iptables 规则把0.0.0.0:80的流量 DNAT 到本机的127.0.0.1:3000(如果本机有该服务实例),或者转发到其他节点的对应端口(通过ingress网络)。

问题在于:

  • 如果你只有 1 个 manager 节点,那它挂了,80 端口就彻底不可用;
  • 如果你有 3 个 manager 节点,但ports没做mode: host,那么流量只会打到有服务实例的 manager 节点,其他 manager 节点的 80 端口是空转的;
  • ingress网络的负载均衡是 L4(TCP 层),不支持 HTTPS 终止、URL 路由、Header 修改等 L7 功能。

所以,生产环境暴露服务,必须用反向代理(如 Nginx、Traefik)作为统一入口。它应该:

  • 部署为 global service(每个节点一个实例);
  • 通过networks: [myapp_internal]连接到你的业务网络(而非ingress);
  • 用deploy.endpoint_mode: dnsrr让它通过 DNS 轮询发现后端服务(Swarm 内置 DNS 支持tasks.<service_name>解析);
  • 自己处理 SSL、WAF、限流、日志。
services: traefik: image: traefik:v2.10 command: - "--providers.docker=true" - "--providers.docker.swarmMode=true" - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" ports: - "80:80" - "443:443" deploy: mode: global placement: constraints: - node.role == manager networks: - myapp_internal

这样,所有流量先到 Traefik,再由 Traefik 负载均衡到后端服务,完全绕开了ingress网络的局限性。

关键提醒:永远不要在docker stack deploy的ports里暴露数据库、Redis、Elasticsearch 等内部服务端口。它们应该只连myapp_internal网络,通过服务名(如redis://redis:6379)访问。暴露到宿主机端口,等于把攻击面扩大到整个集群。

4. 故障排查的黄金四步法:当docker service ps显示Rejected时,你该查什么、怎么查、为什么这么查

Swarm 的错误信息向来以“简洁”著称。docker service ps myapp输出里,STATUS列出现Rejected、Pending、Failed、Preparing,每一个词背后都藏着一条清晰的排查路径。与其盲目重启,不如按顺序问四个问题。

4.1 第一步:查节点状态——docker node ls是否全是Ready?

这是最基础也最容易被跳过的一步。docker node ls输出如下:

ID HOSTNAME STATUS AVAILABILITY MANAGER STATUS ENGINE VERSION abc123... node-a Ready Active Leader 24.0.7 def456... node-b Ready Active Reachable 24.0.7 ghi789... node-c Down Active 24.0.7

如果看到Down或Unavailable,说明该节点上的 dockerd 进程已退出,或网络完全中断。此时:

  • 登录该节点,执行systemctl status docker,看是否active (running);
  • 如果是inactive (dead),执行systemctl start docker;
  • 如果是active但状态仍是Down,执行docker node update --availability active <NODE_ID>强制恢复(Swarm 有时会因短暂网络抖动误判)。

注意:AVAILABILITY列的Drain表示该节点拒绝接收新任务,但已有服务继续运行;Pause表示暂停调度,但不驱逐现有服务。Drain常用于滚动升级前,Pause用于临时维护。

4.2 第二步:查服务事件——docker service events是否有service update rejected?

docker service events --since 1h myapp会输出最近一小时该服务的所有事件。重点关注:

  • service update rejected:说明docker stack deploy或docker service update命令被拒绝,原因通常是:
    • 镜像不存在(pull access denied);
    • 资源不足(no suitable node found,提示insufficient resources);
    • 约束不满足(no suitable node found,提示constraint not satisfied);
    • 网络不可用(network not found)。

例如,你看到:

2024-06-15T08:22:14.332212700Z service update rejected myapp.1 abc123... no suitable node found: no node with label com.example.type==compute

这就直接告诉你:你想部署的服务要求节点有com.example.type==compute标签,但当前集群里没有一个节点满足。解决方案是:

  • 检查docker node inspect <NODE_ID>确认标签是否存在;
  • 或者临时放宽约束,改成node.labels.com.example.type != "io"(排除 IO 型节点即可)。

实操技巧:把docker service events加到你的终端 alias 里:alias swarmsvc='docker service events --since 30m',排查时输入swarmsvc myapp,效率翻倍。

4.3 第三步:查实例日志——docker service logs为何比docker logs更准?

docker logs <CONTAINER_ID>只能看到单个容器的日志,而docker service logs myapp会聚合所有实例的日志,并按时间戳排序。更重要的是,它能显示容器启动失败前的最后几行输出,这是定位问题的关键。

比如,你执行docker service logs --tail 50 myapp,看到:

myapp.1.abc123@node-a | Error: connect ECONNREFUSED 10.0.1.5:5432 myapp.1.def456@node-b | Error: connect ECONNREFUSED 10.0.1.5:5432

这说明所有实例都连不上 PostgreSQL,地址10.0.1.5是数据库服务在ingress网络的 VIP。此时你应该:

  • 执行docker service ps postgres,确认 PostgreSQL 服务是否在运行;
  • 如果postgres也在Rejected状态,说明是数据库服务本身的问题;
  • 如果postgres是Running,但STATUS列显示0/1(0 个实例运行),说明数据库容器启动失败,再查docker service logs postgres。

关键区别:docker service logs默认只显示Running实例的日志。如果实例刚启动就崩溃,它可能来不及输出日志就被 kill。此时要用--previous参数:docker service logs --previous myapp,查看上次崩溃前的日志。

4.4 第四步:查网络连通性——docker exec进容器里ping和telnet是终极手段

当以上三步都没找到原因,就进入“外科手术式”排查:

  1. 找一个Running状态的实例 ID:docker service ps myapp | grep Running | head -1 | awk '{print $1}';
  2. 进入该容器:docker exec -it <INSTANCE_ID> sh;
  3. 在容器内执行:
    • ping -c 3 tasks.postgres(Swarm DNS 解析服务名);
    • telnet tasks.postgres 5432(测试端口连通性);
    • nslookup tasks.postgres(确认 DNS 返回的 IP 是否正确)。

如果ping通但telnet不通,说明网络可达,但目标服务没监听或防火墙拦截;
如果ping不通,说明ingress网络或 overlay 网络有问题,执行docker network inspect ingress查看子网、网关、是否启用加密(Encrypted: true);
如果nslookup返回空,说明 Swarm DNS 服务异常,可能是dockerd的--dns配置错误,或ingress网络的 DNS 服务器(127.0.0.11)没响应。

终极经验:我在某次排查中发现,tasks.postgres解析出的 IP 是10.0.1.5,但telnet 10.0.1.5 5432超时。我登录postgres服务所在节点,执行ss -tlnp | grep :5432,发现它只监听127.0.0.1:5432,没监听0.0.0.0:5432!原来 PostgreSQL 配置文件里listen_addresses = 'localhost',导致它拒绝来自 overlay 网络的连接。改listen_addresses = '*'并重启,问题解决。这个细节,任何日志都不会告诉你,只有亲手telnet才能发现。

5. 生产就绪的七项加固:从密码管理到日志归档,一份可直接落地的 checklist

Swarm 集群上线只是开始,真正的挑战在稳定运行。以下是我在多个项目中沉淀出的七项生产就绪加固措施,每一项都对应一个真实风险点,且都有可执行的命令或配置。

5.1 密码与密钥管理:永远不要把root密码、数据库密码写进docker-compose.yml

Swarm 原生支持docker secret,它把敏感数据加密存储在 Raft 日志中,只在需要的容器内以文件形式挂载(/run/secrets/mydb_password),且不留在镜像层或容器文件系统中。

使用流程:

  1. 在 manager 节点创建 secret:
    echo "my-super-secret-password" | docker secret create db_password -
  2. 在docker-compose.yml中引用:
    services: api-server: image: myapp:v1.0 secrets: - db_password environment: - DB_PASSWORD_FILE=/run/secrets/db_password
  3. 应用内读取:
    # Python 示例 with open(os.environ.get("DB_PASSWORD_FILE"), "r") as f: password = f.read().strip()

注意:docker secret只能被service使用,不能被docker run的单个容器使用;secret 文件默认权限是0444(只读),容器内无法修改;删除 secret 会立即从所有使用它的容器中移除文件。

5.2 日志驱动配置:用json-file+max-size防止磁盘被日志撑爆

Docker 默认日志驱动是json-file,但默认不限制大小,日志文件会无限增长。生产环境必须配置:

# 编辑 /etc/docker/daemon.json { "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }

然后重启 dockerd:systemctl restart docker。

这样,每个容器的日志文件最大 10MB,最多保留 3 个(xxx.log,xxx.log.1,xxx.log.2),超出后自动轮转。

实操对比:某次未配置,一个高频日志服务在 3 天内生成了 42GB 日志,导致节点磁盘 100%,所有容器无法启动。配置后,单节点日志总量稳定在 300MB 以内。

5.3 镜像签名验证:用notary强制校验镜像完整性

Swarm 本身不校验镜像签名,但你可以通过docker trust(基于 Notary)强制要求:

# 启用内容信任 export DOCKER_CONTENT_TRUST=1 # 拉取已签名镜像(如果未签名,会报错) docker pull registry.example.com/myapp:v1.2 # 部署时自动校验 docker stack deploy -c prod.yml myapp

要启用此功能,必须:

  • 镜像仓库支持 Notary(如 Docker Hub、Harbor);
  • 镜像发布者已用私钥签名;
  • 你本地有对应的公钥(docker trust signer add)。

价值:防止中间人篡改镜像,或误拉取被污染的第三方镜像。某次安全审计中,我们发现一个alpine:latest镜像被植入挖矿木马,但因启用了DOCKER_CONTENT_TRUST,部署直接失败,避免了损失。

5.4 节点健康检查脚本:用cron定

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询