跳转到内容

部署与运维

生产形态是一个二进制 + 一个配置目录 + 一个 systemd unit。没有数据库服务、没有消息 队列、没有运行时依赖——除非你要 BLE,那多一个 libdbus-1-3。

文中所有实测数字都出自同一台机器:N150 小主机,Proxmox VE 9.2.3,容器是 Debian 13 的 非特权 LXC(256M / 2 core / 4G)。

CI 每次构建都产出两个 artifact,直接下载就行,不必自己 cargo build:

artifact 含 BLE 链接方式
rha-x86_64-gnu-full 是 动态,目标机要有 libdbus-1-3
rha-x86_64-musl-nobluetooth 否 完全静态,扔进空目录就能跑

判据只有一条:要不要接小米的 BLE 设备——温湿度计、体重秤这类走 mibeacon 的东西。要就拿 gnu 变体,不要就拿 musl。

原因是 BLE 那条路依赖 bluer → libdbus-1,那是个 C 库,进不了完全静态的 musl 产物。 所以「单文件零依赖」和「能收 BLE 广播」在这里是二选一,不是配置开关。

其余功能两个变体完全一样。HomeKit 桥走的是 IP,依赖链 (mDNS 那一串)全是纯 Rust,静态链接没有障碍,musl 变体里也有。

三个约定位置:

/usr/local/bin/rha 二进制
/etc/rha/ 配置目录(rha.toml、devices.toml、automations/、bridges.toml、spec.d/)
/var/lib/rha/history.db 历史库

--config /etc/rha 指的就是那个配置目录,形状和快速开始里的 examples/quickstart 完全一样。

历史库要显式挪出配置目录。 [storage].path 缺省是 <配置目录>/history.db,也就是 /etc/rha/history.db——能写,但一个每分钟都在追加写的 SQLite 文件不该躺在配置目录里(备份配置时会顺手带走几百兆,回滚配置时又容易误伤):

[storage]
path = "/var/lib/rha/history.db"

packaging/rha.service 可以直接用。几个不明显的地方:

Type=notify + WatchdogSec=30s。 daemon 就绪后给 systemd 发 READY=1,之后每 15 秒(半周期)喂一次狗。主循环卡死时喂狗停止,systemd 把它重启掉——这是「进程还在、 但已经不干活了」的唯一兜底。真机上用 SIGSTOP 模拟过:Watchdog timeout (limit 30s)! → SIGABRT → 重启。

/etc/rha 必须可写。 unit 开了 ProtectSystem=strict(整个 /etc 变只读),然后 又用 ReadWritePaths 把 /etc/rha 单独放回来。三个理由,只要占一条就绕不开:

  1. MCP 的 upsert_rule 要往 <配置目录>/automations/ 落规则文件(先写 .toml.tmp 再 rename);
  2. storage 的缺省路径就在配置目录下(按上面挪走以后这条不成立);
  3. 面板文件存在 <配置目录>/dashboards/。

只有第 1 条是真正无法回避的。完全不需要让 agent 写规则的话可以去掉这一项,upsert_rule 会因为写盘失败返回错误,不会静默丢弃。

BLE 部署下不要再往严里调。 unit 里刻意没有 PrivateUsers / RestrictAddressFamilies 这类选项——它们会切断和 bluetoothd 的 D-Bus 通信(D-Bus 是 Linux 上进程之间互相喊话的 总线,蓝牙栈就挂在上面)。纯 musl 无 BLE 的部署可以自行加严。

主力形态是 Proxmox VE 上的一个非特权 Debian LXC。packaging/lxc-setup.sh 在 PVE 宿主上以 root 跑:

Terminal window
./lxc-setup.sh --ctid 140 --ip 192.168.52.40/21 --gw 192.168.50.1 --dry-run # 先看一遍
./lxc-setup.sh --ctid 140 --ip 192.168.52.40/21 --gw 192.168.50.1

它替你做的:建容器(unprivileged + onboot)→ 对齐时区 → 探 DNS → 装 ca-certificates 和 libdbus-1-3 → 建 rha 服务账号 → 建 /var/lib/rha、/etc/rha、/etc/rha/{automations,bridges,dashboards},以及一个空的 /etc/rha/rha.env 占位文件。

