Old-School Networking / 2026 ・ No.11
いにしえのネットワーク入門
第11回 開発者のネットワーク ── localhost、Docker の中と外、プロキシ
公開
第10回までで、申請ボタンの1回の通信を、ケーブルから HTTP までたどりました。今回は、毎日いちばん長くいるのに、いちばん迷う場所、開発者の PC の中を歩きます。
localhost とは何か、「スマホから見えない」はなぜ起きるか、Docker のコンテナはどこにあるのか、会社のプロキシの向こうへはどう出るのか、開発サーバーのプロキシはなぜ CORS を消すのか。Docker と練習用ネットワークで、ひとつずつ確かめます。
いちばん迷う場所
新井ケーブルから HTTP まで、地図は全部わかりました。なのに、ぼくの localhost:3000 がどこにあるのかはわかりません。
古井地図の外じゃない。地図の中に、小さな地図が入れ子になっているんだ。PC の中にもう1台の PC(コンテナ)、その中にまた localhost。
新井マトリョーシカですか。
古井そう。しかも人形ごとに「自分」の呼び方が同じ localhost なんだ。迷うに決まっている。
全12回の路線図(いまここ:第11回)
- localhost と待ち受けの住所 ── 127.0.0.1 で待つとスマホからは届かない、0.0.0.0 なら届く(実測)
- Docker ── コンテナは PC の中のもう1台の PC。-p は宛先の書きかえ(実測と Wireshark)
- コンテナの中の localhost ── DB に localhost でつながらない理由と、名前で呼ぶ方法(実測)
- プロキシ ── 会社のプロキシの CONNECT、NO_PROXY、開発サーバーのプロキシで CORS が消えるわけ(実測)
練習には、クラウド上の Linux を「開発者の PC」に見立てました。この PC の上で Docker 29 を動かし、家の LAN にスマホ役(192.168.1.20)を、別の場所に「会社」(社内の PC とプロキシ)をネットワーク名前空間で作っています。申請の API は、だれから届いたか(you)と、自分はだれか(me)を返す練習用のものです。
localhost は名前
PC には、外とつながる口(LAN の口や Wi-Fi)のほかに、自分自身へ折り返すだけの口、ループバック(lo)があります。住所は 127.0.0.1。実は 127 で始まる住所はすべてループバックで、練習の PC でも 127.0.0.2 に送ると自分に届きました。ここに送ったパケットは、ケーブルにも電波にも出ません。
そして localhost は名前です。第9回の名前と同じく、OS の 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
# ちなみに、この PC の 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 を見に行く。「サーバーは動いているのにつながらない」の典型です。
設定ファイルに書く宛先は、名前の localhost より、住所の 127.0.0.1 のほうが迷いません。この回の開発サーバーの設定でも、そう書いています(第8章)。
待ち受けの住所: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 自身から
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"}
# 同じ LAN のスマホから
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 番で、もう1つ起動すると
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 で待つと、内側の口でしか待っていないので、同じ LAN のスマホからは断られました(PC 自身でも、LAN の住所 192.168.1.10 あてだと断られます)。0.0.0.0 は「すべての口で」という待ち方の印で、スマホから届き、API はスマホの住所 192.168.1.20 を見ています。
スマホで画面を確かめたいときに困るのがこれです。開発サーバーの Vite は、ふだん localhost だけで待ち、--host を付けると LAN にも開きます。
# --host を付けると、LAN にも公開される(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)など PC が持っている口がすべて並びます。スマホから届くのは、同じ LAN の 192.168.1.10 だけです。
0.0.0.0 で待つのは「この PC のどの口からでもどうぞ」という意味です。カフェの Wi-Fi では、同じ Wi-Fi の見知らぬ人にも開いています。確かめ終わったら閉じましょう。番号の取り合い(EADDRINUSE)が起きたら、ss -ltnp などで、だれが先に座っているかを見ます。
Docker:コンテナはもう1台の PC
API をコンテナに入れて、-p 8080:3000 で起動しました。中と外から、まわりを見てみます。
# API をコンテナで動かし、PC の 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
# PC の側: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 から / スマホから
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"}
コンテナは PC の中のもう1台の PC。-p は宛先の書きかえ
コンテナの中には、自分の eth0(172.17.0.2)と、自分の lo(127.0.0.1)がありました。外への道は 172.17.0.1、つまり PC の上に Docker が作った docker0 という橋(第3回のスイッチの仲間)です。コンテナは、PC の中に置かれたもう1台の小さな PC なのです。
では -p 8080:3000 は何をしたか。PC の決まりに「8080 番に来たら、宛先を 172.17.0.2 の 3000 番に書きかえる」(DNAT)が1行増えていました。第5回の家のルーターの NAT と同じしくみで、今回は PC がルーターの役をしています。
スマホの頼みごとは、PC の入口では 192.168.1.10 の 8080 番あて、コンテナの手前では 172.17.0.2 の 3000 番あて。送り元はスマホのままなので、API は「192.168.1.20 から来た」と答えました。一方、PC 自身から localhost:8080 に送ると、答えは「172.17.0.1 から」。こちらは docker-proxy という Docker の中継役が受け取り、代わりにつなぎ直しているからです。
Mac や Windows の Docker Desktop では、コンテナは PC の上ではなく、Docker が用意した Linux の仮想マシンの中で動きます。docker0 もその中にあるので、PC から 172.17.0.2 に直接は届きません。-p で公開した番号を使うのが、どこでも通じるやり方です。
コンテナの中の localhost
コンテナが「もう1台の PC」なら、コンテナの中の localhost はそのコンテナ自身です。ここから、よくある3つのつまずきを再現しました。
# ① コンテナの中のアプリが 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
# ② 公開を PC の中だけにする(-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
# ③ コンテナから、PC で動いている開発サーバーへ(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 に届いた頼みごとを受け取れず、PC からは「Connection reset by peer」、スマホからは「つながらない」になりました。コンテナの中のアプリは 0.0.0.0 で待たせます。② 公開を PC の中だけにしたいときは、-p 127.0.0.1:8082:3000 と口を書きます。何も書かないと、Docker はすべての口(0.0.0.0)で公開します。Docker の説明書も、そのままだと外からも見えると注意しています。③ コンテナから PC の開発サーバーを呼ぶとき、localhost はコンテナ自身なので届きません。host.docker.internal(ここでは 172.17.0.1)で PC を呼び、PC の側は 0.0.0.0 で待たせます。
API とデータベースを Docker Compose で並べると、もう1つの定番、「DB に 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 の中から DB につなぐ
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 から見て、DB は localhost:5432 ではなく db:5432。一方、名前を決めていないふつうの docker run どうし(最初からある bridge という網)では、名前では呼べませんでした。
新井DB の設定、PC で動かしていたときのまま localhost にしていました……。
古井コンテナに引っ越したら、住所録も書きかえる。人間の引っ越しと同じだ。
会社のプロキシ
会社では、社内の PC から外へ直接は出られず、プロキシ(代理人)を通すことがよくあります。練習の「会社」でも、社内の PC(10.0.0.10)が知っているのはプロキシ(10.0.0.8 の 3128 番、tinyproxy)だけにしました。
# 社内の PC は、外の名前も引けず、外へ出る道もない
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 などで確かめを切るのではありません。
プロキシの落とし穴
プロキシの設定は、環境変数で道具に伝えるのが一般的です。ところが、道具ごとに読み方が少しずつ違います。
# ① 自分の PC の 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 だけでは、プロキシを使わず直接出ようとして失敗しました。③ Node.js の fetch は、そのままでは環境変数を見ません。Node.js 22.21 から入った NODE_USE_ENV_PROXY=1 を付けると、試験的という注意つきで、プロキシを通りました。
新井curl は通るのに、npm も git も Docker も通らない日がありました。
古井道具ごとに、別の場所に書くんだ。npm は npm の設定、git は git の設定、Docker はデーモンの設定。1つ通ったから全部通る、とは思わないことだ。
開発サーバーのプロキシと CORS
第10回の CORS を、開発中の PC で再現しました。画面は Vite の開発サーバー(localhost:5173)、API は 127.0.0.1:3000 で、API は CORS の許可を返しません。Chromium で画面を開き、申請を2通りで送りました。
# 開発サーバー(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 が許可のヘッダーを返します。
自分の PC で確かめる
| 調べたいこと | 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 | ||
「つながらない」と思ったら、まずどこから、どこの、どの口の何番へ送っているのかを1行で書き出してみましょう。PC から? コンテナの中から? localhost はだれのこと? それだけで半分は解けます。
結 — 一枚まとめと、次回
- localhost は名前。127.0.0.1 にも ::1 にもなりうる。道具によって試し方が違うので、迷ったら 127.0.0.1 と書く。
- サーバーは口を選んで待つ。127.0.0.1 は PC の中だけ、0.0.0.0 はすべての口(スマホからも、カフェの他人からも)。
- コンテナはもう1台の PC。自分の住所と localhost を持ち、docker0 の橋でつながる。-p は宛先の書きかえ(DNAT)。
- コンテナの中のアプリは 0.0.0.0 で待つ。公開を PC の中だけにするなら -p 127.0.0.1:…。
- となりのコンテナは名前で(db:5432)、PC は host.docker.internal で呼ぶ。
- HTTPS のプロキシは CONNECT でトンネル。NO_PROXY、小文字の http_proxy、道具ごとの設定に注意。
- 番号が違えば別の Origin。開発サーバーのプロキシで同じ Origin に見せれば、CORS は起きない。
新井localhost が3つも4つもあるなんて。でも、どこから送っているかを考えれば迷わなくなりました。
古井次回は最終回、「つながらない」ときの切り分けだ。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)を開発者の PC に見立て、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