登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  linux

Linux systemd socket激活服务

来源:17golang原创

时间:2026-11-15 16:45:02 249浏览 收藏

如果一个 Linux 服务只有收到请求时才值得启动,systemd 的 socket 激活可以把“监听端口”和“启动进程”拆开:.socket 单元先持有监听入口,连接到来后再拉起匹配的 .service,服务进程从 systemd 传入的文件描述符开始工作,而不是再次绑定端口。

这套机制真正容易出错的地方,不是把两个文件写出来,而是没有分清三个边界:谁拥有监听 socket、程序从哪个 fd 读取、一个服务还是每个连接创建实例。下面用一个 TCP 示例把配置、安全和审计一次串起来。

官方文档:https://www.freedesktop.org/software/systemd/man/latest/systemd.socket.html

最小可用方案是同名的 demo.socket 与 demo.service,让 demo.socket 配置 ListenStream=127.0.0.1:9000,让服务程序通过 LISTEN_FDS 接收从 fd 3 开始的监听描述符。新写的长连接服务通常从 Accept=no 开始,再按隔离需求评估 Accept=yes。

先看清 systemd socket 激活的资产边界

在传统服务里,进程自己调用 bind()、listen(),并在启动阶段承担端口冲突和权限处理。socket 激活把这些责任前移给 systemd:.socket 单元创建并监听地址,匹配的服务被启动后接收已经打开的描述符。

这会带来三个直接结果:

  • 监听端口由 socket 单元持有,服务程序不应再对同一个端口执行一次 bind()。
  • 服务启动时必须识别 systemd 的 fd 传递协议;使用原生接口时,首个描述符是 fd 3,后续依次是 fd 4、fd 5。
  • socket 单元和 service 单元的名称默认对应,例如 demo.socket 对应 demo.service;若使用 Accept=yes,通常对应 demo@.service 模板。
systemd socket激活中socket单元、文件描述符和service单元的静态关系图
图1:说明图展示监听入口、文件描述符和服务进程的静态边界,帮助理解谁创建 socket、谁消费 fd;不是截图或运行证据。

图中的关系可以浓缩成一句话:.socket 是入口资产的所有者,.service 是处理者,二者之间的契约是文件描述符,而不是端口号字符串。

用一对最小 unit 配出按需启动服务

先创建系统级 socket 单元。示例只监听回环地址,避免把实验入口直接暴露到所有网卡;SocketMode 只对 Unix socket、FIFO 等文件系统节点有意义,因此 TCP 示例不需要它。

# /etc/systemd/system/demo.socket
[Unit]
# 给 unit 一个明确用途,便于 systemctl status 和日志检索
Description=Demo TCP socket

[Socket]
# 仅监听本机 9000 端口,避免示例默认对外暴露
ListenStream=127.0.0.1:9000
# 明确关联的 service;不写时默认按同名 demo.service 查找
Service=demo.service

[Install]
# enable 后由 sockets.target 统一拉起监听入口
WantedBy=sockets.target

再写 service 单元。这里用专用系统用户运行,使用 Type=exec 让 systemd 在执行文件真正成功后再判断启动阶段;NoNewPrivileges、只读根目录和私有临时目录是可继续按程序需求收紧的起点。

# /etc/systemd/system/demo.service
[Unit]
# service 由 demo.socket 触发,After 让依赖关系表达得更清楚
Description=Demo socket-activated service
After=demo.socket

[Service]
# 让进程不以 root 身份处理请求
User=demo
Group=demo
Type=exec
# 程序必须读取 systemd 传入的 fd,而不是自行 bind 9000
ExecStart=/usr/local/libexec/demo-server
# 限制进程通过 setuid 等方式再取得更高权限
NoNewPrivileges=yes
# 程序若不需要写系统目录,优先使用只读根目录
ProtectSystem=strict
# 给需要的运行时写入目录单独开口,按实际程序调整
ReadWritePaths=/run/demo
# 将临时目录与其他服务隔离
PrivateTmp=yes

[Install]
# socket 激活场景通常启用 .socket;service 可不单独 enable
WantedBy=multi-user.target