二进制、unit 和配置要你自己传,脚本尾部把命令逐条列出来了(pct push 那几条)。

脚本不幂等:容器已经存在时会在 pct create 那一步直接中断。重跑前先 pct destroy <ctid>,或者跳到脚本末尾的手动步骤。容器内部的步骤本身都是幂等的。

两个坑,脚本已经处理,换环境时仍要留意

Section titled “两个坑,脚本已经处理,换环境时仍要留意”

DNS 会黑洞。 不传 --nameserver 时 PVE 把宿主的 /etc/resolv.conf 抄进容器。 宿主要是装了 Tailscale,那份文件指向 MagicDNS 的 100.100.100.100——容器不在 tailnet 里,这个地址不可达,于是 apt-get update 无限挂起且一个字都不报。脚本缺省把 nameserver 退回网关(家用 LAN 上通常就是 DNS),并在装包前探测一次;探测外面套了 timeout,因为 getent 自己不超时,不套的话这个「检查」本身就会卡死。

时区默认是 UTC。 规则里的 time = "HH:MM..HH:MM" 条件和 cron 触发都按容器本地 时间判定,不对齐宿主会整体偏几个小时(实测偏 8 小时,所有定时规则在错误的时刻触发, 而日志看起来一切正常)。脚本会同步宿主时区。

内存给 256M 是留了余量的起点,目标形态是 128M:首次部署时 apt 的峰值容易顶到 128M,跑稳之后 pct set 140 --memory 128 收下来。

只有用 mibeacon 才需要这一节,也是整套部署里最容易 卡住的一步。不用 BLE 就部署 musl 变体,下面全都不需要。

思路是:bluetoothd 跑在 PVE 宿主上独占射频,容器只当 D-Bus 客户端。这样容器不需要 任何 /dev 设备权限,保持 unprivileged,只需要能连上宿主的系统总线。下面这套在 N150 上实测跑通,五步缺一不可。

  1. 宿主装并启用 BlueZ(PVE 默认只有内核驱动,不装 BlueZ)

    Terminal window
    apt-get install -y bluez && systemctl enable --now bluetooth
    bluetoothctl show # 确认 Powered: yes
  2. 把宿主的 D-Bus socket 挂进容器,挂到独立路径——不要覆盖容器自己的 /var/run/dbus/system_bus_socket,那会和容器内的 dbus-daemon 打架。先在容器里 install -d /var/lib/rha-dbus,然后 /etc/pve/lxc/<CTID>.conf 追加:

    lxc.mount.entry: /var/run/dbus/system_bus_socket var/lib/rha-dbus/system_bus_socket none bind,create=file 0 0
  3. 让 rha 的 uid 在容器内外一致。 宿主的 dbus 只接受 EXTERNAL 认证(靠 SO_PEERCRED 读对端 uid),而 libdbus 总是显式断言自己的 uid。非特权容器里的 uid 999 在宿主看来是 100999,对不上就直接 REJECTED EXTERNAL。往 /etc/subuid 加 root:999:1、/etc/subgid 加 root:991:1,然后配置里追加:

    lxc.idmap: u 0 100000 999
    lxc.idmap: u 999 999 1
    lxc.idmap: u 1000 101000 64536
    lxc.idmap: g 0 100000 991
    lxc.idmap: g 991 991 1
    lxc.idmap: g 992 100992 64544

    改 idmap 之后磁盘上原有文件的属主要一起改,否则 rha 连自己的配置都读不到:

    Terminal window
    pct stop <CTID> && pct mount <CTID>
    find /var/lib/lxc/<CTID>/rootfs -uid 100999 -exec chown 999 {} +
    find /var/lib/lxc/<CTID>/rootfs -gid 100991 -exec chgrp 991 {} +
    pct unmount <CTID> && pct start <CTID>
  4. 宿主上也得存在 uid 999 这个账号。

    Terminal window
    groupadd --system --gid 991 rha
    useradd --system --uid 999 --gid 991 --no-create-home --shell /usr/sbin/nologin rha
  5. 让 rha 去连宿主的总线(容器内 /etc/systemd/system/rha.service.d/dbus.conf):

    [Service]
    Environment=DBUS_SYSTEM_BUS_ADDRESS=unix:path=/var/lib/rha-dbus/system_bus_socket
    # ProtectSystem=strict 会让该路径只读,而 connect() 一个 unix socket 需要写权限
    ReadWritePaths=/var/lib/rha-dbus

