build+docs: 用 HostStack agent 源码实证校正部署事实,砍掉 dig 的 42.7MB 膨胀

从本机缓存的 registry.hoststack.dev/hoststack/agent 镜像里直接读出运行时
配置,纠正了此前靠猜的四条部署事实:

hoststack.yaml
- 撤回上一版把 install: 合并进 build.command 的"修正"——schema 里
  install.command 本来就是合法键(agent 的 schema 与 yaml-parser.ts 均定义),
  install/build/start 是三条独立命令
- 补上为什么这个文件不能删:runtime: rust 的框架默认 start 命令是
  ./target/release/app,与 Cargo 包名无关。删掉 YAML 后 Start Command
  回落到 app,进程在绑定端口前就以 not found 退出
- 记录实测优先级:YAML 值生效,省略才回落框架默认值(Cargo.toml 只声明
  [[bin]] message-weave,仓库里既无 app 目标也无 app.rs)

Dockerfile
- 移除 bind9-dnsutils:连带 libicudata.so.72(29.8MB)等多级依赖,实测
  边际成本 42.7MB,是其余 7 个工具总和(19.7MB)的两倍多
- DNS 解析用基础镜像自带的 getent 已覆盖(getent hosts / ahostsv4 / ahostsv6)
- 注释写明 19.7MB 的实测口径

docs/deployment.md
- §5.1 重写:build 在 rust:slim-trixie、runner 是 debian:trixie-slim,
  本仓库 Dockerfile 完全不被执行
- 修正"没有 install: 键"与"validate 拦未知键"两处错误说法:agent 部署路径
  不应用 zod 校验,未知键被静默丢弃而非报错,validate 才是唯一防线
- §4.1 容器安全档改成源码实测值:readonlyRootfs/dropCapabilities/
  noNewPrivileges 全为 true,pidsLimit 256,tmpfs 选项 rw,nosuid,nodev,
  size=64m——原先写的 noexec 并不存在;并标注 dev 档 rootfs 可写
- §8.1 明确两条部署路径的镜像差异(bookworm vs trixie)
- §6 HEALTHCHECK 与二进制"内置 health 子命令"的过期说法
- 删除文档中指向不存在文件 app.rs 的引用

验证:GATE-DOCS PASS 6/6(PATH AUDIT 369 path-refs / 0);
runtime 阶段实测构建成功且 curl/ps/pgrep/ss/ip/jq/nc/getent 齐备、
dig 按预期缺席、getent hosts 可解析、uid 999;
8 个工具 dpkg Installed-Size 合计 7935kB 而全量 du 差 62.3MB,
剔除 dig 后 19.7MB——这个差额就是本次砍掉的膨胀。
This commit is contained in:
anonsaber 2026-09-29 12:15:52 +08:00
parent 417f378d68
commit 978288f8d7
3 changed files with 85 additions and 20 deletions

View File

@ -11,11 +11,19 @@ FROM debian:bookworm-slim AS runtime
# 诊断工具有意内置:容器 rootfs 只读,运行期无法 apt 安装,只能在构建期装入。
# 全部为只读查询能力(本机探活 / 进程 / socket / DNS / JSON 解析),
# 不引入包管理之外的写能力,也不增加 shell 解释器。
# 合计实测 19.7 MB(用 du -sb 对全量文件系统与 debian:bookworm-slim 对比测得)。
#
# 刻意不装 bind9-dnsutils(dig):它连带 libicudata 等依赖要多花 42.7 MB,
# 而 DNS 解析用基础镜像自带的 getent 就能覆盖:
# getent hosts motofans.club -> A 记录
# getent ahostsv4 <host> -> 仅 IPv4
# getent ahostsv6 <host> -> 仅 IPv6
# 需要 TLS 握手细节时改用 curl -sv,两者都已在上面。
# 注意:不要在此加入 RUN_MODE —— 该标识符已随 Config::from_env() 删除,代码零读取。
RUN apt-get update \
&& apt-get install --no-install-recommends -y \
ca-certificates tini \
curl procps iproute2 jq bind9-dnsutils netcat-openbsd \
curl procps iproute2 jq netcat-openbsd \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --create-home --home-dir /nonroot --shell /usr/sbin/nologin messageweave
COPY --from=builder /build/target/release/message-weave /usr/local/bin/message-weave

View File

