Old-School Networking / 2026 ・ No.11
古法网络入门
第11讲 开发者的网络 ── localhost、Docker 的里和外、代理
发布
到第 10 讲为止,我们从网线一直追到 HTTP,走完了申请按钮的一次通信。这一讲要走的,是每天待得最久、却最容易迷路的地方:开发者的电脑内部。
localhost 是什么,为什么“手机看不到”,Docker 的容器在哪里,怎样穿过公司的代理出去,开发服务器的代理为什么能让 CORS 消失。用 Docker 和练习网络逐一确认。
最容易迷路的地方
新井从网线到 HTTP,地图我全都懂了。可我还是不知道我的 localhost:3000 在哪儿。
古井它不在地图外面。地图里套着小地图。电脑里有另一台电脑(容器),里面又有 localhost。
新井俄罗斯套娃吗?
古井对。而且每个娃娃称呼“自己”都叫 localhost。不迷路才怪。
全 12 讲路线图(当前位置:第 11 讲)
- localhost 与等待的地址 ── 在 127.0.0.1 上等待手机访问不到,在 0.0.0.0 上就可以(实测)
- Docker ── 容器是电脑里的另一台电脑。-p 是改写目标地址(实测与 Wireshark)
- 容器里的 localhost ── 为什么用 localhost 连不上数据库,以及用名字访问的方法(实测)
- 代理 ── 公司代理的 CONNECT、NO_PROXY、开发服务器的代理为什么能消除 CORS(实测)
练习时,把云端的 Linux 当作“开发者的电脑”。在这台电脑上运行 Docker 29,用网络命名空间在家里的局域网放一台手机(192.168.1.20),在另一个地方做一个“公司”(公司内的电脑和代理)。申请 API 是练习用的,会返回请求来自谁(you)和自己是谁(me)。
localhost 是名字
电脑除了连到外面的口(局域网口、Wi-Fi),还有一个只折返回自己的口:环回(lo)。地址是 127.0.0.1。其实以 127 开头的地址全都是环回,在练习电脑上发往 127.0.0.2 也回到了自己。发到这里的数据包,既不上网线,也不上电波。
而 localhost 是名字。和第 9 讲的名字一样,通过操作系统的 hosts 等变成地址。通常是 127.0.0.1,但也可能是 IPv6 的环回 ::1。练习用的云端 Linux 没有 IPv6,所以只有这一部分是在另一台 Linux(Ubuntu 22.04)上试的。
# 让服务器只在 ::1(IPv6 的环回)上等待,用 localhost 访问
# curl 两个都试
pc$ curl -v http://localhost:18011/
* Trying 127.0.0.1:18011...
* connect to 127.0.0.1 port 18011 failed: Connection refused
* Trying ::1:18011...
* Connected to localhost (::1) port 18011
< HTTP/1.0 200 OK
# Node.js 的 fetch 只试 127.0.0.1
pc$ node -e "fetch('http://localhost:18011/')"
ECONNREFUSED
# 顺便一提,这台电脑的 hosts 里 localhost 只对应 127.0.0.1
pc$ grep localhost /etc/hosts
127.0.0.1 localhost
::1 ip6-localhost ip6-loopback
localhost 的目标怎么试,因工具而异
同样是“localhost”,curl 在 127.0.0.1 被拒后又试了 ::1 并连上;Node.js 的 fetch 只试了 127.0.0.1,结果是 ECONNREFUSED。服务器只在 ::1 上等,调用方却去找 127.0.0.1——这是“服务器明明在运行却连不上”的典型。
写在配置文件里的目标,用地址 127.0.0.1 比用名字 localhost 更不容易出错。本讲开发服务器的配置也是这样写的(第八章)。
等待的地址:127.0.0.1 与 0.0.0.0
服务器不仅决定端口,还决定在哪个口上等。把同一个 API 分别在 127.0.0.1 的 3000 号和 0.0.0.0 的 3001 号上启动。
# 让同一个 API 分别在 127.0.0.1:3000 和 0.0.0.0:3001 上等待
pc$ ss -ltn
Local Address:Port
127.0.0.1:3000
0.0.0.0:3001
# 从电脑自身
pc$ curl http://localhost:3000/api/whoami
{"you":"127.0.0.1","me":"pc","listen":"127.0.0.1:3000"}
pc$ curl http://127.0.0.2:3001/api/whoami
{"you":"127.0.0.1","me":"pc","listen":"0.0.0.0:3001"}
# 从同一局域网的手机
phone$ curl http://192.168.1.10:3000/api/whoami
curl: (7) Failed to connect to 192.168.1.10 port 3000 after 0 ms
phone$ curl http://192.168.1.10:3001/api/whoami
{"you":"192.168.1.20","me":"pc","listen":"0.0.0.0:3001"}
# 在 3001 号上再启动一个
Error: listen EADDRINUSE: address already in use 0.0.0.0:3001
# 是谁在用 3001 号
pc$ ss -ltnp 'sport = :3001'
0.0.0.0:3001 users:(("node",pid=748,fd=21))
在哪个口上等,决定了谁能访问
在 127.0.0.1 上等待,只在内侧的口等,所以同一局域网的手机被拒绝(就算是电脑自己,发往局域网地址 192.168.1.10 也会被拒)。0.0.0.0 是“所有的口都等”这种等待方式的标记,手机能访问,API 看到的是手机的地址 192.168.1.20。
想用手机确认页面时,卡住的就是这里。开发服务器 Vite 平时只在 localhost 上等,加上 --host 才向局域网开放。
# 加上 --host 就会向局域网公开(出现 Network 行。节选)
pc$ npx vite --host --port 5174
➜ Local: http://localhost:5174/
➜ Network: http://192.168.1.10:5174/
➜ Network: http://172.17.0.1:5174/
➜ Network: http://172.18.0.1:5174/
phone$ curl http://192.168.1.10:5174/ → 200
phone$ curl http://192.168.1.10:5173/ → curl: (7) Failed to connect
Network 行里列出了电脑拥有的所有口,包括 docker0(172.17.0.1)等。手机能访问的,只有同一局域网的 192.168.1.10。
在 0.0.0.0 上等待,意思是“这台电脑的哪个口进来都行”。在咖啡馆的 Wi-Fi 上,同一 Wi-Fi 的陌生人也能进来。确认完就关掉吧。发生端口争用(EADDRINUSE)时,用 ss -ltnp 等看看是谁先占了座位。
Docker:容器是另一台电脑
把 API 放进容器,用 -p 8080:3000 启动。从里面和外面看看周围。
# 在容器里运行 API,把电脑的 8080 号接到容器的 3000 号
pc$ docker run -d --name nw11-api -p 8080:3000 … node api11.mjs
# 从容器内部看到的自己
api$ ip -4 addr; ip route
inet 127.0.0.1/8 scope host lo
inet 172.17.0.2/16 … eth0
default via 172.17.0.1 dev eth0
# 电脑一侧:名为 docker0 的网桥,和改写地址的规则
pc$ ip -4 addr show docker0
inet 172.17.0.1/16 … docker0
pc$ iptables -t nat -S DOCKER
-A DOCKER ! -i docker0 -p tcp --dport 8080
-j DNAT --to-destination 172.17.0.2:3000
pc$ ss -ltnp 'sport = :8080'
0.0.0.0:8080 users:(("docker-proxy",…))
# 从电脑 / 从手机
pc$ curl http://localhost:8080/api/whoami
{"you":"172.17.0.1","me":"api","listen":"0.0.0.0:3000"}
phone$ curl http://192.168.1.10:8080/api/whoami
{"you":"192.168.1.20","me":"api","listen":"0.0.0.0:3000"}
容器是电脑里的另一台电脑。-p 是改写目标地址
容器里有自己的 eth0(172.17.0.2)和自己的 lo(127.0.0.1)。出去的路是 172.17.0.1,也就是 Docker 在电脑上建的、名为 docker0 的网桥(第 3 讲交换机的同类)。容器就是放在电脑里的另一台小电脑。
那么 -p 8080:3000 做了什么?电脑的规则里多了一行:“发到 8080 号的,把目标改写成 172.17.0.2 的 3000 号”(DNAT)。这和第 5 讲家用路由器的 NAT 是同一个机制,这次由电脑来当路由器。
手机的请求,在电脑入口处是发往 192.168.1.10 的 8080 号,在容器跟前变成发往 172.17.0.2 的 3000 号。发送方仍是手机,所以 API 回答“来自 192.168.1.20”。另一方面,从电脑自己发往 localhost:8080,回答是“来自 172.17.0.1”。这是因为由 Docker 的中继 docker-proxy 接收,再替你重新连接。
在 Mac 和 Windows 的 Docker Desktop 上,容器不在电脑本身上运行,而是在 Docker 准备的 Linux 虚拟机里。docker0 也在里面,所以从电脑无法直接访问 172.17.0.2。使用 -p 公开的端口,是哪里都通用的做法。
容器里的 localhost
既然容器是“另一台电脑”,容器里的 localhost 就是那个容器自己。下面重现三个常见的坑。
# ① 容器里的应用在 127.0.0.1 上等待(-p 8081:3000)
pc$ curl http://localhost:8081/api/whoami
curl: (56) Recv failure: Connection reset by peer
phone$ curl http://192.168.1.10:8081/api/whoami
curl: (7) Failed to connect to 192.168.1.10 port 8081 after 0 ms
# ② 只在电脑内部公开(-p 127.0.0.1:8082:3000)
pc$ curl http://localhost:8082/api/whoami
{"you":"172.17.0.1","me":"local","listen":"0.0.0.0:3000"}
phone$ curl http://192.168.1.10:8082/api/whoami
curl: (7) Failed to connect to 192.168.1.10 port 8082 after 0 ms
# ③ 从容器访问电脑上运行的开发服务器(3000=127.0.0.1,3001=0.0.0.0)
pc$ docker run --add-host=host.docker.internal:host-gateway …
localhost:3001 → 127.0.0.1 ECONNREFUSED
host.docker.internal:3001 → 172.17.0.1 连上了
host.docker.internal:3000 → 172.17.0.1 ECONNREFUSED
① 容器里的应用在 127.0.0.1 上等待时,经 DNAT 到达 eth0 的请求收不到,从电脑访问是“Connection reset by peer”,从手机访问是“连不上”。容器里的应用要在 0.0.0.0 上等待。② 只想在电脑内部公开时,像 -p 127.0.0.1:8082:3000 这样写上口。什么都不写,Docker 就在所有的口(0.0.0.0)上公开。Docker 的文档也提醒,默认情况下外面也能看到。③ 从容器调用电脑上的开发服务器时,localhost 是容器自己,所以访问不到。用 host.docker.internal(这里是 172.17.0.1)来叫电脑,电脑一侧在 0.0.0.0 上等待。
用 Docker Compose 把 API 和数据库摆在一起,又会碰到另一个经典问题:“用 localhost 连不上数据库”。
# compose.yaml:api(Node.js)和 db(PostgreSQL)。db 不对外公开
pc$ docker compose ps
api 0.0.0.0:8080->3000/tcp
db 5432/tcp
# api 内部的“电话簿”
api$ cat /etc/resolv.conf
nameserver 127.0.0.11
# 从 api 内部连接数据库
api$ node ck11.mjs localhost:5432 db:5432 nothere:5432
localhost:5432 → 127.0.0.1 ECONNREFUSED
db:5432 → 172.18.0.2 连上了 (2.60 ms)
nothere:5432 查不到名字 ENOTFOUND
# 普通 docker run 启动的容器之间,不能用名字互相访问
nw11-bad:3000 查不到名字 ENOTFOUND
有几个容器就有几个 localhost。隔壁用名字访问
Compose 会建一个专用网络(172.18.0.0/16),让容器之间能用服务名互相访问。回答名字的,是每个容器里位于 127.0.0.11 的 Docker 的 DNS。从 api 来看,数据库不是 localhost:5432,而是 db:5432。而没有定名字的普通 docker run 之间(一开始就有的名为 bridge 的网络),不能用名字访问。
新井数据库的配置,我还保持着在电脑上运行时的 localhost……
古井搬进了容器,通讯录也得改。和人搬家一样。
公司的代理
在公司里,公司内的电脑常常不能直接出去,要通过代理。练习用的“公司”里,公司内的电脑(10.0.0.10)也只认识代理(10.0.0.8 的 3128 号,tinyproxy)。
# 公司内部的电脑查不到外面的名字,也没有出去的路
corp$ curl https://expenses.example:3443/api/me
curl: (6) Could not resolve host: expenses.example
# 用环境变量告诉它使用代理
corp$ export HTTPS_PROXY=http://10.0.0.8:3128
corp$ curl -v https://expenses.example:3443/api/me
* Uses proxy env variable HTTPS_PROXY == 'http://10.0.0.8:3128'
> CONNECT expenses.example:3443 HTTP/1.1
< HTTP/1.0 200 Connection established
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519
* ALPN: server accepted h2
< HTTP/2 401
{"error":"login required"} (0.054 s)
# 不加密的 HTTP 会把整个 URL 交给代理
corp$ http_proxy=http://10.0.0.8:3128 curl -v http://expenses.example:3000/api/expenses
> GET http://expenses.example:3000/api/expenses HTTP/1.1
< HTTP/1.1 200 OK
< Via: 1.1 proxy.corp.example (tinyproxy/1.11.1)
HTTPS 要请代理用 CONNECT 挖一条隧道
没有代理,连外面的名字都查不到。设置 HTTPS_PROXY 后,curl 向代理请求 CONNECT expenses.example:3443,收到“Connection established”之后,在这条隧道里开始和第 8 讲一样的 TLS 握手。HTTPS 时,代理知道的只有目的地的名字和端口。不加密的 HTTP 则用 GET http://… 把整个 URL 交出去,回复里还带上了代理的 Via 头部。
有的公司,代理会用自己的证书检查 HTTPS 的内容。这时会出现第 8 讲的“unable to get local issuer certificate”。解决办法是让工具信任公司的 CA(Node.js 用 NODE_EXTRA_CA_CERTS 等),而不是用 -k 之类关掉验证。
代理的陷阱
代理的设置,一般用环境变量告诉工具。可是,每个工具的读法都有些不同。
# ① 连自己电脑的 localhost 都交给了代理
corp$ http_proxy=http://10.0.0.8:3128 curl http://localhost:3000/api/whoami
500 Unable to connect
Tinyproxy was unable to connect to the remote web server.
# 用 NO_PROXY 告诉它“这里直连”
corp$ http_proxy=… NO_PROXY=localhost,127.0.0.1 curl http://localhost:3000/api/whoami
{"you":"127.0.0.1","me":"corp-pc","listen":"127.0.0.1:3000"}
# ② curl 不会把大写的 HTTP_PROXY 用于 http://
corp$ HTTP_PROXY=http://10.0.0.8:3128 curl http://expenses.example:3000/api/expenses
curl: (6) Could not resolve host: expenses.example
# ③ Node.js 的 fetch 默认不看环境变量
corp$ HTTPS_PROXY=http://10.0.0.8:3128 node -e "fetch('https://expenses.example:3443/api/me')"
error EAI_AGAIN
# Node.js 22 里,设置 NODE_USE_ENV_PROXY=1 后才会看(带有“实验性”提示)
corp$ HTTPS_PROXY=… NODE_USE_ENV_PROXY=1 node -e "fetch(…)"
Warning: EnvHttpProxyAgent is experimental, …
ok {"error":"login required"}
① 设着代理去访问自己的 localhost,curl 也把它交给代理,代理去找代理自己的 localhost,结果是“Unable to connect”(500)。用 NO_PROXY=localhost,127.0.0.1 告诉它“这里直连”,就能访问了。② curl 用于 http:// 的 http_proxy 只读小写(文档里也这么写)。只设大写的 HTTP_PROXY,curl 不用代理、直接出去,于是失败。③ Node.js 的 fetch 默认不看环境变量。加上 Node.js 22.21 引入的 NODE_USE_ENV_PROXY=1,在带着“实验性”提示的情况下通过了代理。
新井有一天 curl 能通,npm、git、Docker 却都不通。
古井每个工具都要写在不同的地方。npm 写在 npm 的配置里,git 写在 git 的配置里,Docker 写在守护进程的配置里。别以为一个通了就全都通了。
开发服务器的代理与 CORS
在开发中的电脑上重现第 10 讲的 CORS。页面是 Vite 的开发服务器(localhost:5173),API 在 127.0.0.1:3000,API 不返回 CORS 许可。用 Chromium 打开页面,用两种方式发送申请。
# 启动开发服务器(Vite)
pc$ npx vite
VITE v7.3.7 ready in 217 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
# 从页面(5173 号)直接向 API(3000 号)POST(Chromium 的控制台)
Access to fetch at 'http://localhost:3000/api/expenses'
from origin 'http://localhost:5173' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present …
→ Failed to fetch
# 经开发服务器 POST(把 /api 转给 127.0.0.1:3000 的设置)
POST http://localhost:5173/api/expenses
→ 201 {"id":"EXP-2026-0012","status":"submitted"}
# API 的记录:直接发送时只收到预检(OPTIONS),正式的 POST 没有来
OPTIONS /api/expenses from 127.0.0.1 ← 直接
POST /api/expenses from 127.0.0.1 ← 经由
端口不同就是不同的 Origin。交给开发服务器转发,就是同一个 Origin
同样是 localhost,端口不同就是不同的 Origin。直接发送时,浏览器只发了预检(OPTIONS),因为没有许可,就没发正式的 POST。API 的记录里也只留下了 OPTIONS。在开发服务器的配置里写上 proxy: { '/api': 'http://127.0.0.1:3000' },页面发往 /api/expenses,在浏览器看来对象一直是 5173 号。开发服务器在背后转给 API,返回了 201。
开发服务器的代理是“只在开发时用的捷径”。正式环境也想让页面和 API 看起来是同一个 Origin,就用 Web 服务器(nginx 等)把 /api 转给 API,做成同样的形式。如果保持不同的 Origin,就像第 10 讲那样,由 API 返回许可的头部。
在自己的电脑上确认
| 想查的事 | Mac | Windows | Linux |
|---|---|---|---|
| 在等待的端口和口 | lsof -nP -iTCP -sTCP:LISTEN | netstat -ano | findstr LISTENING | ss -ltnp |
| 3000 号的主人 | lsof -nP -i :3000 | netstat -ano | findstr :3000 | ss -ltnp 'sport = :3000' |
| 代理的设置 | scutil --proxy | netsh winhttp show proxy | env | grep -i proxy |
| 容器公开的端口 | docker port 容器名 | ||
| 容器的网络和地址 | docker network inspect bridge | ||
| 从容器里面看 | docker exec -it 容器名 sh | ||
觉得“连不上”时,先用一行写下来:从哪里、发往哪里的哪个口的几号。从电脑?从容器里?localhost 指的是谁?光是这样,问题就解决了一半。
结 — 一页总结与下一讲
- localhost 是名字,可能是 127.0.0.1,也可能是 ::1。工具的试法不同,拿不准就写 127.0.0.1。
- 服务器选口等待。127.0.0.1 只限电脑内部,0.0.0.0 是所有的口(手机能进,咖啡馆的陌生人也能进)。
- 容器是另一台电脑,有自己的地址和 localhost,通过 docker0 网桥相连。-p 是改写目标(DNAT)。
- 容器里的应用在 0.0.0.0 上等待。只在电脑内部公开就用 -p 127.0.0.1:…。
- 隔壁的容器用名字(db:5432),电脑用 host.docker.internal 访问。
- HTTPS 的代理用 CONNECT 建隧道。注意 NO_PROXY、小写的 http_proxy、各工具各自的设置。
- 端口不同就是不同的 Origin。用开发服务器的代理装成同一个 Origin,就不会发生 CORS。
新井localhost 竟然有三四个。不过只要想清楚是从哪里发的,就不会迷路了。
古井下一讲是最后一讲:“连不上”时的排查。把 11 讲的地图从下层开始用手指一层层划过去,找出卡在哪里。
参考
- Docker Docs — Networking overview(127.0.0.11)
- Docker Docs — Port publishing and mapping
- Docker Docs — Bridge network driver
- Docker Docs — Networking in Compose
- Docker Docs — Networking on Docker Desktop
- Docker Docs — Networking how-tos(host.docker.internal)
- curl — man page(http_proxy, NO_PROXY)
- Node.js — HTTP(Built-in Proxy Support, NODE_USE_ENV_PROXY)
- Vite — Server Options(server.host, server.proxy)
- RFC 6761 — Special-Use Domain Names(localhost)
- RFC 9110 — HTTP Semantics(CONNECT, Via)
- Tinyproxy
记录来自笔者把云端 Linux(Ubuntu 24.04,内核 6.18)当作开发者的电脑、用 Docker 29.9.2 和网络命名空间搭建的练习网络,于 2026 年 10 月 25 日抓取。API 是用 Node.js 22.23 写的练习用程序,容器镜像是 node:22-alpine 和 postgres:17-alpine,代理是 tinyproxy 1.11.1,开发服务器是 Vite 7.3.7,浏览器是 Playwright 的 Chromium。这台 Linux 没有 IPv6,所以只有 localhost 与 ::1 的实验是在另一台 Linux(Ubuntu 22.04.5,curl 7.81.0)上做的。名字和地址都是为说明而保留的或为练习而制作的。表中的数字和输出都是从这些记录和当时命令的输出读出的实际数值(较长的输出省略了一部分)。登场人物均为虚构。
古法网络入门 第11讲 — 开发者的网络 / 2026.10