localhost 不一定是 127.0.0.1
credit: Nano Banana

localhost 不一定是 127.0.0.1

約 9,501 字

前幾年的 iTHome 鐵人賽,我在追 Vite 的原始碼的時候,追著追著發現了一個有趣的 commit

這個 commit 的改動看起來超級簡單,只是把預設的 host 從 127.0.0.1 改成 localhost。但這兩個不是一樣的東西嗎?改這做什麼?我猜大多數的網站工程師可能跟我一樣,我們很早就聽說localhost 或是 127.0.0.1 就是本機電腦的網址」,這兩個可以互換使用。但如果真的一樣,為什麼 Vite 的維護者要特地發 PR 來改這個?我們就來好好挖一下這個看起來有點無聊的改動,來了解一下 127.0.0.1localhost 到底差在哪裡。

怕各位沒耐心看完,先說結論:

不要假設 localhost 就是 127.0.0.1,這個假設不一定是正確的。

表面上的「相同」

先來做個小實驗,打開你的終端機,分別輸入:

$ ping localhost
PING localhost (127.0.0.1): 56 data bytes
64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.077 ms
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.130 ms
64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.131 ms

以及:

$ ping 127.0.0.1
PING 127.0.0.1 (127.0.0.1): 56 data bytes
64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.090 ms
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.094 ms
64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.115 ms

看起來一模一樣,回應的 IP 位置都是 127.0.0.1,連延遲也差不多。如果各位曾經在自己的電腦開發過網站的話,也可以用瀏覽器開 http://localhost:3000http://127.0.0.1:3000,看到的結果也完全相同。

就是這種「相同」讓我們在開發的時候不知不覺養成「這兩個東西可以隨便換著用」的習慣,反正都一樣。不過這個「相同」只是表象。你可能覺得「把大象放進冰箱」和「把大象塞進冰箱」是一樣意思,但如果大象會說話的話,對大象來說體驗可是不太一樣的。

本質上的不同

127.0.0.1 是什麼?

127.0.0.1 是一個 IP 位址,而且是一個很特別的 IP 位址。

在 IPv4 的世界裡,127.0.0.1 稱為 loopback 位址,它之所以特別就在於這個 IP 位址永遠指向「自己這台電腦」。當你的程式嘗試連接 127.0.0.1,封包根本不會離開你的電腦,直接在網路堆疊(Network Stack)內部繞一圈就回來了。就像是你寄一封信給自己家的地址,郵差連門都不用出,拿到信之後直接把信放進你的信箱就好。

補個冷知識,根據 RFC 1122(Section 3.2.1.3)和 RFC 5735 的規範,其實整個 127.0.0.0/8 網段(從 127.0.0.0127.255.255.255,大約有 1,600 萬個 IP 位置)都是保留給 loopback 用的。規範是一回事,實作又是另一回事。如果你手邊有 Linux 機器的話不妨試試看,這整個網段通常都能正常運作,不過在 macOS 上預設只配置了 127.0.0.1,如果你想用 127.0.0.2 之類的位址,需要手動加上 alias:

$ sudo ifconfig lo0 alias 127.0.0.2

不過實務上應該沒人這樣搞,127.0.0.1 應該就很夠用啦。

因為 127.0.0.1 是寫死在作業系統網路協定堆疊裡的,所以它不需要任何 DNS 解析,不需要查詢任何設定檔,作業系統看到這個位址就知道「喔,要送給自己」。同時因為 loopback 的封包根本不會經過實體網路卡,所以即使你把網路線拔掉、關掉 Wi-Fi、甚至在飛機上開飛航模式,127.0.0.1 照樣能用。這也是為什麼開發者可以在沒有網路的環境下繼續開發和測試本機服務。

localhost 是什麼?

localhost 是一個完全不同的東西。它不是 IP 位置,它是一個「主機名稱(hostname)」。主機名稱就像是給 IP 位址取的綽號,例如手機通訊錄裡「媽媽」這個名字對應到 0912-054-088 這個號碼。當打電話給「媽媽」的時候,手機會幫我們查詢對應的號碼,然後撥打過去。

localhost 也是一樣。當你嘗試連接 localhost 的時候,作業系統需要先「查詢」這個名稱對應到什麼 IP 位址,然後才能建立連線。

