我跑的大多数服务都是常开的:它们 socket()、bind()、listen(),然后就干等——哪怕一天只来两次请求,也一直占着一个端口、一个常驻内存里的进程。systemd socket 激活把这套翻了过来:监听 socket 归 systemd 所有,端口由它打开,而你的服务可以彻底死着,直到第一个连接到来——那一刻 systemd 才启动服务,并把那个已经打开的 fd 交给它。服务从不调用 bind(),它只是在系统递给它的一个 ListenStream fd 上 accept。下面我在 systemctl --user 下(无需 root)真正搭一个 socket 激活的服务,看着一个连接把它从 inactive (dead) 拉起来,再把它停掉、看着 listener 活下来,最后用 systemd-socket-activate 把同样的事再来一遍。所有输出都是这台机器(systemd 262)上的真实结果。
LISTEN_FDS / sd_listen_fds() 被告知该在哪个 fd 上 accept。systemd socket 激活到底是什么
正常情况下,提供服务的进程也正是占端口的进程:它创建 socket、绑到地址端口、调用 listen(),然后 accept。systemd socket 激活把前三步从你的程序里搬进 systemd。.socket unit 持有一条 ListenStream=(一个 TCP 端口、一个 UNIX socket 路径等等);systemd 打开它并一直保持。你程序的 .service 开机并不启动——它会一直停在 inactive (dead)、MainPID 为 0,没有进程、没有常驻内存。第一次有客户端连接时,systemd 察觉到流量,启动服务,把那个正在监听的 fd 递过去。
第一次让我吃惊的是:端口其实始终开着。socket 活在管理器里,不在你的服务里,所以它能挺过服务的死亡与任何重启。服务只是按需挂上去——它不是代理也不是包装;你的进程在一个真正的 socket 上 accept,就像它自己创建了这个 socket 一样。
它好在哪(又在什么时候只是噪音)
我会反复用它的四个理由:
- 没有常驻守护进程。 罕用的服务可以常驻内存为零,直到有人来访问。在这台机器上,服务是
inactive (dead)、MainPID 0,而端口仍在应答。 - 重启而不丢 listener。 因为 socket 归管理器持有,你可以停、升级、重启服务,进来的连接只是排在 backlog 里,而不是被
connection refused。下文有演示。 - 权限分离。 对系统服务来说,管理器(常是 root)可以持有低端口,服务则能以无特权的
User=运行、依旧在它继承来的 fd 上 accept——“端口在管理器里监 443,应用以nobody跑。” - 并行启动。 socket 很廉价,所以 systemd 可以先把它们全部打开,再按需并行地启动真正的服务,而不是串行地等每个守护进程 bind。
诚实的代价是多了一层间接。你的程序没打开自己的 socket,所以“哪个进程 bind() 了、它起没起”这种本能反应,不再指向你的服务。而且它只在你的程序配合时才有用:程序必须会用继承来的 fd。一个硬编码了自己 socket()/bind()/listen() 的服务会无视传进来的 fd 并 bind 失败,因为端口已经被管理器占着。如果服务是 7×24 高负载、必须随时热着,或者根本接不了继承来的 fd,那 socket 激活对你毫无好处,只会多一个 .socket 文件、多一层要想的东西。
.socket unit 与它的 .service 配对
socket 激活是一对:一个 .socket unit 说在哪儿监听,一个同名的 .service unit 说跑什么。不显式指定时,demo-echo.socket 触发 demo-echo.service。下面是我那两个文件,逐字来自临时目录:
# demo-echo.socket
[Unit]
Description=Scratch echo socket (systemd owns this listener)
[Socket]
ListenStream=127.0.0.1:18765
# Accept= is left unset -> defaults to false -> ONE service instance that
# receives the listening fd as fd 3 and accepts connections itself.
# (Accept=yes would instead fork a per-connection unit, inetd-style, fd 0.)
[Install]
WantedBy=sockets.target
# demo-echo.service
[Unit]
Description=Echo listener started on first connection by demo-echo.socket
[Service]
Type=exec
ExecStart=/tmp/opencode/work/socket-activation/listener
Restart=on-failure
StandardOutput=journal
StandardError=journal
真正承重的两条指令:
ListenStream=是 systemd 打开的地址。127.0.0.1:18765是 TCP 监听;一个裸路径(如/run/foo.sock)是 UNIX socket;一个裸18765会在所有网卡上绑 IPv4+IPv6。可重复写来开多个 socket;同类还有ListenDatagram=(UDP)和ListenSequentialPacket=。Accept=选择激活模型。默认Accept=no只启动一个实例,把监听 fd(fd 3)交给它去跑 accept 循环,像 nginx。Accept=yes是 inetd 模型:systemd 自己 accept 每个连接,然后 fork 一个模板服务(通常是@.service)的每连接实例,把已连接的 socket 作为 fd 0(stdin)传入。一个常驻 accept 循环,还是每连接一个进程——想清楚再选。
注意 service 从头到尾没提 socket 一嘴。这正是它的优雅之处:程序不关心 socket 是谁开的,所以同一个二进制稍后在 systemd-socket-activate 下也能跑。
程序如何拿到 fd:sd_listen_fds、LISTEN_FDS、fd 3
systemd 启动一个 Accept=no 的服务时,在 exec() 之前做三件事:把监听 socket 复制进进程的 fd 3,并设置两个环境变量——LISTEN_FDS(传了几个 fd)和 LISTEN_PID(传给谁的 pid,这样 fork 出来的子进程就不能偷偷复用它们)。规范的读取方式是 libsystemd 的帮手 sd_listen_fds():它校验 LISTEN_PID == getpid()、返回数量,并把第一个 fd 放在 SD_LISTEN_FDS_START(即 3)。我这个小监听程序会把它收到的都打印出来,好让机制可见:
/* listener.c — a deliberately tiny socket-activated listener.
*
* It does NOT create the listening socket. systemd owns it, does the
* bind()+listen(), and passes the fd to us at startup (fd 3). We read
* the fd via sd_listen_fds(), accept() on it, and answer with our own
* PID so the activation is visible. Works identically under
* `systemctl --user` (fd inherited as fd 3) and `systemd-socket-activate`.
*
* Build: gcc -O2 -Wall -o listener listener.c -lsystemd
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <signal.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <systemd/sd-daemon.h>
int main(void) {
/* Capture the raw env systemd set, before sd_listen_fds consumes it. */
const char *raw_fds = getenv("LISTEN_FDS");
const char *raw_pid = getenv("LISTEN_PID");
/* unset_env=1: validate LISTEN_PID == getpid() AND unset the vars, so
* a forked/exec'd child cannot passively reuse a stale fd number. */
int n = sd_listen_fds(1);
if (n < 0) { fprintf(stderr, "sd_listen_fds failed: %s\n", strerror(-n)); return 1; }
if (n != 1) { fprintf(stderr, "expected exactly 1 inherited fd, got %d\n", n); return 1; }
int fd = SD_LISTEN_FDS_START; /* == 3, first fd systemd passed us */
printf("listener up: pid=%d LISTEN_FDS=%s LISTEN_PID=%s "
"-> sd_listen_fds()=%d listening_fd=%d\n",
(int)getpid(), raw_fds ? raw_fds : "(unset)",
raw_pid ? raw_pid : "(unset)", n, fd);
fflush(stdout);
signal(SIGPIPE, SIG_IGN); /* a client hang-up must not kill us */
for (;;) {
struct sockaddr_in peer;
socklen_t plen = sizeof peer;
int c = accept(fd, (struct sockaddr *)&peer, &plen);
if (c < 0) { if (errno == EINTR) continue; perror("accept"); continue; }
char body[256];
ssize_t r = read(c, body, sizeof body - 1);
if (r < 0) r = 0;
body[r] = '\0';
while (r > 0 && (body[r-1] == '\n' || body[r-1] == '\r')) body[--r] = '\0';
char out[256];
int len = snprintf(out, sizeof out,
"handled by socket activation: pid=%d listening_fd=%d received=\"%s\" (%zd bytes)\n",
(int)getpid(), fd, body, r);
(void)write(c, out, (size_t)len);
printf("accepted %s:%u -> served by pid=%d on inherited_fd=%d received=\"%s\"\n",
inet_ntoa(peer.sin_addr), (unsigned)ntohs(peer.sin_port),
(int)getpid(), fd, body);
fflush(stdout);
close(c);
}
return 0;
}
编译用 gcc -O2 -Wall -o listener listener.c -lsystemd。关键那几行是:sd_listen_fds(1) 解析出唯一的 fd,然后 accept(fd, …) 在它上面 accept——我们从不调用 socket() 或 bind(),因为 listen 早已由 systemd 替我们做了。sd_listen_fds(1) 返回 1,就是 fd 完好送达的证据。
在 systemctl --user 下真实激活
把两个 unit 软链进 ~/.config/systemd/user,daemon-reload,然后启动socket——不是服务。服务会一直死着直到客户端出现:
$ systemctl --user enable --now demo-echo.socket
Created symlink '/home/awinx/.config/systemd/user/sockets.target.wants/demo-echo.socket' → '/tmp/opencode/work/socket-activation/demo-echo.socket'.
$ ss -tln | grep 18765
LISTEN 0 4096 127.0.0.1:18765 0.0.0.0:*
$ systemctl --user status demo-echo.socket --no-pager
● demo-echo.socket - Scratch echo socket (systemd owns this listener)
Active: active (listening) since Tue 2026-10-13 04:23:28 CST
Triggers: ● demo-echo.service
Listen: 127.0.0.1:18765 (Stream)
$ systemctl --user status demo-echo.service --no-pager
○ demo-echo.service - Echo listener started on first connection ...
Active: inactive (dead)
TriggeredBy: ● demo-echo.socket
端口在 LISTEN、socket unit active (listening),而服务 inactive (dead)、没有进程——整个承诺三行讲完。socket 通告 Triggers: demo-echo.service,服务报告 TriggeredBy: demo-echo.socket,systemd 已经把这一对连上了。
现在连上去,看服务凭空起来:
$ python3 -c 'import socket; s=socket.create_connection(("127.0.0.1",18765)); s.sendall(b"first-connection\n"); print("client got:", s.recv(200).decode().strip())'
client got: handled by socket activation: pid=614805 listening_fd=3 received="first-connection" (16 bytes)
$ journalctl --user -u demo-echo.service --no-pager
Starting Echo listener started on first connection by demo-echo.socket...
Started Echo listener started on first connection by demo-echo.socket.
listener up: pid=614805 LISTEN_FDS=1 LISTEN_PID=614805 -> sd_listen_fds()=1 listening_fd=3
accepted 127.0.0.1:55546 -> served by pid=614805 on inherited_fd=3 received="first-connection"
一秒前 MainPID 还是 0,日志显示服务以 pid 614805 活了过来。那行启动信息把整个机制一次讲清:systemd 设置了 LISTEN_FDS=1 和 LISTEN_PID=614805,sd_listen_fds() 把它们解析成 listening_fd=3——程序 accept 的这个 fd,正是管理器递给它的那个。随后它把这两个变量 unset,好让 fork 出的子进程没法复用陈旧的 fd 号。
重启而不丢掉 listener
这是我最看重的那个性质。我只停服务,然后看端口是否还开着:
$ systemctl --user stop demo-echo.service
Stopping 'demo-echo.service', but its triggering units are still active:
demo-echo.socket
$ ss -tln | grep 18765
LISTEN 0 4096 127.0.0.1:18765 0.0.0.0:*
$ systemctl --user is-active demo-echo.service
inactive
systemd 甚至警告说 socket 仍然 active——ss 证实了它:listener 挺过了服务的死亡,所以间隙里连上来的客户端只是排在 backlog 等待,而不是被拒。重连,就有个全新进程在同一端口上被拉起:
$ python3 -c 'import socket; s=socket.create_connection(("127.0.0.1",18765)); s.sendall(b"second-connection\n"); print("client got:", s.recv(200).decode().strip())'
client got: handled by socket activation: pid=615413 listening_fd=3 received="second-connection" (17 bytes)
$ journalctl --user -u demo-echo.service --no-pager | grep -E 'listener up|accepted'
listener up: pid=614805 LISTEN_FDS=1 LISTEN_PID=614805 -> sd_listen_fds()=1 listening_fd=3
accepted 127.0.0.1:55546 -> served by pid=614805 on inherited_fd=3 received="first-connection"
listener up: pid=615413 LISTEN_FDS=1 LISTEN_PID=615413 -> sd_listen_fds()=1 listening_fd=3
accepted 127.0.0.1:38462 -> served by pid=615413 on inherited_fd=3 received="second-connection"
换二进制、停服务、重启——socket 一刻都不眨,下一个连接唤醒一个崭新的进程。这种模型也让 listener 层面的零宕期变得微不足道:socket 的生命周期与服务的生命周期解耦了。
拆卸时咬过我的一个后果:一个已经启动的实例会通过 fd 3 持有监听 fd 的副本,所以 disable socket 不会杀掉一个正在运行的服务——端口会一直开到那个实例退出。ss -tlnp 甚至在 unit 已经没了之后还显示 users:(("listener",pid=615413,fd=3))。激活归激活,清理时记得服务和 socket 一起清。
不写 unit 也能测:systemd-socket-activate
不必有 .socket unit 也能演练这套机制——systemd-socket-activate 一条命令就能绑端口、在第一个连接到来时启动你的程序。我拿同一个二进制指向一个新端口:
$ systemd-socket-activate -l 127.0.0.1:18766 ./listener &
$ python3 -c 'import socket; s=socket.create_connection(("127.0.0.1",18766)); s.sendall(b"via systemd-socket-activate\n"); print("client got:", s.recv(200).decode().strip())'
client got: handled by socket activation: pid=615573 listening_fd=3 received="via systemd-socket-activate" (27 bytes)
而 systemd-socket-activate 自己的输出把这次 fd 交接讲得很清楚:
Listening on 127.0.0.1:18766 as 3.
Communication attempt on fd 3.
Executing: /tmp/opencode/work/socket-activation/listener
listener up: pid=615573 LISTEN_FDS=1 LISTEN_PID=615573 -> sd_listen_fds()=1 listening_fd=3
accepted 127.0.0.1:44704 -> served by pid=615573 on inherited_fd=3 received="via systemd-socket-activate"
as 3 是它在报告 listener 会成为 fd 3;Communication attempt on fd 3 是触发;Executing: ... 是拉起。这是在你动 unit 文件之前,验证自己 sd_listen_fds() 接线对不对最快的方法——而且因为程序对 socket 来源无感,同一个二进制在这里和真正的 .socket 下都能跑。
接线之前先校验这对 unit
systemd-analyze verify 能抓出文件里看不出来的错。我这对干净的 unit 安静无声:
$ systemd-analyze verify ./demo-echo.socket ./demo-echo.service; echo "exit=$?"
exit=0
但它不是橡皮章。Accept= 打错字,以及一个 .socket 找不到它的 .service,都会大声失败:
$ systemd-analyze verify ./broken-accept.socket
broken-accept.socket: Failed to parse Accept=maybe, ignoring: Invalid argument
broken-accept.socket: service broken-accept.service not loaded, socket cannot be started.
第二行把配对规则讲清楚了:.socket 需要一个可触发的 .service,而 verify 会在找不到时直说。daemon-reload 之前先跑它。
什么时候不值得
给你边界,而不是一把锤子:
- 常热服务。 如果应用持续有流量,你本来就得让它常驻;socket 激活省不下什么,只添间接。
- 首连延迟。 空闲之后第一个客户端会在线承担进程启动开销。如果冷启动会击穿 SLO,那就让它热着。
- 必须自己占 socket 的应用。 用特殊选项做裸
bind()、或自己管理 socket 生命周期的软件,可能会和管理器打架。只有程序肯用继承来的 fd,它才有意义。 Accept=no期待一个常驻 accept 循环,所以那种一生一了的 per-connection 场景该用Accept=yes——代价是每连接一次 fork。- 有状态的推理。 “哪个 pid 持有端口”在 socket 归管理器、服务被反复重启时会变得不好想,就像上文拆卸那样。
至于其它一切——低频的边车、一天用几次的内部工具、想绑特权端口却能以无特权二进制运行的服务——它就是“一个守护进程一直在跑”和“端口在等着被用”之间的区别。
说到这儿
一个 socket 激活的服务是三样小而可检查的产物:一个带一条 ListenStream= 的 .socket、一个 accept 递给它的 fd 的 .service,以及一个用 sd_listen_fds() 读这个 fd 的程序。socket 归 systemd、按第一个连接启动你、还能让你重启服务而端口不眨。要 unit 文件层面的机制,我的 systemd unit file 速查 覆盖了 ExecStart、Type 与校验;而一旦服务按需启动,自然的下一步就是缩小运行进程能碰到的东西,我在 systemd 加固 里做这件事。打开 socket,校验它,然后让连接去启动。