部署 unit 后,常用的管理动作是先让 systemd 重新读取文件,再启用 socket。这里的 enable --now demo.socket 表示立即建立监听并让它随系统启动;服务进程仍然等到有连接时才由 socket 单元触发。

# 重新读取 unit 文件,避免继续使用旧配置
sudo systemctl daemon-reload
# 启用并立即启动监听入口,服务进程按需拉起
sudo systemctl enable --now demo.socket
# 查看 socket 的监听状态和关联 unit
systemctl status demo.socket
# 查看服务最近的启动与退出记录
journalctl -u demo.service -n 50 --no-pager

让服务正确接收 systemd 传入的 fd

使用原生协议时,systemd 会通过 LISTEN_PID、LISTEN_FDS 等环境变量描述传入的文件描述符,首个 fd 从 3 开始。推荐在进程早期调用 sd_listen_fds(1):参数为 1 时,库会在读取后清理这些环境变量,减少它们被子进程误用的机会。

下面的 C 片段只展示契约重点:检查收到的 fd 数量,用 sd_is_socket_inet 判断 fd 3 是否是预期的 TCP 监听 socket,然后由程序继续执行 accept()。它不是运行结果截图,也不要求在本机启动服务。

// demo-server.c:接收 systemd 传入的 TCP 监听 fd
#include 
#include 
#include 
#include 
#include 
#include 

// 业务层负责读写连接,并在处理结束时关闭 client_fd
static void handle_client(int client_fd);