@ -124,14 +124,32 @@
### 5.1 HostStack 原生 Rust 部署
HostStack 使用仓库根目录 `hoststack.yaml`:Rust runtime 先执行安装命令
`cargo fetch --locked`,再执行构建命令 `cargo build --release --locked`,服务命令为
`./target/release/message-weave`,监听单个
HTTP 端口并以 `/healthz` 做 interval 30 秒、timeout 5 秒的健康检查。服务命令里**不带**
`--debug`——远程联调开关只应临时加上(改法见 `hoststack.yaml` 的注释,双因子见 §2.1)。
`REDIS_URL` 与
HostStack 使用仓库根目录 `hoststack.yaml`:`runtime: rust` 的 agent 在
`rust:slim-trixie` 里执行 `install.command`(`cargo fetch --locked`)与
`build.command`(`cargo build --release --locked`),把产物拷进
`debian:trixie-slim` 的 runner 容器,以 `start.command`(`./target/release/message-weave`)
启动,监听单个 HTTP 端口,并以 `/healthz` 做 interval 30 秒、timeout 5 秒的健康检查。
`install:` **是**合法的 schema 键——`install` / `build` / `start` 是三条独立命令,
依赖获取不写进 `build.command`。服务命令里**不带** `--debug`——远程联调开关只应临时加上
(改法见 `hoststack.yaml` 的注释,双因子见 §2.1)。`REDIS_URL` 与
`CONFIG_ENCRYPTION_KEY` 必须配置为 HostStack Secret;不得将密钥值写入 YAML、镜像、日志
或代码仓库。现有 Dockerfile 部署方式仍受支持。
或代码仓库。**本仓库的 Dockerfile 不由该路径执行**——runner 镜像是 HostStack 自带的
`debian:trixie-slim`,不是本文件的 `debian:bookworm-slim`,见 §3 与 §8.1 的说明。
**该 YAML 是运行时配置的真源,且不能删除**:YAML 声明的字段**覆盖**控制台已存的同名
配置,YAML 省略的字段**回落到运行时框架默认值**——Rust 的默认值是 `cargo fetch` /
`cargo build --release` / `./target/release/app`。最后那个默认值由 HostStack 自带、
与本仓库包名无关:`Cargo.toml` 只声明一个 `[[bin]]`(名字 `message-weave`,
路径 `src/main.rs`),仓库里既没有 `app` 这个二进制目标也没有对应源文件。删掉本文件后
Start Command 回落为 `./target/release/app`,进程在绑定端口前就以 `not found` 退出。
线上实测印证了此行为:控制台 UI 显示的是默认值 `./target/release/app`,而实际 PID 1 是
`./target/release/message-weave`——说明在生效的是 YAML,不是控制台。
**校验是宽松的,不是拒绝式的**:agent 的部署路径不应用 zod schema 校验,
`install` / `build` / `start` 等都是可选对象,**未知键会被静默丢弃而不是报错**,
拼错一个键名(如 `healthcheck` 全小写)不会让部署失败,只会让对应配置悄悄失效。
因此 `hoststack validate`(本地类型检查,不发起 API 调用)是唯一有效的防线,
应放进 CI;本机无 Node 环境时至少保留一次人工 review。
全局业务开关通过受保护的 `GET|PUT /api/enabled` 管理,持久化 Redis key 为
`config:enabled`,默认关闭且读取失败 fail-closed。关闭时 `/webhook/tg`、`/push/jmap`、
@ -345,7 +363,7 @@ spec:
- `GET /ready` → 端到端就绪探针(`ARCH-READY-BASELINE`):检查配置完整性 + Redis 可达性 + 出站只读探测(`GET {jmap_origin}/.well-known/jmap` 带 Basic 认证、`GET https://api.telegram.org/bot<token>/getMe`,各 3s 超时、**并行**(`tokio::join!`),最坏约 3s);四者全过 `200`(就绪报告 JSON 含真实 `jmap`/`telegram` 字段),任一失败 `503`(标准错误 envelope `{"error":"service_unavailable","request_id":<id>}` + `Retry-After: 30`)。因此**LB / ingress 的探针超时必须 > 3s(建议 ≥10s)**。Uptime Kuma 按状态码(期望 200)监控,不受响应体变化影响。
- 两者均为**公开探针**(`SAF-PROBE-PUBLIC`):无鉴权、仅返回健康状态、**不含敏感信息**。
- `/ready` 已对 JMAP session 与 Telegram getMe 做**真实出站探测**(3s/个),但不覆盖真实消息投递验收;编排与 Uptime Kuma 可用它做端到端就绪探测,注意其最坏约 3s,LB 探测超时需 > 3s(见上)。
- Docker `HEALTHCHECK` 指向同一监听端口(app 内置 `health` 子命令或 wget 同端口),**不依赖独立端口**(`C-NO-TCP-EXPOSE`)
- Docker `HEALTHCHECK` 指向同一监听端口(`wget`/`curl` 探同端口),**不依赖独立端口**(`C-NO-TCP-EXPOSE`)。二进制没有 `health` 子命令可指,见 §8.1 的说明
- 仅保留通用平台映射(compose `HEALTHCHECK`、k8s probe);不写 Cloud Run/Fly 等特指内容(`NG-SERVERLESS-BIND`)
---
@ -365,13 +383,24 @@ spec:
### 8.1 运行方式与就绪探测
> **本节的两条部署路径不要混淆**:上面的构建/运行命令是本仓库 **Dockerfile 路径**(本地 `docker run`
> 或其他容器宿主机);线上 HostStack 部署走 §5.1 的 `hoststack.yaml` + `runtime: rust` agent,
> 在 `rust:slim-trixie` 里构建、拷进 `debian:trixie-slim` 的 runner 容器运行,
> **完全不执行本 Dockerfile**——两者连 Debian 版本都不同(bookworm vs trixie)。
> 服务 argv 是 `./target/release/message-weave` 而不是镜像里的
> `tini -- /usr/local/bin/message-weave`,因此镜像内的诊断工具与 `ENTRYPOINT` 在线上都不生效。
> 判定当前跑的是哪条路径,看容器内 `/proc/1/cmdline` 与 `/etc/os-release`。
> **本仓库没有 `docker-compose.yml`。**此前本节给过一段 compose 片段,其中的 `healthcheck` 写成
> `message-weave health --addr 127.0.0.1:8080` —— 这个子命令不存在(`src/main.rs` 无任何 CLI
> 参数解析,二进制只起 axum 服务)。照抄那段示例必然得到:healthcheck 永久失败,或容器被判定
> healthy 却什么都不做。示例已删除,改为下面的事实约定。
>
> 镜像现已内置 `curl`,因此用 `HEALTHCHECK` 探活在技术上可行;但本节仍建议由平台 ingress/探针
> 探活——`/ready` 是唯一同时校验 redis/jmap/telegram 的信号,`HEALTHCHECK` 只能证明进程活着。
> 镜像(`runtime` 阶段)现已内置 `curl`,因此在 **Dockerfile 部署路径**下用 `HEALTHCHECK` 探活在
> 技术上可行;HostStack 官方文档也把 Dockerfile `HEALTHCHECK` 定为把新副本加入 Traefik 池前的
> readiness 关卡。但 `runtime: rust` 的 HostStack agent 不走本 Dockerfile,该路径的 readiness 由
> `hoststack.yaml` 的 `healthCheck.path` 决定,所以仍以平台探针为准——`/ready` 是唯一同时校验
> redis/jmap/telegram 的信号,`HEALTHCHECK` 只能证明进程活着。
**运行**
@ -385,8 +414,23 @@ docker run --env-file .env -p 8080:8080 messageweave:latest
- runtime 阶段为 `debian:bookworm-slim`,非 root(`useradd --system`,实测 uid 999,非 1000);
无本地 volume、无数据库引擎(`C-NO-LOCAL-WRITE` / `C-NO-DB`)。
- 镜像内置只读诊断工具:`curl`、`procps`(`ps`/`pgrep`/`free`)、`iproute2`(`ss`/`ip`)、`jq`、
`bind9-dnsutils`(`dig`)、`netcat-openbsd`(`nc`)。容器 rootfs 只读,这些工具**只能在构建期装入**,
`netcat-openbsd`(`nc`),合计实测 **19.7 MB**(全量文件系统 `du` 对比
`debian:bookworm-slim`)。容器 rootfs 只读,这些工具**只能在构建期装入**,
运行期无法 `apt` 安装——这也是它们写进 Dockerfile 而不是留给运维现场装的原因。
刻意**不装** `bind9-dnsutils`(`dig`):它连带 `libicudata.so.72` 等多级依赖要多花
**42.7 MB**,是其余 7 个工具总和的两倍多;DNS 解析用基础镜像自带的 `getent` 即可
(`getent hosts <h>` 出 A 记录,`getent ahostsv4` / `getent ahostsv6` 分开取)。
**但这条只对 Dockerfile 部署路径成立**:`runtime: rust` 的 agent 在
`rust:slim-trixie` 里构建、把产物拷进 `debian:trixie-slim` 的 runner 容器运行,
**完全不执行本 Dockerfile**,因此这些工具在 HostStack 服务里并不存在(线上实测
`pgrep: not found`)。该路径下改用内核接口诊断:`/proc/1/cmdline`(实际 argv)、
`/proc/1/environ`(实际 env)、`/proc/net/tcp`、`/proc/<pid>/fd`。
HostStack 生产部署的容器安全档(由 agent 镜像内的部署执行器源码实测确认)为
`readonlyRootfs: true` + `dropCapabilities: true` + `noNewPrivileges: true` +
`pidsLimit: 256`;`/tmp` 与 `/var/tmp` 是 `rw,nosuid,nodev,size=64m` 的 tmpfs——
注意**没有** `noexec`,但重启即清空,所以不要在那里放可执行文件或期望重启后仍在的文件。
**dev 环境与此不同**:dev 档为 `readonlyRootfs: false`、`noNewPrivileges: false`,
rootfs 可写,同一个二进制在 dev 与生产的可写行为可能不同。
- Redis 由外部已认证实例提供,不与此服务同容器运行(`C-REDIS-EXTERNAL`)。
**就绪探测:用 `/ready`,不要用 `/healthz`**
@ -398,8 +442,9 @@ docker run --env-file .env -p 8080:8080 messageweave:latest
| `GET /api/status` | 缺必需环境变量时 503 + `{"status":"configuration-setup","missing":[...]}` | 排查"起来了但没干活" |
> **由平台 ingress 探测 `/ready`。**镜像现已内置 `curl`(见 Dockerfile `runtime` 阶段),
> 所以容器内写 `HEALTHCHECK` 在技术上可行;但 `/healthz` 只能证明进程活着,
> `/ready` 才同时校验 Redis 与上游,因此仍以平台探针为准。
> 所以在 Dockerfile 部署路径下容器内写 `HEALTHCHECK` 在技术上可行(HostStack 的 `runtime: rust`
> 路径不走该 Dockerfile,readiness 由 `hoststack.yaml` 的 `healthCheck.path` 决定);但
> `/healthz` 只能证明进程活着,`/ready` 才同时校验 Redis 与上游,因此仍以平台探针为准。
**缺必需环境变量不会崩溃,但也不会干活。**缺 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时,进程
降级为只读配置路由(内存态、空 token、`NoopWorker`),容器继续应答请求但不做任何业务。排查这类

