Node.js trên VPS có IPv6: hai lỗi kết nối ẩn và cách sửa bằng 2 flag
Nội dung bài viết

Case này làm mình mất khá nhiều thời gian, nên mình ghi lại cho ai đang tự vận hành hệ thống Node.js trên VPS/Server.

Một hệ thống mình xây dựng cho khách hàng có backend Node.js chạy bằng PM2, thường xuyên gọi API của các đối tác bên ngoài. Một ngày đẹp trời, các lời gọi này bắt đầu lỗi lúc được lúc không.

Code không đổi dòng nào. Máy local và beta chạy bình thường. Chỉ trên VPS là lỗi.

Lần theo thì ra không phải một lỗi mà là hai, nằm chồng lên nhau. Cả hai đều đến từ cách Node.js xử lý mạng khi máy chủ có IPv6, và cả hai đều là thay đổi mặc định của Node chứ không phải bug trong code của mình.

Bối cảnh

  • VPS có cả IPv4 và IPv6.
  • Đối tác A chỉ whitelist địa chỉ IPv4 của VPS.
  • Đối tác B phản hồi chậm: mở kết nối TCP mất khoảng 375ms.
  • Node.js bản 20 trở lên.

Triệu chứng nhìn từ log thì rất mơ hồ: request tới đối tác A bị từ chối, request tới đối tác B thì timeout, thường là AggregateError kèm ETIMEDOUT. Không có gì trong log chỉ thẳng tới IPv6.

Lỗi 1: Node không còn ưu tiên IPv4

Trước Node 17, dns.lookup() luôn đưa địa chỉ IPv4 lên đầu. Từ Node 17, mặc định đổi thành verbatim: kết quả DNS được giữ nguyên thứ tự hệ điều hành trả về. Trên VPS có IPv6, hệ điều hành thường xếp IPv6 lên trước.

Hệ quả: Node kết nối ra ngoài bằng IPv6. Đối tác A nhìn thấy request đến từ một địa chỉ IPv6 lạ hoắc, không có trong whitelist, nên từ chối.

Máy local của mình không có IPv6 ra Internet nên không bao giờ gặp. Đây chính là lý do kinh điển "local chạy được, server thì không".

Lỗi 2: Happy Eyeballs cắt kết nối sau 250ms

Từ Node 20, autoSelectFamily (thuật toán Happy Eyeballs, RFC 8305) được bật mặc định. Khi một tên miền có nhiều địa chỉ, Node thử lần lượt từng địa chỉ, và mỗi lần thử chỉ được 250ms. Quá thời gian đó, Node bỏ lần thử hiện tại và chuyển sang địa chỉ kế tiếp.

Với đối tác B, kết nối cần khoảng 375ms:

  1. Node thử địa chỉ đầu tiên → sau 250ms chưa xong → bị cắt, dù chỉ cần thêm ~125ms nữa là thành công.
  2. Node chuyển sang địa chỉ còn lại (IPv6) → không kết nối được.
  3. Hết địa chỉ để thử → báo lỗi, thường là AggregateError kèm ETIMEDOUT.

Đây là phần khó chịu nhất của case này: chỉ sửa lỗi 1 thì chưa đủ. Sau khi ép IPv4 lên trước, IPv4 được thử đầu tiên nhưng vẫn bị cắt ở mốc 250ms. Nhìn bề ngoài thì như bản sửa không có tác dụng, rất dễ khiến mình nghi ngờ hướng đi đúng và quay lại bản cũ.

Cũng vì cái mốc 250ms này mà lỗi xuất hiện "lúc được lúc không": hôm nào mạng tốt, đối tác B bắt tay xong dưới 250ms thì chạy, hôm nào chậm hơn một chút là lỗi.

Cách sửa

Thêm 2 flag khi khởi động Node:

  • --dns-result-order=ipv4first: đưa IPv4 lên trước, giống hành vi Node cũ → sửa lỗi 1.
  • --no-network-family-autoselection: tắt Happy Eyeballs, kết nối được chờ theo timeout bình thường, không bị cắt sau 250ms → sửa lỗi 2.

Với PM2, mình khai báo trong ecosystem.config.js để áp dụng cho mọi process:

// VPS có IPv6: ép Node gọi API ngoài bằng IPv4 trước (đối tác whitelist IPv4)
// và tắt cơ chế cắt kết nối sau 250ms của Node (có đối tác connect ~375ms)
const NODE_ARGS = "--dns-result-order=ipv4first --no-network-family-autoselection";

module.exports = {
  apps: [
    { name: "app",    script: "./dist/index.js",  node_args: NODE_ARGS },
    { name: "worker", script: "./dist/worker.js", node_args: NODE_ARGS },
  ],
};

Cẩn thận khi áp dụng với PM2

pm2 restart hoặc pm2 reload có thể vẫn chạy process với tham số cũ, vì PM2 nhớ cấu hình lúc process được tạo. Khi đó bạn dễ tưởng flag không có tác dụng, trong khi thật ra nó chưa hề được nạp.

