File Descriptor, Unix Domain Socket và kiến trúc service local với Nginx, Node.js & Go

· 22 min read Read this in English

Khi triển khai backend trên Linux, ta thường nói về HTTP, TCP port, reverse proxy hay container. Nhưng dưới các abstraction đó là một primitive đơn giản hơn và rất quan trọng: file descriptor (FD).

Hiểu FD, socket và Unix Domain Socket (UDS) giúp thiết kế service rõ ràng hơn: Nginx public-facing ở ngoài, ứng dụng Node.js hoặc Go ở phía trong, còn giao tiếp nội bộ đi qua Unix socket có permission rõ ràng và không cần mở TCP port không cần thiết.

Bài viết này tổng quát hóa mô hình đó, từ kernel primitives đến một kiến trúc deploy thực tế.

File descriptor: “handle” của tài nguyên I/O

Trong Unix/Linux, file descriptor là một số nguyên nhỏ đại diện cho một tài nguyên I/O mà process đang mở.

Ví dụ một process có thể có:

FD 0  -> stdin
FD 1  -> stdout
FD 2  -> stderr
FD 3  -> file log
FD 4  -> TCP listen socket :443
FD 5  -> Unix socket /run/api/api.sock
FD 6  -> client TCP connection
FD 7  -> upstream connection đến app

Kernel không chỉ dùng FD cho file trên ổ đĩa. Nhiều loại tài nguyên có thể được thao tác qua FD:

  • Regular file
  • TCP/UDP socket
  • Unix domain socket
  • Pipe và FIFO
  • Device file
  • Event notification primitives như epoll, eventfd, timerfd
  • Log file, cache file và temporary file

Vì vậy, “everything is a file” không hoàn toàn có nghĩa mọi thứ là file trên filesystem. Chính xác hơn:

Nhiều tài nguyên Unix có thể được process quản lý bằng một interface kiểu file descriptor.

Các thao tác quen thuộc như read(), write(), close(), fcntl(), poll() hay epoll() hoạt động trên rất nhiều loại FD.

Socket là file descriptor, nhưng không phải file thường

Lời gọi native socket() trả về một FD. Ứng dụng dùng FD đó để gửi hoặc nhận dữ liệu.

int fd = socket(AF_INET, SOCK_STREAM, 0);

Dù socket có thể được đọc và ghi giống file, semantics của nó khác file thông thường.

Thuộc tính Regular file Stream socket
Dữ liệu persistent Không
Có offset Không
Có thể seek Thường có Không
Endpoint từ xa Không
Backpressure Thường không theo network flow
EOF Hết file Peer đóng kết nối
Đồng thời nhiều reader/writer Phụ thuộc file semantics Phụ thuộc protocol và connection state

Một TCP socket là một byte stream hai chiều. Nó không bảo toàn ranh giới message.

Ví dụ sender viết:

write("hello")
write("world")

Receiver không được giả định sẽ nhận đúng hai lần đọc tương ứng. Nó có thể nhận:

helloworld

Hoặc:

hel
loworld

Hoặc bất cứ cách chia nào khác.

Đó là lý do protocol trên TCP hoặc Unix stream socket cần framing: HTTP headers + Content-Length, chunked encoding, protobuf length-prefix, newline-delimited JSON, hoặc một cơ chế xác định message boundary khác.

Các loại socket quan trọng

Socket thường được nhìn theo hai chiều: address familytransport semantics.

Address family

Family Mục đích
AF_INET IPv4
AF_INET6 IPv6
AF_UNIX / AF_LOCAL IPC giữa process trên cùng host
AF_VSOCK Giao tiếp host–VM hoặc guest–guest
AF_NETLINK User space giao tiếp với kernel Linux
AF_PACKET Truy cập packet/frame mức thấp
AF_CAN CAN bus, phổ biến trong automotive/embedded

Trong backend web thông thường, ba family bạn sẽ gặp nhiều nhất là AF_INET, AF_INET6AF_UNIX.

Socket type

