跳到主要内容

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)

关键文件

路径角色
Dockerfiles6-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.pyS6ServiceManagerregister_profile_gateway, unregister_profile_gateway, start/stop/restart/is_running, list_profile_gateways
hermes_cli/container_boot.pyreconcile_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 机制阻碍了这一点:

  1. cont-init.d 脚本不接收 CMD 参数——因此 stage2 hook 无法解析 docker run <image> chat -q "hi" 来设置供服务 run 脚本使用的 HERMES_ARGS
  2. /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

添加新的静态服务

  1. 创建 docker/s6-rc.d/<name>/type,内容为 longrun\n,并创建 docker/s6-rc.d/<name>/run(使用 #!/command/with-contenv sh + # shellcheck shell=sh)。
  2. 在 run 脚本顶部通过 s6-setuidgid hermes 切换到 hermes 用户(除非你特别需要 root 权限)。
  3. 创建空的 docker/s6-rc.d/<name>/dependencies.d/base,使其等待 base bundle。
  4. 创建空的 docker/s6-rc.d/user/contents.d/<name>,使其加入 user bundle。
  5. 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-svstathermes 二进制文件之所以有效,是因为 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 内置工具的交互时加载。