Unix domain sockets — быстрее TCP на одной машине
Когда два процесса на одной машине должны общаться по запрос-ответ протоколу — HTTP, JSON-RPC, бинарный custom — стандартный выбор это TCP-сокет на localhost. Работает, отлажено, привычно. Но есть лучший вариант: Unix domain socket (UDS). Тот же API сокетов, тот же send/recv, но без сетевого стека под капотом — kernel доставляет данные прямо в очередь получателя, без TCP/IP, без портов, без routing-а.
Unix sockets — любимый механизм Docker (/var/run/docker.sock), PostgreSQL (/var/run/postgresql/.s.PGSQL.5432), Nginx-uwsgi, gunicorn workers, systemd-journald. Везде, где сервисы живут на одной машине, UDS дают 30-50% выигрыш по latency и неплохой по throughput по сравнению с TCP localhost. Плюс безопасность: pid и uid отправителя видны, доступ контролируется файловыми permissions.
В этом уроке: чем UDS физически отличаются от TCP, два типа (stream vs datagram), как их использовать в коде, fd passing — уникальная фишка UDS, и реальные production-сценарии.
Откуда выигрыш в производительности
TCP-сокет на localhost проходит через весь сетевой стек: socket API -> TCP layer -> IP layer -> loopback driver -> обратно в IP -> TCP -> socket. Это десятки функций, упаковка в TCP-сегменты, ARP, checksums, congestion control. Всё это нужно для разных машин, но на одном хосте — бесполезный overhead.
Unix socket — shortcut. Kernel создаёт две очереди (send/recv) между двумя сокетами и просто перекидывает данные. Никакого TCP, никакого IP, никакого checksum. Просто memcpy в kernel space.
Цифры зависят от железа и kernel-версии, но порядок — TCP loopback в ~2-3 раза медленнее UDS на маленьких сообщениях. На больших разница меньше (overhead amortized over data size).
Файл-представитель в файловой системе
Unix socket существует как файл специального типа в файловой системе:
# Создаём socket с помощью netcat:
nc -l -U /tmp/my.sock &
NC_PID=$!
ls -la /tmp/my.sock
# srw-rw-r-- 1 myuser myuser 0 May 18 10:00 /tmp/my.sock
# ^
# 's' -- socket
# Подключаемся:
echo "hello" | nc -U /tmp/my.sock
# в Terminal 1 nc -l выведет: hello
kill $NC_PID
rm /tmp/my.sock
Тип файла s — socket. Размер всегда 0 — это просто маркер. Permissions работают: если файл 600, никто кроме owner-а не подключится. Это безопасность file-system-level, чего TCP-сокет на localhost не даёт.
Альтернатива — abstract namespace sockets (Linux-специфика): имя начинается с \0, файл не создаётся, но сокет идентифицируется в kernel-space. Полезно для приложений в read-only filesystems:
import socket
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.bind('\0my_abstract_socket') # имя в abstract namespace
# Никакого файла в /tmp/ или где-либо
Stream vs datagram sockets
Как и TCP/UDP, Unix sockets имеют два режима:
SOCK_STREAM — байтовый поток, гарантированная доставка, в порядке отправки. Аналог TCP. Соединение устанавливается через connect/accept. Используется чаще всего.
SOCK_DGRAM — сообщения с границами, без соединения. Аналог UDP, но локально доставка гарантирована (kernel не «теряет» сообщения, если буфер не переполнен). Используется реже — например, systemd-journald принимает логи через datagram socket.
# Server (stream):
cat > uds_server.py << 'EOF'
import socket
import os
SOCK = '/tmp/echo.sock'
if os.path.exists(SOCK):
os.unlink(SOCK)
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(SOCK)
os.chmod(SOCK, 0o600)
server.listen(5)
print(f"Listening on {SOCK}")
while True:
conn, _ = server.accept()
data = conn.recv(4096)
print(f"Got: {data!r}")
conn.sendall(b"echo: " + data)
conn.close()
EOF
python3 uds_server.py &
cat > uds_client.py << 'EOF'
import socket
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
client.connect('/tmp/echo.sock')
client.sendall(b"hello UDS")
print(client.recv(4096).decode())
client.close()
EOF
python3 uds_client.py
# echo: hello UDS
kill %1
rm /tmp/echo.sock
API идентичен TCP. Разница только в AF_UNIX вместо AF_INET и в bind/connect с file path вместо (host, port).
File descriptor passing — уникальная фишка UDS
Одна возможность, которая есть только у Unix sockets и недоступна никаким другим IPC механизмам — передача file descriptor от процесса A к процессу B. Звучит магически, но это так: один процесс открыл файл (или socket, или pipe), передал fd через UDS, второй процесс получил тот же открытый ресурс.
Использование:
- systemd socket activation: systemd слушает сокет, при первом соединении запускает сервис и передаёт ему fd сокета. Сервис продолжает accept’ить, не зная, что сокет был не им создан.
- nginx upgrade: новый nginx-master запускается, получает от старого fd слушающего сокета через UDS, начинает обслуживать — без потери соединений.
- Privilege separation: привилегированный процесс открывает /dev/* (требует root), передаёт fd не-root процессу — тот работает без привилегий.
# Минимальный пример на Python (sendmsg / recvmsg с SCM_RIGHTS):
cat > fd_pass_server.py << 'EOF'
import socket, os, array
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try: os.unlink('/tmp/fdpass.sock')
except: pass
server.bind('/tmp/fdpass.sock')
server.listen(1)
conn, _ = server.accept()
# Открываем файл, передаём fd:
fd = os.open('/etc/hostname', os.O_RDONLY)
conn.sendmsg(
[b'fd_passed'],
[(socket.SOL_SOCKET, socket.SCM_RIGHTS, array.array('i', [fd]))]
)
print("Sent fd")
os.close(fd)
conn.close()
EOF
cat > fd_pass_client.py << 'EOF'
import socket, os, array
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
client.connect('/tmp/fdpass.sock')
fds = array.array('i')
msg, anc, flags, _ = client.recvmsg(4096, socket.CMSG_LEN(fds.itemsize))
for cmsg_level, cmsg_type, cmsg_data in anc:
if cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS:
fds.frombytes(cmsg_data[:len(cmsg_data) - (len(cmsg_data) % fds.itemsize)])
new_fd = fds[0]
print("Got fd", new_fd)
print("File content:", os.read(new_fd, 100).decode())
os.close(new_fd)
EOF
python3 fd_pass_server.py &
sleep 0.5
python3 fd_pass_client.py
# Got fd 4
# File content: my-hostname
Здесь sendmsg с auxiliary data типа SCM_RIGHTS — стандартный механизм передачи fd. Получатель должен принять через recvmsg с буфером для ancillary data.
Реальные сценарии использования
Пример nginx -> gunicorn через UDS:
# gunicorn слушает на UDS:
gunicorn -b unix:/tmp/gunicorn.sock myapp:app
# nginx конфиг:
# upstream myapp {
# server unix:/tmp/gunicorn.sock fail_timeout=0;
# }
# server {
# location / {
# proxy_pass http://myapp;
# }
# }
Когда client подключается к nginx, nginx форвардит запрос через UDS gunicorn-у. По сравнению с TCP localhost экономия 5-10% latency, плюс tighter security (файловые permissions ограничивают, кто может подключиться).
Permissions и безопасность
Файловые permissions — big win UDS:
# Создать сокет, доступный только текущему пользователю:
chmod 600 /tmp/my.sock
# Доступ группе:
chmod 660 /tmp/my.sock
chgrp myapp_group /tmp/my.sock
Дополнительно — SO_PEERCRED (Linux) или getpeereid (BSD): сервер может узнать pid/uid подключившегося клиента.
# Python: узнать UID клиента после accept
import socket, struct
conn, _ = server.accept()
SO_PEERCRED = 17
creds = conn.getsockopt(socket.SOL_SOCKET, SO_PEERCRED, struct.calcsize('3i'))
pid, uid, gid = struct.unpack('3i', creds)
print(f"Client: PID={pid}, UID={uid}, GID={gid}")
Это решает аутентификацию в local context — проверять не «правильный ли пароль», а «правильный ли uid».
PostgreSQL по дефолту использует UDS authentication как ‘peer auth’: если ваш uid в системе совпадает с именем PG-юзера — пускает без пароля. Это работает только через UDS, потому что TCP-соединение не несёт credentials. Поэтому psql на localhost ‘просто работает’, а через TCP требует пароль.
Минусы и ограничения
UDS не панацея. Минусы:
- Same machine only. Очевидно: файла нет на другой машине.
- Truncated path names. На многих системах
sun_pathвсего 108 байт. Длинные пути не помещаются. Используйте короткие пути типа/tmp/x.sock. - Cleanup. Если сервер не сделал
unlink(path)при выходе — файл остался, при следующем bind будет EADDRINUSE. Лекарство — atexit-hook и/или unlink перед bind. - No transport-level encryption. TLS не работает напрямую на UDS (хотя теоретически можно надеть). Но в локальном context это редко нужно — доступ контролируется permissions.
Попробуй сам
Проверьте, что Docker daemon слушает UDS:
ls -la /var/run/docker.sock
# srw-rw---- 1 root docker 0 May 18 09:00 /var/run/docker.sock
# Подключиться напрямую через curl:
sudo curl --unix-socket /var/run/docker.sock http://localhost/version
# {"Version":"24.0.5", ...}
# Это и есть Docker API через UDS
Запустите PostgreSQL и подключитесь через UDS:
# Если есть postgres:
psql -h /var/run/postgresql -U myuser mydb
# или просто:
psql -U myuser mydb # по умолчанию идёт через UDS на localhost
# Через TCP (медленнее, требует пароль):
psql -h 127.0.0.1 -U myuser mydb
Замерьте разницу:
# 100k простых запросов через TCP:
time pgbench -h 127.0.0.1 -U myuser -c 1 -j 1 -T 10 mydb
# Через UDS:
time pgbench -h /var/run/postgresql -U myuser -c 1 -j 1 -T 10 mydb
# Типично UDS на 10-30% быстрее по TPS
Сделайте echo-сервер на Python:
cat > echo_uds.py << 'EOF'
import socket, os, time
PATH = '/tmp/echo_test.sock'
try: os.unlink(PATH)
except FileNotFoundError: pass
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(PATH)
os.chmod(PATH, 0o600)
server.listen(5)
start = time.time()
count = 0
while time.time() - start < 5:
conn, _ = server.accept()
while True:
data = conn.recv(4096)
if not data: break
conn.sendall(data)
count += 1
conn.close()
print(f"{count} round-trips in 5 sec = {count/5:.0f} req/sec")
os.unlink(PATH)
EOF
# Запустить сервер на 5 сек и клиента параллельно:
python3 echo_uds.py &
sleep 0.5
python3 -c "
import socket
c = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
c.connect('/tmp/echo_test.sock')
import time
start = time.time()
count = 0
while time.time() - start < 4:
c.sendall(b'x'*100)
c.recv(4096)
count += 1
print(f'Client did {count} round-trips')
c.close()
"
wait
На локальной машине типично 50-100k round-trips/sec через UDS. Сравните с TCP — получится меньше.