Type Đặc điểm Ví dụ
SOCK_STREAM Reliable, ordered byte stream, không giữ message boundary TCP, Unix stream socket
SOCK_DGRAM Message-oriented, giữ datagram boundary UDP, Unix datagram
SOCK_SEQPACKET Reliable, ordered và giữ message boundary Một số Unix socket use case
SOCK_RAW Raw network packet Network tooling, packet inspection

Ứng dụng web reverse-proxied thường chạy trên SOCK_STREAM: TCP hoặc Unix stream socket.

Unix Domain Socket là gì?

Unix Domain Socket là socket dùng cho IPC giữa các process trên cùng host.

Thay vì bind một địa chỉ IP và port:

127.0.0.1:3000

service có thể bind vào một socket path:

/run/my-api/api.sock

Ví dụ luồng kết nối:

Nginx
  |
  | connect("/run/my-api/api.sock")
  v
Node.js hoặc Go service

Điểm quan trọng: file /run/my-api/api.sock không phải file chứa request/response. Nó là filesystem entry đại diện cho một socket endpoint mà kernel quản lý.

Khi xem bằng ls -l, socket thường hiện với ký tự đầu là s:

srw-rw---- 1 my-api nginx 0 Jul 18 15:00 api.sock

Trong đó:

  • s: socket file
  • owner my-api: user chạy application
  • group nginx: Nginx được phép kết nối
  • rw-rw----: chỉ owner và group có quyền phù hợp

Vì sao nên dùng UDS cho service cùng máy?

Nếu Nginx và application chạy trong cùng host, cùng network namespace và không cần mở service ra ngoài mạng, UDS thường là default tốt.

Không cần mở TCP port

Nếu app chỉ nhận traffic từ Nginx, bind 0.0.0.0:3000 là không cần thiết.

Ngay cả bind loopback:

127.0.0.1:3000

cũng vẫn là một TCP listener. Nó có thể hợp lý, nhưng không thể hiện rõ “đây là local IPC only” như UDS.

UDS giúp tách rõ:

Public traffic     -> Nginx :80 / :443
Internal traffic   -> /run/my-api/api.sock

Permission là lớp kiểm soát truy cập

Với TCP localhost, kiểm soát truy cập thường dựa vào firewall, network namespace, application authentication hoặc việc tin rằng chỉ local process mới truy cập được.

Với UDS filesystem socket, Linux permission trở thành một phần của security boundary:

/run/my-api/api.sock

Bạn có thể quyết định cụ thể process nào được connect bằng user, group và socket mode.

Điều này đặc biệt hữu ích với:

  • Nginx reverse proxy đến API backend
  • PHP-FPM
  • Node.js realtime service
  • Go control-plane daemon
  • Local metrics service
  • Agent giao tiếp với host service
  • Docker-style local API
  • Privileged node-level service

Ít abstraction thừa hơn

UDS không cần IP address, port allocation, NAT hay routing table cho giao tiếp nội bộ đơn giản.

Nói cách khác, nếu hai process chỉ có lý do giao tiếp vì đang chạy trên cùng một machine, UDS mô tả đúng bản chất kiến trúc hơn TCP.

Nhưng không phải “UDS luôn nhanh hơn”

UDS thường tránh một phần network stack và có thể hiệu quả hơn loopback TCP. Tuy nhiên, đây hiếm khi là lý do chính để chọn nó.

Trong phần lớn hệ thống web, latency bị chi phối bởi:

  • Database query
  • Remote API call
  • Serialization/deserialization
  • TLS ở edge
  • Disk I/O
  • Queueing và lock contention
  • GC hoặc CPU saturation

Lý do mạnh nhất để dùng UDS là locality, security boundary và operational clarity, không phải một benchmark microsecond.

Nginx: proxy được xây quanh file descriptor

Nginx là một ví dụ rất rõ về tư duy event-driven trên Linux.

Một worker Nginx đồng thời quản lý nhiều FD:

FD listen :443
FD client A
FD upstream A
FD client B
FD upstream B
FD static asset file
FD access log
FD error log
FD temporary cache file
...

Khi proxy một HTTP request, Nginx thường giữ ít nhất hai socket connection:

Client TCP/TLS socket  <->  Nginx  <->  Upstream socket

Nếu upstream là Node.js hoặc Go chạy local bằng UDS:

Internet client
    |
    | TCP + TLS
    v
