File Descriptor, Unix Domain Socket và kiến trúc service local với Nginx, Node.js & Go
· 22 min read Read this in EnglishKhi 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 | Có | Không |
| Có offset | Có | Không |
Có thể seek | Thường có | Không |
| Endpoint từ xa | Không | Có |
| Backpressure | Thường không theo network flow | Có |
| 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 family và transport 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_INET6 và AF_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.Server và net.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, Read và Write 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à
80và443. - 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
LimitNOFILEnhấ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