验证:rha doctor 的蓝牙项应该从 [WARN] bluetooth: bluetooth check unavailable 变成 [OK] bluetooth: default adapter present。别忘了容器里还要 apt install libdbus-1-3,且二进制必须是 gnu 变体。

配置里任何字符串都可以写 ${VAR},加载时从环境变量取值:

[api]
token = "${RHA_TOKEN}"
[[device]]
name = "plug_console"
adapter = "miot"
token = "${MIOT_TOKEN_PLUG}"

变量没定义就是加载失败(undefined environment variables: RHA_TOKEN, ...),不会 静默变成空串。

配套的是 systemd 的 EnvironmentFile,unit 里已经写好了:

EnvironmentFile=-/etc/rha/rha.env

文件建成 0640 root:rha——root 写,服务用户读,别人看不见:

RHA_TOKEN=...
MIOT_TOKEN_PLUG=...
THERMO_BINDKEY=...

前缀那个 - 不能删。没有它,文件不存在时 systemd 视为致命错误,unit 直接起不来 (lxc-setup.sh 只创建空占位文件,内容由你填)。

[api].token 有非空 + 最短 16 字符的校验,太短或为空的配置起不来:

rha.toml: [api].token 太短或为空(至少需要 16 个字符) —— 弱/空 token 等于不设防,
局域网里任何人都能读写全屋状态; 用 `openssl rand -hex 16` 生成一个

这条是 0.11.0 加的(面板的会话 cookie 用它做 HMAC 签名),从旧版本升上来的部署要先跑 一遍 rha check 再重启。

五、改了配置:热加载还是重启

Section titled “五、改了配置:热加载还是重启”

热加载现在覆盖设备的增、删、改,不只是规则。 加一台温湿度计不需要重启整个 daemon ——既有设备的连接、会话、轮询节奏都不受影响,只有新那台会起来。

