| 症状 |
localhost:3000 に繋がらないが 127.0.0.1:3000 なら繋がる |
| 犯人 |
localhost が ::1(IPv6) に先に解決され、サーバは IPv4 でしか待っていない |
| 一番速い直し方 | 接続先を 127.0.0.1 に固定 or サーバをデュアルスタック(::)で立てる |
結論から。localhost と 127.0.0.1 は同じ穴じゃない。localhost は名前で、getaddrinfo が ::1(IPv6のループバック) を先に返すことがある。サーバが IPv4 でしか待っていないと、そこで ECONNREFUSED になる。127.0.0.1 と数字で書けば IPv4 直行だから繋がる。この1点だけ掴めば、たぶん今日のうちに帰れる。
この記事は、ローカルのDBやAPIに localhost で繋ごうとして ECONNREFUSED ::1 を食らった人向け。全部この Mac で叩いた実ログを貼る。
- 3行で終わる話を、なぜ1時間溶かしたか
- ping は 127.0.0.1 なのに、アプリは ::1 を見ていた
- サーバは IPv4 でしか待っていなかった(再現)
- なぜ curl だと気づけないのか — Happy Eyeballs
- Node 17 で既定が変わって、::1 が先に来るようになった
- 直し方は3つ、効き目と副作用が違う
- もう一つの罠 — MySQL の localhost は TCP じゃない
- デメリット・留意点
- 今日・今週・今月やること
3行で終わる話を、なぜ1時間溶かしたか
ローカルで立てたサーバに、別のスクリプトから繋ぎに行った。エラーはこれ。
# よく見るやつ。address が ::1 なのがヒント
Error: connect ECONNREFUSED ::1:8931
at TCPConnectWrap.afterConnect [as oncomplete]
このとき自分は ::1 を素通りして「ポート間違えたか?」「サーバ落ちてる?」を先に疑ったんですよ。lsof -i :8931 でサーバは生きてる。ブラウザで http://127.0.0.1:8931 を開くと普通に見える。なのにスクリプトからだけ落ちる。ぶっちゃけ、ここで30分は溶かした。
犯人は行頭の ::1 だった。エラーは最初から「IPv6のループバックに繋ぎに行って断られた」と言っていた。読めていなかったのは自分。
ping は 127.0.0.1 なのに、アプリは ::1 を見ていた
localhost が何に化けるかは、コマンドによって違う。まず名前解決を素で見る。
# macOS。localhost は ::1 と 127.0.0.1 の両方を持っている
$ grep -i localhost /etc/hosts
127.0.0.1 localhost
::1 localhost
# ping は IPv4 を選んだ
$ ping -c1 localhost
PING localhost (127.0.0.1): 56 data bytes
64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.045 ms
ping localhost は 127.0.0.1。だから「localhost は 127.0.0.1 でしょ」と思い込む。ところが、アプリが内部で呼ぶ getaddrinfo は順番が違った。
# resolve_order.py — アプリが実際に見る解決順を出す
import socket
for fam, typ, proto, cn, sa in socket.getaddrinfo(
"localhost", 8000, type=socket.SOCK_STREAM):
print(fam.name, sa)
$ python3 resolve_order.py
AF_INET6 ('::1', 8000, 0, 0) # ← 先頭が IPv6
AF_INET ('127.0.0.1', 8000)
ping は 127.0.0.1、getaddrinfo は ::1 が先頭。ここがズレの正体。多くのクライアントは「返ってきたリストの先頭」から順に繋ぎに行くので、先頭が ::1 だと、まず IPv6 で叩く。
サーバは IPv4 でしか待っていなかった(再現)
問題は「クライアントが ::1 を選ぶ」ことじゃなくて、「サーバが ::1 で待っていない」こととの組み合わせで起きる。片方だけなら平和。両方揃うと落ちる。
サーバを IPv4 (127.0.0.1) 限定で立てて、素朴なクライアントで繋いでみる。
# サーバは 127.0.0.1 だけで待つ(IPv6では待たない)
$ python3 -m http.server 8931 --bind 127.0.0.1 &
# naive_client.py — getaddrinfo の先頭候補だけ使う(フォールバックしない)
import socket
infos = socket.getaddrinfo("localhost", 8931, type=socket.SOCK_STREAM)
fam, typ, proto, cn, sa = infos[0] # 先頭 = ::1 を掴む
print("先頭候補:", fam.name, sa)
s = socket.socket(fam, typ, proto); s.settimeout(3)
try:
s.connect(sa); print("接続OK")
except OSError as e:
print("接続失敗:", e)
$ python3 naive_client.py
先頭候補: AF_INET6 ('::1', 8931, 0, 0)
接続失敗: [Errno 61] Connection refused # ← ::1 には誰も居ない
[Errno 61] Connection refused。サーバは 127.0.0.1 で元気に動いているのに、クライアントは ::1 を叩いて断られた。ブラウザや 127.0.0.1 直打ちが平然と動くのに、スクリプトだけ落ちる理由がこれ。「サーバの待ち受けアドレス」と「クライアントの解決先」がすれ違っている。
なぜ curl だと気づけないのか — Happy Eyeballs
ここで厄介なのが curl。同じ localhost を叩いても、curl は落ちない。
# 127.0.0.1 直打ち → 当然OK
$ curl -sS -m3 -o /dev/null -w "HTTP %{http_code} 接続先=%{remote_ip}\n" http://127.0.0.1:8931/
HTTP 200 接続先=127.0.0.1
# localhost でもOK。しかも接続先は 127.0.0.1 になっている
$ curl -sS -m3 -o /dev/null -w "HTTP %{http_code} 接続先=%{remote_ip}\n" http://localhost:8931/
HTTP 200 接続先=127.0.0.1
curl は ::1 が先頭でも、少し待って IPv4 にフォールバックする。これが Happy Eyeballs(RFC 8305 / 元は 6724 のアドレス選択)という仕組みで、IPv6を先に試し、ダメなら素早くIPv4に切り替えて「繋がるほうを使う」。
だから「curl では繋がるのに、アプリからは繋がらない」という、一番人を混乱させる状態が生まれる。切り分けで curl localhost を打って「繋がるじゃん」と思ったら、それはcurlが救済しているだけで、アプリはフォールバックしていない、という落とし穴。切り分けは curl -6 localhost で明示的にIPv6を狙うと本音が出る。
# IPv6を明示 → サーバが居ないので正直に失敗する
$ curl -sS -m3 -6 http://localhost:8931/
curl: (7) Failed to connect to localhost port 8931 after 0 ms: Couldn't connect to server
Node 17 で既定が変わって、::1 が先に来るようになった
この問題が2021年ごろから一気に噴き出したのは、Node.js のせい(というか仕様変更)。dns.lookup の verbatim 既定が変わった。
- Node 16 まで:
verbatim: false= 解決結果を並べ替えて IPv4を先に返す - Node 17 から:
verbatim: true= OSが返した順(=::1が先)をそのまま使う
この Mac (Node v26) で両方を出すとハッキリする。
$ node -e 'const dns=require("dns");
dns.lookup("localhost",{all:true,verbatim:true},(e,a)=>console.log("v17+ 既定:",a));
dns.lookup("localhost",{all:true,verbatim:false},(e,a)=>console.log("v16以前:",a));'
v16以前: [ { address: "127.0.0.1", family: 4 }, { address: "::1", family: 6 } ]
v17+ 既定: [ { address: "::1", family: 6 }, { address: "127.0.0.1", family: 4 } ]
つまり「Node をアップグレードしたら、今まで動いていた localhost 接続が急に ECONNREFUSED ::1 になった」の犯人はこれ。詳細は公式 dns ドキュメントと既定を変えた PR #39987 に書いてある。Python の socket.getaddrinfo は元からOS順なので、Pythonでも同じ症状が出る。
直し方は3つ、効き目と副作用が違う
順に「速いけど対症療法」から「根っこを直す」まで。
1. クライアント側: localhost を 127.0.0.1 に書き換える(一番速い)
# localhost の先頭は ::1、127.0.0.1 の先頭は当然 v4
socket.getaddrinfo("localhost", 80, type=socket.SOCK_STREAM)[0][4] # ('::1', 80, 0, 0)
socket.getaddrinfo("127.0.0.1", 80, type=socket.SOCK_STREAM)[0][4] # ('127.0.0.1', 80)
接続文字列やenv(DATABASE_URL=postgres://...@127.0.0.1:5432/... など)で localhost を数字にするだけ。名前解決を挟まないので ::1 に化けようがない。同じサーバに対して、接続成功率は localhost 経由 0% → 127.0.0.1 経由 100% に変わる。ローカル開発なら、まずこれで足りる。
2. サーバ側: デュアルスタック(::)で待つ(根本寄り)
サーバを ::(全IPv6アドレス、v4射影込み)で立てると、::1 でも 127.0.0.1 でも受けられる。
# 127.0.0.1 だけ → ::1 で来た客を取りこぼす
python3 -m http.server 8931 --bind 127.0.0.1
# :: で待つ → localhost(::1) も 127.0.0.1 も両方つながる
$ curl -s -o /dev/null -w "localhost→%{remote_ip}\n" http://localhost:8932/ # ::1
$ curl -s -o /dev/null -w "127.0.0.1→%{remote_ip}\n" http://127.0.0.1:8932/ # 127.0.0.1
localhost→::1
127.0.0.1→127.0.0.1
Node の app.listen(3000) は既定でデュアルスタック寄りだが、app.listen(3000, "127.0.0.1") と明示するとIPv4限定になって同じ罠に戻る。「なぜか localhost だけ落ちる」ときは、listen の第2引数を疑う。
3. Node なら解決順を戻す(アプリ全体に効かせる)
# 起動時に IPv4 を優先へ戻す(Node 17+ 用)
node --dns-result-order=ipv4first server.js
--dns-result-order=ipv4first で v16 以前の挙動に戻る。既存資産を触りたくない時の逃げ道。ただし本番がIPv6前提の環境だと将来詰むので、あくまでローカル向け。
もう一つの罠 — MySQL の localhost は TCP じゃない
localhost と 127.0.0.1 が別物になるケースはもう一つある。MySQL/MariaDB は、localhost を渡すと Unixドメインソケット、127.0.0.1 を渡すと TCP を使う。IPv6以前に、そもそも経路が別。
# localhost → ソケット(/tmp/mysql.sock など)。TCPポートは見ない
mysql -h localhost -u root
# 127.0.0.1 → TCP 3306。ポートやfirewallの影響を受ける
mysql -h 127.0.0.1 -u root
「127.0.0.1 では入れるのに localhost だと Can't connect through socket が出る」時はこれ。ソケットのパスがズレているか、そもそもソケットを無効にしている。挙動は公式マニュアルの Connecting に明記されている。「localhost = 127.0.0.1」という思い込みが、IPv6でもソケットでも裏切られる、という話。
デメリット・留意点
127.0.0.1 固定は速いけど、万能じゃない。
- IPv6-onlyな環境で詰む: 将来IPv6前提のCIやコンテナに移すと、v4固定のURLが今度は繋がらなくなる。ローカルの逃げとしては良いが、本番設定にそのまま持ち込まない。
-
--dns-result-orderはプロセス全体に効く: 外部APIへの通信の解決順も変わる。ローカル起動スクリプト限定にとどめるのが無難。 -
デュアルスタックはアプリ対応が要る:
::で待っても、アプリがAF_INET決め打ちだと結局片肺。フレームワークのlisten実装を一度確認する。 -
/etc/hostsから::1 localhostを消すのは非推奨: 一見効くが、IPv6を正しく使いたい他ツールを壊す。hostsをいじるより接続先を直すほうが安全。
要は「症状が消える手」と「根っこが直る手」は別物。ローカルは1、共有するコードは2で寄せるのが自分の落とし所。
今日・今週・今月やること
-
今日(5分): 手元で
python3 -c "import socket; print(socket.getaddrinfo('localhost',80,type=socket.SOCK_STREAM)[0][4])"を叩いて、自分の環境の先頭が::1か127.0.0.1か確認する。::1なら予備軍。 -
今週: ローカル開発の接続文字列(
.envのDATABASE_URL/REDIS_URLなど)を grep して、localhostを127.0.0.1に寄せるか判断する。grep -rn "localhost" .env*から。 -
今月: Node プロジェクトを16→18以降に上げる予定があるなら、リリースノートの
verbatim変更を頭に入れて、起動スクリプトに--dns-result-orderの退避策を1行コメントで残しておく。アップグレード当日にECONNREFUSED ::1で慌てないための保険。
localhost は名前、127.0.0.1 は住所。名前は解決順しだいで ::1 に化ける。エラーの行頭が ::1 だったら、もう犯人はそこにいる。