中文 English

Portainer 改个 stack 一直 500?我花了一晚上才明白,是 Docker 引擎里多了一个「重影」网络

发布时间: 2026-06-13
Docker Docker Compose Portainer 网络排障 容器化 运维 故障排查

先说结论

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> 替换,只保留公开信息、官方源码、错误文本这些可分享的内容。

两个 librespeed_default 同时存在的奇景

图 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 上:

  1. 进 Stacks → 选 librespeed → 点 Editor。
  2. 把 compose 文本整段粘过去。
  3. 点 Update the stack。
  4. 浏览器立刻弹出红色 500 Internal Server Error

看 30 秒、刷新、再点一次。还是 500。

第一反应肯定是:compose 写错了?于是把文本复制出来跑 yamllint、跑 docker compose config,都过了。再改回去一次,还是 500。这就离谱了。

2. 问题表现:Portainer 报 500,但 container 死活起不来

从用户视角看到的事实是:

光看 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 的完整调用路径:

一次 stack 改配置请求的完整链路

图 2:从浏览器到 dockerd 的五层链路。错误信息从最底层一路冒泡上来,Portainer 只是把它包成 500 退给浏览器。

注意几个细节:

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:80volumeshost: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 名字时不知道该用哪一个。

但第一次看到这句话时容易困惑:

要回答这些,先得去引擎里看网络列表。

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..."

注意两点:

  1. Containers: {} 是空的。两个网络都没有任何容器挂在上面。它们是「孤儿」。
  2. 两个网络的 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 现场画了出来:

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))
}

这段代码的查找顺序是:

  1. 完整 ID 匹配 — 命中就直接返回。
  2. 完整 NAME 匹配 — 把所有 nw.Name() == term 的塞进 listByFullName。如果恰好只有 1 个,OK 返回。
  3. 如果有 ≥ 2 个同名的,立刻报错 is ambiguous (N matches found on name)绝不选
  4. 否则回退到 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 这两段关键代码并排放在一起:

源码级追踪:是谁吐出了「is ambiguous」

图 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 失败后的反复重试

这是最常见也最让人抓狂的场景:

  1. 用户在 Portainer 上点 Update the stack,引擎说 ambiguous。
  2. Portainer 报 500。
  3. 用户立刻再点一次(甚至点 5、6 次),每次都重发同样 compose。
  4. 每次点都让引擎再走一次 resolveOrCreateNetwork → 失败,但失败不会让引擎清理掉任何已有的孤儿网络。
  5. 网络列表里孤儿越来越多,但用户一直以为是 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>

判断「哪个是孤儿」的关键标志:

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 默认会跳过 bridgehostnone 三个系统网络,只删「没有容器引用」的自定义网络。我们的孤儿网络就是没有容器引用的自定义网络,所以 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 上实际看到的样子:

Portainer Stack Editor 与 500 弹窗

图 6:Portainer UI 上那一闪而过的 500 弹窗。错误信息要看「Details」或者 DevTools 的 Response Body 才能翻出来。

11. Q&A

Q1:为什么 docker network ls 能看到两个 librespeed_default,但 docker compose down 报「network not found」?

因为 compose downproject 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 内部 NetworkCreateNetworkRemove 之间存在时间窗口。但你可以:

  1. 部署前先 docker network prune -f 清理一次;
  2. up 时加 --remove-orphans
  3. 用脚本/CI 自动巡检;
  4. 不要在 Portainer 上连点 Update 按钮——每次重试都是新一轮 compose up,会让状况更糟。

Q6:两个同名网络都有容器挂在上面,能不能删?

不能直接 docker network rm,会报「has active endpoints」。需要:

  1. docker compose down 把容器停了;
  2. 或者用 docker network disconnect <net> <container> 把容器摘下来;
  3. 然后再删网络。

Q7:除了 librespeed_default,还有什么网络会撞名?

任何 compose 项目都会有 <project>_default。常见撞名场景:

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 无关,但其实逻辑是一样的:你能看到的「正常」,和引擎实际在做的事,经常不是同一件事。

参考资料

  1. moby 源码:daemon/network.go FindNetwork(),错误抛出位置 is ambiguous (N matches found on name)https://github.com/moby/moby/blob/master/daemon/network.go
  2. docker compose 源码:pkg/compose/create.go resolveOrCreateNetwork(),「Either not found, or name is ambiguous」注释:https://github.com/docker/compose/blob/main/pkg/compose/create.go
  3. docker compose 源码:pkg/compose/create.go removeDivergedNetwork(),config-hash 不一致时的网络重建逻辑:https://github.com/docker/compose/blob/main/pkg/compose/create.go
  4. docker compose 源码:pkg/compose/down.go removeNetwork()compose down 的网络清理逻辑:https://github.com/docker/compose/blob/main/pkg/compose/down.go
  5. compose-spec:06-networks.mddefault 网络与 name 字段定义:https://github.com/compose-spec/compose-spec/blob/main/06-networks.md
  6. compose-spec:api/labels.gocom.docker.compose.project / com.docker.compose.config-hash 等标签常量:https://github.com/docker/compose/blob/main/pkg/api/labels.go
  7. Docker 官方文档:docker network prunehttps://docs.docker.com/reference/cli/docker/network/prune/
  8. Docker 官方文档:Networking overview:https://docs.docker.com/engine/network/
  9. Docker 官方文档:Compose networking:https://docs.docker.com/compose/networking/
  10. Portainer 文档:Stacks 与 stack update:https://docs.portainer.io/user/docker/stacks