Terminal window
rha reload # 走 API
systemctl reload rha # 等价,unit 的 ExecReload 是 kill -HUP
kill -HUP <pid> # 同上
改了什么 怎么生效
automations/*.toml 规则与命名动作 reload
devices.toml 增设备 / 删设备 / 改参数 reload
rha.toml 的 [api] / [storage] systemctl restart
rha.toml 的 [llm] / [notify] systemctl restart
bridges.toml systemctl restart
spec.d/*.toml 型号表 systemctl restart

校验全过才切换,失败时旧配置继续跑,不存在一次 reload 把系统搞挂的路径——和 nginx 拒绝加载坏配置是同一个语义。删设备时如果还有规则引用它的实体,整份配置会被拒绝,错误里 点名是哪条规则挡着。

改参数(比如设备 IP 变了)等价于只重启那一台。判据取自 devices.toml 里这一条的 原文,所以改 label 也算改——代价是显示名一变会让这台设备重连一次,换来的是判据 只有一条、不可能漏掉某种改动而静默失效。

spec.d/ 要重启是刻意的:reload 用的是启动时读进内存的那份型号表,跑着的 miot adapter 手里就是它。拿新读的表去校验会放行一台运行期根本认不出型号的设备——它会一遍遍 启动失败,而 reload 报的是成功。

Terminal window
# 1. 取 CI artifact,推到容器(先推成另一个名字,服务不受影响)
pct push 140 rha /usr/local/bin/rha.new --perms 0755
# 2. 新二进制 + 现有配置先验一遍
pct exec 140 -- bash -lc 'set -a; . /etc/rha/rha.env; set +a
/usr/local/bin/rha.new --config /etc/rha check'
# 3. 留一份回滚用的,再换上去
pct exec 140 -- bash -lc 'cp /usr/local/bin/rha /usr/local/bin/rha.prev
mv /usr/local/bin/rha.new /usr/local/bin/rha
systemctl restart rha'

先 check 再替换:配置和新二进制不兼容时不至于把服务弄挂。第 2 步里服务仍然跑着内存 里的旧代码,换文件也不影响它。

回滚就是 mv /usr/local/bin/rha.prev /usr/local/bin/rha && systemctl restart rha。 storage 是 SQLite 单文件,跨大版本回滚前先 cp 一份 history.db。

发的是 SIGTERM,rha 走的是优雅关停:先给 systemd 发 STOPPING=1,再取消所有任务、 让 adapter 收尾。实测 702ms 干净退出,全程没有 SIGKILL。所以升级期间正在写的历史点不会 被截断,设备连接也是正常断开而不是被拔线。

(kill -9 当然还是硬杀,那条路径靠 Restart=always 兜底,实测能自动拉起。)

涉及新配置字段时,先换二进制再改配置

Section titled “涉及新配置字段时,先换二进制再改配置”

[api] 那段是 deny_unknown_fields——不认识的字段直接报错。反过来做的话,从「改完配置」 到「换完二进制」这段窗口里任何一次重启,旧二进制都会因为不认识新字段而起不来。

唯一的例外是 spec.d/ 型号表,顺序正好相反:新二进制需要 spec.d/ 才能建出 miot 实体,而旧二进制完全无视这个目录。所以先 rha spec-gen 生成 表、再换二进制。

用了 HomeKit 桥的话,升级后必须确认 c# 递增了

Section titled “用了 HomeKit 桥的话,升级后必须确认 c# 递增了”

c# 是 HAP 的配置版本号,iOS 只在它变化时才重新拉取配件表。配件表指纹里含 FirmwareRevision,而那个值就是 rha 的版本号——所以每次升级都必然让 c# 加一。

升级后日志里应该有这一行:

配件表有变化, c# 已递增, iid 换用新号段

没有这行就是出问题了:iOS 手里还是旧配件表,而配件的编号(iid)已经换了含义,现象 是「家庭」里全部 No Response。这条不是理论——0.7.0 就因为这个全灭过一次,修法是让 iid 号段随 c# 整体前进,保证一个 iid 永远不改变含义。

顺带:升级后 iOS 会多拉一次配件表,这是 c# 递增的正常代价,不用管。

三层,从不碰网络到碰网络:

  1. rha check——完全离线。 校验整份配置,包括「规则引用的实体存不存在」。改完配置 都先跑这条,全过才谈别的。

    Terminal window
    rha --config /etc/rha check
    # config ok: 39 entities, 7 rules
  2. rha doctor——会探设备可达性。 六类检查:配置能不能加载、spec.d/ 有没有型号、 adapter 认不认得、storage 路径写不写得了、蓝牙适配器在不在、每台设备通不通,以及 token 格式。

    Terminal window
    rha --config /etc/rha doctor
    # [OK] config: 39 entities, 7 rules
    # [OK] miot-spec: 12 个型号
    # [OK] adapter-registration: 11 device(s), all adapters registered
    # [WARN] bluetooth: bluetooth check unavailable (needs a linux build with the mibeacon feature)
    # [OK] device-reachability: plug_console (192.168.52.124:54321) responded

    任一项 FAIL 时退出码是 1(WARN 不算),可以直接串进部署脚本。

  3. 看日志。 journalctl -u rha -f。启动正常的标志是 rha listening on ... 加 notified systemd READY=1;systemctl status rha 显示 Started 而不是 activating 就说明 Type=notify 那条链路是通的。

这几个数字出自 2026-07-26 的首次部署实测:N150 小主机 / PVE 9.2.3 / CT 140,Debian 13 非特权 LXC,256M / 2 core / 4G,跑的是 musl 静态无 BLE 变体。

项 实测
稳态 RSS 11.8M
峰值 RSS 12.2M
整个容器(含 systemd、journald) 17M(配额 256M)
容器重启后拉起 1 秒内(onboot + systemctl enable)
SIGTERM 到进程退出 702ms,无 SIGKILL

所以容器配额收到 128M 是够的,256M 只是首次部署时给 apt 留的余量。接 BLE 之后换成了 glibc 变体,那一版没有重新量过——多出来的是 D-Bus 客户端和 BLE 中心 Hub,量级上不改变 结论,但要精确数字的话得自己在目标机上再量一次。