一个永远只指向可达实例的分布式注册表

Consul 维护一份存活的健康服务清单

服务注册表的价值完全取决于它有多诚实。Consul 把注册的实例收进 catalog,并给它们挂上健康检查,于是「某个服务的健康实例在哪」这个问题的答案会随现实变化。注册本身很简单;检查才是全部的产品价值,因为一份列出死实例的注册表比静态清单更糟——它会主动把流量送进坑里。 这个答案的新鲜程度,取决于两个第一周之后没人再看的选择:检查多久跑一次,以及它到底测的是什么。每 30 秒跑一次的检查会瞎 30 秒;只证明进程存在的检查则永远瞎着。 注册,以及两个层级的检查 实例通过配置文件或注册 API 登记名字、地址、端口以及一条或多条检查。检查默认以 critical 状态起步,这是刻意的:在健康至少被验证过一次之前,服务无法进入可用池——想要初始就是 passing,必须显式写出来。检查的状态由它自己判定:脚本检查按退出码决定(0 为通过、非 0 为 critical),HTTP 检查按响应码决定,输出超过 4KB 会被截断,脚本检查默认 30 秒超时。也就是说,「检查通过」这几个字的含义完全取决于你写的那条命令。 同一台节点上有两个层级的检查。serfHealth 这样的节点级检查服务名为空,描述的是这台机器;service:web-1 这样的服务级检查绑定在某一条注册上。它们各自独立失败,但只取健康的过滤器要求节点上所有检查都通过,所以节点级检查变 critical 会一次带走这台机器上的所有服务,而服务级检查变 critical 只移除一个实例。 调用方实际拿到什么 catalog 会回答两个不同的问题。不加过滤时,/v1/health/service/web 返回全部注册以及每条检查的状态;加上 passing=true 时,只返回所有检查都通过的实例。DNS 与 SRV 永远按第二种方式回答,所以解析器不会拿到一个不能用的名字,而这个通过集合就是负载均衡池。 catalog 同时也是最终一致的,机制叫反熵。注册是即时的——agent 会通知 leader——但修复是周期性的:每个 agent 按一个间隔把自己的本地状态与 catalog 对齐,128 个节点以内的集群是 1 分钟,随集群规模增长而变长,并且每个 agent 的起始时间都会错开,以免所有人同时同步。同步失败只会被记进日志、等下一个周期重试,所以一条错误的 catalog 记录可以安静地存活长达一个间隔。读路径也有自己的取舍:默认情况下 agent 会把 catalog 读请求转发给 leader,并用一个假定领导地位短期稳定的 leader lease,因此很快但偶尔可能返回陈旧结果;consistent 模式要求 leader 先向多数派确认自己仍是 leader,stale 模式则允许任何 server 不做检查就回答。这三档没有对错,只有你愿意为一个多新的答案付出多少延迟。 ...

四月 6, 2028 · 1 分钟 · XB Field Notes
按首个连接才拉起服务的按需监听钩子。

socket 激活让服务按需启动

端口永远就绪,而进程只在有人敲门时才起来,这正是少用却沉重的服务该有的妙招。每个闲置守护进程都在花钱:常驻内存、周期性唤醒、补丁面、日志噪音。一台机器上几十个不常用的服务加起来,税交了不少,事一件没干。套接字激活把交易反过来——内核拿着监听套接字,守护进程出生之前它就归系统服务管理器所有,第一个带着真活的连接到了,服务进程才出生。 交接的顺滑让一切透明。客户端照常连端口,不知道家里没人;管理器拉起服务,把已经接受的连接递进去,第一个客户端都不必等两遍。做对了,按需启动和常开除了账单看不出区别——除了首击延迟,这是唯一要诚实预算的成本。 让监听器先等着 systemd 在服务之前先把 socket 打开,第一个连接到来时才启动单元,并把收到的文件描述符交过去。把 ListenStream 配好一次,让 socket 在任何守护进程起来之前就先诞生并绑定好。套接字单元和服务单元按名配对,一个管监听、一个管干活;默认模式把整个监听套接字交给单个服务实例,按连接派生的模式则给简单请求处理配每连接一个实例。 分离还顺手解决了开机排序。不用写脆弱的先后依赖:套接字开机早期就在,客户端随便什么顺序起,连不上就等一等、重试一次,而不是直接报错。排队的连接在内核积压里等着,开机竞态变成了队列深度而不是错误。当然积压长度也要调:启动慢的服务遇上开机早期的连接洪峰,溢出起来和普通监听队列一样。 服务按需唤醒 空闲的守护进程可以一直停着,用每实例的内存与启动周期,换一次交接的开销。所以套接字激活的服务可以一直睡,直到第一个请求值得它跑起来。这套最适合管理工具、老协议网关、定期但罕见的任务——闲忙比按天算、不按分钟算的服务。 闲时退出补上闭环:服务空闲若干秒自己停,系统在零成本和满能力之间振荡,不用人管。套接字从不关闭,下一个客户端透明触发下一次启动。没有闲时退出,激活只省了开机内存,进程还是越积越多——按需启动加闲时停止成对出现,妙招才变成稳态节省。 用速度换内存 代价是第一次访问有一段短暂热身,这适合很少跑的任务,而对始终热呼的路径很浪费。在首次调用的延迟可接受、而内存节省真的有意义时才开启它。动手前两边都量化:从连接到首个响应的冷启动耗时,常开会永远占住的常驻内存。启动三百毫秒换八百内存,大胆开;预热二十秒换五十内存,算了。 有些负载无论如何都受不了这个间隙。靠健康检查编排的系统会在启动期判端点死亡,负载均衡会摘掉首个慢后端,超时卡得紧的客户端没等服务热完就失败了。这些就让守护进程常开,内存该花就花——或者开机时预热一次,激活只覆盖之后漫长的闲置。 坏掉的样子 标志性故障是惊群式启动:监控扫一轮,五十个睡着的服务同时被连醒,同时启动,主机内存告急、负载冲顶,服务大面积变慢。每个激活单独看都合理,合在一起就是踩踏。用错峰检查间隔、按服务的启动限制、足够短的闲时退出,把稳态重叠压小。测试激活要在监控负载下测,不只拿一条手工请求测。 第二种故障是半吊子适配:服务认定套接字归自己,启动时自己绑端口,和管理器已绑的套接字撞车。被激活的那一刻恰恰启动失败,连接挂住,日志怪地址已被占用。服务必须接受递进来的文件描述符而不是自己绑;上游代码改不动,就写个小垫片继承套接字再拉起守护进程过渡。 快速验证 端到端验证按需行为: systemctl status foo.socket foo.service ss -ltnp | grep ':8080' curl -m 10 localhost:8080/health; systemctl show foo.service -p ActiveState,ExecMainStartTimestamp 请求之前服务未激活、套接字在监听;请求之后时间戳显示一次由连接触发的新启动。 延伸阅读 重启策略需要一个速率限制 维护定时器应让其最近结果可见 来源 systemd.socket 手册,套接字单元官方参考,已核验 2026-06-01。 systemd.service 手册,服务单元行为与闲时退出,已核验 2026-09-05。

