0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

localhost だと繋がらないのに 127.0.0.1 なら繋がる — 犯人は IPv6 の ::1 だった

0
Posted at
症状 localhost:3000 に繋がらないが 127.0.0.1:3000 なら繋がる
犯人 localhost::1(IPv6) に先に解決され、サーバは IPv4 でしか待っていない
一番速い直し方 接続先を 127.0.0.1 に固定 or サーバをデュアルスタック(::)で立てる

結論から。localhost127.0.0.1 は同じ穴じゃない。localhost は名前で、getaddrinfo::1(IPv6のループバック) を先に返すことがある。サーバが IPv4 でしか待っていないと、そこで ECONNREFUSED になる。127.0.0.1 と数字で書けば IPv4 直行だから繋がる。この1点だけ掴めば、たぶん今日のうちに帰れる。

この記事は、ローカルのDBやAPIに localhost で繋ごうとして ECONNREFUSED ::1 を食らった人向け。全部この Mac で叩いた実ログを貼る。

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 EyeballsRFC 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.lookupverbatim 既定が変わった。

  • 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. クライアント側: localhost127.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 じゃない

localhost127.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])" を叩いて、自分の環境の先頭が ::1127.0.0.1 か確認する。::1 なら予備軍。
  • 今週: ローカル開発の接続文字列(.envDATABASE_URL / REDIS_URL など)を grep して、localhost127.0.0.1 に寄せるか判断する。grep -rn "localhost" .env* から。
  • 今月: Node プロジェクトを16→18以降に上げる予定があるなら、リリースノートの verbatim 変更を頭に入れて、起動スクリプトに --dns-result-order の退避策を1行コメントで残しておく。アップグレード当日に ECONNREFUSED ::1 で慌てないための保険。

localhost は名前、127.0.0.1 は住所。名前は解決順しだいで ::1 に化ける。エラーの行頭が ::1 だったら、もう犯人はそこにいる。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?