View File

@ -7,9 +7,18 @@
# chat allowlist, LLM settings) is NOT read from environment variables at all.
# It is written to Redis via /api/bootstrap or admin PUT on /api/config.
#
# Container rootfs is read-only, so diagnostic tools must be installed at image
# build time (see the `runtime` stage of Dockerfile): curl, procps (ps/pgrep),
# iproute2 (ss/ip), jq, bind9-dnsutils (dig), netcat-openbsd (nc).
# Precedence (verified in the HostStack agent source, deploy-executor.ts):
# a value declared here wins; a field omitted here falls back to the
# per-runtime framework default. For runtime: rust those defaults are
# `cargo fetch` / `cargo build --release` / `./target/release/app`.
#
# That last default is why start.command MUST be declared below: `app` is not
# this package's binary name. Cargo.toml declares [[bin]] name = "message-weave"
# with path = "src/main.rs"; there is no `app` target and no src/app.rs.
# Deleting this file reverts the service to a start command that exits with
# `./target/release/app: not found` before it can bind the port.
#
# Do NOT add --debug here for production. See the note above start.command.
services:
web:
type: web_service
@ -17,6 +26,7 @@ services:
install:
command: cargo fetch --locked
build:
# --locked on both steps: fail rather than rewriting Cargo.lock mid-deploy.
command: cargo build --release --locked
start:
# Binary is named after the Cargo package: `message-weave`, not `app`.
@ -24,10 +34,12 @@ services:
command: ./target/release/message-weave
# Temporary diagnosis only: change the line above to
# command: ./target/release/message-weave --debug
# and set the DEBUG_TOKEN secret. Then probe the debug surface from
# inside the container, never through the public domain:
# and set the DEBUG_TOKEN secret. The debug surface is gated by BOTH
# factors — the argv flag AND a non-empty DEBUG_TOKEN — read once at
# process start. Then probe it from inside the container, never through
# the public domain:
# curl -s -i -H "Authorization: Bearer <token>" http://127.0.0.1:8080/debug/config
# Debug routes are gated by BOTH factors (see docs/deployment.md §2.1).
# See docs/deployment.md §2.1 for the double-factor gate.
healthCheck:
# /healthz only, never /ready. /ready also probes redis, the JMAP session
# and Telegram getMe, so a transient upstream blip would restart the