int main(void) {
    // 读取 LISTEN_FDS,并在返回前清理传递协议的环境变量
    int fd_count = sd_listen_fds(1);
    if (fd_count 

编译时需要链接 libsystemd,例如使用 pkg-config --cflags --libs libsystemd 获取当前系统的编译参数。示例省略了 handle_client 的业务实现,重点是不要把 fd 3 当成普通标准输入,也不要在程序里重新绑定 9000。

# 由 pkg-config 读取当前系统的 libsystemd 头文件和链接参数
cc demo-server.c -o /usr/local/libexec/demo-server \
  $(pkg-config --cflags --libs libsystemd)
# 把可执行文件交给专用用户读取,避免 unit 运行时依赖可写工作目录
sudo install -o root -g demo -m 0750 demo-server /usr/local/libexec/demo-server

Accept=no 和 Accept=yes 不是同一个并发模型

Accept=no 是默认值:一个 service 单元接收所有监听 socket,程序自己 accept() 并管理连接。它适合新写的、具备连接循环和统一资源池的服务,激活开销也只集中在启动服务的阶段。

Accept=yes 则为每个入站连接创建一个 service 实例,只把该连接 socket 交给实例。此时应提供同名模板,例如 demo@.service;这种模式更接近 inetd,隔离粒度更细,但实例数量、生命周期和日志关联会变得更复杂。数据报 socket 和 FIFO 不按这个方式为每个连接拆分。

# /etc/systemd/system/demo-per-connection.socket
[Unit]
# 该例用于说明逐连接实例,不与前面的 demo.socket 同时占用同一端口
Description=Per-connection demo socket

[Socket]
# 每个 TCP 连接都交给一个 demo-per-connection@.service 实例
ListenStream=127.0.0.1:9001
Accept=yes

[Install]
# 让逐连接 socket 随 sockets.target 启动
WantedBy=sockets.target

# /etc/systemd/system/demo-per-connection@.service
[Service]
# @ 前的模板名必须与 socket 名称对应
ExecStart=/usr/local/libexec/demo-one-connection
# 每个实例使用独立进程,程序从 inetd 风格入口读取连接
StandardInput=socket
StandardOutput=socket
User=demo
NoNewPrivileges=yes
# 失败实例不长期堆积,具体回收策略应结合日志需求设置
CollectMode=inactive-or-failed
systemd Accept no与Accept yes并发模型及权限审计边界结构图
图2:结构图对比单服务接收监听 fd 与逐连接模板实例的静态关系,并标出权限、日志和实例回收边界;不代表某次运行结果。
选择服务收到什么适合场景主要代价
Accept=no监听 socket,首个通常是 fd 3长连接、统一连接池、应用自己管理并发程序要自己 accept、限流和处理连接错误
Accept=yes每个实例只收到一个连接已有 inetd 风格程序、需要逐连接进程隔离实例数、日志关联、资源回收更复杂

把安全威胁落到配置和审计动作

socket 激活减少了服务常驻时间,但不会自动解决权限问题。可以把风险拆成四类:监听面过大、服务权限过高、fd 语义误判、实例或日志失控。

  1. 监听面:能绑定回环地址就不要写成 0.0.0.0;确实要对外提供服务时,再配合主机防火墙和网络边界。
  2. 文件节点权限:Unix socket 使用 SocketUser、SocketGroup、SocketMode 控制访问;TCP 监听则重点检查地址、端口和服务用户。
  3. 服务权限:至少明确 User、Group、NoNewPrivileges,再根据程序写入需求配置 ReadWritePaths,不要为了省事关闭整体保护。
  4. 输入契约:服务启动时拒绝 0 个或多余的 fd,并用 sd_is_socket_* 检查类型;这样手工启动二进制时不会悄悄进入错误分支。
  5. 实例与审计:Accept=yes 要关注失败实例的回收和日志关联;Accept=no 则要把连接级错误写入服务日志,避免只看到一个模糊的进程退出。

上线前可以按下面的清单复查配置关系:

  • systemctl cat demo.socket demo.service 能看到当前生效文件,且没有同端口的旧 socket 单元。
  • systemctl is-active demo.socket 显示监听单元处于 active;服务是否 active 要结合是否已经有连接来判断。
  • ss -ltnp 的监听地址符合预期,不能只看服务进程名就推断端口归属。
  • journalctl -u demo.socket -u demo.service 能区分 socket 单元问题和服务进程问题。
  • 服务二进制以专用用户运行,且没有把 token、私钥或可写目录通过 Environment= 直接暴露在 unit 文件中。
# 只读取 unit 和状态,不修改正在运行的服务
systemctl cat demo.socket demo.service
systemctl is-active demo.socket
# 观察 systemd 记录的监听入口和服务异常,按 unit 分组阅读
journalctl -u demo.socket -u demo.service --since "10 minutes ago" --no-pager
# 查看监听地址,确认没有意外绑定到所有网卡
ss -ltnp | grep ':9000'

几个最容易混淆的边界

为什么服务启动了却提示端口已被占用?

通常是服务仍保留了传统的 bind() 逻辑。socket 激活下,端口已经由 .socket 持有,服务应该消费传入的 fd;如果同一份程序既支持手工启动又支持 systemd,建议把“使用传入 fd”和“自行创建监听 socket”做成两个明确分支,避免两个路径同时执行。

为什么只写了 service,连接却不会自动启动它?

service 单元不会凭空创建监听入口。需要同名 .socket,并且把 socket 单元启动起来;同时确认 Service= 是否指向正确的 unit。systemd 文档也明确说明,socket 到 service 的默认命名关系可以被 Service= 改写。

什么时候应该使用 StandardInput=socket?

这是传统 inetd 风格的传递方式:连接 socket 通过标准输入和标准输出提供,而不是通过 LISTEN_FDS 传递。新写的程序更适合使用原生 fd 传递并显式检查 socket 类型;只有程序本身就是 stdin/stdout 协议时,才选择这一兼容边界。

如何选择 systemd-socket-activate 做临时排查?

它可以在命令行临时启动一个 socket-activated 程序,用于检查程序是否能接收 fd;它不是生产 unit 的替代品。排查时仍然要区分 --inetd 的标准输入输出模式和默认的 fd 3 及以上传递模式。

总结一下:把监听入口交给 .socket,让服务通过文件描述符协议接管请求,再用 Accept=no/yes 明确并发模型。真正进入生产前,把监听地址、专用用户、沙箱、日志和实例回收一起写进审计清单,socket 激活才不只是“能启动”,而是可控、可追溯的服务边界。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>