Nginx :443
    |
    | Unix Domain Socket
    v
/run/api/api.sock
    |
    v
Go hoặc Node.js application

Nginx nhận dữ liệu từ client FD, parse HTTP, áp dụng TLS/routing/rate limit/buffering, rồi chuyển request sang upstream FD. Response quay lại theo chiều ngược lại.

Từ góc nhìn kernel, đó là luồng dữ liệu giữa các FD mà Nginx điều phối.

Nginx reverse proxy đến UDS

Ví dụ cấu hình Nginx cho Go API:

upstream api_backend {
    server unix:/run/my-api/api.sock;
    keepalive 64;
}

server {
    listen 443 ssl http2;
    server_name api.example.com;

    location /api/ {
        proxy_http_version 1.1;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_pass http://api_backend;
    }
}

Nginx vẫn proxy HTTP bình thường. Việc thay đổi nằm ở transport giữa Nginx và upstream:

Trước: Nginx -> 127.0.0.1:8080
Sau:  Nginx -> unix:/run/my-api/api.sock

HTTP, REST, gRPC-over-HTTP/2 hay WebSocket logic không tự động thay đổi chỉ vì upstream chuyển từ loopback TCP sang UDS. Tuy nhiên, hãy kiểm tra framework/runtime có hỗ trợ đúng protocol và keep-alive theo cách bạn cần.

Node.js với Unix socket

Trong Node.js, module node:net hỗ trợ TCP và Unix stream socket qua cùng abstraction net.Servernet.Socket.

import fs from 'node:fs';
import http from 'node:http';

const socketPath = '/run/my-api/api.sock';

try {
  fs.unlinkSync(socketPath);
} catch (err) {
  if (err.code !== 'ENOENT') throw err;
}

const server = http.createServer(async (req, res) => {
  if (req.url === '/healthz') {
    res.writeHead(200, { 'content-type': 'application/json' });
    return res.end(JSON.stringify({ ok: true }));
  }

  res.writeHead(200, { 'content-type': 'application/json' });
  res.end(JSON.stringify({ service: 'node-api' }));
});

server.listen(socketPath, () => {
  fs.chmodSync(socketPath, 0o660);
  console.log(`Listening on ${socketPath}`);
});

function shutdown() {
  server.close(() => {
    try {
      fs.unlinkSync(socketPath);
    } catch (err) {
      if (err.code !== 'ENOENT') console.error(err);
    }
    process.exit(0);
  });
}

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

Điểm cần nhớ: net.Socket và HTTP request body là stream. Không được coi mỗi data event là một JSON message hoàn chỉnh.

Nếu đang xây protocol custom trên net.Socket, bạn phải tự thiết kế framing. Nếu dùng HTTP, HTTP parser đã giải quyết framing cho bạn.

Backpressure trong Node.js

Node stream API đưa backpressure lên application layer:

const writable = someStream.write(chunk);

if (!writable) {
  await once(someStream, 'drain');
}

Nếu tiếp tục ghi khi downstream không tiêu thụ kịp, application có thể tích lũy buffer trong memory. Điều này đặc biệt nguy hiểm với:

  • Large response streaming
  • WebSocket fan-out
  • Proxy tự viết trong Node.js
  • Video/media relay
  • Client chậm hoặc cố tình slow-read

Nginx buffering có thể giảm áp lực trong một số luồng HTTP, nhưng không thay thế việc xử lý backpressure đúng trong application.

Go với Unix socket

Go hỗ trợ UDS qua package net.

package main

import (
	"log"
	"net"
	"net/http"
	"os"
)

const socketPath = "/run/my-api/api.sock"

func main() {
	_ = os.Remove(socketPath)

	listener, err := net.Listen("unix", socketPath)
	if err != nil {
		log.Fatal(err)
	}
	defer listener.Close()

	if err := os.Chmod(socketPath, 0o660); err != nil {
		log.Fatal(err)
	}

	mux := http.NewServeMux()

	mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("content-type", "application/json")
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte(`{"ok":true}`))
	})

	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("content-type", "application/json")
		_, _ = w.Write([]byte(`{"service":"go-api"}`))
	})

	server := &http.Server{
		Handler: mux,
	}

	log.Printf("listening on unix://%s", socketPath)

	if err := server.Serve(listener); err != nil &&
		err != http.ErrServerClosed {
		log.Fatal(err)
	}
}