這個查詢過程通常是這樣:

  1. 先查 /etc/hosts 檔案(Windows 是 C:\Windows\System32\drivers\etc\hosts

  2. 如果找不到,才會去問 DNS 伺服器

檢視一下你電腦裡的 /etc/hosts 檔案,你可能會看到像這樣的設定:

127.0.0.1       localhost
::1             localhost

看到了嗎?這裡定義了 localhost 對應到 127.0.0.1,同時也對應到 ::1,這是 IPv6 的 loopback 位址。另外,localhost 這個名稱在 RFC 6761 中被定義為「特殊用途域名」(Special-Use Domain Name)。這表示它有一些特殊的行為規範,例如 DNS 伺服器不應該回應 localhost 的查詢等。

是說,/etc/hosts 這個檔案是可以被修改的,也就是理論上你可以把 localhost 指向任何你想要的 IP 位址,只是通常沒事不會這樣做,不知道什麼時候會造成什麼奇怪的問題。

我用一個簡單的表格來整理這兩者的差異:

項目127.0.0.1localhost
類型IP 位址主機名稱(hostname)
DNS 解析不需要需要(查 hosts 或 DNS)
IPv6 支援否(純 IPv4)是(可解析到 ::1)
可被修改否(寫死在協定中)是(透過 hosts 檔案)
解析速度最快稍慢(需要查詢)

這些差異在大多數情況下不會造成問題。但是,當 IPv6 加入戰局之後,故事就變得不一樣了。

IPv6?

大家知道的 IP 位置是由 4 個 0~255 的數字組成的,所以算一下就知道 IP 位置應該會有 256 的 4 次方,大概是 43 億個。43 億個聽起來很多,但隨著網路的普及,每個人手上可能都不只一個能上網的裝置,IP 位址其實早在 2011 年就發完了。那怎辦?反正就再想辦法定義新的 IP 格式就行啦,所以後來就有了 IPv6,然後之前的就稱之為 IPv4。

IPv6 的格式看起來跟 IPv4 有滿大的差別,是由 8 組 4 個 16 進位數字組成,中間用冒號 : 分隔,像這樣:

2001:0db8:85a3:0000:0000:8a2e:0370:7334

一樣算一下數學,8 組 16 進位數字,每組有 2 的 16 次方(65,536)個組合,8 組的話就是 2 的 128 次方個,這個數字相當大,大到連單位都不知道用什麼量詞才好。這個數字大到什麼程度呢?如果把地球上每一粒沙子都分配一個 IPv6 位址,還會剩下一大堆沒用完,反正夠用就是了。

而在 IPv6 的世界裡,loopback 位址不再是 127.0.0.1,而是 ::1。這是一個極度簡化的表示法,完整版是 0000:0000:0000:0000:0000:0000:0000:0001

附帶一提,::1 這種簡寫方式叫做「零壓縮」(zero compression)。IPv6 位址中連續的零可以用 :: 來表示,但一個位址中只能用一次。所以 ::1 就是前面全部都是零,最後一個是 1 的意思。

現在,讓我們再看一次 /etc/hosts 檔案:

127.0.0.1       localhost
::1             localhost

發現問題了嗎?

localhost 這個名稱同時對應到兩個 IP 位址:127.0.0.1(IPv4)和 ::1(IPv6)。當你的程式查詢 localhost 對應的 IP 位址時,作業系統會回傳哪一個?

看情況。

不同的作業系統、不同的設定、甚至不同的名稱解析器,可能會有不同的行為。有些會優先回傳 IPv4,有些會優先回傳 IPv6。

不同作業系統的差異

讓我們來看看幾個常見作業系統的預設行為:

  • macOS:系統預設會優先使用 IPv6。如果你在 Mac 上工作,localhost 很可能會先解析到 ::1

  • Linux:取決於 /etc/gai.conf 的設定,大多數現代 Linux 發行版預設也是 IPv6 優先。

  • Windows:Windows 10 之後也傾向優先使用 IPv6,但行為可能因網路設定而異。

如果你的電腦是 macOS 或 Linux 的話,可以試著開終端機輸入指令來確認你的系統是怎麼解析 localhost 的:

$ getent hosts localhost
127.0.0.1       localhost
::1             localhost

或是用 Python 執行一小段程式碼:

$ python -c "import socket; print(socket.getaddrinfo('localhost', None))"
[(<AddressFamily.AF_INET6: 30>, <SocketKind.SOCK_DGRAM: 2>, 17, '', ('::1', 0, 0, 0)), (<AddressFamily.AF_INET6: 30>, <SocketKind.SOCK_STREAM: 1>, 6, '', ('::1', 0, 0, 0)), (<AddressFamily.AF_INET: 2>, <SocketKind.SOCK_DGRAM: 2>, 17, '', ('127.0.0.1', 0)), (<AddressFamily.AF_INET: 2>, <SocketKind.SOCK_STREAM: 1>, 6, '', ('127.0.0.1', 0))]

這個「看情況」,就是這篇文章要講的故事起點...

Node.js 17 的「重大」變更

在 Node.js 的世界裡,dns.lookup() 這個函式負責把主機名稱解析成 IP 位址。這個函式有一個選項叫做 verbatim,中文可以翻譯成「逐字的」。在 Node.js 16(含)之前,verbatim 的預設值是 false。這表示 Node.js 會「重新排序」名稱解析的結果,把 IPv4 位址排在前面。換句話說,不管系統名稱解析器回傳什麼順序,Node.js 都會好心地幫你把 IPv4 放前面。

但是在 Node.js 17 這個行為改變了,看看這個精美的 PRverbatim 的預設值改成了 true。這表示 Node.js 會「尊重」系統名稱解析器的回傳順序,不會自作主張地重新排序。

感覺滿好的,尊重系統設定聽起來也合理,但這個改動影響了不少現有程式的行為,尤其是那些 hardcode 監聽 localhostloopback 的程式。

補充:在更新版本的 Node.js 中,verbatim 選項已被標記為 deprecated,建議改用 order 選項來控制排序行為

問題是怎麼發生的

讓我用一段程式碼來說明這個問題,假設我有一個簡單的 HTTP 伺服器,會聽 127.0.0.1:3000

const http = require("http");

const server = http.createServer((req, res) => {
  res.end("Hello from server!");
});

// 明確指定監聽 127.0.0.1(IPv4)
server.listen(3000, "127.0.0.1", () => {
  console.log("Server is running on 127.0.0.1:3000");
});

然後 Client 端程式連接 localhost:3000

const http = require("http");

http
  .get("http://localhost:3000/", (res) => {
    let data = "";
    res.on("data", (chunk) => (data += chunk));
    res.on("end", () => console.log("Response:", data));
  })
  .on("error", (err) => {
    console.error("Error:", err.message);
  });

在 Node.js 16,這段程式碼運作正常:

$ node server.js &
Server is running on 127.0.0.1:3000

$ node client.js
Response: Hello from server!

但在 Node.js 17(或更新的版本),如果你的系統名稱解析器優先回傳 IPv6,你可能會看到:

$ node server.js &
Server is running on 127.0.0.1:3000

$ node client.js
Error: connect ECONNREFUSED ::1:3000

ECONNREFUSED ::1:3000 錯誤訊息就看的出來 Client 端試著連結 IPv6 的 ::1,但伺服器只監聽 IPv4 的 127.0.0.1,所以連線被拒絕了,更多討論細節可參考 Node.js Issue #40537

這不是 Node.js 的 bug,這是刻意的行為改變。Node.js 團隊認為尊重系統名稱解析器的順序是更正確的做法,不過這個改變造成了很多現有程式的相容性問題。

補個不重要的冷知識,雖然跟 Node.js 無關,但類似的情況在 Docker Desktop(macOS / Windows)也可能發生。Docker Desktop 在 Mac 和 Windows 上運作的方式是透過一個輕量級的 Linux 虛擬機器。當你在容器中開一個 port,Docker 會把這個 port 轉發到你的本機。但問題是 Docker Desktop 只會把 port 轉發到 IPv4 位址(127.0.0.1),不會轉發到 IPv6 位址(::1)。

所以你可能會遇到這種情況:

# 啟動 nginx 容器,把容器的 80 port 對應到本機的 3000 port
$ docker run -p 9527:80 nginx

# 用 127.0.0.1 連接 - 成功
$ curl http://127.0.0.1:9527
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...

# 用 localhost 連接 - 可能失敗!
$ curl http://localhost:9527
curl: (7) Failed to connect to localhost port 9527: Connection refused

原理也是一樣的,這也是為什麼開發者會在 Docker 環境下遇到,明明容器跑得好好的但就是連不上的情況。不過,如果在你自己電腦上測試不一定會遇到這個問題,原因可能是:

  1. 較新版的 curl 實作了 Happy Eyeballs,會自動嘗試 IPv4 和 IPv6,哪個先通就用哪個。

  2. 你的系統解析 localhost 時優先回傳 IPv4(可以用 dscacheutil -q host -a name localhost 確認)。

  3. 較新版的 Docker Desktop 可能已經修正這個問題。

Vite 的解決方案

好,現在我們可以回到最一開始講到的那個 Vite 的 PR 了。

考古時間!

讓我們來看一下這段歷史:

在 2021 年的這個 PR 說到為了安全性,Vite 把預設的 host 從允許網路存取改成只監聽 127.0.0.1。這樣可以避免開發伺服器被區域網路上的其他裝置存取,看起來合理。

然後在 2022 年丟出來的這個 Issue,社群發現 Vite 在 Node.js 17+ 環境下會出問題。因為 Vite 強制使用 127.0.0.1,但其他工具(例如透過 net.connect() 連接 localhost)可能會解析到 ::1,導致連線失敗。所以在這個 PR,也就是我開頭提到的那個 commit,把預設的 host 改回使用 localhost

PR #8543 的具體改動

但再往下追就會發現這次的 PR 的改動挺有意思的。首先,新增了一個 loopbackHosts 的集合來處理各種 loopback 位址:

export const loopbackHosts = new Set([
  "localhost",
  "127.0.0.1",
  "::1",
  "0000:0000:0000:0000:0000:0000:0000:0001",
]);

然後,在啟動伺服器的時候,使用 dns.lookup() 來取得 localhost 的實際解析結果:

import dns from "dns/promises";

// 使用 verbatim: true 取得系統實際的解析結果
const result = await dns.lookup("localhost", { verbatim: true });

這樣做的好處是:伺服器會監聽系統名稱解析器「實際會回傳」的那個位址,確保客戶端連接 localhost 時能夠成功。verbatim: true 參數是指不要重新排序,這樣可以確保 Vite 拿到的結果跟其他程式查詢 localhost 時拿到的結果一致。

Node.js 20 的「快樂眼球」

Node.js 20 加了一個新功能,讓這個問題變得可能沒那麼痛苦:autoSelectFamily

這個功能實作了 RFC 8305 中描述的「Happy Eyeballs」演算法。這個名字很有趣,中文可翻譯成「快樂眼球」,意思是讓使用者不用眼睜睜看著連線超時,能夠更快建立連線,這樣就快樂了?(工程師還真是容易滿足的生物)

Node.js 怎麼實作的?當 autoSelectFamily 啟用時(Node.js 20+ 預設啟用),連線過程會這樣進行:

  1. 先透過 dns.lookup() 取得所有可用的位址。

  2. 依序嘗試連接每個位址。

  3. 每個位址最多等待 autoSelectFamilyAttemptTimeout 毫秒(預設 250ms)。

  4. 如果超時就換下一個位址,直到成功或全部失敗。

雖然這跟 RFC 8305 描述的「同時發起多個連線」不太一樣,Node.js 的實作比較像是「快速輪流嘗試」而非「並行競速」,但這應該也夠用了。有興趣看看 Node.js 是怎麼實作的,可以直接去翻 lib/net.js 原始碼,搜尋 internalConnectMultiple 函數。

實際使用

const net = require("net");

const socket = net.connect({
  host: "localhost",
  port: 3000,
  autoSelectFamily: true, // Node.js 20+ 預設是 true
  autoSelectFamilyAttemptTimeout: 250, // 每次嘗試的超時時間
});

socket.on("connect", () => {
  console.log("Connected!");
  console.log("Local address:", socket.localAddress);
  console.log("Remote address:", socket.remoteAddress);
});

不過歹誌不是憨人想的這麼簡單,根據 Issue #54359 記載,Happy Eyeballs 的實作也帶來了一些新問題,最主要的是那個 250ms 的超時時間:

  • 在某些網路環境下,250ms 可能太短了

  • 如果第一個位址剛好在 251ms 的時候要成功,但因為超時被放棄了,就會浪費時間去嘗試第二個位址

你可以透過 autoSelectFamilyAttemptTimeout 來調整這個超時時間,但要找到一個適合所有情況的值並不容易。

小結

看起來一樣的東西,底層可能完全不同,127.0.0.1localhost 可能在 99% 的情況下確實可以互換,但是那個 1% 就是那個「最厲害的 But」,說不定可能會讓人 debug 到懷疑人生。

追原始碼、看 PR 是很有價值的學習方式,特別是 GitHub 上的 Issue 和 PR 裡有很多東西可以挖。這些第 0 手資料往往比網路上二手、三手的技術文章更有深度、更準確。想深入了解某個技術決策的「為什麼」時,直接去看原始碼和相關討論是最好的方式。看不懂原始碼?現在不是有 AI 了嗎?問它啊!

下次當你看到一個看似「無聊」的 PR 時,不妨停下來想想:為什麼要這樣改?背後有什麼原因?然後你可能會發現一個兔子洞,一頭栽進去學到一些可能有用或可能沒用的東西 :)

參考資料

留言討論