systemd socket 激活实战:低频服务平时零内存常驻,连接到了才拉起

一个你从没在意过的常驻内存开销

个人站长的 VPS 往往只有 1GB 或 2GB 内存,而服务器上跑着一堆"平时根本没人用、但必须一直开着"的服务:一个偶尔要用的内部 API、一个几天才访问一次的管理后台、一个只在备份时段被调用的守护进程。这些服务用 systemd 常驻的方式跑着,每个进程哪怕空闲也要占几 MB 到几十 MB 的 RSS(物理内存),十几个凑一起,几百 MB 就没了。

更别扭的是,它们大部分时间都在"空转"——进程活着,但没有任何请求。你为了它们花的每一分内存,都是纯粹的浪费,换来的只是"需要时能立刻响应"这点便利。

systemd 提供了一个非常优雅的机制解决这个问题:socket activation(套接字激活)。思路是——让 systemd 先替你"占住"监听端口,真正的服务进程平时根本不启动。当第一个连接到达端口时,systemd 才拉起服务,并把已经建立好的套接字"交接"给进程处理。处理完空闲下来后,进程可以自动退出,内存完全归还。

这样一来,一个平时没人访问的服务,常驻内存可以是零。这不仅是省内存,还顺带解决了"启动顺序"和"重启不丢连接"的问题——因为端口是被 systemd 一直占着的。本文用一个真实场景,把 socket activation 从原理到落地完整走一遍。

先理解它到底做了什么

传统的守护进程模式是这样的:服务自己负责创建 socket、绑定端口、listen,然后卡在 accept() 上等连接。这意味着服务必须常驻,因为端口是它自己占着。

socket activation 把这两件事拆开了:

第一步,systemd 读取一个 .socket 单元文件,由它来创建并监听套接字,一直占着端口。
第二步,当套接字上出现第一个连接(或数据包)时,systemd 才启动对应的 .service,并且不再让服务自己去 bind/listen,而是通过环境变量(LISTEN_FDS)把已经准备好、已经在 listen 的文件描述符直接传递给服务进程。

所以支持 socket activation 的程序必须能"接收"别人给的 fd,而不是自己去 bind。好在这个能力在很多语言里实现起来非常简单(systemd 提供了 sd_listen_fds() 之类的辅助函数),Nginx 也有一个 ngx_http_core_module 的原生支持方式。对不支持的程序,可以用 systemd-socket-proxyd 做一层代理兜底(后面会讲)。

动手:给一个 Python 小服务做套接字激活

假设你有一个内部小接口,用 Python 写的,平时几乎没人访问,但不想让它 7×24 占着内存。先写一个能接收 systemd 传入 fd 的版本。

# /opt/demo/app.py
import os
import socket
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer

def get_systemd_socket():
    # systemd 传递的 fd 从 3 开始,数量记录在 LISTEN_FDS
    if "LISTEN_FDS" not in os.environ:
        # 没被 systemd 拉起时,退化到自己监听(方便本地调试)
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        s.bind(("127.0.0.1", 8081))
        s.listen(128)
        return s
    fd = 3  # 第一个传入的 fd
    # 关键:告诉 systemd 这些 fd 从现在起归我管,别回收
    os.environ.pop("LISTEN_FDS", None)
    return socket.fromfd(fd, socket.AF_INET, socket.SOCK_STREAM)

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-Type", "text/plain")
        self.end_headers()
        self.wfile.write(b"socket activation works\n")
    def log_message(self, *a):
        pass

sock = get_systemd_socket()
HTTPServer(sock, Handler).serve_forever()

注意 socket.fromfd(fd, ...):这就是"从别人手里接过套接字"的关键。fd 编号是 3(0/1/2 分别是标准输入输出错误),这是 systemd 的约定。

写 .socket 和 .service 两个单元

socket activation 需要成对的单元:.socket 定义监听什么,.service 定义被拉起时怎么跑。

# /etc/systemd/system/demo.socket
[Unit]
Description=Demo socket activation listener

[Socket]
# 监听地址,等价于服务自己 bind 127.0.0.1:8081
ListenStream=127.0.0.1:8081
# 传输层安全可选:限制只接受本机
Accept=no

