Socket 激活让 systemd 在应用进程启动之前先打开监听端点。端点上的活动随后可以触发兼容服务,并把管理器已经持有的套接字描述符传给它。这里真正分开的,是监听器所有权与应用生命周期。端点已经开放,还不证明应用完成初始化、接受了请求或生成了正确响应;传输层能连接与业务层能工作,需要分别验证。

坏掉的样子

应用必须理解激活协议。如果守护进程无条件再次绑定同一个端点,就可能因为管理器已经占用而失败。激活机制把绑定责任移到 socket 单元,并不会消除地址冲突、接口缺失、文件系统权限错误,或其他导致首次绑定失败的条件。因此不能把“端口先开好”写成所有启动竞态已经自动消失,真正的监听建立仍然可能失败。

使用 Accept=no 时,服务接收监听套接字并自行接受连接;Accept=yes 则为合适的流套接字支持按连接启动服务实例的模式。它们对应不同的应用契约,不能互换。任何一种模式都不意味着任意守护进程会自动判断空闲、完成工作后退出,或者只在安全时重新启动。是否退出,以及退出时哪些请求已经完成,仍然由应用行为决定。

排队连接也有边界。客户端可以在应用初始化完成之前建立传输状态,随后因为等不到响应而超时。Socket 激活不会让数据库依赖自动就绪,也不会提供无限请求队列。真实工作负载仍需要合适的服务依赖、就绪逻辑与客户端超时;先拥有监听器只是交付链路的一部分,不等于依赖链已经完整兑现。

快速验证

在 PID 1 为 systemd 的一次性 Linux 系统中,以管理员身份运行下面的 Python 脚本。记录环境为 Debian 12、systemd 252.39。脚本在临时目录中使用 Unix 流套接字,创建唯一命名的 socket 与 service,不暴露公共 TCP 端口。工作程序有意只处理一条以换行结尾的消息,然后退出:

from pathlib import Path
from tempfile import TemporaryDirectory
import json,socket,subprocess,time,uuid

name='xbcatm-socket-'+uuid.uuid4().hex[:8];service=name+'.service';unit=name+'.socket'
paths={n:Path('/run/systemd/system')/n for n in (service,unit)}
assert all(not p.exists() for p in paths.values())
def run(*args):
    result=subprocess.run(args,capture_output=True,text=True,timeout=10)
    assert result.returncode==0,(args,result.stderr)
    return result.stdout.strip()
def value(key):return run('systemctl','show',service,'-p',key,'--value')
def wait_inactive():
    deadline=time.monotonic()+5
    while value('ActiveState')!='inactive':
        assert time.monotonic()<deadline,'service did not exit';time.sleep(0.02)
with TemporaryDirectory(prefix=name+'-',dir='/run') as directory:
    root=Path(directory);endpoint=root/'listener.sock';worker=root/'worker.py'
    worker.write_text(r'''from pathlib import Path
import errno,json,os,socket
root=Path(__file__).parent
assert int(os.environ['LISTEN_PID'])==os.getpid()
assert int(os.environ['LISTEN_FDS'])==1
with socket.socket(fileno=3) as listener:
    assert listener.getsockopt(socket.SOL_SOCKET,socket.SO_ACCEPTCONN)==1
    with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as duplicate:
        try:duplicate.bind(str(root/'listener.sock'))
        except OSError as error:assert error.errno==errno.EADDRINUSE
        else:raise AssertionError('unexpected duplicate bind success')
    connection,_=listener.accept()
    with connection,connection.makefile('rb') as incoming:
        message=incoming.readline(256);assert message.endswith(b'\n')
        connection.sendall(b'ack '+message)
    (root/(str(os.getpid())+'.json')).write_text(json.dumps({'pid':os.getpid(),'fd':3,'message':message.decode().strip()}))
''')
    paths[unit].write_text(f'[Socket]\nListenStream={endpoint}\nAccept=no\nSocketMode=0600\nRemoveOnStop=yes\n')
    paths[service].write_text(f'[Service]\nType=exec\nExecStart=/usr/bin/python3 {worker}\n')
    try:
        run('systemd-analyze','verify',*[str(p) for p in paths.values()]);run('systemctl','daemon-reload')
        run('systemctl','start',unit)
        assert endpoint.is_socket() and value('ActiveState')=='inactive' and value('MainPID')=='0'
        print('socket listening while service has no process')
        for message in ('first','second'):
            with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as client:
                client.settimeout(5);client.connect(str(endpoint));client.sendall((message+'\n').encode())
                with client.makefile('rb') as response:assert response.readline()==('ack '+message+'\n').encode()
            wait_inactive();assert endpoint.is_socket() and value('Result')=='success'
        records=[json.loads(p.read_text()) for p in root.glob('*.json')]
        assert len(records)==2 and len({row['pid'] for row in records})==2
        assert {row['message'] for row in records}=={'first','second'}
        assert all(row['fd']==3 for row in records)
        print('two requests: two deliberate one-request workers; each uses inherited listening fd 3')
        print('rebinding the occupied socket fails; manager retains listener between workers')
        run('systemctl','stop',unit);assert not endpoint.exists()
        print('stopping socket removes its endpoint with RemoveOnStop=yes')
    finally:
        run('systemctl','stop',unit,service)
        for name,path in paths.items():
            if run('systemctl','show',name,'-p','ActiveState','--value')=='failed':run('systemctl','reset-failed',name)
            path.unlink()
        run('systemctl','daemon-reload')

