这事儿我以前干过十几次,本该手到擒来。
思路很直:在笔记本上跑 Portainer(Docker 管理界面),远端服务器装 Portainer Agent,用 Cloudflare Tunnel 打通,让俩端点隔着 SSH(再套一层 Cloudflare Access)聊得不亦乐乎,外网啥都看不到。
所有零件我都摸过:SSH 隧道, Docker, Portainer, Cloudflare。没有新东西。
但这次,它罢工了。
远端 Agent 在听;本地 UI 在跑;我在终端里手搓 curl,两个端点都能打通。可一到 Portainer 的 UI 上点 Connect,直接给了我联网界最干净, 也最无情的报错:
Get "https://host.containers.internal:19001/ping": dial tcp 10.88.0.1:19001: connect: connection refused
connection refused。不是"打不通", 不是"没路由",而是有人在门口把门砰的一声给你摔上。
我盯着这个报错沉默了挺久。链路里每个环节单测都绿了:Agent 在听, SSH 隧道也绑好了端口, 笔记本上 curl 隧道端口通, Portainer 的容器也健康。
零件都没毛病。错误里倒是冒出了两个我平时从来不细想的东西:一个是我说不清来路的地址 10.88.0.1,另一个是我习惯性手就能敲出来, 但其实没想过含义的名字 host.containers.internal。
就在那一刻,我意识到问题在哪了。
老实交代
我这些年靠的是"背命令"。
会敲 ssh -L,知道能转端口;会写 docker run -p 9001:9001,知道能"暴露"端口;127.0.0.1 是 localhost,这谁不知道呢。用得多了,肌肉记忆就接管了大脑。真出问题了,也常常是 Stack Overflow 换个 flag 继续莽过去,完全没把"为啥"搞明白。
大学学过 OSI 模型,七层背得贼溜:物理, 数据链路, 网络, 传输, 会话, 表示, 应用。考试问我肯定能全答上来。但"网桥"和"路由器"的区别? 说实话,那会儿我真讲不明白。工作了好多年,也靠着上层抽象把活儿干了,下边几层就当不存在一样。
可它们真的在那,躺着不响。公司规模一大,需求往往就顺着蛋糕的切面往下探:反向代理, 流媒体, 多地域容灾, 零信任, 还有那句灵魂拷问"为啥开发环境行, 预发就不行"。每一个都戳在我一直糊弄过去的知识空洞上。
这次也是:容器里报 connection refused,目标明明是我笔记本上开的口子。错不在工具,错在我自己那点"差不多先生"式的心态。
所以我决定补课。
这是一篇开放式系列的第一篇。我不写教科书(世界不缺,作者也不该是我),我就盯着当前把我卡住的那一层,刨到不困惑为止,然后再往下一层挖。Portainer 这次的事故会作为主线,每篇都回到它。
等这个系列写完,开头那条报错应该能像读中文一样,一眼看懂。
而这篇要搞清的第一件事是:在这次问题所涉及的"最低那一层",到底发生了什么。10.88.0.1 到底是谁? 它跟 127.0.0.1 有啥区别? 所谓"桥(bridge)“到底是个啥玩意儿?
“等等,我电脑里头的网络,自己是咋转的? "
先说把我拍醒的那一巴掌。
远端 Agent 监听 19001;我把笔记本的 19001 用 SSH 隧道转到了远端的 19001;我在笔记本上 curl https://127.0.0.1:19001/ping,回得干干净净。
但同一台笔记本上, 跑在容器里的 Portainer Server,去连同样的地址,却得到"connection refused”。
同一台机器, 同一个内核, 同一个端口号,结果居然不一样?
怎么可能?
我先前的直觉模型是错的:一台电脑就"一张网”,不是么? “我的机器"要么能到,要么到不了。如果我在终端 curl 成功,那同一台机器上跑的别的东西也应该能成功。
这个模型从很久以前就不对了。现代 Linux 不是"一张网”,而是"网中之网"——在一个内核里拼出了好几张网。容器, 虚拟机, VPN, 乃至普普通通的 SSH 隧道,全靠这件事成立。只不过平时不需要想,直到有一天它以一种别扭的姿势坏给你看。
要解释为啥容器看不见我终端能看到的东西,我得从最笨, 最底层的那个家伙讲起:容器和宿主之间,那台"虚拟交换机"。
我用一个小剧场讲,稍微忍耐一下。演员阵容不大。
小剧场:会议室与递条子的前台
- Margaret:会议室中间的前台,耐心无限,手里拿个小本本。一天到晚就干一件事:给桌子边的人传纸条。
- Alice, Bob, Carlos:坐在桌边的三位同事。每人胸前都别着一张奇怪的名牌,比如 aa:42:81:9c:0e:33,机器印的,看着就不像给人类读的。
- 小本本:只记录"谁坐在哪个座位"。没人教她怎么记,都是她自己观察写下的。
- 纸条:对折的小条,外面写"要给谁"(名牌),里面写内容。Margaret 只看外面,不看里面。
故事开始。
会议室的一天
Alice 第一天来上班,挑了个空位——3 号,坐下。
Margaret 并不知道 Alice 的存在,小本本里也没 Alice 的记录。
过会儿,Alice 写了张"早上好"要给 Carlos,递给 Margaret。Margaret 看外面写的收件人是 Carlos,心里想"Carlos? 他在 7 号位",然后把纸条递到了 7 号。
走之前,Margaret 顺手扫了眼纸条外面的"发件人"——Alice 的名牌,又看了眼她刚拿纸条的座位,刷刷写进小本本: Alice(aa:42:81:9c:0e:33)— 3 号位。
十分钟后,Bob(5 号位)要给 Alice 写条子。Margaret 翻开本子,查到 Alice 在 3 号,稳准快送达。
这就是 Margaret 的理想工作流:她认识的人,翻一页就送到。
可如果在她"还不认识 Alice"的那五分钟里,Bob 突然要给 Alice 送条呢? 这会儿 Alice 还没发过任何纸条,Margaret 根本就没法在本子里查到她。
Margaret 会摊手, 然后干一件粗暴而有效的事:把这条纸条复印一堆,除了 Bob 的座位,其他每个人都发一份。谁是 Alice 谁就回,其他人看一眼不是自己的名牌,就丢纸篓里。
这就是 Margaret 的"全活"。两个动作:
- 查得到就直达(forwarding);
- 查不到就全发(flooding)。
而且每一条她经手的纸条——不管是直达的还是要全发的——她都会顺便学习:这条从 3 号位来的, 发件人名牌 aa:42:81:9c:0e:33——记上。新同事只要发过一条,Margaret 就知道他在哪了,而且不会记错——因为她只记录亲眼所见。
把戏拆穿:她在计算机里是谁
- Margaret 就是一台交换机(switch)。在软件里也叫桥(bridge),算法一模一样。机房里那种金属盒子是硬件交换机;Linux 内核里的数据结构是软件网桥。行为一致。
- “座位"是交换机的端口(port)——插网线的那个口。注意这可不是 TCP 端口,撞名了而已。
- 名牌是 MAC 地址。网卡出厂就刻着,长得就像 aa:42:81:9c:0e:33。
- 小本本是 MAC 地址表(MAC address table)。
- 查得到就送叫转发(forwarding)。
- 查不到就全发叫泛洪(flooding)。
- 她靠"看见谁从哪个口发出了帧"来自学习(MAC learning)。不配 DHCP, 不配配置文件,插上线发一帧,交换机就知道你在哪。
重点是:她从不看"纸条里写了啥”。不管上面跑 ARP, IP, TCP, HTTP,跟她都没关系。她只认"外面写给谁",不是就全发,是就直达。这也是为啥硬件交换机能用专用芯片一秒钟转发上百万帧:逻辑简单到不能再简单。
当 Bob 想找一个"他不认识的人"
问题来了:Bob 一开始连 Alice 的名牌都不知道,他怎么写外面的"收件人"?
回到剧场。
今天 Bob 收到个任务:“给管烤面包机订单的人递个条”。他不知道是谁,不知道名牌,不知道座位。
Margaret 帮不上忙。她的小本本只记"名牌—座位"的映射,不知道"谁管什么业务"。
但 Bob 有个招:他在纸条里面写"你好我是 Bob,名牌 bb:11:22:33:44:55,坐 5 号。谁管烤面包机请回信告诉我你的名牌"。然后,重点来了,他在外面的"收件人"一栏,不写具体的名牌,而是写:
FF:FF:FF:FF:FF:FF
这是一个保留的"特殊名牌"。没人真的佩戴它。它的意思是:大家都听着(broadcast)。
他把纸条递给 Margaret。Margaret 翻本子当然翻不到这个"名牌",于是走她"查不到就全发"的流程:除了 Bob 的座位,其他所有座位都发一份。
注意,这不是 Margaret 的"特判",而是 Bob 主动写了一个所有人都认得的"大家一起上"的目的名牌,逼着交换机走泛洪。交换机自己分不清这是不是 ARP,更不懂"烤面包机"是何物,她只是按规矩干活。
大家都收到复印件了,绝大多数人一看跟自己无关,扔了。只有 Alice——原来她就管烤面包机——认真看完回了一条,这次她把外面的收件人写成了 Bob 的真名牌,里面写:“你好,我是 Alice,我的名牌是 aa:42:81:9c:0e:33”。
Margaret 在把回条送回 5 号的同时,也顺手把 Alice 记进了本子。自此 Bob 知道了 Alice 的名牌,Margaret 也知道了 Alice 的座位,天下太平。
翻译回计算机:这套来回就是 ARP(Address Resolution Protocol)。那个 FF:FF:FF:FF:FF:FF 是广播 MAC 地址。协议的精髓就是:先用广播问"谁是 X 的 IP 拥有者",对方用自己的真 MAC 回;你记住它的 MAC,以后就可以直连。
两个常见"为什么会泛洪"的触发点,其实分别是:
- 交换机真不认识(MAC 表里没有),只好泛洪;
- 发送方故意把目的 MAC 写成广播地址,让交换机去泛洪。
(小彩蛋:FF:FF:FF:FF:FF:FF 全是 1 不是随便拍脑袋定的。MAC 地址第一个字节的最低位就是"组播/广播"标志,硬件只用一个按位判断就能快速认出"这玩意儿要大家一起看"。从设计之初就给硬件友好了。)
会议室变成"虚拟"的了
刚才说的是物理交换机的世界:铁盒子, 网线, 端口。
我笔记本上的那台交换机是"虚拟"的,跑在 Linux 内核里,叫 bridge。Docker 默认叫 docker0,Podman 默认叫 podman0。行为跟物理交换机一模一样:一本 MAC 表,两个动作,广播照做,其他一概不懂。区别只是:Margaret 入驻了内核,这间房间是内存里的数据结构,不是铜线和晶体管。
容器启动时,内核会造一根"虚拟网线"(veth pair),就像一根两头的线:一头塞进容器里,变成容器看到的 eth0;另一头插在 Margaret 的会议室里某个座位上。内核保证"一端进,另一端出",就是这么朴素。
关键点:宿主机自己也坐在 Margaret 的房间里。房间一搭好,内核就让宿主也占个座,配个名牌,再配个 IP。Margaret 不在乎谁是宿主, 谁是容器,反正都是戴着名牌的"人",她只按规矩转纸条。
在这一层,Docker/Podman 干的其实是三件无聊但重要的小事:
- 让内核建房(bridge);
- 容器启停时,用 veth 把它们插上/拔下;
- 确保宿主机也在房里有自己的座位。
至于"怎么转发",全是内核在干,容器运行时只是搭台。
这下我的报错顺了:10.88.0.1 是 Podman 这间房(podman0)里,宿主机那个座位的 IP。站在容器的视角,它就是"房里那个坐着宿主的口子"。host.containers.internal 不过是它的一个顺口的 DNS 别名(Docker 一样,只是常见的是 172.17.0.1,对应 docker0 里的宿主座位)。
但整栋楼不止这一个房间
既然宿主在 podman0 这间房里是 10.88.0.1,那 127.0.0.1 呢? 那个"本机地址"是啥?
127.0.0.1 是另一间房。
想象公司走廊的另一头,还有个超小的私人房间,只有一个座位——你的。房里也有个前台,叫 Lorraine。她的工作更简单:你给她纸条,她走两步又把纸条递回你手上。房里没人,纸条永远不会离开这个小房间。
Lorraine 是回环接口(loopback,lo,对应 127.0.0.1)。所有 Linux 主机都有这么一间"自言自语室"。
重点来了:宿主机同时坐在两间房里。它在 Margaret 的房里有个座(10.88.0.1),也在 Lorraine 的房里有个座(127.0.0.1)。不同房,不同前台,不同地址。
而当一个程序想"监听"某个端口时,它其实是在决定:把"门卫"安排在哪间房门口。监听 127.0.0.1,门卫就站在 Lorraine 的房里;监听 10.88.0.1,门卫就站在 Margaret 的房里;监听 0.0.0.0,那就每间房门口都站一个(只要宿主在那间房里有座)。
我的 bug 就卡在这一步。
我的 SSH 隧道在本地"开监听"(把 19001 口子开起来,进来的连接全转发到远端)时,默认把门卫安排在了 127.0.0.1。也就是说,门卫一直蹲在 Lorraine 的小黑屋里。
我在宿主上跑 curl https://127.0.0.1:19001/ping,那当然通——我是在 Lorraine 的房里敲门,门卫就在眼前。
可容器在 Margaret 那间大房里,它去找的是宿主在这间房的门——10.88.0.1:19001。容器的请求到了 Margaret 的房门口,敲了半天,门卫不在这屋啊! 这屋没人听,宿主在这间房的这个口子上没有监听,回答自然就是:connection refused。
解决方案一旦看清楚,简单得有点丢人:把 SSH 隧道的监听绑定到 10.88.0.1,或者干脆 0.0.0.0。反正让门卫出现在容器真正会来敲门的那间房就行。
但要走到"简单得丢人"的这一步,我得先知道"原来有好几间房"。要知道有房,得懂"bridge"是啥;要懂 bridge,得知道 Margaret 只干两件事。这就是为啥这篇要从这里开刀。
讲明白了吗?
比之前强多了,但还没圆满。
我现在知道了 10.88.0.1 为啥重要, 它跟 127.0.0.1 有啥本质区别。我能在脑子里看见 Margaret 的会议室:容器占一个座, 宿主占一个座,前台按"名牌—座位"把纸条转来转去,不懂上层协议。也知道为啥"只在某个接口上监听"的服务,会对来自另一间房的敲门视而不见。ARP 也不再神秘了:只是 Bob 用"大家一起看"的目的 MAC 问了一嗓子,交换机忠实地泛洪,因为它只会干这件事。
但一个更大的问号冒出来了:上面的故事默认"容器有自己的网络栈,可以插到 Margaret 的房里"。可容器归根结底只是跑在同一个内核里的进程,它怎么会"有自己的一块网卡, 一个 IP, 甚至有自己的一间 Lorraine 小黑屋的 127.0.0.1",而且跟宿主的 127.0.0.1 互不干扰?
再想想——我总说"容器是隔离的"。到底隔离的是什么? 用的是什么机制? 内核里哪一层在实施这个隔离?
一个坑刚填平,下面就是个更大的坑。
答案是 Network Namespace。它让一台 Linux 机器在内核里"分身多用",同时跑出很多套"自以为是全世界的网络栈",每一套都有自己的 127.0.0.1。容器, VPN, 很多现代基础设施的魔法,都栖身于此。一旦这个概念卡进脑子,前面提到的 veth 从比喻,变成了看得见摸得着的"管子"。
下篇见,我们从这里开始挖。
(人类写作,必要处参考了 AI 的点子与润色。)