二月 1, 2028 · 1 分钟 · XB Field Notes
账号管理器可以在没有登录时继续存在,已启用服务仍有各自生命周期。

Linger 延长 systemd 用户管理器在登录之外的寿命

Linger 改变的是一个账号的 systemd 用户管理器在什么时候保持可用。启用后,logind 可以在开机时启动该管理器,并在该账号登录会话结束后继续保留它,从而支持普通账号拥有的无人值守工作。这个标志控制管理器寿命,各个服务仍然遵守自己的激活、失败、停止和安装规则。账号可以没有登录会话,同时有管理器运行,但这不等于所有应用都已经启动。 坏掉的样子 开启 linger 不会自动启用账号配置目录里的每一个单元文件。服务仍需要通过启用关系、依赖、定时器、套接字或其他明确路径受到激活。因此管理器可能完全正常,目标应用却保持非活动。应同时检查两者,而不能把一个 linger 标志理解成当前所有服务的运行清单,更不能仅凭它存在就认定某个后台端口已经提供业务响应。 进程不会跨过整台机器重启继续存活。Linger 可以让新的用户管理器在开机时启动,已启用单元再创建新的进程,原来的内存状态已经消失。加密家目录或较晚挂载的路径,也可能让配置和应用数据在管理器启动时不可用。需要保留的状态应持久化,开机依赖应明确安排,不能靠“会跨重启”这种模糊说法跳过实际恢复过程。 Linger 也不是让崩溃应用无限重启的指令,不会自动把任意 shell 后台任务纳入服务管理。重启策略、资源限制和 logind 对会话进程的处理,分别属于不同控制。稳定的后台角色需要真实用户单元和可观察结果,不能只依赖登出前启动过一个命令,再把账号管理器继续存在当成那个命令必然受控运行。 快速验证 在具备 systemd、logind、用户 D-Bus 支持与账号管理工具的一次性 Linux 系统中,以管理员身份运行下面的 Python 脚本。实测环境为 Debian 12、systemd 252.39。它创建临时账号和家目录,只为该账号开启 linger,最后回收本次改动,全程不为这个账号建立登录会话: from pathlib import Path from tempfile import TemporaryDirectory import os,pwd,subprocess,time,uuid name='xbcatm-linger-'+uuid.uuid4().hex[:8];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) run('loginctl','enable-linger',name) wait_for(lambda:Path(f'/run/user/{uid}/bus').exists()) assert run('loginctl','show-user',name,'-p','Linger','--value')=='yes' assert run('loginctl','show-user',name,'-p','Sessions','--value')=='' assert manager_value('ActiveState')=='active' assert userctl('show',unit,'-p','ActiveState','--value')=='inactive' print('linger starts a manager with zero login sessions; merely installing a user unit does not start it') userctl('enable','--now',unit);wait_for(lambda:(home/'worker-pid').exists()) first=(home/'worker-pid').read_text();first_manager=manager_value('MainPID') run('systemctl','restart',manager) wait_for(lambda:Path(f'/run/user/{uid}/bus').exists() and (home/'worker-pid').read_text()!=first) assert manager_value('MainPID')!=first_manager assert userctl('show',unit,'-p','MainPID','--value')==(home/'worker-pid').read_text() assert run('loginctl','show-user',name,'-p','Sessions','--value')=='' print('enabled user service starts as a new process after a real manager restart') userctl('disable',unit) assert userctl('show',unit,'-p','ActiveState','--value')=='active' print('disable removes future enablement but does not stop the current service') userctl('stop',unit);run('loginctl','disable-linger',name) wait_for(lambda:manager_value('ActiveState')=='inactive',seconds=20) print('with no sessions and linger disabled, user manager stops after policy delay') finally: run('loginctl','disable-linger',name) run('systemctl','stop',manager,f'user-runtime-dir@{uid}.service') run('userdel',name) 执行启用后,用户管理器变为活动,其总线端点出现,logind 报告 linger 已开启、会话列表为空。但已经安装的工作单元仍然非活动。这证明让管理器保持可用,与请求某个应用运行,是不同操作;即使文件已经出现在正确配置目录中,也还需要符合实际契约的激活入口。 ...

四月 20, 2027 · 2 分钟 · XB Field Notes