net.Listen("unix", path) tạo Unix stream listener. http.Server không quan tâm listener là TCP hay Unix socket, miễn nó nhận được một net.Listener.

Đây là một abstraction mạnh của Go:

HTTP server
    |
net.Listener
    |
TCP listener hoặc Unix listener

Application protocol không bị khóa vào transport cụ thể.

Deadline trong Go

Trong Go, ReadWrite trên connection có thể block. Với backend production, cần giới hạn tài nguyên bằng timeout và deadline:

server := &http.Server{
    Handler:           mux,
    ReadHeaderTimeout: 5 * time.Second,
    ReadTimeout:       15 * time.Second,
    WriteTimeout:      30 * time.Second,
    IdleTimeout:       60 * time.Second,
}

Ngoài timeout ở Nginx, application cũng cần deadline riêng. Nginx bảo vệ edge; application bảo vệ chính nó và downstream dependencies.

File descriptor là unit capacity

Một sai lầm phổ biến là chỉ theo dõi CPU, RAM và số request/second. Trong hệ thống network-heavy, FD là một capacity primitive cần monitor.

Các nguồn tiêu thụ FD phổ biến:

  • Client connections
  • Upstream keep-alive connections
  • TCP listeners
  • Unix socket listeners
  • WebSocket connections
  • Database connections
  • Redis/Kafka/NATS clients
  • Static files
  • Log files
  • Temporary files
  • Pipes giữa parent/child process
  • epoll, eventfd, inotify descriptors

Với reverse proxy, một request active có thể dùng ít nhất:

1 FD client connection
+ 1 FD upstream connection
= 2 FD

Nếu chạy 20.000 WebSocket clients và giữ một upstream socket cho mỗi client:

20.000 client FDs
+ 20.000 upstream FDs
+ listeners, logs, cache, overhead

Bạn có thể chạm giới hạn open files trước khi CPU hay RAM đầy.

Các triệu chứng điển hình:

EMFILE: too many open files
accept() failed
socket() failed
open() failed

Đồng bộ FD limits

FD limit cần được cấu hình xuyên suốt stack.

systemd LimitNOFILE
        |
        v
Nginx worker_rlimit_nofile
        |
        v
Nginx worker_connections
        |
        v
Node.js / Go service process limits
        |
        v
Database pool, HTTP client pool, WebSocket limits

Ví dụ Nginx:

worker_processes auto;
worker_rlimit_nofile 65536;

events {
    worker_connections 16384;
}

Một worker có worker_connections 16384 không có nghĩa là phục vụ đúng 16.384 end-user connections khi đang proxy. Upstream connections cũng tiêu FD, do đó budget thực tế thấp hơn.

Ở systemd:

[Service]
LimitNOFILE=65536

Đừng tăng limit vô hạn chỉ để “hết lỗi”. FD limit là một hàng rào bảo vệ. Việc tăng limit phải đi kèm:

  • Rate limit
  • Connection limit
  • Queue limit
  • Memory budget
  • Timeout
  • Backpressure
  • Load shedding
  • Observability

Nginx buffering và slow clients

Nginx có thể buffer response từ upstream. Điều này giúp tách tốc độ của application khỏi tốc độ đọc của client.

Fast Go/Node upstream
        |
        v
Nginx buffer
        |
        v
Slow Internet client

Nếu response nhỏ hoặc vừa, Nginx có thể đọc nhanh từ upstream, trả upstream về pool, rồi từ từ gửi cho client.

Nếu tắt proxy buffering toàn cục, slow client có thể kéo dài lifetime của upstream connection. Điều này làm tăng:

  • Số FD active
  • Memory pressure
  • Goroutine hoặc callback lifetime
  • Connection pool occupancy
  • Latency tail

Chỉ nên tắt buffering cho luồng thật sự cần streaming end-to-end, ví dụ:

  • Server-Sent Events
  • Một số AI token streaming endpoint
  • Long polling
  • Một số response media relay
  • WebSocket, vốn có cơ chế upgrade và proxy behavior riêng