只启动 socket 单元后,端点已经存在,对应服务却仍为非活动,主进程编号为零。第一个客户端连接随后触发工作程序执行,客户端发送有完整边界的消息,并检查应用回复,不会仅凭 connect 成功就认定服务可用。流式连接并不自动划分应用消息,所以样本明确使用换行作为这一小型协议的结束标记。

工作程序检查 LISTEN_PID 是否指向自身,确认 LISTEN_FDS 声明一个继承描述符,然后包装编号三的描述符,并验证它已经处于监听状态。这些编号与环境检查符合原生激活契约。生产程序如果接收多个描述符,还应识别和验证自己真正期待的套接字,不能只看存在一个整数就随意当成某个固定用途的连接。

再次绑定同一个 Unix 套接字路径时,会得到 EADDRINUSE。工作程序随后继续使用继承的监听器,返回预期回复。这项受控失败说明,应用不能既保留普通启动流程中无条件 bind 的逻辑,又假定激活机制会神奇地避开冲突。监听器已经由谁建立,必须在程序启动协议中有明确答案。

工作进程成功退出后,管理器仍然持有监听端点。第二次连接启动新的工作进程,处理第二条消息后同样退出。记录中的进程编号不同,两次都使用了描述符三。出现两个进程,是因为本例程序有意每次只处理一个请求;并不是 Accept=no 自身要求每个连接必须创建新进程。普通长期运行的兼容服务可以继续接受后续连接。

停止 socket 单元后,端点被移除,因为样本显式设置了 RemoveOnStop=yes。脚本再停止可能残留的实验单元,删除运行时定义和临时文件。这是已经配置并观察到的清理行为,不能扩大为所有套接字路径都会在任意相关守护进程退出时自动消失;端点与服务的生命周期需要分别理解。

同时设计交接与停止

长期运行的守护进程应按自己的集成契约接受并服务继承套接字,由正常关闭流程决定什么时候关闭描述符。如果希望应用空闲后退出,就要验证空闲判定和进行中请求的处理,管理器无法推断业务工作何时安全完成。节省常驻进程资源,是匹配了应用生命周期之后可能得到的结果,不是只启用 socket 单元便自动获得的保证。

维护时,停止后续激活与终止当前工作是不同决定,应同时检查 socket 和 service。服务也可能被其他依赖或操作者显式启动,而仍然活动的监听器可能再次触发它。应测试真实部署的单元关系,不能以为停止一个眼前的进程就永久禁用了端点,否则维护期间仍可能出现新的业务执行。

设置合适的端点权限与服务身份。传递已经打开的描述符,可以让监听建立权限与应用执行权限不同,但不会自动把服务变成沙箱。应验证真实请求处理、重新启动、排队客户端的结果和失败诊断。实际优势在于监听器与进程之间存在明确、可测试的交接,而不是自动就绪或完全没有资源成本。

延伸阅读

来源