ismile@portfolio:~$ cat notes/0007-linux-for-backend-engineers-part-6.md
Linux for Backend Engineers Part 6
Linux for Backend Engineers
- Part 1: The Foundation
- Part 2: Files, Filesystems, and Permissions
- Part 3: Processes and Threads
- Part 4: Memory Management
- Part 5: Signals and Communication
- Part 6: IPC, Networking, and Containers ← current
13. Inter‑Process Communication & Networking
Processes are isolated by design. But real systems need to cooperate:
- A web server needs to talk to a database
- A shell pipeline chains commands (
ls | grep txt). - A browser fetches a page from a server on the internet.
The kernel provides communication channels that allow processes to exchange data while maintaining isolation. All of these channels use file descriptors, the same interface we explored in Section 12.
IPC Mechanisms (Same Machine)
├── Pipes (anonymous, named)
├── Unix Domain Sockets
└── Shared Memory
Networking (Across Machines)
├── TCP Sockets
└── UDP Sockets13.1 Anonymous Pipes: Parent-Child Communication
What: A unidirectional byte stream, created with pipe(), used between related processes.
How it works:
Parent Process
│
├── pipe() creates two FDs:
│ ├── fd[0] = read end
│ └── fd[1] = write end
│
├── fork() → Child inherits both FDs
│
├── Parent closes fd[0] (read) ────┐
│ │
│ Child closes fd[1] (write) ────┤
│ │
│ Parent writes to fd[1] ────────┼──► Pipe Buffer ──► Child reads fd[0]
│ │
└── Data flows one way ────────────┘Important:
close(fd[0])in the parent does not close the pipe's read end for the child. Afterfork(), parent and child each have their own copy of the file descriptors. The parent closes its unused read copy, while the child keeps itsfd[0]for reading.
Shell example - the pipe operator:
# ls writes to pipe, wc reads from pipe
ls -l | wc -lHere's what the shell actually does:
Shell Process (PID 100)
│
├── pipe() → [3 (read), 4 (write)]
│
├── fork() → Child 1 (PID 101)
│ ├── dup2(4, 1) # stdout → pipe write end
│ ├── close(3) # close read end
│ └── exec("ls")
│
├── fork() → Child 2 (PID 102)
│ ├── dup2(3, 0) # stdin → pipe read end
│ ├── close(4) # close write end
│ └── exec("wc")
│
├── close(3, 4) in parent
└── wait for childrenNode.js:
const { spawn } = require("child_process");
// Create two child processes connected by a pipe
const ls = spawn("ls", ["-l"]);
const wc = spawn("wc", ["-l"]);
// Connect ls.stdout to wc.stdin
ls.stdout.pipe(wc.stdin);
// Display output
wc.stdout.on("data", (data) => {
console.log("Line count:", data.toString());
});13.2 Named Pipes (FIFOs): Unrelated Process Communication
What: A pipe with a filesystem path. Any process can open it.
Create a FIFO:
mkfifo /tmp/myfifo
ls -l /tmp/myfifo
# prw-r--r-- 1 user user 0 Dec 1 10:00 /tmp/myfifo
# ^--- 'p' means pipeUse it:
# Terminal 1 (reader — blocks until writer connects)
cat /tmp/myfifo
# Terminal 2 (writer)
echo "Hello through FIFO" > /tmp/myfifo
# Terminal 1 immediately prints: Hello through FIFOHow it works:
Process A Process B
│ │
│ open("/tmp/myfifo", O_WRONLY) │
│─────────────────────────────────────►│
│ │ open("/tmp/myfifo", O_RDONLY)
│ │
│ write(fd, "Hello") │
│─────────────────────────────────────►│
│ │ read(fd, buffer)
│ │
│ ┌──────────────────┐ │
│ │ Kernel Buffer │ │
│ └──────────────────┘ │
│ │
│ close(fd) │ close(fd)
│ │Node.js:
// reader.js
const fs = require("fs");
const reader = fs.createReadStream("/tmp/myfifo");
reader.on("data", (chunk) => {
console.log("Received:", chunk.toString());
});
// writer.js
const fs = require("fs");
const writer = fs.createWriteStream("/tmp/myfifo");
writer.write("Hello via FIFO\n");
writer.end();Use cases:
- Logging daemon receiving from multiple sources
- Simple job queue on single machine
- Quick data exchange between tools
Limitations:
- Unidirectional (need two FIFOs for bidirectional)
- One reader, one writer (no multiple clients)
- Data evaporates when both ends close
13.3 Unix Domain Sockets: Local, Bidirectional, Multi-Client
What: Connection-oriented sockets identified by a filesystem path. Full-duplex and can handle multiple clients.
Data travels entirely inside the kernel - zero network stack overhead.
Advantages over FIFOs:
- Bidirectional — both sides can send and receive simultaneously
- Multiple clients — one server can accept many connections
- Connection-oriented — like TCP but without network overhead
- File permissions — can restrict access via filesystem permissions
How it works:
Server Process Client Process
│ │
│ socket(AF_UNIX, SOCK_STREAM) │ socket(AF_UNIX, SOCK_STREAM)
│ │
│ bind("/tmp/myapp.sock") │
│ │
│ listen() │ connect("/tmp/myapp.sock")
│ │
│ accept() ──► new FD for client │
│ │
│ read(fd) ◄──────── write(fd) ──────────│
│ │
│ write(fd) ────────► read(fd) ──────────│
│ │Node.js server:
const net = require("net");
const fs = require("fs");
const SOCKET_PATH = "/tmp/echo.sock";
// Clean up any existing socket file
if (fs.existsSync(SOCKET_PATH)) {
fs.unlinkSync(SOCKET_PATH);
}
const server = net.createServer((socket) => {
console.log("Client connected");
// Echo data back
socket.on("data", (data) => {
console.log("Received:", data.toString());
socket.write(`Echo: ${data}`);
});
socket.on("close", () => {
console.log("Client disconnected");
});
});
server.listen(SOCKET_PATH, () => {
console.log("Unix socket server listening on", SOCKET_PATH);
});Node.js client:
const net = require("net");
const client = net.createConnection("/tmp/echo.sock", () => {
console.log("Connected to server");
client.write("Hello over Unix socket!\n");
});
client.on("data", (data) => {
console.log("Server replied:", data.toString());
client.end();
});Use cases:
- Docker daemon (
/var/run/docker.sock) - PostgreSQL default socket (
/var/run/postgresql/.s.PGSQL.5432) - Nginx + PHP-FPM communication
- Local database connections (faster than TCP)
- Any local service where security and speed matter
13.4 Network Sockets: Communication Across Machines
When processes run on different machines, they use network sockets identified by IP addresses and port numbers.
TCP: Reliable, Ordered, Connection-Oriented
Think of TCP like a phone call:
- Must establish a connection (dial)
- Both parties must be present
- Messages arrive in order
- No data loss
Server (192.168.1.10:3000) Client (192.168.1.20)
│ │
│ socket() │ socket()
│ bind(3000) │
│ listen() │
│ accept() ◄───────────────── connect(192.168.1.10:3000)
│ │
│ TCP Handshake │
│◄────────────────────────────────────►│
│ │
│ read() ◄─────────────────── write() │
│ write() ───────────────────► read() │Server (192.168.1.10:3000) Client (192.168.1.20)
│ │
│ socket() │ socket()
│ │
│ bind(3000) │
│ │
│ listen() │
│ │
│ accept() ◄────────────────────────────────| connect(192.168.1.10:3000)
│ │
│ ◄─────────────── TCP Handshake ──────────►│
│ │
│ read() ◄──────────────────────── write() │
│ write() ─────────────────────────► read() │
│ │Node.js TCP server:
const net = require("net");
const server = net.createServer((socket) => {
console.log("New connection from", socket.remoteAddress);
socket.on("data", (data) => {
console.log("Received:", data.toString());
socket.write(`Received ${data.length} bytes\n`);
});
socket.on("error", (err) => {
console.error("Socket error:", err.message);
});
});
server.listen(3000, () => {
console.log("TCP server on port 3000");
});Node.js TCP client:
const net = require("net");
const client = net.createConnection({ port: 3000, host: "localhost" }, () => {
console.log("Connected");
client.write("Hello TCP!\n");
});
client.on("data", (data) => {
console.log("Server says:", data.toString());
client.end();
});UDP: Unreliable, Unordered, Connectionless
Think of UDP like sending postcards:
- No connection needed
- Each message is independent
- Messages might arrive out of order
- Messages might never arrive
- But very fast with low overhead
Node.js UDP listener:
const dgram = require("dgram");
const server = dgram.createSocket("udp4");
server.on("message", (msg, rinfo) => {
console.log(`Got: ${msg} from ${rinfo.address}:${rinfo.port}`);
// Reply
server.send("ACK", rinfo.port, rinfo.address);
});
server.on("listening", () => {
const address = server.address();
console.log(`UDP server listening on ${address.address}:${address.port}`);
});
server.bind(41234);Node.js UDP sender:
const dgram = require("dgram");
const client = dgram.createSocket("udp4");
const message = Buffer.from("Hello UDP!");
client.send(message, 41234, "localhost", (err) => {
if (err) console.error(err);
else console.log("Message sent");
});
client.on("message", (msg) => {
console.log("Server replied:", msg.toString());
client.close();
});13.5 Comparison: Choosing the Right IPC Method
Need to communicate?
│
├── Simple parent → child stream?
│ └── Pipe
│
├── Local unrelated processes?
│ └── Unix Domain Socket / FIFO
│
├── Different machines?
│ └── Network Socket
│
├── Very fast shared data?
│ └── Shared Memory
│
└── Simple notification?
└── Signal| Method | Scope | Direction | Multiple Clients | Latency | Data Size | Use Case |
|---|---|---|---|---|---|---|
| Anonymous Pipe | Parent-child | One-way | No | Lowest | Stream | Shell pipelines |
| Named Pipe (FIFO) | Same machine | One-way | No | Low | Stream | Simple producer-consumer |
| Unix Socket | Same machine | Two-way | Yes | Low | Stream | Database connections, Docker API |
| TCP Socket | Any machine | Two-way | Yes | Higher | Stream | Web servers, APIs |
| UDP Socket | Any machine | One-way (datagram) | Yes | Lowest | Small packets | DNS, metrics, game streaming |
13.6 The Complete Picture: FDs Everywhere
Let's tie it all together. Here's what a real Node.js web server's FD table might look like:
Node.js Process (PID 4500)
│
├── FD 0 → /dev/pts/0 (terminal stdin)
├── FD 1 → /dev/pts/0 (terminal stdout)
├── FD 2 → /dev/pts/0 (terminal stderr)
│
├── FD 3 → socket (listening) (TCP :3000)
│
├── FD 4 → socket (client A) (TCP connection from browser)
├── FD 5 → socket (client B) (TCP connection from browser)
├── FD 6 → socket (client C) (TCP connection from mobile app)
│
├── FD 7 → file (/var/log/app.log)
├── FD 8 → socket (Unix socket to PostgreSQL)
├── FD 9 → pipe (to child process)
└── FD 10 → file (/etc/config.json)All these different resources - terminals, files, pipes, sockets are managed through the same file descriptor interface.
13.7 Practical Exercise: Build a Simple IPC System
Let's combine everything into a practical example:
Scenario: A logging service that accepts messages via:
- A named pipe (for simple tools)
- A Unix socket (for local processes)
- A TCP socket (for remote processes)
const fs = require("fs");
const net = require("net");
// 1. Named pipe input
const FIFO_PATH = "/tmp/log-fifo";
if (!fs.existsSync(FIFO_PATH)) {
require("child_process").execSync(`mkfifo ${FIFO_PATH}`);
}
const logMessage = (source, message) => {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] [${source}] ${message.toString().trim()}`);
};
// Named pipe reader
const fifoReader = fs.createReadStream(FIFO_PATH);
fifoReader.on("data", (data) => logMessage("FIFO", data));
// Unix socket server
const UNIX_SOCKET = "/tmp/log.sock";
if (fs.existsSync(UNIX_SOCKET)) fs.unlinkSync(UNIX_SOCKET);
const unixServer = net.createServer((socket) => {
socket.on("data", (data) => logMessage("UNIX", data));
});
unixServer.listen(UNIX_SOCKET);
// TCP server
const tcpServer = net.createServer((socket) => {
socket.on("data", (data) => logMessage("TCP", data));
});
tcpServer.listen(3000);
console.log("Logging service ready:");
console.log(" FIFO: /tmp/log-fifo");
console.log(" Unix socket: /tmp/log.sock");
console.log(" TCP: localhost:3000");Test it:
# Via FIFO
echo "Hello from FIFO" > /tmp/log-fifo
# Via Unix socket
echo "Hello from Unix socket" | socat - UNIX-CONNECT:/tmp/log.sock
# Via TCP
echo "Hello from TCP" | nc localhost 300013.8 Key Takeaways
IPC methods differ in scope, direction, and capacity.
Anonymous pipes connect parent-child processes.
Named pipes (FIFOs) connect unrelated processes on the same machine.
Unix domain sockets provide bidirectional, multi-client communication locally.
TCP sockets provide reliable communication across machines.
UDP sockets provide fast but unreliable communication.
All IPC methods use file descriptors — same interface, different implementations.
Choose the simplest method that meets your needs:
- Parent-child → anonymous pipe
- Two local processes → named pipe
- Multiple local clients → Unix socket
- Remote clients → TCP/UDP socket
14. Linux & Containers: Namespaces and cgroups
Containers are not virtual machines. A VM virtualizes hardware and runs its own kernel; a container is a normal Linux process isolated and restricted by the host kernel.
Linux containers mainly rely on two kernel features:
- Namespaces → control what a process can see.
- cgroups → control what a process can use.
💡 Mental Model: A container is a process in an isolated box.
14.1 Namespaces: Isolation
A namespace gives a process its own restricted view of system resources.
| Namespace | Isolates | Example |
|---|---|---|
| PID | Process IDs | Container sees its app as PID 1 |
| Network | Interfaces and ports | Container gets its own eth0 |
| Mount | Filesystem | Container has its own / |
| User | UID/GID | Root inside ≠ root on host |
| UTS | Hostname | Container has its own hostname |
| IPC | IPC resources | Isolated shared memory/semaphores |
For example:
Host
├── systemd (PID 1)
├── nginx (PID 800)
└── node (PID 4523)
│
└── Container
└── node (PID 1)
The container thinks Node is PID 1, while the host sees it as PID 4523.
Node.js relevance
When Node runs as PID 1 inside a container, it does not behave like a traditional init system. For applications that spawn child processes, using an init such as tini helps handle orphaned processes and zombie reaping.
14.2 cgroups: Resource Limits
Namespaces control visibility. cgroups control resources.
They can limit:
- Memory
- CPU
- Number of processes
- Block I/O
Example:
docker run --memory=512m --cpus=1.5 my-node-appThe container can use at most 512 MB of memory and 1.5 CPU cores.
If a container exceeds its memory limit, the kernel's OOM mechanism can kill processes inside that cgroup without requiring the entire host to fail.
14.3 How Docker Creates a Container
Conceptually, Docker does something like:
Docker
│
├── Create process
├── Create namespaces → Isolation
├── Configure cgroups → Resource limits
├── Set container root FS
└── exec() Node.js
Underneath, this involves Linux primitives such as clone(), filesystem operations, cgroup configuration, and execve().
So Docker is not creating a small VM. The Linux kernel is providing the isolation and resource control.
14.4 OverlayFS: How Container Images Work
Container images are built from read-only layers.
When a container starts, a writable layer is placed on top:
┌──────────────────────┐
│ Writable Layer │ ← Container changes
├──────────────────────┤
│ Application Layer │ ← Your code
├──────────────────────┤
│ Dependencies Layer │ ← node_modules
├──────────────────────┤
│ Base Image Layer │ ← Node / OS
└──────────────────────┘
This allows multiple containers to share the same read-only image layers while each container gets its own writable layer.
🎯 Key takeaway:
Namespaces = what the container sees.
cgroups = what the container can use.
OverlayFS = how the container filesystem is layered.