Portainer 改个 stack 一直 500?我花了一晚上才明白,是 Docker 引擎里多了一个「重影」网络
先说结论
Portainer 改一个 stack,浏览器上弹出
500 Internal Server Error。你以为又是 YAML 写错了,回去检查 compose 语法、缩进、引号、镜像 tag,全部都对。但每次重试都是同一个 500。真正的问题藏在 HTTP response body 的
message字段最深处:network librespeed_default is ambiguous (2 matches found on name)。 也就是说,Docker 引擎里同时存在两个librespeed_default网络——两个 ID、两个Created时间、两个com.docker.compose.config-hash,但名字一模一样。Compose 启动时让引擎去 lookup 名字,引擎说「我选不出来」,compose up 失败,Portainer 把这条错误原样包成 500 退给浏览器。修复办法朴素到尴尬:在引擎上删掉其中一个孤儿网络(
docker network rm <id>或在 Portainer 的 Networks 里点 Remove),再原样点一次 Update the stack,就过了。
这篇文章源于一次真实的 stack 改挂载路径的过程。全程我已经把所有内网地址、镜像仓库地址、卷挂载路径、容器名、用户名密码用 <PLACEHOLDER> 替换,只保留公开信息、官方源码、错误文本这些可分享的内容。
图 1:本文自制题图。Portainer 报 500,引擎里的真相是同名的网络有两个。
1. 问题背景:一次「看似无害」的 stack 更新
故事发生在家里一台 24 小时开机的 NAS 上。Portainer 装在宿主机里管理一堆 stack,其中有一个 librespeed,是 LinuxServer 那个轻量级 speedtest 镜像。
stack 当初的 compose 长这样(脱敏后):
version: '3'
services:
librespeed:
image: <REGISTRY>/linuxserver/librespeed:5.4.1
container_name: librespeed
restart: always
environment:
- PUID=1000
- PGID=1000
- TZ=Asia/Shanghai
ports:
- "8888:80"
volumes:
- /volume2/docker/Librespeed/config:/config
stdin_open: true
tty: true
有一天想把配置目录从老 volume 迁到正经的数据卷,就把挂载路径从 /volume2/... 改成了 /volume1/...,顺便去掉 image 里的 tag 让它走 latest。
在 Portainer UI 上:
- 进 Stacks → 选
librespeed→ 点 Editor。 - 把 compose 文本整段粘过去。
- 点 Update the stack。
- 浏览器立刻弹出红色
500 Internal Server Error。
看 30 秒、刷新、再点一次。还是 500。
第一反应肯定是:compose 写错了?于是把文本复制出来跑 yamllint、跑 docker compose config,都过了。再改回去一次,还是 500。这就离谱了。
2. 问题表现:Portainer 报 500,但 container 死活起不来
从用户视角看到的事实是:
- Portainer 上
librespeed这个 stack 显示Status: 2(inactive),点 Logs 是空。 docker ps -a看不到任何librespeed容器。也就是说,新容器根本没建出来。- 浏览器上的 500 弹窗只有一句「failed to deploy a stack」,没有可执行的下一步。
光看 500 的对话框是没法定位的。打开 DevTools → Network → 找到那个 PUT 请求 → 看 Response Body,会看到这样一个 JSON:
{
"message": "failed to deploy a stack: compose up operation failed: Error response from daemon: network librespeed_default is ambiguous (2 matches found on name)",
"details": "Failed to deploy a stack: compose up operation failed: Error response from daemon: network librespeed_default is ambiguous (2 matches found on name)"
}
这一坨字符串就是这次排障的关键证据链。
图 2 给出了从浏览器按钮到 dockerd 的完整调用路径:
图 2:从浏览器到 dockerd 的五层链路。错误信息从最底层一路冒泡上来,Portainer 只是把它包成 500 退给浏览器。
注意几个细节:
- Portainer 真的没做太多事。 它只是把
StackFileContent这个文本塞进 docker compose 库,调用ComposeStackManager.Deploy()。它不知道也不关心你 compose 里写了什么。 - docker compose 也没做太多事。 它的活是解析 compose → 调 docker engine API 去创建网络、卷、容器。它相信 docker engine 返回的错误。
- 真正吐错误的是 docker engine 自己,更具体地说是
daemon/network.go里的FindNetwork()。
3. 第一轮排查:以为又是 YAML 写错了
第一轮「是不是我写错」必须先排掉,否则后面所有判断都没意义。
3.1 用 docker compose config 静态校验
把 Portainer 里的文本复制成本地 docker-compose.yml:
docker compose config --quiet
# 无输出 = 静态语法没问题
--quiet 模式只打印错误,没有输出说明 YAML 解析、字段拼写、version 字段、image 解析都没问题。
3.2 用 docker compose config 实际展开
再跑一次不带 --quiet 的版本,看引擎能读出什么:
docker compose config
# 应输出展开后的 YAML,port 映射、volume 挂载、env 列表
输出和预期一致:ports 正确展开成 8888:80,volumes 是 host:container 形式,没有警告。
3.3 在 Portainer DevTools 里看返回码
打开浏览器 DevTools 抓那个 PUT 请求:
PUT /api/stacks/<stack_id>?endpointId=<endpoint_id> HTTP/1.1 500
状态码是 500,但 body 里有完整错误文本。Portainer 的 500 不是「我内部崩了」,而是「docker engine 抛了一个错误,我把它原样转成 500 给你」。
到这里已经可以下结论:YAML 没写错,错误是来自容器编排链路。
4. 转折:日志里那句「模糊」的话
回头看那段 JSON,错误主体是:
network librespeed_default is ambiguous (2 matches found on name)
这句话非常直白:引擎里有 2 个名字叫 librespeed_default 的网络,它 lookup 名字时不知道该用哪一个。
但第一次看到这句话时容易困惑:
- 什么叫「名字撞了」?compose 每次
up不都是先创建网络再起容器吗?网络怎么会重名? - 为什么
docker compose down没有把网络删干净? - 是 2 个版本都残留,还是 1 个是上一次失败的残留 + 1 个是这次新创建的?
要回答这些,先得去引擎里看网络列表。
5. 根因:两个 librespeed_default 网络同时存在
我打开 Portainer 的 Networks 页面,看到 librespeed_default 出现了 2 次:
| NETWORK ID | NAME | DRIVER | SCOPE | CONTAINERS |
|---|---|---|---|---|
a1b2... |
librespeed_default |
bridge | local | {} |
7z8y... |
librespeed_default |
bridge | local | {} |
两个网络的 NAME 完全一样,但 NETWORK ID 不同。点开看 Labels,两个都有:
"com.docker.compose.project": "librespeed",
"com.docker.compose.network": "default",
"com.docker.compose.config-hash": "aacc11d868e9..."
注意两点:
Containers: {}是空的。两个网络都没有任何容器挂在上面。它们是「孤儿」。- 两个网络的
config-hash完全相同。这意味着它们是同一份 compose 文件在极短时间内被创建了两次。
再看创建时间:
a1b2... Created: 2026-06-13T06:55:33.119070909+08:00
7z8y... Created: 2026-06-13T06:55:33.119233258+08:00
差了 0.000162 秒。 这几乎可以肯定是同一次 compose up 调用里,引擎内部产生了两个同名网络,然后第二个还没来得及被清理,第二次 up 又想 lookup 名字,于是触发 ambiguous。
图 3 把当时的 docker network ls 现场画了出来:
图 3:两个「孤儿」网络的真实样子——同 NAME、同 Driver、同 project label、同 config-hash,但不同 ID。
到这一步,根因就清楚了:Docker 引擎里有两个名字相同的网络,docker compose 启动时让引擎去 lookup 名字,引擎说「我选不出来」,所以 compose up 失败。
6. 深入:Docker 引擎为什么报「ambiguous」?
为了彻底理解这个错误,我打开 moby(Docker 引擎的开源实现)的源码,找到 daemon/network.go 里的 FindNetwork():
// FindNetwork returns a network based on:
// 1. Full ID
// 2. Full Name
// 3. Partial ID
// as long as there is no ambiguity
func (daemon *Daemon) FindNetwork(term string) (*libnetwork.Network, error) {
var listByFullName, listByPartialID []*libnetwork.Network
for _, nw := range daemon.getAllNetworks() {
nwID := nw.ID()
if nwID == term {
return nw, nil
}
if strings.HasPrefix(nw.ID(), term) {
listByPartialID = append(listByPartialID, nw)
}
if nw.Name() == term {
listByFullName = append(listByFullName, nw)
}
}
switch {
case len(listByFullName) == 1:
return listByFullName[0], nil
case len(listByFullName) > 1: // ← 我们撞在这里
return nil, errdefs.InvalidParameter(
fmt.Errorf("network %s is ambiguous (%d matches found on name)",
term, len(listByFullName)))
case len(listByPartialID) == 1:
return listByPartialID[0], nil
case len(listByPartialID) > 1:
return nil, errdefs.InvalidParameter(
fmt.Errorf("network %s is ambiguous (%d matches found based on ID prefix)",
term, len(listByPartialID)))
}
return nil, errdefs.NotFound(libnetwork.ErrNoSuchNetwork(term))
}
这段代码的查找顺序是:
- 完整 ID 匹配 — 命中就直接返回。
- 完整 NAME 匹配 — 把所有
nw.Name() == term的塞进listByFullName。如果恰好只有 1 个,OK 返回。 - 如果有 ≥ 2 个同名的,立刻报错
is ambiguous (N matches found on name),绝不选。 - 否则回退到 ID 前缀匹配,同样有歧义就报错。
这个设计很合理:与其让引擎选错,不如直接拒掉,强迫调用方先清理环境。问题在于,调用方(docker compose)收到这个错误后,并不能直接修好自己。
7. 深入:Compose 又在做什么?
打开 docker compose 源码 pkg/compose/create.go,找到 resolveOrCreateNetwork():
// First, try to find a unique network matching by name or ID
res, err := s.apiClient().NetworkInspect(ctx, n.Name, ...)
if err == nil {
inspect := res.Network
if inspect.Name == n.Name || inspect.ID == n.Name {
// 找到就 return inspect.ID
...
}
}
// ignore other errors. Typically, an ambiguous request by name
// results in some generic `invalidParameter` error
// Either not found, or name is ambiguous - use NetworkList to list by name
nwList, err := s.apiClient().NetworkList(ctx, client.NetworkListOptions{
Filters: make(client.Filters).Add("name", n.Name),
})
...
注释里直接写明:「name ambiguous」会返回 invalidParameter 错误,compose 看到这种错误就放弃 lookup、转去 NetworkList。
但 NetworkList 用的是「name 包含匹配」(substring match),并不是精确匹配。返回结果里仍然有 2 个网络,compose 在自己的代码里做了二次过滤:
networks := slices.DeleteFunc(nwList.Items, func(net network.Summary) bool {
return net.Name != n.Name
})
它把 net.Name != n.Name 的删掉,按理说只留精确匹配的。但「精确匹配」的网络有 2 个,于是它继续往下走,发现「带正确 project label 的网络也找不到」或者「找到 2 个但不知道该用哪个」。
无论如何,到这一步,compose 已经放弃了清理自己,直接把 docker engine 抛上来的 ambiguous 错误原样往上抛。最后被 Portainer 包成 500。
图 4 把 moby 和 docker compose 这两段关键代码并排放在一起:
图 4:moby 引擎的 FindNetwork() 和 docker compose 的 resolveOrCreateNetwork()。错误是引擎吐的,但引擎只报不修;compose 想接也接不住。
8. 复盘:孤儿网络是怎么产生的?
知道根因后,下一个自然的问题是:这两个孤儿是怎么混进去的?
回头看代码,docker compose 启动时会调用 resolveOrCreateNetwork,找不到就 NetworkCreate。当旧网络因为 config-hash 不一致需要重建时,它会先 removeDivergedNetwork() 删旧的,再 NetworkCreate() 建新的。
最常见的「双胞胎」成因有四种:
8.1 启动被 SIGKILL 中断
compose up 在创建网络过程中被打断(比如 OOM、被 Portainer 重试按钮打断、宿主机 reboot),删除网络的步骤没跑完,但新网络已经建出来了。下次 up 时引擎里有 2 个网络,旧的没容器、新的没容器,都是孤儿。
8.2 同一份 stack 被重复部署
如果同时跑了两个 compose up(比如 Portainer 自动重试 + 手动命令行),两个进程会同时调用 NetworkCreate。dockerd 的 NetworkCreate 本来应该对同名返回 Conflict 错误让调用方走重试,但在「刚好没有同名 → 两个都成功」的极短窗口里,两个网络都会被建出来。
8.3 Portainer 失败后的反复重试
这是最常见也最让人抓狂的场景:
- 用户在 Portainer 上点 Update the stack,引擎说 ambiguous。
- Portainer 报 500。
- 用户立刻再点一次(甚至点 5、6 次),每次都重发同样 compose。
- 每次点都让引擎再走一次
resolveOrCreateNetwork→ 失败,但失败不会让引擎清理掉任何已有的孤儿网络。 - 网络列表里孤儿越来越多,但用户一直以为是 compose 写错了。
8.4 历史失败的 compose down
如果 compose down 在删网络那一步因为网络里还有别的引用而失败,下次 compose up 就会看到「旧的还在」。再加上 compose down 失败重试、网络不挂在容器上就删不掉,就会堆积。
这次我遇到的是 8.1 + 8.3 组合:第一次更新挂载路径时被中断,孤儿网络落下来了;之后反复在 Portainer 上重试,每次都让 500 重现,孤儿网络始终没人清理。
9. 修复:删除孤儿网络,再重试
修复分三步:定位 → 删除 → 重试。
9.1 定位孤儿
# 列出所有项目名为 librespeed 的网络
docker network ls --filter label=com.docker.compose.project=librespeed
# 详细看每个网络的 Labels 和 Containers
docker network inspect <id1> <id2>
判断「哪个是孤儿」的关键标志:
Containers: {}(没有容器挂在上面)Created时间比另一个早(早的那个通常是上一轮失败的残留)com.docker.compose.config-hash相同(同一份 compose 算出来的)- IPAM 子网不同(如果 IPAM 也没建好,那就是彻底没起来的孤儿)
9.2 删除孤儿
# 命令行
docker network rm <orphan_id>
# 或者在 Portainer UI 上
# Networks → 选 librespeed_default (ID 不同的那个) → Remove
永远不要一次删两个。先删一个,验证效果,必要时再删另一个。
9.3 重试 Portainer stack 更新
回到 Portainer 那个 stack,点 Update the stack,用原来的 compose 内容。如果一切顺利,这次返回 HTTP/1.1 200 OK,容器自动起来,端口 8888 也绑上。
$ curl -I http://<NAS_HOST>:8888/
HTTP/1.1 200 OK
Server: nginx
Content-Length: 10252
图 5 给出完整的修复流程:
图 5:定位孤儿 → 删除 → 重试 Portainer update。三步结束。
10. 预防:四种可长期复用的方法
孤儿网络是 compose + 引擎交互中偶发的「未定义行为」,没法完全避免,但可以让它很容易被发现。
10.1 docker network prune
docker network prune
# 或者强制跳过确认
docker network prune -f
network prune 默认会跳过 bridge、host、none 三个系统网络,只删「没有容器引用」的自定义网络。我们的孤儿网络就是没有容器引用的自定义网络,所以 prune 一定会清理掉。
加 --filter 可以更精确:
# 只删 24 小时前创建的孤儿
docker network prune --filter "until=24h"
# 只删某个 label 的孤儿
docker network prune --filter "label=com.docker.compose.project=oldproject"
不过要小心:如果某个 stack 还没起容器但已经把网络预创建好(比如 debug 阶段),prune 会把它也删了。
10.2 docker compose --remove-orphans
docker compose up --remove-orphans 会删掉「compose 文件里没声明、但挂在 stack 标签下的容器」。它管的是容器,不是网络。但开起来之后能减少「半截子 stack」遗留。
10.3 通过 COMPOSE_REMOVE_ORPHANS 环境变量
export COMPOSE_REMOVE_ORPHANS=1
docker compose up -d
效果等同于 --remove-orphans。这个环境变量对 Portainer 也有效(只要在 stack 的环境变量里设置)。
10.4 写一个定期巡检脚本
#!/bin/bash
# 找出项目标签下、没有容器的孤儿网络
docker network ls --quiet \
--filter label=com.docker.compose.project \
| while read net_id; do
containers=$(docker network inspect "$net_id" \
--format '{{ len .Containers }}')
if [ "$containers" = "0" ]; then
echo "Orphan network: $net_id"
docker network rm "$net_id"
fi
done
可以挂 cron 每天跑一次,或者塞进 Prometheus 告警里:docker_network_orphans_total > 0 就发通知。
10.5 不要相信 Portainer 顶栏的统计
Portainer 顶栏常常显示「全部 stack 都健康」——但它检查的是容器数量。孤儿网络不在它的健康检查范围内。docker network ls 才是真相的唯一来源。
图 6 画了 UI 上实际看到的样子:
图 6:Portainer UI 上那一闪而过的 500 弹窗。错误信息要看「Details」或者 DevTools 的 Response Body 才能翻出来。
11. Q&A
Q1:为什么 docker network ls 能看到两个 librespeed_default,但 docker compose down 报「network not found」?
因为 compose down 用 project label + network label 一起过滤找网络,只匹配它「认识」的那一个。孤儿网络的 label 可能略有差异(比如缺失某个 com.docker.compose.network=default),就会被 down 跳过。也就是说,compose down 不会清理孤儿网络——它只清理自己「认领」的资源。
Q2:500 错误是 Portainer 自己的问题吗?
不是。Portainer 的 500 表示「我帮你调了下层,下层告诉我失败了,我把错误原样包成 500 退给你」。真正决定 500 的是 docker engine 抛出的 is ambiguous 错误。修好引擎那层,Portainer 自然就 200 了。
Q3:删掉孤儿网络会不会丢数据?
不会。网络本身只是子网定义 + 网桥,不存业务数据。业务数据在挂载的 volume 里,跟网络没关系。如果你担心,最稳的做法是先看 Containers: {} 字段——空的网络就是没承载业务的孤儿,删了无副作用。
Q4:docker compose config 跑过没问题,为什么还是 500?
config 只能告诉你 compose 文本在语法层面是合法的。它没法告诉你运行环境里是不是有冲突。同名网络、端口占用、镜像拉不到、卷路径不存在——这些都不在它的检查范围。YAML 校验 ≠ 部署成功。
Q5:能彻底避免「重影」网络吗?
完全避免很难,因为 dockerd 内部 NetworkCreate 和 NetworkRemove 之间存在时间窗口。但你可以:
- 部署前先
docker network prune -f清理一次; up时加--remove-orphans;- 用脚本/CI 自动巡检;
- 不要在 Portainer 上连点 Update 按钮——每次重试都是新一轮
compose up,会让状况更糟。
Q6:两个同名网络都有容器挂在上面,能不能删?
不能直接 docker network rm,会报「has active endpoints」。需要:
- 先
docker compose down把容器停了; - 或者用
docker network disconnect <net> <container>把容器摘下来; - 然后再删网络。
Q7:除了 librespeed_default,还有什么网络会撞名?
任何 compose 项目都会有 <project>_default。常见撞名场景:
- 同一个项目名复用(开发 → 测试用同一个名字);
- 跨 compose 文件 include / merge 时命名重复;
- 一次性 stack(如
mynginx_20240601)用完不清理。
Q8:Portainer 6.x 以后还有这个问题吗?
引擎层逻辑没变,is ambiguous 还是 dockerd 报的,Portainer 6.x 一样会包成 500。这是 docker engine 的设计,不是 Portainer 的 bug。
Q9:有没有比 docker network rm 更安全的方式?
对孤儿网络(无容器),rm 是安全的。如果担心选错,可以先 docker network disconnect 把所有 endpoint 摘掉,再 rm。Prune 命令在确认无引用前不会动网络,也是更安全的选择。
Q10:能不能用 iptables 看到网络冲突?
不能。iptables 看到的是网络规则,不是网络对象本身。要查「同名」必须走 docker network ls + docker network inspect。这是 dockerd 内部的命名空间表。
12. 复盘:从 500 到根因的一段弯路
这次排障最值得复盘的是:我们一开始花了 30 分钟在 compose 文本上找问题,因为 500 通常意味着「你写错了」。 但实际上 Portainer 500 几乎从不来自它的代码——它只是个壳。
如果第一时间打开 DevTools 看 Response Body,把那段 4 行 JSON 读完,「is ambiguous (2 matches found on name)」会立刻把视线从 compose 文本转向 docker engine 网络表。后面 90% 的时间是在「确认根因」和「写修复脚本」,而不是在「找问题」。
教训只有一条:遇到 500,第一件事是读完整个 response body。 不要相信弹窗、不要相信「再试一次就会好」、不要相信 Portainer 顶栏的绿色对勾。错误信息总在那个最长的字符串里。
不要在交互式终端里测试非交互式场景下的行为——这听起来跟今天的 bug 无关,但其实逻辑是一样的:你能看到的「正常」,和引擎实际在做的事,经常不是同一件事。
参考资料
- moby 源码:
daemon/network.goFindNetwork(),错误抛出位置is ambiguous (N matches found on name):https://github.com/moby/moby/blob/master/daemon/network.go - docker compose 源码:
pkg/compose/create.goresolveOrCreateNetwork(),「Either not found, or name is ambiguous」注释:https://github.com/docker/compose/blob/main/pkg/compose/create.go - docker compose 源码:
pkg/compose/create.goremoveDivergedNetwork(),config-hash 不一致时的网络重建逻辑:https://github.com/docker/compose/blob/main/pkg/compose/create.go - docker compose 源码:
pkg/compose/down.goremoveNetwork(),compose down的网络清理逻辑:https://github.com/docker/compose/blob/main/pkg/compose/down.go - compose-spec:
06-networks.md,default网络与name字段定义:https://github.com/compose-spec/compose-spec/blob/main/06-networks.md - compose-spec:
api/labels.go,com.docker.compose.project/com.docker.compose.config-hash等标签常量:https://github.com/docker/compose/blob/main/pkg/api/labels.go - Docker 官方文档:
docker network prune:https://docs.docker.com/reference/cli/docker/network/prune/ - Docker 官方文档:Networking overview:https://docs.docker.com/engine/network/
- Docker 官方文档:Compose networking:https://docs.docker.com/compose/networking/
- Portainer 文档:Stacks 与 stack update:https://docs.portainer.io/user/docker/stacks