Cách chắc chắn nhất là xóa rồi khởi động lại từ file cấu hình:

pm2 delete ecosystem.config.js
pm2 start ecosystem.config.js
pm2 save

Sau đó kiểm tra bằng pm2 describe <tên app> và xem dòng node args đã có đủ hai flag chưa.

Sau khi áp dụng, các process gọi được hai đối tác ổn định trở lại, không còn cảnh lúc được lúc không.

Cách chẩn đoán nếu bạn gặp lỗi tương tự

Bước 1: VPS đang đi ra ngoài bằng IP nào?

curl -4 ifconfig.me   # IPv4 công khai
curl -6 ifconfig.me   # có kết quả nghĩa là VPS có IPv6 ra Internet

Bước 2: Node phân giải tên miền đối tác theo thứ tự nào?

node -e "require('dns').lookup('api.doitac.com', { all: true }, (e, a) => console.log(a))"

Nếu IPv6 nằm đầu danh sách mà đối tác chỉ whitelist IPv4, đó là lỗi 1.

Bước 3: Kết nối tới đối tác mất bao lâu?

curl -4 -o /dev/null -s -w "connect: %{time_connect}s\n" https://api.doitac.com

Nếu time_connect thường xuyên vượt 0.25s, rất có thể bạn đang dính lỗi 2. Bạn có thể xem mốc thời gian mà bản Node của mình đang dùng bằng:

node -e "console.log(require('net').getDefaultAutoSelectFamilyAttemptTimeout())"

Bảng tra nhanh theo triệu chứng

Triệu chứngNghi can
Đối tác báo IP không được phép, dù đã đăng ký IP của VPSNode đang đi ra bằng IPv6 (lỗi 1)
AggregateError / ETIMEDOUT, lúc được lúc khôngKết nối bị cắt ở mốc 250ms (lỗi 2)
Đã thêm ipv4first mà vẫn timeoutCòn dính lỗi 2, cần tắt hoặc nới Happy Eyeballs
Đã thêm flag mà không có gì thay đổiPM2 chưa nạp flag, kiểm tra pm2 describe
Local chạy, server lỗiKhác biệt môi trường mạng (IPv6, độ trễ), không phải code

Các hướng xử lý khác

Hai flag không phải cách duy nhất. Tùy hoàn cảnh, bạn có thể chọn:

  • Ép IPv4 cho từng request: truyền family: 4 vào http.Agent/https.Agent (với axios thì qua httpAgent/httpsAgent). Phạm vi nhỏ hơn, nhưng phải sửa ở mọi chỗ gọi API.
  • Đặt trong code thay vì flag: gọi dns.setDefaultResultOrder("ipv4first") và net.setDefaultAutoSelectFamily(false) ở đầu chương trình. Hiệu quả tương đương, nhưng phải nhớ đặt trước mọi lời gọi mạng.
  • Nới thời gian chờ thay vì tắt Happy Eyeballs: gọi net.setDefaultAutoSelectFamilyAttemptTimeout(1000) ở đầu chương trình. Giữ được lợi ích của Happy Eyeballs khi một địa chỉ chết hẳn.
  • Tắt IPv6 trên VPS: hiệu quả, nhưng ảnh hưởng mọi thứ trên máy chứ không chỉ Node.

Mình chọn dùng flag vì đó là một thay đổi duy nhất trong file cấu hình, không động vào code, và áp dụng đồng đều cho mọi process.

Bài học rút ra

"Local chạy được" chưa có nghĩa là xong. Môi trường mạng của server (IPv6, độ trễ tới đối tác) có thể khác hoàn toàn máy dev. Khi một lỗi chỉ xuất hiện trên server mà code không đổi, hãy nghi ngờ môi trường trước.

Một triệu chứng có thể là nhiều lỗi chồng lên nhau. Sửa lỗi 1 xong mà vẫn hỏng không có nghĩa là bản sửa sai. Nếu mình lùi lại ipv4first ở thời điểm đó thì sẽ phải đi lại từ đầu.

Đọc changelog khi nâng phiên bản Node. Cả hai thay đổi mặc định ở Node 17 và Node 20 đều làm đổi hành vi mạng mà không báo lỗi gì lúc build hay lúc khởi động.

Whitelist IP thì nhớ cả IPv6. Khi đăng ký IP với đối tác, hãy kiểm tra xem server thực sự đi ra bằng IP nào, đừng mặc định là IPv4.

Ghi lý do ngay trong file cấu hình. Vài tháng sau sẽ không ai nhớ vì sao có hai flag lạ trong ecosystem.config.js, và rất có thể có người xóa đi vì "trông thừa". Một dòng comment là đủ để cứu cả hệ thống.

Nếu bạn từng gặp lỗi mạng nào "local chạy, server không" kỳ lạ hơn, để lại nhận xét cho mình biết nhé.