systemd 用户管理器代表一个账号运行服务。同一账号的多个登录会话通常共享这个管理器,而不是每个 shell 都拥有一套私有服务世界。用户单元可以放在 ~/.config/systemd/user,通过 systemctl --user 操作对应管理器。这提供的是以账号权限执行的生命周期管理,并不自动隔离所有共享系统资源。使用它之前,需要同时理解服务属于谁,以及管理器在什么条件下继续存在。

坏掉的样子

关闭一个终端,不一定停止用户服务,因为另一个登录会话可能仍然存在,管理器寿命也受到 linger 和停止延迟配置影响。即使最后一个会话结束,立即终止也不是普遍规则。必须跟随某一次图形会话结束的应用,需要明确的生命周期关系,不能假定任何用户单元天然具有这个范围。账号级后台任务与某个窗口的寿命,是不同需求。

选错管理器也是常见故障。普通 systemctl 操作系统管理器,systemctl --user 则需要连接正确用户的管理器及其运行时通信端点。通过另一身份执行命令,或者所在环境没有完成会话准备,都可能导致连接失败。仅仅设置一个环境变量,不会创建管理器,也不会授予控制其他账号的权限;诊断时应先确认执行身份与目标总线是否一致。

用户服务也不会自动继承发出控制命令的那个终端环境。所需路径、配置和图形会话变量,应通过有意选择的机制提供。使用普通账号会减少权限,但该账号仍可以按既有权限访问共享可写文件、使用网络套接字并消耗资源。因此“没有 root”并不等于“所有影响都绝对局限于自己的文件”,还要检查真实权限和资源限制。

快速验证

在具备 systemd、logind、PAM 集成、用户 D-Bus 会话与账号管理工具的一次性 Linux 系统中,以管理员身份运行下面的 Python 脚本。记录环境为 Debian 12、systemd 252.39。脚本创建临时账号、两个真实 PAM 会话和一个用户服务,不修改任何已有人员的单元或登录状态:

from pathlib import Path
from tempfile import TemporaryDirectory
import os,pwd,subprocess,time,uuid

name='xbcatm-user-'+uuid.uuid4().hex[:8];sessions=[name+'-a.service',name+'-b.service'];unit='field-worker.service'
def run(*args):
    result=subprocess.run(args,capture_output=True,text=True,timeout=20)
    assert result.returncode==0,(args,result.stderr)
    return result.stdout.strip()
def wait_for(check,seconds=5):
    deadline=time.monotonic()+seconds
    while not check():
        assert time.monotonic()<deadline,'state transition timed out';time.sleep(0.05)
with TemporaryDirectory(prefix=name+'-',dir='/run') as directory:
    root=Path(directory);root.chmod(0o755);home=root/'home';home.mkdir()
    run('useradd','--no-create-home','--user-group','--home-dir',str(home),'--shell','/bin/sh',name)
    account=pwd.getpwnam(name);uid=account.pw_uid;gid=account.pw_gid;manager=f'user@{uid}.service'
    def userctl(*args):
        return run('setpriv','--reuid='+str(uid),'--regid='+str(gid),'--init-groups',
                   'env',f'XDG_RUNTIME_DIR=/run/user/{uid}',f'DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{uid}/bus',
                   'systemctl','--user',*args)
    def manager_value(key):return run('systemctl','show',manager,'-p',key,'--value')
    try:
        units=home/'.config/systemd/user';units.mkdir(parents=True)
        (home/'worker.py').write_text("from pathlib import Path\nimport os,time\np=Path(__file__).with_name('worker-pid');t=p.with_suffix('.tmp');t.write_text(str(os.getpid()));t.replace(p)\ntime.sleep(60)\n")
        (units/unit).write_text('[Service]\nType=exec\nExecStart=/usr/bin/python3 %h/worker.py\n[Install]\nWantedBy=default.target\n')
        for path in [home,*home.rglob('*')]:os.chown(path,uid,gid)
        for session in sessions:
            run('systemd-run','--quiet','--unit='+session,'--property=Type=simple',
                '--property=User='+name,'--property=PAMName=login','/usr/bin/sleep','60')
        wait_for(lambda:Path(f'/run/user/{uid}/bus').exists())
        wait_for(lambda:len(run('loginctl','show-user',name,'-p','Sessions','--value').split())==2)
        assert run('loginctl','show-user',name,'-p','Linger','--value')=='no'
        initial=manager_value('MainPID');assert initial!='0'
        userctl('enable','--now',unit);wait_for(lambda:(home/'worker-pid').exists())
        worker_pid=(home/'worker-pid').read_text()
        assert userctl('show',unit,'-p','MainPID','--value')==worker_pid
        run('systemctl','stop',sessions[0])
        wait_for(lambda:len(run('loginctl','show-user',name,'-p','Sessions','--value').split())==1)
        assert manager_value('MainPID')==initial and userctl('show',unit,'-p','MainPID','--value')==worker_pid
        print('two PAM sessions share one user manager; closing one leaves its service unchanged')
        run('systemctl','stop',sessions[1])
        wait_for(lambda:manager_value('ActiveState')=='inactive',seconds=20)
        assert not Path('/proc/'+worker_pid).exists()
        print('after the final session and configured stop delay, non-lingering manager and worker stop')
        print('controllers use setpriv and the actual user bus; they do not create extra PAM sessions')
    finally:
        for session in sessions:
            if run('systemctl','show',session,'-p','LoadState','--value')!='not-found':run('systemctl','stop',session)
        run('systemctl','stop',manager,f'user-runtime-dir@{uid}.service')
        run('userdel',name)

