Hermes S6 容器监管
修改、调试或扩展 Hermes Agent Docker 镜像中的 s6-overlay 监管树——添加新服务、调试配置文件网关、理解架构 B 主程序模式。
技能元数据
| 来源 | 可选 — 使用 hermes skills install official/devops/hermes-s6-container-supervision 安装 |
| 路径 | optional-skills/devops/hermes-s6-container-supervision |
| 版本 | 1.0.0 |
| 作者 | Hermes Agent |
| 许可证 | MIT |
| 平台 | linux |
| 标签 | docker, s6, supervision, gateway, profiles |
| 相关技能 | hermes-agent, hermes-agent-dev |
参考:完整 SKILL.md
以下是 Hermes 在触发此技能时加载的完整技能定义。这是技能激活时代理看到的指令。
Hermes s6-overlay 容器监管
何时使用此技能
在处理以下情况时加载此技能:
- 在 Hermes Docker 镜像中添加或删除静态服务(应在每次容器启动时受监管的内容,如仪表板)
- 诊断为何按配置文件的网关未启动、重启或在
docker restart后无法存活 - 理解为何容器的 CMD 是
/opt/hermes/docker/main-wrapper.sh以及带前导破折号的参数如何传递给用户程序 - 修改
cont-init.d启动脚本(UID 重映射、卷种子填充、配置文件协调) - 更改按配置文件网关的渲染运行脚本(第 4 阶段)
如果你只是运行 Hermes Agent 并希望使用 Docker,请参阅 website/docs/user-guide/docker.md。
架构概览
/init ← PID 1 (s6-overlay v3.2.3.0)
├── cont-init.d ← oneshot setup, runs as root
│ ├── 01-hermes-setup ← docker/stage2-hook.sh
│ │ ├── UID/GID remap
│ │ ├── chown /opt/data
│ │ ├── chown /opt/data/profiles (every boot)
│ │ ├── seed .env / config.yaml / SOUL.md
│ │ └── skills_sync.py
│ └── 02-reconcile-profiles ← hermes_cli.container_boot
│ ├── chown /run/service (hermes-writable for runtime register)
│ └── walk $HERMES_HOME/profiles/<name>/gateway_state.json
│ → recreate /run/service/gateway-<name>/
│ → auto-start only those with prior_state == "running"
│
├── s6-rc.d (static services, in /etc/s6-overlay/s6-rc.d/)
│ ├── main-hermes/run ← exec sleep infinity (no-op slot)
│ └── dashboard/run ← if HERMES_DASHBOARD=1, runs `hermes dashboard`
│
├── /run/service (s6-svscan watches; tmpfs)
│ ├── gateway-coder/ ← runtime-registered per-profile
│ │ ├── type ("longrun")
│ │ ├── run ("#!/command/with-contenv sh ... exec s6-setuidgid hermes hermes -p coder gateway run")
│ │ ├── down (marker — present means "registered but don't auto-start")
│ │ └── log/run (s6-log → $HERMES_HOME/logs/gateways/coder/current)
│ └── ...
│
└── CMD ("main program") ← /opt/hermes/docker/main-wrapper.sh
└── routes user args: bare exec | hermes subcommand | hermes (no args)
— exec'd by /init with stdin/stdout/stderr inherited (TTY for --tui)
关键文件
| 路径 | 角色 |
|---|---|
Dockerfile | s6-overlay 安装 + cont-init.d 接线 + ENTRYPOINT ["/init", "/opt/hermes/docker/main-wrapper.sh"] |
docker/stage2-hook.sh | “旧入口点逻辑”——UID 重映射、chown、种子填充、技能同步。作为 cont-init.d/01-hermes-setup 运行。 |
docker/cont-init.d/02-reconcile-profiles | 在每次启动时调用 hermes_cli.container_boot,以从持久卷恢复配置文件网关槽位。 |
docker/main-wrapper.sh | 容器的 CMD。路由用户参数,通过 s6-setuidgid 降级到 hermes,exec 执行所选程序。 |
docker/s6-rc.d/main-hermes/run | 无操作 sleep infinity——存在该槽位是为了使 s6-rc 用户 bundle 有效;主 hermes 作为 CMD 运行,而非作为受监管的服务。 |
docker/s6-rc.d/dashboard/run | 条件服务——除非 HERMES_DASHBOARD 为真值,否则执行 exec sleep infinity。 |
docker/entrypoint.sh | 向后兼容垫片,exec 执行 stage2 hook。硬编码旧入口点路径的外部脚本仍然有效。 |
hermes_cli/service_manager.py | S6ServiceManager:register_profile_gateway, unregister_profile_gateway, start/stop/restart/is_running, list_profile_gateways。 |
hermes_cli/container_boot.py | reconcile_profile_gateways()——遍历持久配置文件,重新生成 s6 槽位,输出 container-boot.log。 |
hermes_cli/gateway.py::_dispatch_via_service_manager_if_s6 | 拦截 hermes gateway start/stop/restart 并在容器中运行时路由到 s6。 |
为什么采用架构 B(CMD 作为主程序,而非 s6 监管)
原始计划(v1–v3)要求主 hermes 作为受监管的 s6-rc 服务运行。两个真实的 s6-overlay v3 机制阻碍了这一点:
- cont-init.d 脚本不接收 CMD 参数——因此 stage2 hook 无法解析
docker run <image> chat -q "hi"来设置供服务run脚本使用的HERMES_ARGS。 /run/s6/basedir/bin/halt不会传播写入/run/s6-linux-init-container-results/exitcode的退出码。无论何种情况,容器始终以 143 (SIGTERM) 退出。s6 作者 skarnet 在 issue #477 中确认:“如果你想要容器关闭,你需要要么让你的 CMD 退出,或者,如果你没有 CMD,则写入你想要的容器退出码然后调用 halt”。
因此,我们使用 s6-overlay 原生的 CMD 模式:ENTRYPOINT ["/init", "/opt/hermes/docker/main-wrapper.sh"]。/init 自动将 wrapper 前置到用户参数之前——因此 docker run <image> --version 变为 /init main-wrapper.sh --version,且 --version 不会被 /init 的 POSIX shell 拦截。wrapper 通过 s6-setuidgid 降级到 hermes,然后 exec 执行所选程序。程序的退出码成为容器退出码,完全符合 pre-s6 tini 契约。
权衡:主 hermes 在 s6 下不受监管。这与其在 tini(pre-s6 镜像)下的行为完全一致。仪表板监管是唯一的新保证——而 /run/service/ 下的按配置文件网关获得完全监管。
快速食谱
验证运行中的容器中 s6 是否为 PID 1
docker exec <c> sh -c 'cat /proc/1/comm; readlink /proc/1/exe'
# Expect: s6-svscan or init / /package/admin/s6/.../s6-svscan
检查配置文件网关服务
# /command/ isn't on docker-exec PATH — use absolute path
docker exec <c> /command/s6-svstat /run/service/gateway-<name>
# "up (pid …) … seconds" → running
# "down (exitcode N) … seconds, normally up, want up, …" → s6 wants it up but the process keeps exiting (crash loop)
# "down … normally up, ready …" → user stopped it
手动启动/停止服务
docker exec <c> /command/s6-svc -u /run/service/gateway-<name> # up
docker exec <c> /command/s6-svc -d /run/service/gateway-<name> # down
docker exec <c> /command/s6-svc -t /run/service/gateway-<name> # SIGTERM (restart)
查看 cont-init 协调器日志
docker exec <c> tail -n 50 /opt/data/logs/container-boot.log
# 2026-05-21T06:18:05+0000 profile=coder prior_state=running action=started
# 2026-05-21T06:18:05+0000 profile=writer prior_state=stopped action=registered
添加新的静态服务
- 创建
docker/s6-rc.d/<name>/type,内容为longrun\n,并创建docker/s6-rc.d/<name>/run(使用#!/command/with-contenv sh+# shellcheck shell=sh)。 - 在 run 脚本顶部通过
s6-setuidgid hermes切换到 hermes 用户(除非你特别需要 root 权限)。 - 创建空的
docker/s6-rc.d/<name>/dependencies.d/base,使其等待 base bundle。 - 创建空的
docker/s6-rc.d/user/contents.d/<name>,使其加入 user bundle。 - Dockerfile 中的
COPY docker/s6-rc.d/会自动拾取它——无需其他更改。
更改每个 profile 的 gateway 运行命令
编辑 hermes_cli/service_manager.py 中的 S6ServiceManager._render_run_script。该函数在启动协调期间也由 hermes_cli/container_boot.py::_register_service 调用,因此它是单一事实来源。更新 tests/hermes_cli/test_service_manager.py::test_s6_register_creates_service_dir_and_triggers_scan 中相应的断言。
运行 Docker 测试 harness
docker build -t hermes-agent-harness:latest .
HERMES_TEST_IMAGE=hermes-agent-harness:latest scripts/run_tests.sh tests/docker/ -v
# Expect 19 passed, 0 xfailed against the s6 image
该 harness 位于 tests/docker/ 中,当 Docker 不可用时会被跳过。每个测试的超时时间已增加到 180 秒(参见 tests/docker/conftest.py)。
常见陷阱
通过 docker exec 出现 "command not found"
/command/(s6-overlay 放置其二进制文件的位置)仅对由监督树生成的进程(服务、cont-init.d、main-wrapper.sh)在 PATH 中可用。docker exec <c> s6-svstat … 会因为 "command not found" 而失败;请始终使用绝对路径 /command/s6-svstat。hermes 二进制文件之所以有效,是因为 Dockerfile 将 /opt/hermes/.venv/bin 添加到了运行时 ENV PATH 中。
Profile 目录所有权
cont-init 协调器以 hermes 身份运行(02-reconcile-profiles 中的 s6-setuidgid hermes)。如果 profile 目录最终归 root 所有(例如,因为默认情况下以 root 身份运行了 docker exec <c> hermes profile create …),协调器将无法读取 SOUL.md 并因 PermissionError 而失败。缓解措施:stage2-hook.sh 在每次启动时都将 $HERMES_HOME/profiles 的所有权更改为 hermes,且具有幂等性。不要删除该代码块。
由 docker exec 写入的文件归 root 所有
docker exec 默认为 root。要么传递 --user hermes,要么依赖下次重启时的 stage2 chown 扫描。不要手动以 root 身份在 $HERMES_HOME/profiles/<name>/ 下写入文件——下一次协调过程会清理它们,但进行中的操作可能会遇到权限错误。
服务槽存在但 s6-svstat 显示 "s6-supervise not running"
服务目录位于 tmpfs 上,并在容器重启时被清除。要么 cont-init 协调器尚未运行(在 docker restart 后稍等片刻),要么它失败了。检查 docker logs <c> | grep '02-reconcile'。
Gateway 启动后立即退出(svstat 中显示 down (exitcode 1))
最可能的原因是 profile 没有配置模型或认证。服务槽是正确的——gateway 本身未配置。首先运行 hermes -p <profile> setup。s6 监督器将持续重启它;这是期望的行为(当你修复配置后,下一次尝试将成功并保持运行)。
协调器跳过了某个 profile
协调器以 SOUL.md 的存在 作为“真实 profile”标记的关键依据。hermes profile create 总是会生成它。如果 profile 目录缺少 SOUL.md(孤立目录、部分恢复、备份进行中),协调器会故意跳过它。添加一个 SOUL.md(即使为空)以重新启用。
“救命,容器以 143 退出!”
检查是否有东西调用了 s6-svscanctl -t 或 /run/s6/basedir/bin/halt——两者都会导致 /init 开始阶段 3 关闭,但返回 143 (SIGTERM) 而不是期望的退出代码。这是从 A 到 B 的第二阶段架构转变。对于具有真实退出代码的容器关闭,你必须让 CMD (main-wrapper.sh) 正常退出;不要试图从 finish 脚本控制退出。
相关技能
hermes-agent-dev:通用 hermes-agent 代码库导航hermes-tool-quirks:特定的 Hermes-tool 变通方法(sed/grep 等)——在调试 s6 栈与 hermes 内置工具的交互时加载。