[Install]
WantedBy=sockets.target
# /etc/systemd/system/demo.service
[Unit]
Description=Demo HTTP service (socket activated)
# 依赖同名 socket,保证端口先被 systemd 占住
Requires=demo.socket
After=demo.socket

[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/demo/app.py
# 空闲多久后自动退出,让内存归还
# 这里配合程序里的超时逻辑;下面用 systemd 侧的手段也行
Restart=on-failure

[Install]
WantedBy=multi-user.target
# 启用并启动 socket(注意:只 enable socket,不 enable service)
systemctl daemon-reload
systemctl enable --now demo.socket

# 此时 service 应该是 inactive(没启动)
systemctl status demo.service
systemctl status demo.socket

关键点:demo.service 不要 enable。你要 enable 的是 demo.socket。socket 一起来,端口就被 systemd 占住了;service 保持 inactive,不占任何进程内存。

验证:连一下,看它按需启动

# 此时访问会触发 systemd 拉起 service
curl -s http://127.0.0.1:8081
# 输出:socket activation works

# 查看 service 是否被"唤醒"了
systemctl status demo.service
# 应看到 Active: active (running),且日志里有 Starting 记录

# 看 systemd 的套接字激活证据
journalctl -u demo.service -n 20 --no-pager

第一次 curl 会稍微慢一点,因为 service 是那一刻才启动的(进程启动 + 解释器加载,通常几十到几百毫秒)。之后连续请求就很快了,因为进程已经在跑。这就是 socket activation 的典型特征:首次访问有冷启动延迟,之后正常。

让空闲后自动退出,把内存真正还回去

光"按需启动"还不够——如果 service 起来了就再也不退出,那内存还是一次性占着。真正的收益来自"空闲后自动退出"。有两种做法。

做法一:程序自己控制。在服务里加一个"无连接 N 秒后 sys.exit(0)"的逻辑。因为退出的只是进程,systemd 占着的端口还在,下一个请求来了又会重新拉起,用户无感。

# 在 app.py 里给 server 加一个 idle 超时(示意)
# 常用做法是设置 socket 超时,处理完请求后在没有新连接时退出
sock.settimeout(60)   # 60 秒内没有新连接就抛 timeout
try:
    HTTPServer(sock, Handler).serve_forever()
except socket.timeout:
    sys.exit(0)       # 干净退出,端口仍归 systemd

做法二:用 systemd 侧的超时。配合上 [Socket] 里的 MaxConnections 或服务的 RuntimeMaxSec,强制服务跑一段时间后退出:

# 在 demo.service 的 [Service] 段加:
RuntimeMaxSec=5min

这样 service 最多跑 5 分钟就自动结束,下次访问再拉起。对一个"几天才被访问一次"的服务来说,它一年里真正占用内存的时间可能只有几分钟。

大招:用 systemd-socket-proxyd 激活"不支持 fd 传递"的服务

现实里很多服务(老旧的 CGI、某些用现成二进制的程序)不支持从 systemd 接收 fd。这时不用改代码,systemd-socket-proxyd 就是为这个场景准备的:让 systemd 监听真实端口,触发时先启动 proxyd,proxyd 再去启动/连接真正的后端,做一个透明的双向转发。

# /etc/systemd/system/backend.socket
[Unit]
Description=Proxy socket for legacy backend
[Socket]
ListenStream=127.0.0.1:9000
[Install]
WantedBy=sockets.target
# /etc/systemd/system/backend.service
[Unit]
Description=Socket proxy front-end
Requires=backend.socket
After=backend.socket

[Service]
# proxyd 接收 systemd 给的 fd,转发到真正的后端地址
ExecStart=/usr/lib/systemd/systemd-socket-proxyd 127.0.0.1:9001
# proxyd 需要后端在跑,所以依赖后端服务
After=realbackend.service
Requires=realbackend.service
# 后端本身仍然是个普通 systemd 服务,正常写
# /etc/systemd/system/realbackend.service
[Service]
ExecStart=/usr/local/bin/my-legacy-app --listen 127.0.0.1:9001

这样外部的连接进 9000,systemd 拉起 proxyd 和后端,proxyd 把连接转发过去。对不支持 socket activation 的程序,这是最通用的落地方式。代价是多一层转发(本地环回,开销可忽略),换来的是按需启动的架构。

几个必须知道的坑

坑一:service 和 socket 不能同时 enable。如果你把 demo.service 也设成开机自启,那两个都会试图占 8081,端口冲突。记住:只 enable .socket,service 由 socket 按需拉起。

坑二:程序必须处理 LISTEN_FDS,不能自己再 bind。如果程序还在自己 bind(),会报 "Address already in use",因为端口已经被 systemd 占了。这也是最常见的失败原因——服务根本不支持 socket activation 却没发现。

坑三:systemctl restart 会真正重启 service,但 socket 一直活着。这正是我们想要的(重启服务不影响端口监听、不丢连接),但排查时要知道,restart demo.service 之后端口仍在监听是正常的。

坑四:日志要看对单元。请求触发启动、程序报错,都记在 demo.service 的日志里,而不是 demo.socket。排查用 journalctl -u demo.service -f。

坑五:start-limit 限制。如果服务频繁崩溃又频繁被拉起,systemd 会触发启动频率限制(默认 5 次/10 秒)而拒绝再启动。日志里会出现 start request repeated too quickly。用 systemctl reset-failed demo.service 复位,或者调整 StartLimitIntervalSec。

什么时候值得用,什么时候不值得

socket activation 不是万金油,要分场景。它适合:访问频率低(几小时/几天一次)、对首次延迟不敏感、进程本身占内存可观的服务,比如内部管理接口、低频 API、按需触发的工具守护进程。它不适合:高并发热点服务(冷启动延迟会成为用户痛点)、需要预热缓存/JIT 的服务(每次重启都要重新预热)、以及本身启动就很慢的服务(每次首访都要等它启动完)。

判断标准很朴素:如果这个服务"大部分时间空闲、少部分时间被用",socket activation 就是纯赚;如果它"一直在处理请求",那常驻本来就合理,套它反而引入冷启动抖动。对个人站长的低配 VPS,我建议先拿两三个低频服务试点,用 systemd-cgtop 或 smem 观察省下的内存,再决定要不要推广。

常见故障速查

启动报 Address already in use:service 里程序仍在自己 bind,或者是 service 和 socket 同时被 enable 抢端口。前者改程序用 LISTEN_FDS,后者取消 service 的开机自启。
port 起来但连不上、curl 一直转圈:service 被拉起了但没能正确接收 fd(比如 socket.fromfd 用错 fd 号),看 journalctl -u demo.service 里的异常。
首次访问特别慢:正常现象,冷启动开销。如果慢到无法接受(超过 1 秒),说明这个服务不适合 socket activation,考虑让它常驻。
start request repeated too quickly:服务反复崩溃触发限流,systemctl reset-failed 复位,并先修好崩溃原因。
改了 .socket 不生效:改的是 unit 文件,必须 systemctl daemon-reload 再 systemctl restart demo.socket。

FAQ

Q:socket activation 和 Docker 的按需启动是一回事吗?
A:理念相通(都是延迟启动),但 Docker 本身不原生支持 systemd 的 fd 传递。要在容器里用,得靠 podman 的 socket activation 支持(Podman 3.x+ 有相关能力),或者容器外做 proxyd 转发,复杂度更高。

Q:会让我的服务变慢吗?
A:不影响稳态性能。唯一代价是首次连接的冷启动延迟,之后与常驻无异。对低频服务,这点延迟通常无感。

Q:端口一直被 systemd 占着,算不算多余开销?
A:systemd 本身一直在跑,多监听一个 socket 的开销可以忽略(几个 fd 而已),相比省下一整个常驻进程的内存,完全划算。

Q:怎么确认它到底有没有生效?
A:两条:systemctl status demo.service 在没人访问时应显示 inactive;curl 一次后立刻变成 active。看到这个"由静到动"的过程,就是生效了。

Last modification:October 2nd, 2026 at 01:25 pm

Leave a Comment