Ví dụ SSE:

location /events {
    proxy_pass http://api_backend;
    proxy_http_version 1.1;

    proxy_buffering off;
    proxy_cache off;

    proxy_read_timeout 1h;
}

Với endpoint thông thường, giữ buffering mặc định thường là lựa chọn an toàn hơn.

WebSocket: connection dài hạn là FD dài hạn

WebSocket bắt đầu bằng HTTP, sau đó upgrade thành persistent bidirectional connection.

Browser
   |
   | HTTP Upgrade
   v
Nginx
   |
   | proxied Upgrade
   v
Node.js realtime service

Nginx cần forward các header upgrade:

location /ws {
    proxy_pass http://realtime_backend;
    proxy_http_version 1.1;

    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";

    proxy_read_timeout 1h;
    proxy_send_timeout 1h;
}

Với WebSocket, mỗi client connection thường tồn tại trong thời gian dài. Vì vậy, nó phải được sizing như một connection capacity problem, không phải request-per-second problem.

Node.js thường phù hợp cho connection-heavy realtime workloads do event-loop và stream APIs. Go cũng phù hợp nhờ goroutine + network poller, nhưng cả hai đều vẫn bị giới hạn bởi:

  • FD budget
  • Kernel socket buffers
  • Memory per connection
  • Ping/pong policy
  • Idle timeout
  • Fan-out strategy
  • Downstream backpressure
  • Reconnect storm khi deploy hoặc upstream fail

UDS lifecycle: quyền, path và stale socket

UDS có một operational concern mà TCP port không có: socket path là filesystem object.

Đặt socket ở runtime directory:

/run/my-api/api.sock

Thay vì:

/tmp/api.sock
./api.sock
/var/www/project/api.sock

/run phù hợp vì đây là runtime state. Sau reboot, filesystem runtime thường được làm sạch, giảm rủi ro stale socket lâu dài.

Một pattern systemd tốt:

[Unit]
Description=My Go API
After=network.target

[Service]
Type=simple
User=my-api
Group=my-api
SupplementaryGroups=nginx

RuntimeDirectory=my-api
RuntimeDirectoryMode=0750
UMask=0007

ExecStart=/usr/local/bin/my-api
Restart=always
RestartSec=2

LimitNOFILE=65536

[Install]
WantedBy=multi-user.target

Kết quả mong muốn:

/run/my-api/          owner my-api:my-api, mode 0750
/run/my-api/api.sock  owner my-api:nginx,  mode 0660

Bạn có thể cần điều chỉnh group ownership trong lúc startup tùy service manager và application code. Mục tiêu là đơn giản:

  • App có quyền tạo và quản lý socket.
  • Nginx có quyền connect.
  • User không liên quan không có quyền traverse directory hoặc connect socket.
  • Không service nào cần chạy root chỉ để bind UDS.

Graceful shutdown

Khi deploy phiên bản mới, app không nên đóng mọi connection ngay lập tức.

Luồng shutdown lý tưởng:

1. Nhận SIGTERM
2. Ngừng accept connection mới
3. Nginx chuyển request mới sang instance healthy khác, hoặc nhận 502 ngắn nếu single instance
4. Cho request in-flight hoàn tất trong giới hạn deadline
5. Đóng listener và cleanup socket path
6. Exit

Trong Go:

ctx, cancel := signal.NotifyContext(
	context.Background(),
	syscall.SIGINT,
	syscall.SIGTERM,
)
defer cancel()

go func() {
	<-ctx.Done()

	shutdownCtx, shutdownCancel := context.WithTimeout(
		context.Background(),
		20*time.Second,
	)
	defer shutdownCancel()

	_ = server.Shutdown(shutdownCtx)
}()

Trong Node.js, gọi server.close() để ngừng nhận connection mới, sau đó quản lý các keep-alive/WebSocket connection theo shutdown deadline của bạn.

Với WebSocket, cần có chiến lược riêng: thông báo reconnect, drain dần, hoặc dùng nhiều replicas/upstream để không reset toàn bộ client cùng lúc.

Khi nào không nên dùng UDS?