两个临时会话启动器使用相同账号,并设置 PAMName=login。脚本等待 logind 报告两个会话、且用户总线可用后,才继续控制服务。启动器使用 simple 类型,因此命令返回不会被当成 PAM 已完成的证据;会话数量和总线状态提供了明确的观察边界,避免把尚在建立的登录环境当成已经可用。

工作进程通过用户管理器启动,并被启用到用户默认 target。它以原子替换方式把自身编号写入无害文件,脚本再与用户单元报告的主进程编号比较。系统管理器中的 user@UID.service 则提供账号管理器自身的另一编号。这样可以分别追踪管理器与它运行的服务,避免把两层进程身份混在一起。

停止第一个 PAM 会话后,会话数量降到一,但管理器和工作进程编号都保持不变。这直接反驳了“每个登录 shell 拥有独立管理器,shell 结束服务就必须消失”的理解。仍然存在的另一个会话继续使用同一份账号级服务上下文,没有因为一次会话结束就重新启动所有后台工作。

第二个会话停止后,脚本等待未启用 linger 的用户管理器按当前策略变为非活动,工作进程也随之消失。有限等待容纳了文档中的停止延迟,并不意味着每台机器都使用相同延迟,也不意味着无关 shell 进程一定适用相同终止规则。观察对象是用户管理器及其监管的工作单元,而不是把所有同 UID 进程都混为一类。

控制命令通过 setpriv 切换身份并连接真实用户总线,不会额外建立 PAM 会话来意外延长管理器寿命。如果诊断命令自身不断创建新会话,就可能破坏“最后会话已经结束”的测试前提。清理时停止实验启动器和运行时管理器,删除临时账号及其家目录,让验证也覆盖本次资源的回收。

为服务选择合适寿命

应先决定工作属于一次图形会话、账号的全部会话,还是无人登录时也必须运行的机器角色。用户管理器适合账号拥有的后台任务,linger 则可以有意把管理器寿命延长到登录之外。这项选择需要明确,因为它会改变资源和网络监听器什么时候继续活动,不能在部署后仅靠“用户服务”这个名字推定其运行窗口。

以账号权限作为起点,再为真实工作负载增加受支持的资源限制与沙箱。一些执行约束需要普通用户管理器不具备的权限或内核支持,不能假定系统单元的安全设置原样复制就一定生效。应同时验证限制实际执行,以及应用仍能完成必要工作,避免只有配置看起来严格,却没有对应运行时证据。

保持单元启用、应用环境与可观察结果一致,排障时查看目标用户的单元与日志。尤其当多个会话或部署工具共同控制一个账号时,共享状态本身就是需要理解的契约。只有明确了共享寿命与执行权限,用户管理器才能简化监管,而不是把某个终端的临时状态错误传播成长期服务行为。

延伸阅读

来源