File Descriptor and Unix Domain Socket in Web Service Architecture
· 9 min read Đọc bài này bằng tiếng ViệtWhen designing web services on Linux, discussions often begin with HTTP, TLS, reverse proxies, ports, and containers. Beneath those layers is a more fundamental primitive: the file descriptor.
File descriptors explain why files, TCP connections, Unix sockets, pipes, and Linux event mechanisms can all be handled through a broadly consistent I/O model. Unix Domain Sockets, or UDS, are a direct application of that model: instead of making services on the same machine communicate through TCP ports, they can communicate through a socket path in the filesystem.
This is especially useful in an architecture where Nginx sits at the public edge while Node.js or Go services run as private backends.
What is a file descriptor?
A file descriptor, usually shortened to FD, is a small integer assigned by the kernel to represent an open I/O resource owned by a process.
A web-service process may hold descriptors like these:
0 -> stdin
1 -> stdout
2 -> stderr
3 -> access log
4 -> TCP listener on :443
5 -> client connection
6 -> upstream connection
7 -> Unix socket listener
An FD does not only represent a disk file. In Linux, it can represent:
- Regular files
- TCP or UDP sockets
- Unix Domain Sockets
- Pipes between processes
- Log files and temporary files
- Event facilities such as epoll, eventfd, and timerfd
So “everything is a file” is best understood in a practical sense:
Linux exposes many I/O resources through a common file-descriptor model.
A process can read, write, close, configure, or wait for readiness on many resource types through primitives such as read, write, close, poll, and epoll.
A socket is a type of FD
When an application creates a socket, the kernel returns an FD. The application uses that descriptor to send and receive data.
With TCP or Unix stream sockets, the socket is a bidirectional byte stream. It does not inherently understand the boundaries of an HTTP request, JSON object, or application message.
For example, a sender may write:
hello
world
The receiver may observe helloworld in one read, or receive the data in several smaller parts. This is why HTTP, gRPC, and custom protocols need framing.
That leads to an important rule:
Sockets transport bytes; protocols define messages.
HTTP defines request and response boundaries. WebSocket defines frames. A custom protocol must define its own message length, delimiter, or framing format.
What is a Unix Domain Socket?
A Unix Domain Socket is a socket used for communication between processes on the same host.
Instead of listening on a TCP address:
127.0.0.1:8080
a service can listen on a filesystem path:
/run/my-api/api.sock
The traffic path becomes:
Nginx -> /run/my-api/api.sock -> application
That path appears in the filesystem, but it is not a regular file containing request or response data. It is the name of a socket endpoint managed by the kernel.
UDS is a good fit when:
- Both processes run on the same host.
- The backend should not be directly reachable over the network.
- There is a clear local trust boundary.
- Unix users, groups, and filesystem permissions should control access.
Why UDS fits local web services
For a backend that only serves traffic behind Nginx, exposing a TCP listener on 0.0.0.0:3000 is usually unnecessary. Even 127.0.0.1:3000 remains a TCP endpoint, while UDS makes the local-only intent explicit.
A cleaner architecture is:
Internet
|
HTTPS :443
|
Nginx
|
Unix Domain Socket
|
Node.js or Go service
The main benefit of UDS is not just performance. Its greater value is architectural and operational:
- Backends do not need their own network ports.
- Filesystem permissions become an access-control layer.
- The topology clearly separates public ingress from internal IPC.
- Local-only services do not require port allocation.
- There is less risk of accidentally exposing a backend through firewall or network configuration mistakes.
UDS does not replace TCP when services run on different hosts, VMs, Kubernetes nodes, or other real network boundaries. In those cases, TCP, QUIC, or another network-native transport remains the correct choice.
Nginx and file descriptors
Nginx is an event-driven reverse proxy that handles many connections by monitoring the readiness of many FDs.
While proxying a request, Nginx commonly manages at least two socket descriptors:
Client connection <-> Nginx <-> Upstream connection
When a local backend is reached through UDS:
Browser
|
TCP/TLS
|
Nginx
|
Unix socket
|
Go API or Node.js service
Nginx reads the request from the client socket, performs TLS termination, routing, rate limiting, and buffering, then forwards it through an upstream socket. The response travels in the opposite direction.
Nginx also holds descriptors for static files, access logs, error logs, cache files, and proxy temporary files. This makes FD limits a real capacity concern, especially with high keep-alive concurrency or long-lived WebSocket connections.
Proxying from Nginx to UDS
A minimal Nginx configuration can look like this:
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;
}
}
The backend still serves HTTP normally. Only the transport from Nginx to the application changes:
Nginx -> local TCP -> backend
becomes:
Nginx -> UDS -> backend
This is an infrastructure decision, not a business-protocol change.
Node.js: stream abstractions over sockets
Node.js has an event-driven, stream-oriented model that maps naturally to socket I/O. A Node.js HTTP server can listen directly on a 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);
In production, the service should manage the socket lifecycle: ensure the directory exists, safely remove stale socket files at startup, set appropriate permissions, and perform graceful shutdown on SIGTERM.
The most important operational concern in Node.js is backpressure. A socket has finite buffers; if a client reads slowly, an application cannot write indefinitely without growing memory usage.
When a Node stream’s write() returns false, the application should wait for drain before sending more data. This matters most for streaming responses, WebSocket fan-out, media relays, and custom proxies.
Go: HTTP independent of transport
Go naturally separates application protocols from transport. An http.Server accepts a net.Listener, and that listener can be backed by TCP or a 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))
The HTTP handler does not need to know whether a request arrived through TCP or UDS. A service can change transport without changing its business logic.
The Go runtime manages network polling and allows goroutines to block at the API level while efficiently handling many connections. However, applications still need timeouts, deadlines, connection limits, and body-size limits. Goroutines do not remove FD, memory, or kernel socket-buffer constraints.
FD is a capacity unit
In connection-heavy systems, CPU and memory are not enough. FD usage is also a first-class capacity metric.
An active proxied request may use:
1 FD for the client connection
1 FD for the upstream connection
WebSocket and SSE connections retain those descriptors much longer than typical HTTP requests.
Database pools, Redis clients, log files, static assets, and temporary files can also consume FDs. When the process runs out of descriptors, failures often look like:
too many open files
EMFILE
accept() failed
FD limits should therefore align across the stack:
systemd LimitNOFILE
|
Nginx worker FD limit
|
Nginx worker_connections
|
Node.js / Go process limit
|
database and outbound connection pools
Increasing LimitNOFILE alone is not enough. It should be paired with timeouts, rate limits, maximum connection limits, backpressure, and observability.
Buffering, timeouts, and slow clients
Nginx can buffer backend responses before delivering them to clients. This allows a backend to complete work quickly without keeping its upstream connection occupied solely because an Internet client reads slowly.
For ordinary API responses, proxy buffering should usually remain enabled. Disable it only for endpoints that truly require end-to-end streaming, such as SSE or selected token-streaming endpoints.
Nginx, Node.js, and Go should all define timeouts for different phases:
- Upstream connection time.
- Request header and body read time.
- Upstream response time.
- Idle time for keep-alive or long-lived connections.
- Graceful shutdown deadline.
A robust system is not only one that serves fast clients efficiently. It must also protect itself against slow clients, stuck upstreams, and traffic spikes.
Operating UDS safely
Runtime sockets should live under /run, for example:
/run/my-api/api.sock
Avoid placing them in a shared /tmp directory or in a source directory.
A practical systemd pattern is:
[Service]
User=my-api
Group=my-api
SupplementaryGroups=nginx
RuntimeDirectory=my-api
RuntimeDirectoryMode=0750
UMask=0007
LimitNOFILE=65536
The intended permission model is:
/run/my-api/ my-api:my-api 0750
/run/my-api/api.sock my-api:nginx 0660
The application can create the socket, Nginx can connect to it, and unrelated processes have no access.
Closing principle
File descriptors are the shared I/O foundation of Unix/Linux. Files, sockets, pipes, and event mechanisms can all be managed through FDs, even though they have different semantics.
Unix Domain Sockets provide a natural way to connect services on the same host. In a common web stack, Nginx receives public traffic over TCP/TLS, while Node.js and Go backends receive local traffic through UDS.
A practical rule is:
Use Nginx and TCP/TLS at the public edge. Use UDS for local IPC within the same host and trust boundary. Use TCP, QUIC, or another network-native transport when crossing VMs, container namespaces, nodes, or real networks.
Comments