UDS không nên bị xem là giải pháp mặc định cho mọi mô hình.

Dùng TCP/QUIC thay vì UDS khi:

  • Service chạy ở node khác.
  • Service có thể scale sang nhiều VM hoặc physical host.
  • Bạn cần service discovery.
  • Bạn cần network load balancing.
  • Nginx và backend ở network namespace khác mà không muốn quản lý shared socket mount.
  • Bạn cần cross-platform portability.
  • Backend được cung cấp bởi managed service hoặc external dependency.
  • Bạn muốn quan sát traffic bằng network tooling chuẩn theo địa chỉ IP/port.

Trong Kubernetes, UDS có thể rất tốt cho sidecar cùng Pod hoặc local node agent với hostPath, nhưng nó không thay thế Kubernetes Service cho giao tiếp cross-Pod/cross-node.

Kiến trúc khuyến nghị

Với một host chạy Nginx, Node.js realtime service và Go API, kiến trúc đơn giản có thể là:

                        Internet
                           |
                    TCP/TLS :443
                           |
                           v
                    +-------------+
                    |    Nginx    |
                    | TLS, route, |
                    | rate limit, |
                    | static, log |
                    +-------------+
                      |         |
             UDS      |         | UDS
                      v         v
          /run/api/api.sock   /run/realtime/app.sock
                 |                    |
                 v                    v
             Go API             Node.js realtime
                 |                    |
                 +-------- TCP -------+
                          |
                  PostgreSQL / Redis /
                  Kafka / remote APIs

Phân tách này có lợi ích rõ ràng:

  • Public ingress tập trung ở Nginx.
  • TLS, request policy, routing và edge observability không bị nhân bản trong từng service.
  • Backend không cần mở public port.
  • UDS permissions là local security boundary.
  • Node.js và Go giữ trách nhiệm business logic, timeout nội bộ và application-level concurrency.
  • Database/cache/external service vẫn dùng TCP vì chúng là network boundary thực sự.

Checklist production

  • Nginx chỉ mở những port public cần thiết, thường là 80443.
  • Backend local ưu tiên bind UDS dưới /run/<service>/.
  • Không dùng socket world-writable (0666).
  • Dùng user riêng cho từng service, không chạy Node/Go app dưới root.
  • Dùng group tối thiểu để Nginx connect vào UDS.
  • Bảo vệ permission của cả directory chứa socket.
  • Dùng systemd RuntimeDirectory để quản lý lifecycle runtime path.
  • Có cleanup stale socket an toàn khi startup.
  • Cấu hình LimitNOFILE nhất quán cho Nginx và backend.
  • Theo dõi FD usage, active connections, upstream failures, queueing và latency.
  • Đặt connect/read/write/idle timeout ở Nginx lẫn application.
  • Chỉ retry request idempotent hoặc request có idempotency key.
  • Tôn trọng backpressure ở Node.js stream và Go I/O.
  • Không tắt Nginx buffering toàn cục.
  • Có graceful shutdown và connection draining khi deploy.
  • Kiểm tra WebSocket timeout, upgrade header và capacity theo số connection đồng thời.

Kết

File descriptor là primitive gắn kết file, pipe, socket và event notification trong Unix/Linux. Socket là một loại kernel object được thao tác qua FD; Unix Domain Socket là cách diễn đạt tự nhiên cho giao tiếp giữa các process cùng host.

Trong kiến trúc web thực tế, Nginx đứng ở public edge và điều phối các FD của client, upstream, file tĩnh, buffer và log. Node.js và Go có thể chạy phía sau Nginx qua UDS để giảm bề mặt mạng, dùng filesystem permission làm local access control, và giữ deployment topology rõ ràng.

Nguyên tắc thực dụng là:

Public traffic dùng TCP/TLS qua Nginx; local service-to-service IPC dùng UDS khi cùng host và cùng trust boundary; mọi network boundary thực sự dùng TCP, QUIC hoặc protocol phù hợp.

Thiết kế theo nguyên tắc này không chỉ “native Linux” hơn. Nó còn giúp hệ thống dễ bảo mật, dễ vận hành và dễ mở rộng đúng hướng khi workload tăng.

Bình luận

  1. Đang tải bình luận…