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 是长期有效的,一旦泄露,攻击者可以用它把任意机器加入集群并运行恶意服务。
正确做法是:
- 初始化后,立即用
docker swarm join-token manager获取 manager token; - 用 manager token 添加至少一个备用管理节点(推荐 3 个管理节点,奇数防脑裂);
- 然后执行
docker swarm join-token --rotate worker,生成新 token 并使旧 token 失效; - 同理,定期轮换 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 倍(给突发流量一点空间,但不过度);- CPU
limits用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是终极手段
当以上三步都没找到原因,就进入“外科手术式”排查:
- 找一个
Running状态的实例 ID:docker service ps myapp | grep Running | head -1 | awk '{print $1}'; - 进入该容器:
docker exec -it <INSTANCE_ID> sh; - 在容器内执行:
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),且不留在镜像层或容器文件系统中。
使用流程:
- 在 manager 节点创建 secret:
echo "my-super-secret-password" | docker secret create db_password - - 在
docker-compose.yml中引用:services: api-server: image: myapp:v1.0 secrets: - db_password environment: - DB_PASSWORD_FILE=/run/secrets/db_password - 应用内读取:
# 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,部署直接失败,避免了损失。