File Descriptor và Unix Domain Socket trong kiến trúc Web Service
· 10 min read Read this in EnglishTrong một hệ thống web chạy trên Linux, chúng ta thường bắt đầu từ HTTP, TLS, reverse proxy, port hoặc container. Nhưng ở tầng thấp hơn, phần lớn hoạt động I/O đều quy về một khái niệm rất cơ bản: file descriptor.
File descriptor giải thích vì sao file, TCP connection, Unix socket, pipe và nhiều cơ chế event của Linux có thể được xử lý theo một mô hình thống nhất. Unix Domain Socket, hay UDS, là một ứng dụng trực tiếp của mô hình đó: thay vì để các service cùng máy giao tiếp qua TCP port, chúng có thể giao tiếp qua một socket path trong filesystem.
Đây là một pattern đặc biệt hữu ích khi triển khai Nginx ở public edge, còn Node.js hoặc Go chạy như các backend service nội bộ.
File descriptor là gì?
File descriptor, thường gọi tắt là FD, là một số nguyên mà kernel cấp cho process để đại diện cho một tài nguyên I/O đang mở.
Ví dụ, một process web service có thể giữ các FD như sau:
0 -> stdin
1 -> stdout
2 -> stderr
3 -> access log
4 -> TCP listener :443
5 -> client connection
6 -> upstream connection
7 -> Unix socket listener
Điểm quan trọng là FD không chỉ đại diện cho file trên disk. Trong Linux, FD có thể đại diện cho:
- Regular file
- TCP hoặc UDP socket
- Unix Domain Socket
- Pipe giữa các process
- Log file và temporary file
- Event mechanism như epoll, eventfd hoặc timerfd
Vì vậy, câu “everything is a file” nên được hiểu theo nghĩa thực dụng hơn:
Linux có một mô hình I/O thống nhất, trong đó nhiều loại tài nguyên được process thao tác thông qua file descriptor.
Một process có thể đọc, ghi, đóng hoặc chờ sự kiện trên nhiều loại tài nguyên bằng các primitive tương tự như read, write, close, poll và epoll.
Socket là một loại FD
Khi application mở một socket, kernel trả về một FD. Application dùng FD đó để gửi và nhận dữ liệu.
Với TCP hoặc Unix stream socket, socket là một luồng byte hai chiều. Nó không tự biết đâu là ranh giới của request, JSON object hay business message.
Ví dụ, application gửi hai phần dữ liệu:
hello
world
Bên nhận có thể nhận chúng thành một block helloworld, hoặc thành nhiều phần nhỏ hơn. Đây là lý do HTTP, gRPC và các protocol custom phải có cơ chế framing.
Điều này dẫn đến một nguyên tắc quan trọng:
Socket vận chuyển bytes; protocol định nghĩa message.
HTTP nói cho hai bên biết request bắt đầu và kết thúc ở đâu. WebSocket định nghĩa frame. Một protocol tự thiết kế phải tự quyết định message length, delimiter hoặc format frame.
Unix Domain Socket là gì?
Unix Domain Socket là socket để các process giao tiếp với nhau trên cùng một host.
Thay vì service listen trên TCP port:
127.0.0.1:8080
service có thể listen trên một socket path:
/run/my-api/api.sock
Luồng giao tiếp sẽ như sau:
Nginx -> /run/my-api/api.sock -> application
Socket path đó xuất hiện trong filesystem, nhưng không phải file chứa request hay response. Nó là tên của một socket endpoint do kernel quản lý.
UDS phù hợp khi:
- Hai process chạy cùng host.
- Backend không cần được truy cập trực tiếp từ mạng.
- Có một trust boundary local rõ ràng.
- Muốn dùng Unix user, group và file permissions để kiểm soát quyền kết nối.
Vì sao UDS phù hợp cho local web service?
Với một backend chỉ phục vụ sau Nginx, mở TCP listener ra 0.0.0.0:3000 thường không cần thiết. Kể cả 127.0.0.1:3000 vẫn là một TCP endpoint, trong khi UDS mô tả rõ hơn rằng service này chỉ dành cho local IPC.
Một kiến trúc rõ ràng hơn là:
Internet
|
HTTPS :443
|
Nginx
|
Unix Domain Socket
|
Node.js hoặc Go service
Lợi ích chính của UDS không chỉ là hiệu năng. Giá trị lớn hơn nằm ở thiết kế và vận hành:
- Backend không phải mở port network riêng.
- Permission của filesystem trở thành một lớp access control.
- Topology thể hiện rõ public ingress và internal IPC.
- Không cần quản lý port allocation cho local-only service.
- Giảm nguy cơ expose nhầm backend qua firewall hoặc network configuration.
UDS không thay thế TCP khi service chạy khác host, khác VM, khác node Kubernetes hoặc cần service discovery. Trong các trường hợp đó, TCP, QUIC hoặc một transport network-native vẫn là lựa chọn phù hợp hơn.
Nginx và file descriptor
Nginx là một reverse proxy event-driven, xử lý số lượng lớn connection bằng cách theo dõi readiness của nhiều FD.
Khi proxy một request, Nginx thường làm việc với ít nhất hai socket FD:
Client connection <-> Nginx <-> Upstream connection
Nếu backend chạy local qua UDS:
Browser
|
TCP/TLS
|
Nginx
|
Unix socket
|
Go API hoặc Node.js service
Nginx đọc request từ client socket, áp dụng TLS termination, routing, rate limit và buffering, sau đó chuyển request đến upstream socket. Response đi ngược lại.
Bên cạnh socket FD, Nginx còn quản lý FD cho static files, access log, error log, cache file và proxy temporary file. Vì vậy, giới hạn FD là một capacity concern thực sự, đặc biệt với API có nhiều keep-alive connection hoặc WebSocket connection dài hạn.
Nginx proxy tới Unix socket
Ví dụ cấu hình tối giản:
upstream api {
server unix:/run/my-api/api.sock;
}
server {
listen 443 ssl;
server_name api.example.com;
location / {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://api;
}
}
Từ góc nhìn application, backend vẫn phục vụ HTTP như bình thường. Khác biệt chỉ là transport giữa Nginx và service:
Nginx -> TCP localhost -> backend
được thay bằng:
Nginx -> UDS -> backend
Đây là một thay đổi hạ tầng, không phải thay đổi business protocol.
Node.js: stream abstraction trên socket
Node.js có mô hình event-driven và stream-oriented rất phù hợp với socket I/O. HTTP server trong Node.js có thể listen trực tiếp trên một Unix socket path.
import http from 'node:http';
const socketPath = '/run/my-api/api.sock';
const server = http.createServer((req, res) => {
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ ok: true }));
});
server.listen(socketPath);
Trong production, service cần xử lý lifecycle của socket path: bảo đảm directory tồn tại, dọn stale socket an toàn khi khởi động, đặt permission phù hợp, và graceful shutdown khi nhận SIGTERM.
Điểm quan trọng nhất ở Node.js là backpressure. Socket có buffer hữu hạn; nếu client đọc chậm, application không thể tiếp tục ghi vô hạn mà không tăng memory usage.
Với Node streams, nếu write() trả về false, service nên đợi sự kiện drain trước khi tiếp tục ghi. Điều này quan trọng nhất với streaming response, WebSocket fan-out, media relay hoặc proxy tự viết.
Go: transport độc lập với HTTP server
Go tách application protocol khỏi transport khá tự nhiên. http.Server nhận một net.Listener, còn listener đó có thể là TCP hoặc Unix socket.
listener, err := net.Listen("unix", "/run/my-api/api.sock")
if err != nil {
log.Fatal(err)
}
server := &http.Server{
Handler: handler,
}
log.Fatal(server.Serve(listener))
Điểm hay của mô hình này là HTTP handler không cần biết request đến qua TCP hay UDS. Service có thể chuyển transport mà không phải thay đổi business logic.
Go runtime quản lý network polling và cho phép goroutine block ở API level mà vẫn scale tốt với nhiều connection. Tuy nhiên, application vẫn phải đặt timeout, deadline, connection limit và giới hạn body size. Goroutine không loại bỏ giới hạn FD, memory hay kernel socket buffer.
FD là một đơn vị capacity
Trong hệ thống connection-heavy, chỉ nhìn CPU và RAM là chưa đủ. FD cũng là tài nguyên cần theo dõi.
Một reverse proxy request đang hoạt động có thể dùng:
1 FD cho client connection
1 FD cho upstream connection
WebSocket hoặc SSE còn giữ các FD đó lâu hơn request HTTP thông thường.
Ngoài connection, các resource như database pool, Redis client, log file, static file và temporary file đều có thể tiêu FD. Khi cạn FD, service thường xuất hiện lỗi như:
too many open files
EMFILE
accept() failed
Vì vậy, cần cấu hình đồng bộ:
systemd LimitNOFILE
|
Nginx worker FD limit
|
Nginx worker_connections
|
Node.js / Go service limit
|
database và outbound connection pools
Tăng LimitNOFILE không phải là giải pháp duy nhất. Nó cần đi cùng timeout, rate limit, max connections, backpressure và observability.
Buffering, timeout và slow client
Nginx có thể buffer response từ backend trước khi gửi cho client. Điều này giúp backend trả dữ liệu nhanh và không bị giữ connection quá lâu bởi một Internet client đọc chậm.
Với API response thông thường, Nginx buffering thường nên được giữ bật. Chỉ nên tắt nó cho các luồng cần streaming end-to-end như SSE hoặc một số token-streaming endpoint.
Ở cả Nginx, Node.js và Go, timeout nên được đặt theo từng pha:
- Thời gian kết nối upstream.
- Thời gian đọc request header/body.
- Thời gian chờ upstream response.
- Idle timeout cho keep-alive hoặc long-lived connection.
- Graceful shutdown deadline.
Một hệ thống tốt không chỉ phục vụ được client nhanh; nó còn phải tự bảo vệ trước client chậm, upstream treo và traffic đột biến.
Vận hành UDS an toàn
Socket runtime nên nằm dưới /run, ví dụ:
/run/my-api/api.sock
Không nên đặt socket ở /tmp dùng chung hoặc trong source directory.
Một pattern tốt là để systemd tạo runtime directory:
[Service]
User=my-api
Group=my-api
SupplementaryGroups=nginx
RuntimeDirectory=my-api
RuntimeDirectoryMode=0750
UMask=0007
LimitNOFILE=65536
Mục tiêu permission là:
/run/my-api/ my-api:my-api 0750
/run/my-api/api.sock my-api:nginx 0660
Application có quyền tạo socket; Nginx có quyền connect; process không liên quan không có quyền truy cập.
Kết luận
File descriptor là nền tảng I/O chung của Unix/Linux. File, socket, pipe và event mechanism có thể được process quản lý qua FD, dù semantics của chúng khác nhau.
Unix Domain Socket là cách tự nhiên để kết nối các service trên cùng host. Trong một web stack phổ biến, Nginx nhận public traffic qua TCP/TLS, còn Node.js và Go backend nhận local traffic qua UDS.
Nguyên tắc thực dụng là:
Dùng Nginx và TCP/TLS cho public edge; dùng UDS cho local IPC trong cùng host và trust boundary; dùng TCP, QUIC hoặc transport network-native khi đi qua VM, container namespace, node hoặc mạng thực sự.
Bình luận